UNPKG

@heroku-cli/command

Version:
339 lines (338 loc) 13.9 kB
import { HTTP } from '@heroku/http-call'; import { ux } from '@oclif/core/ux'; import ansis from 'ansis'; import debug from 'debug'; import os from 'node:os'; import * as readline from 'node:readline'; import { HerokuAPIError } from './api-client.js'; import { getStorageConfig } from './credential-manager-core/lib/credential-storage-selector.js'; import { writeLoginState } from './credential-manager-core/lib/login-state.js'; import { saveAuth } from './credential-manager.js'; import { prompter } from './prompter.js'; import { vars } from './vars.js'; const cliDebug = debug('heroku-cli-command'); const hostname = os.hostname(); const thirtyDays = 60 * 60 * 24 * 30; const REDACTED_TOKEN_ASTERISKS = '*'.repeat(10); const headers = (token) => ({ headers: { accept: 'application/vnd.heroku+json; version=3', authorization: `Bearer ${token}` } }); export class Login { config; heroku; loginHost = process.env.HEROKU_LOGIN_HOST || 'https://cli-auth.heroku.com'; constructor(config, heroku) { this.config = config; this.heroku = heroku; } async login(opts = {}) { let loggedIn = false; try { // timeout after 10 minutes setTimeout(() => { if (!loggedIn) ux.error('timed out'); }, 1000 * 60 * 10).unref(); if (process.env.HEROKU_API_KEY) ux.error('Cannot log in with HEROKU_API_KEY set'); if (opts.expiresIn && opts.expiresIn > thirtyDays) ux.error('Cannot set an expiration longer than thirty days'); const previousToken = await this.heroku.getAuth(); const previousAccount = previousToken ? (await this.heroku.getAuthEntry())?.account?.trim() || undefined : undefined; let input = opts.method; if (!input) { if (opts.expiresIn) { // can't use browser with --expires-in input = 'interactive'; } else if (process.env.HEROKU_LEGACY_SSO === '1') { input = 'sso'; } else { ux.stderr(`heroku: Press any key to open up the browser to login or ${ansis.yellow('q')} to exit`); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); // Set raw mode to get immediate keypresses process.stdin.setRawMode(true); process.stdin.resume(); const key = await new Promise(resolve => { process.stdin.once('data', data => { const key = data.toString(); resolve(key); }); }); // Restore normal terminal settings process.stdin.setRawMode(false); rl.close(); ux.stdout(''); input = this.getLoginMethodFromPromptKey(key); } } let auth; switch (input) { case 'b': case 'browser': { auth = await this.browser(opts.browser); break; } case 'i': case 'interactive': { auth = await this.interactive(previousAccount, opts.expiresIn); break; } case 's': case 'sso': { auth = await this.sso(); break; } default: { return this.login(opts); } } await this.saveToken(auth); } catch (error) { throw new HerokuAPIError(error); } finally { loggedIn = true; } } async logout(token) { const resolvedToken = token ?? await this.heroku.getAuth(); if (!resolvedToken) return cliDebug('no credentials to logout'); const requests = []; // for SSO logins we delete the session since those do not show up in // authorizations because they are created a trusted client requests.push(HTTP.delete(`${vars.apiUrl}/oauth/sessions/~`, headers(resolvedToken)) .catch(error => { if (!error.http) throw error; if (error.http.statusCode === 404 && error.http.body && error.http.body.id === 'not_found' && error.http.body.resource === 'session') { return; } if (error.http.statusCode === 401) { return; } throw error; })); // grab all the authorizations so that we can delete the token they are // using in the CLI. we have to do this rather than delete ~ because // the ~ is the API Key, not the authorization that is currently requesting requests.push(HTTP.get(`${vars.apiUrl}/oauth/authorizations`, headers(resolvedToken)) .then(async ({ body: authorizations }) => { // grab the default authorization because that is the token shown in the // dashboard as API Key and they may be using it for something else and we // would unwittingly break an integration that they are depending on const defaultApiToken = await this.defaultToken(); if (defaultApiToken && this.isCurrentOAuthToken(resolvedToken, defaultApiToken)) return; return Promise.all(authorizations .filter(a => a.access_token?.token && this.isCurrentOAuthToken(resolvedToken, a.access_token.token)) .map(a => HTTP.delete(`${vars.apiUrl}/oauth/authorizations/${a.id}`, headers(resolvedToken)))); }) .catch(error => { if (!error.http) throw error; if (error.http.statusCode === 401) { return []; } throw error; })); await Promise.all(requests); } async browser(browser) { const { body: urls } = await HTTP.post(`${this.loginHost}/auth`, { body: { description: `Heroku CLI login from ${hostname}` }, }); const url = `${this.loginHost}${urls.browser_url}`; ux.stderr(`Opening browser to ${url}\n`); let urlDisplayed = false; const showUrl = () => { if (!urlDisplayed) ux.warn('Cannot open browser.'); urlDisplayed = true; }; this.showManualBrowserLoginUrl(url); const open = (await import('open')).default; const cp = await open(url, { wait: false, ...(browser ? { app: { name: browser } } : {}) }); cp.on('error', err => { ux.warn(err); showUrl(); }); if (process.env.HEROKU_TESTING_HEADLESS_LOGIN === '1') showUrl(); cp.on('close', code => { if (code !== 0) showUrl(); }); ux.action.start('heroku: Waiting for login'); const fetchAuth = async (retries = 3) => { try { const { body: auth } = await HTTP.get(`${this.loginHost}${urls.cli_url}`, { headers: { authorization: `Bearer ${urls.token}` }, }); return auth; } catch (error) { if (retries > 0 && error.http && error.http.statusCode > 500) return fetchAuth(retries - 1); throw error; } }; const auth = await fetchAuth(); if (auth.error) ux.error(auth.error); ux.action.start('Logging in'); const { body: account } = await HTTP.get(`${vars.apiUrl}/account`, headers(auth.access_token)); ux.action.stop(); this.heroku.setAuthEntry({ account: account.email, token: auth.access_token }); return { login: account.email, password: auth.access_token, }; } async createOAuthToken(username, password, opts = {}) { function basicAuth(username, password) { let auth = [username, password].join(':'); auth = Buffer.from(auth).toString('base64'); return `Basic ${auth}`; } const headers = { accept: 'application/vnd.heroku+json; version=3', authorization: basicAuth(username, password), }; if (opts.secondFactor) headers['Heroku-Two-Factor-Code'] = opts.secondFactor; const { body: auth } = await HTTP.post(`${vars.apiUrl}/oauth/authorizations`, { body: { description: `Heroku CLI login from ${hostname}`, expires_in: opts.expiresIn || thirtyDays, scope: ['global'], }, headers, }); return { login: auth.user.email, password: auth.access_token.token }; } async defaultToken() { const token = await this.heroku.getAuth(); if (!token) return; try { const { body: authorization } = await HTTP.get(`${vars.apiUrl}/oauth/authorizations/~`, headers(token)); return authorization.access_token && authorization.access_token.token; } catch (error) { if (!error.http) throw error; if (error.http.statusCode === 404 && error.http.body && error.http.body.id === 'not_found' && error.http.body.resource === 'authorization') return; if (error.http.statusCode === 401) return; throw error; } } getLoginMethodFromPromptKey(key) { if (key === '\u0003') { ux.error('Login cancelled by user', { exit: 130 }); } if (key.toLowerCase() === 'q') { ux.error('Login cancelled by user'); } return 'browser'; } async interactive(login, expiresIn) { ux.stderr('heroku: Enter your login credentials\n'); const { email } = await prompter.prompt([{ default: login, message: 'Email', name: 'email', type: 'input', }]); login = email; const { password } = await prompter.prompt([{ message: 'Password', name: 'password', type: 'password', }]); let auth; try { auth = await this.createOAuthToken(login, password, { expiresIn }); } catch (error) { if (error.body && error.body.id === 'device_trust_required') { error.body.message = 'The interactive flag requires Two-Factor Authentication to be enabled on your account. Please use heroku login.'; throw error; } if (!error.body || error.body.id !== 'two_factor') { throw error; } const { secondFactor } = await prompter.prompt([{ message: 'Two-factor code', name: 'secondFactor', type: 'password', }]); auth = await this.createOAuthToken(login, password, { expiresIn, secondFactor }); } this.heroku.setAuthEntry({ account: auth.login, token: auth.password }); return auth; } isCurrentOAuthToken(localToken, apiToken) { const asteriskIndex = apiToken.indexOf(REDACTED_TOKEN_ASTERISKS); if (asteriskIndex === -1) { // raw value stored, direct match works return localToken === apiToken; } const prefix = apiToken.slice(0, asteriskIndex); const suffix = apiToken.slice(asteriskIndex + REDACTED_TOKEN_ASTERISKS.length); return localToken.startsWith(prefix) && (suffix === '' || localToken.endsWith(suffix)); } async saveToken(entry) { await saveAuth(entry.login, entry.password, [vars.apiHost, vars.httpGitHost]); const config = getStorageConfig(); if (config.credentialStore && this.config.dataDir) { await writeLoginState(this.config.dataDir, entry.login); } } showManualBrowserLoginUrl(url) { ux.warn('If browser does not open, visit:'); ux.stderr(ansis.greenBright(url)); } async sso() { const open = (await import('open')).default; let url = process.env.SSO_URL; let org = process.env.HEROKU_ORGANIZATION; if (!url) { const { orgName } = await prompter.prompt([{ default: org, message: 'Organization name', name: 'orgName', type: 'input', }]); org = orgName; url = `https://sso.heroku.com/saml/${encodeURIComponent(org)}/init?cli=true`; } // TODO: handle browser cliDebug(`opening browser to ${url}`); ux.stderr(`Opening browser to:\n${url}\n`); ux.stderr(ansis.gray(`If the browser fails to open or you're authenticating on a remote machine, please manually open the URL above in your browser.\n`)); await open(url, { wait: false }); const { password } = await prompter.prompt([{ message: 'Access token', name: 'password', type: 'password', }]); ux.action.start('Validating token'); const { body: account } = await HTTP.get(`${vars.apiUrl}/account`, headers(password)); ux.action.stop(); this.heroku.setAuthEntry({ account: account.email, token: password }); return { login: account.email, password, }; } }