UNPKG

@heroku-cli/command

Version:
335 lines (334 loc) 13.5 kB
import { HTTP } from '@heroku/http-call'; import { color } from '@heroku-cli/color'; import { ux } from '@oclif/core'; import debug from 'debug'; import inquirer from 'inquirer'; import { Netrc } from 'netrc-parser'; import * as os from 'node:os'; import * as readline from 'node:readline'; import open from 'open'; import { HerokuAPIError } from './api-client.js'; import { vars } from './vars.js'; const cliDebug = debug('heroku-cli-command'); const hostname = os.hostname(); const thirtyDays = 60 * 60 * 24 * 30; const netrc = new Netrc(); 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'); await netrc.load(); const previousEntry = netrc.machines['api.heroku.com']; 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 ${color.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(''); if (key.toLowerCase() === 'q') { ux.error('Login cancelled by user'); } input = 'browser'; } } try { if (previousEntry && previousEntry.password) await this.logout(previousEntry.password); } catch (error) { const message = error instanceof Error ? error.message : String(error); ux.warn(message); } let auth; switch (input) { case 'b': case 'browser': { auth = await this.browser(opts.browser); break; } case 'i': case 'interactive': { auth = await this.interactive(previousEntry && previousEntry.login, 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 = this.heroku.auth) { if (!token) 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(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 === 'session') { return; } if (error.http.statusCode === 401 && error.http.body && error.http.body.id === 'unauthorized') { 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(token)) .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 d = await this.defaultToken(); if (d === token) return; return Promise.all(authorizations .filter(a => a.access_token && a.access_token.token === this.heroku.auth) .map(a => HTTP.delete(`${vars.apiUrl}/oauth/authorizations/${a.id}`, headers(token)))); }) .catch(error => { if (!error.http) throw error; if (error.http.statusCode === 401 && error.http.body && error.http.body.id === 'unauthorized') { 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; }; ux.warn(`If browser does not open, visit ${color.greenBright(url)}`); 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); this.heroku.auth = auth.access_token; ux.action.start('Logging in'); const { body: account } = await HTTP.get(`${vars.apiUrl}/account`, headers(auth.access_token)); ux.action.stop(); 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() { try { const { body: authorization } = await HTTP.get(`${vars.apiUrl}/oauth/authorizations/~`, headers(this.heroku.auth)); 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.body.resource === 'authorization') return; if (error.http.statusCode === 401 && error.http.body && error.http.body.id === 'unauthorized') return; throw error; } } async interactive(login, expiresIn) { ux.stderr('heroku: Enter your login credentials\n'); const emailQuestions = [{ default: login, message: 'Email', name: 'email', type: 'input', }]; const { email } = await inquirer.prompt(emailQuestions); login = email; const passwordQuestions = [{ message: 'Password', name: 'password', type: 'password', }]; const { password } = await inquirer.prompt(passwordQuestions); 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 secondFactorQuestions = [{ message: 'Two-factor code', name: 'secondFactor', type: 'password', }]; const { secondFactor } = await inquirer.prompt(secondFactorQuestions); auth = await this.createOAuthToken(login, password, { expiresIn, secondFactor }); } this.heroku.auth = auth.password; return auth; } async saveToken(entry) { const hosts = [vars.apiHost, vars.httpGitHost]; hosts.forEach(host => { if (!netrc.machines[host]) netrc.machines[host] = {}; netrc.machines[host].login = entry.login; netrc.machines[host].password = entry.password; delete netrc.machines[host].method; delete netrc.machines[host].org; }); if (netrc.machines._tokens) { netrc.machines._tokens.forEach((token) => { if (hosts.includes(token.host)) { token.internalWhitespace = '\n '; } }); } await netrc.save(); } async sso() { let url = process.env.SSO_URL; let org = process.env.HEROKU_ORGANIZATION; if (!url) { const orgQuestions = [{ default: org, message: 'Organization name', name: 'orgName', type: 'input', }]; const { orgName } = await inquirer.prompt(orgQuestions); 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(color.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 passwordQuestions = [{ message: 'Access token', name: 'password', type: 'password', }]; const { password } = await inquirer.prompt(passwordQuestions); ux.action.start('Validating token'); this.heroku.auth = password; const { body: account } = await HTTP.get(`${vars.apiUrl}/account`, headers(password)); ux.action.stop(); return { login: account.email, password, }; } }