UNPKG

12.2 kBJavaScriptView Raw
1"use strict";
2Object.defineProperty(exports, "__esModule", { value: true });
3exports.Login = void 0;
4const tslib_1 = require("tslib");
5const color_1 = tslib_1.__importDefault(require("@heroku-cli/color"));
6const core_1 = require("@oclif/core");
7const http_call_1 = tslib_1.__importDefault(require("http-call"));
8const netrc_parser_1 = tslib_1.__importDefault(require("netrc-parser"));
9const open = require("open");
10const os = tslib_1.__importStar(require("os"));
11const { ux } = core_1.CliUx;
12const api_client_1 = require("./api-client");
13const vars_1 = require("./vars");
14const debug = require('debug')('heroku-cli-command');
15const hostname = os.hostname();
16const thirtyDays = 60 * 60 * 24 * 30;
17const headers = (token) => ({ headers: { accept: 'application/vnd.heroku+json; version=3', authorization: `Bearer ${token}` } });
18class 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 // timeout after 10 minutes
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 // can't use browser with --expires-in
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 // for SSO logins we delete the session since those do not show up in
91 // authorizations because they are created a trusted client
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 // grab all the authorizations so that we can delete the token they are
105 // using in the CLI. we have to do this rather than delete ~ because
106 // the ~ is the API Key, not the authorization that is currently requesting
107 requests.push(http_call_1.default.get(`${vars_1.vars.apiUrl}/oauth/authorizations`, headers(token))
108 .then(async ({ body: authorizations }) => {
109 // grab the default authorization because that is the token shown in the
110 // dashboard as API Key and they may be using it for something else and we
111 // would unwittingly break an integration that they are depending on
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 // ux.warn(`If browser does not open, visit ${color.greenBright(url)}`)
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 // TODO: handle browser
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}
278exports.Login = Login;