1 | "use strict";
|
2 | Object.defineProperty(exports, "__esModule", { value: true });
|
3 | exports.Login = void 0;
|
4 | const tslib_1 = require("tslib");
|
5 | const color_1 = tslib_1.__importDefault(require("@heroku-cli/color"));
|
6 | const core_1 = require("@oclif/core");
|
7 | const http_call_1 = tslib_1.__importDefault(require("http-call"));
|
8 | const netrc_parser_1 = tslib_1.__importDefault(require("netrc-parser"));
|
9 | const open = require("open");
|
10 | const os = tslib_1.__importStar(require("os"));
|
11 | const { ux } = core_1.CliUx;
|
12 | const api_client_1 = require("./api-client");
|
13 | const vars_1 = require("./vars");
|
14 | const debug = require('debug')('heroku-cli-command');
|
15 | const hostname = os.hostname();
|
16 | const thirtyDays = 60 * 60 * 24 * 30;
|
17 | const headers = (token) => ({ headers: { accept: 'application/vnd.heroku+json; version=3', authorization: `Bearer ${token}` } });
|
18 | class Login {
|
19 | constructor(config, heroku) {
|
20 | this.config = config;
|
21 | this.heroku = heroku;
|
22 | this.loginHost = process.env.HEROKU_LOGIN_HOST || 'https://cli-auth.heroku.com';
|
23 | }
|
24 | async login(opts = {}) {
|
25 | let loggedIn = false;
|
26 | try {
|
27 |
|
28 | setTimeout(() => {
|
29 | if (!loggedIn)
|
30 | ux.error('timed out');
|
31 | }, 1000 * 60 * 10).unref();
|
32 | if (process.env.HEROKU_API_KEY)
|
33 | ux.error('Cannot log in with HEROKU_API_KEY set');
|
34 | if (opts.expiresIn && opts.expiresIn > thirtyDays)
|
35 | ux.error('Cannot set an expiration longer than thirty days');
|
36 | await netrc_parser_1.default.load();
|
37 | const previousEntry = netrc_parser_1.default.machines['api.heroku.com'];
|
38 | let input = opts.method;
|
39 | if (!input) {
|
40 | if (opts.expiresIn) {
|
41 |
|
42 | input = 'interactive';
|
43 | }
|
44 | else if (process.env.HEROKU_LEGACY_SSO === '1') {
|
45 | input = 'sso';
|
46 | }
|
47 | else {
|
48 | await ux.anykey(`heroku: Press any key to open up the browser to login or ${color_1.default.yellow('q')} to exit`);
|
49 | input = 'browser';
|
50 | }
|
51 | }
|
52 | try {
|
53 | if (previousEntry && previousEntry.password)
|
54 | await this.logout(previousEntry.password);
|
55 | }
|
56 | catch (error) {
|
57 | const message = error instanceof Error ? error.message : String(error);
|
58 | ux.warn(message);
|
59 | }
|
60 | let auth;
|
61 | switch (input) {
|
62 | case 'b':
|
63 | case 'browser':
|
64 | auth = await this.browser(opts.browser);
|
65 | break;
|
66 | case 'i':
|
67 | case 'interactive':
|
68 | auth = await this.interactive(previousEntry && previousEntry.login, opts.expiresIn);
|
69 | break;
|
70 | case 's':
|
71 | case 'sso':
|
72 | auth = await this.sso();
|
73 | break;
|
74 | default:
|
75 | return this.login(opts);
|
76 | }
|
77 | await this.saveToken(auth);
|
78 | }
|
79 | catch (error) {
|
80 | throw new api_client_1.HerokuAPIError(error);
|
81 | }
|
82 | finally {
|
83 | loggedIn = true;
|
84 | }
|
85 | }
|
86 | async logout(token = this.heroku.auth) {
|
87 | if (!token)
|
88 | return debug('no credentials to logout');
|
89 | const requests = [];
|
90 |
|
91 |
|
92 | requests.push(http_call_1.default.delete(`${vars_1.vars.apiUrl}/oauth/sessions/~`, headers(token))
|
93 | .catch(error => {
|
94 | if (!error.http)
|
95 | throw error;
|
96 | if (error.http.statusCode === 404 && error.http.body && error.http.body.id === 'not_found' && error.http.body.resource === 'session') {
|
97 | return;
|
98 | }
|
99 | if (error.http.statusCode === 401 && error.http.body && error.http.body.id === 'unauthorized') {
|
100 | return;
|
101 | }
|
102 | throw error;
|
103 | }));
|
104 |
|
105 |
|
106 |
|
107 | requests.push(http_call_1.default.get(`${vars_1.vars.apiUrl}/oauth/authorizations`, headers(token))
|
108 | .then(async ({ body: authorizations }) => {
|
109 |
|
110 |
|
111 |
|
112 | const d = await this.defaultToken();
|
113 | if (d === token)
|
114 | return;
|
115 | return Promise.all(authorizations
|
116 | .filter(a => a.access_token && a.access_token.token === this.heroku.auth)
|
117 | .map(a => http_call_1.default.delete(`${vars_1.vars.apiUrl}/oauth/authorizations/${a.id}`, headers(token))));
|
118 | })
|
119 | .catch(error => {
|
120 | if (!error.http)
|
121 | throw error;
|
122 | if (error.http.statusCode === 401 && error.http.body && error.http.body.id === 'unauthorized') {
|
123 | return [];
|
124 | }
|
125 | throw error;
|
126 | }));
|
127 | await Promise.all(requests);
|
128 | }
|
129 | async browser(browser) {
|
130 | const { body: urls } = await http_call_1.default.post(`${this.loginHost}/auth`, {
|
131 | body: { description: `Heroku CLI login from ${hostname}` },
|
132 | });
|
133 | const url = `${this.loginHost}${urls.browser_url}`;
|
134 | process.stderr.write(`Opening browser to ${url}\n`);
|
135 | let urlDisplayed = false;
|
136 | const showUrl = () => {
|
137 | if (!urlDisplayed)
|
138 | ux.warn('Cannot open browser.');
|
139 | urlDisplayed = true;
|
140 | };
|
141 |
|
142 | const cp = await open(url, { app: browser, wait: false });
|
143 | cp.on('error', err => {
|
144 | ux.warn(err);
|
145 | showUrl();
|
146 | });
|
147 | if (process.env.HEROKU_TESTING_HEADLESS_LOGIN === '1')
|
148 | showUrl();
|
149 | cp.on('close', code => {
|
150 | if (code !== 0)
|
151 | showUrl();
|
152 | });
|
153 | ux.action.start('heroku: Waiting for login');
|
154 | const fetchAuth = async (retries = 3) => {
|
155 | try {
|
156 | const { body: auth } = await http_call_1.default.get(`${this.loginHost}${urls.cli_url}`, {
|
157 | headers: { authorization: `Bearer ${urls.token}` },
|
158 | });
|
159 | return auth;
|
160 | }
|
161 | catch (error) {
|
162 | if (retries > 0 && error.http && error.http.statusCode > 500)
|
163 | return fetchAuth(retries - 1);
|
164 | throw error;
|
165 | }
|
166 | };
|
167 | const auth = await fetchAuth();
|
168 | if (auth.error)
|
169 | ux.error(auth.error);
|
170 | this.heroku.auth = auth.access_token;
|
171 | ux.action.start('Logging in');
|
172 | const { body: account } = await http_call_1.default.get(`${vars_1.vars.apiUrl}/account`, headers(auth.access_token));
|
173 | ux.action.stop();
|
174 | return {
|
175 | login: account.email,
|
176 | password: auth.access_token,
|
177 | };
|
178 | }
|
179 | async interactive(login, expiresIn) {
|
180 | process.stderr.write('heroku: Enter your login credentials\n');
|
181 | login = await ux.prompt('Email', { default: login });
|
182 | const password = await ux.prompt('Password', { type: 'hide' });
|
183 | let auth;
|
184 | try {
|
185 | auth = await this.createOAuthToken(login, password, { expiresIn });
|
186 | }
|
187 | catch (error) {
|
188 | if (error.body && error.body.id === 'device_trust_required') {
|
189 | error.body.message = 'The interactive flag requires Two-Factor Authentication to be enabled on your account. Please use heroku login.';
|
190 | throw error;
|
191 | }
|
192 | if (!error.body || error.body.id !== 'two_factor') {
|
193 | throw error;
|
194 | }
|
195 | const secondFactor = await ux.prompt('Two-factor code', { type: 'mask' });
|
196 | auth = await this.createOAuthToken(login, password, { expiresIn, secondFactor });
|
197 | }
|
198 | this.heroku.auth = auth.password;
|
199 | return auth;
|
200 | }
|
201 | async createOAuthToken(username, password, opts = {}) {
|
202 | function basicAuth(username, password) {
|
203 | let auth = [username, password].join(':');
|
204 | auth = Buffer.from(auth).toString('base64');
|
205 | return `Basic ${auth}`;
|
206 | }
|
207 | const headers = {
|
208 | accept: 'application/vnd.heroku+json; version=3',
|
209 | authorization: basicAuth(username, password),
|
210 | };
|
211 | if (opts.secondFactor)
|
212 | headers['Heroku-Two-Factor-Code'] = opts.secondFactor;
|
213 | const { body: auth } = await http_call_1.default.post(`${vars_1.vars.apiUrl}/oauth/authorizations`, {
|
214 | headers,
|
215 | body: {
|
216 | scope: ['global'],
|
217 | description: `Heroku CLI login from ${hostname}`,
|
218 | expires_in: opts.expiresIn || thirtyDays,
|
219 | },
|
220 | });
|
221 | return { password: auth.access_token.token, login: auth.user.email };
|
222 | }
|
223 | async saveToken(entry) {
|
224 | const hosts = [vars_1.vars.apiHost, vars_1.vars.httpGitHost];
|
225 | hosts.forEach(host => {
|
226 | if (!netrc_parser_1.default.machines[host])
|
227 | netrc_parser_1.default.machines[host] = {};
|
228 | netrc_parser_1.default.machines[host].login = entry.login;
|
229 | netrc_parser_1.default.machines[host].password = entry.password;
|
230 | delete netrc_parser_1.default.machines[host].method;
|
231 | delete netrc_parser_1.default.machines[host].org;
|
232 | });
|
233 | if (netrc_parser_1.default.machines._tokens) {
|
234 | netrc_parser_1.default.machines._tokens.forEach((token) => {
|
235 | if (hosts.includes(token.host)) {
|
236 | token.internalWhitespace = '\n ';
|
237 | }
|
238 | });
|
239 | }
|
240 | await netrc_parser_1.default.save();
|
241 | }
|
242 | async defaultToken() {
|
243 | try {
|
244 | const { body: authorization } = await http_call_1.default.get(`${vars_1.vars.apiUrl}/oauth/authorizations/~`, headers(this.heroku.auth));
|
245 | return authorization.access_token && authorization.access_token.token;
|
246 | }
|
247 | catch (error) {
|
248 | if (!error.http)
|
249 | throw error;
|
250 | if (error.http.statusCode === 404 && error.http.body && error.http.body.id === 'not_found' && error.body.resource === 'authorization')
|
251 | return;
|
252 | if (error.http.statusCode === 401 && error.http.body && error.http.body.id === 'unauthorized')
|
253 | return;
|
254 | throw error;
|
255 | }
|
256 | }
|
257 | async sso() {
|
258 | let url = process.env.SSO_URL;
|
259 | let org = process.env.HEROKU_ORGANIZATION;
|
260 | if (!url) {
|
261 | org = await (org ? ux.prompt('Organization name', { default: org }) : ux.prompt('Organization name'));
|
262 | url = `https://sso.heroku.com/saml/${encodeURIComponent(org)}/init?cli=true`;
|
263 | }
|
264 |
|
265 | debug(`opening browser to ${url}`);
|
266 | process.stderr.write(`Opening browser to:\n${url}\n`);
|
267 | process.stderr.write(color_1.default.gray('If the browser fails to open or you’re authenticating on a ' +
|
268 | 'remote machine, please manually open the URL above in your ' +
|
269 | 'browser.\n'));
|
270 | await open(url, { wait: false });
|
271 | const password = await ux.prompt('Access token', { type: 'mask' });
|
272 | ux.action.start('Validating token');
|
273 | this.heroku.auth = password;
|
274 | const { body: account } = await http_call_1.default.get(`${vars_1.vars.apiUrl}/account`, headers(password));
|
275 | return { password, login: account.email };
|
276 | }
|
277 | }
|
278 | exports.Login = Login;
|