@heroku-cli/command
Version:
base class for Heroku CLI commands
336 lines (335 loc) • 14.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.Login = void 0;
const tslib_1 = require("tslib");
const http_call_1 = tslib_1.__importDefault(require("@heroku/http-call"));
const color_1 = tslib_1.__importDefault(require("@heroku-cli/color"));
const core_1 = require("@oclif/core");
const inquirer_1 = tslib_1.__importDefault(require("inquirer"));
const netrc_parser_1 = tslib_1.__importDefault(require("netrc-parser"));
const os = tslib_1.__importStar(require("node:os"));
const readline = tslib_1.__importStar(require("node:readline"));
const open_1 = tslib_1.__importDefault(require("open"));
const api_client_1 = require("./api-client");
const vars_1 = require("./vars");
const debug = require('debug')('heroku-cli-command');
const hostname = os.hostname();
const thirtyDays = 60 * 60 * 24 * 30;
const headers = (token) => ({ headers: { accept: 'application/vnd.heroku+json; version=3', authorization: `Bearer ${token}` } });
class Login {
constructor(config, heroku) {
this.config = config;
this.heroku = heroku;
this.loginHost = process.env.HEROKU_LOGIN_HOST || 'https://cli-auth.heroku.com';
}
async login(opts = {}) {
let loggedIn = false;
try {
// timeout after 10 minutes
setTimeout(() => {
if (!loggedIn)
core_1.ux.error('timed out');
}, 1000 * 60 * 10).unref();
if (process.env.HEROKU_API_KEY)
core_1.ux.error('Cannot log in with HEROKU_API_KEY set');
if (opts.expiresIn && opts.expiresIn > thirtyDays)
core_1.ux.error('Cannot set an expiration longer than thirty days');
await netrc_parser_1.default.load();
const previousEntry = netrc_parser_1.default.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 {
core_1.ux.stderr(`heroku: Press any key to open up the browser to login or ${color_1.default.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();
core_1.ux.stdout('');
if (key.toLowerCase() === 'q') {
core_1.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);
core_1.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 api_client_1.HerokuAPIError(error);
}
finally {
loggedIn = true;
}
}
async logout(token = this.heroku.auth) {
if (!token)
return debug('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_call_1.default.delete(`${vars_1.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_call_1.default.get(`${vars_1.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_call_1.default.delete(`${vars_1.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_call_1.default.post(`${this.loginHost}/auth`, {
body: { description: `Heroku CLI login from ${hostname}` },
});
const url = `${this.loginHost}${urls.browser_url}`;
core_1.ux.stderr(`Opening browser to ${url}\n`);
let urlDisplayed = false;
const showUrl = () => {
if (!urlDisplayed)
core_1.ux.warn('Cannot open browser.');
urlDisplayed = true;
};
// ux.warn(`If browser does not open, visit ${color.greenBright(url)}`)
const cp = await (0, open_1.default)(url, Object.assign({ wait: false }, (browser ? { app: { name: browser } } : {})));
cp.on('error', err => {
core_1.ux.warn(err);
showUrl();
});
if (process.env.HEROKU_TESTING_HEADLESS_LOGIN === '1')
showUrl();
cp.on('close', code => {
if (code !== 0)
showUrl();
});
core_1.ux.action.start('heroku: Waiting for login');
const fetchAuth = async (retries = 3) => {
try {
const { body: auth } = await http_call_1.default.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)
core_1.ux.error(auth.error);
this.heroku.auth = auth.access_token;
core_1.ux.action.start('Logging in');
const { body: account } = await http_call_1.default.get(`${vars_1.vars.apiUrl}/account`, headers(auth.access_token));
core_1.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_call_1.default.post(`${vars_1.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_call_1.default.get(`${vars_1.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) {
core_1.ux.stderr('heroku: Enter your login credentials\n');
const emailQuestions = [{
default: login,
message: 'Email',
name: 'email',
type: 'input',
}];
const { email } = await inquirer_1.default.prompt(emailQuestions);
login = email;
const passwordQuestions = [{
message: 'Password',
name: 'password',
type: 'password',
}];
const { password } = await inquirer_1.default.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_1.default.prompt(secondFactorQuestions);
auth = await this.createOAuthToken(login, password, { expiresIn, secondFactor });
}
this.heroku.auth = auth.password;
return auth;
}
async saveToken(entry) {
const hosts = [vars_1.vars.apiHost, vars_1.vars.httpGitHost];
hosts.forEach(host => {
if (!netrc_parser_1.default.machines[host])
netrc_parser_1.default.machines[host] = {};
netrc_parser_1.default.machines[host].login = entry.login;
netrc_parser_1.default.machines[host].password = entry.password;
delete netrc_parser_1.default.machines[host].method;
delete netrc_parser_1.default.machines[host].org;
});
if (netrc_parser_1.default.machines._tokens) {
netrc_parser_1.default.machines._tokens.forEach((token) => {
if (hosts.includes(token.host)) {
token.internalWhitespace = '\n ';
}
});
}
await netrc_parser_1.default.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_1.default.prompt(orgQuestions);
org = orgName;
url = `https://sso.heroku.com/saml/${encodeURIComponent(org)}/init?cli=true`;
}
// TODO: handle browser
debug(`opening browser to ${url}`);
core_1.ux.stderr(`Opening browser to:\n${url}\n`);
core_1.ux.stderr(color_1.default.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 (0, open_1.default)(url, { wait: false });
const passwordQuestions = [{
message: 'Access token',
name: 'password',
type: 'password',
}];
const { password } = await inquirer_1.default.prompt(passwordQuestions);
core_1.ux.action.start('Validating token');
this.heroku.auth = password;
const { body: account } = await http_call_1.default.get(`${vars_1.vars.apiUrl}/account`, headers(password));
core_1.ux.action.stop();
return {
login: account.email,
password,
};
}
}
exports.Login = Login;