UNPKG

eas-cli

Version:
269 lines (268 loc) 11.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.UserSecondFactorDeviceMethod = void 0; const tslib_1 = require("tslib"); const json_file_1 = tslib_1.__importDefault(require("@expo/json-file")); const core_1 = require("@oclif/core"); const assert_1 = tslib_1.__importDefault(require("assert")); const chalk_1 = tslib_1.__importDefault(require("chalk")); const nullthrows_1 = tslib_1.__importDefault(require("nullthrows")); const fetchSessionSecretAndSsoUser_1 = require("./fetchSessionSecretAndSsoUser"); const fetchSessionSecretAndUser_1 = require("./fetchSessionSecretAndUser"); const ApiV2Error_1 = require("../ApiV2Error"); const api_1 = require("../api"); const createGraphqlClient_1 = require("../commandUtils/context/contextUtils/createGraphqlClient"); const UserQuery_1 = require("../graphql/queries/UserQuery"); const log_1 = tslib_1.__importStar(require("../log")); const prompts_1 = require("../prompts"); const paths_1 = require("../utils/paths"); var UserSecondFactorDeviceMethod; (function (UserSecondFactorDeviceMethod) { UserSecondFactorDeviceMethod["AUTHENTICATOR"] = "authenticator"; UserSecondFactorDeviceMethod["SMS"] = "sms"; })(UserSecondFactorDeviceMethod || (exports.UserSecondFactorDeviceMethod = UserSecondFactorDeviceMethod = {})); class SessionManager { analytics; currentActor; constructor(analytics) { this.analytics = analytics; } getAccessToken() { return process.env.EXPO_TOKEN ?? null; } getSessionSecret() { return this.getSession()?.sessionSecret ?? null; } getSession() { try { return json_file_1.default.read((0, paths_1.getStateJsonPath)())?.auth ?? null; } catch (error) { if (error.code === 'ENOENT') { return null; } throw error; } } async setSessionAsync(sessionData) { await json_file_1.default.setAsync((0, paths_1.getStateJsonPath)(), 'auth', sessionData, { default: {}, ensureDir: true, }); } async logoutAsync() { this.currentActor = undefined; await this.setSessionAsync(undefined); } async getUserAsync() { if (!this.currentActor && (this.getAccessToken() || this.getSessionSecret())) { const authenticationInfo = { accessToken: this.getAccessToken(), sessionSecret: this.getSessionSecret(), }; const actor = await UserQuery_1.UserQuery.currentUserAsync((0, createGraphqlClient_1.createGraphqlClient)(authenticationInfo)); this.currentActor = actor ?? undefined; if (actor) { this.analytics.setActor(actor); } } return this.currentActor; } /** * Ensure that there is a logged-in actor. Show a login prompt if not. * * @param nonInteractive whether the log-in prompt if logged-out should be interactive * @returns logged-in Actor */ async ensureLoggedInAsync({ nonInteractive, }) { let actor; try { actor = await this.getUserAsync(); } catch { } if (!actor) { log_1.default.warn('An Expo user account is required to proceed.'); await this.showLoginPromptAsync({ nonInteractive, printNewLine: true }); actor = await this.getUserAsync(); } const accessToken = this.getAccessToken(); const authenticationInfo = accessToken ? { accessToken, sessionSecret: null, } : { accessToken: null, sessionSecret: (0, nullthrows_1.default)(this.getSessionSecret()), }; return { actor: (0, nullthrows_1.default)(actor), authenticationInfo }; } /** * Prompt the user to log in. * * @deprecated Should not be used outside of context functions, except in the AccountLogin command. */ async showLoginPromptAsync({ nonInteractive = false, printNewLine = false, sso = false, } = {}) { if (nonInteractive) { core_1.Errors.error(`Either log in with ${chalk_1.default.bold('eas login')} or set the ${chalk_1.default.bold('EXPO_TOKEN')} environment variable if you're using EAS CLI on CI (${(0, log_1.learnMore)('https://docs.expo.dev/accounts/programmatic-access/', { dim: false })})`); } if (printNewLine) { log_1.default.newLine(); } if (sso) { await this.ssoLoginAsync(); return; } log_1.default.log(`Log in to EAS with email or username (exit and run ${chalk_1.default.bold('eas login --help')} to see other login options)`); const { username, password } = await (0, prompts_1.promptAsync)([ { type: 'text', name: 'username', message: 'Email or username', }, { type: 'password', name: 'password', message: 'Password', }, ]); try { await this.loginAsync({ username, password, }); } catch (e) { if (e instanceof ApiV2Error_1.ApiV2Error && e.expoApiV2ErrorCode === 'ONE_TIME_PASSWORD_REQUIRED') { await this.retryUsernamePasswordAuthWithOTPAsync(username, password, e.expoApiV2ErrorMetadata); } else { throw e; } } } async ssoLoginAsync() { const { sessionSecret, id, username } = await (0, fetchSessionSecretAndSsoUser_1.fetchSessionSecretAndSsoUserAsync)(); await this.setSessionAsync({ sessionSecret, userId: id, username, currentConnection: 'Browser-Flow-Authentication', }); } async loginAsync(input) { const { sessionSecret, id, username } = await (0, fetchSessionSecretAndUser_1.fetchSessionSecretAndUserAsync)(input); await this.setSessionAsync({ sessionSecret, userId: id, username, currentConnection: 'Username-Password-Authentication', }); } /** * Prompt for an OTP with the option to cancel the question by answering empty (pressing return key). */ async promptForOTPAsync(cancelBehavior) { const enterMessage = cancelBehavior === 'cancel' ? `press ${chalk_1.default.bold('Enter')} to cancel` : `press ${chalk_1.default.bold('Enter')} for more options`; const { otp } = await (0, prompts_1.promptAsync)({ type: 'text', name: 'otp', message: `One-time password or backup code (${enterMessage}):`, }); if (!otp) { return null; } return otp; } /** * Prompt for user to choose a backup OTP method. If selected method is SMS, a request * for a new OTP will be sent to that method. Then, prompt for the OTP, and retry the user login. */ async promptForBackupOTPAsync(username, password, secondFactorDevices) { const nonPrimarySecondFactorDevices = secondFactorDevices.filter(device => !device.is_primary); if (nonPrimarySecondFactorDevices.length === 0) { throw new Error('No other second-factor devices set up. Ensure you have set up and certified a backup device.'); } const hasAuthenticatorSecondFactorDevice = nonPrimarySecondFactorDevices.find(device => device.method === UserSecondFactorDeviceMethod.AUTHENTICATOR); const smsNonPrimarySecondFactorDevices = nonPrimarySecondFactorDevices.filter(device => device.method === UserSecondFactorDeviceMethod.SMS); const authenticatorChoiceSentinel = -1; const cancelChoiceSentinel = -2; const deviceChoices = smsNonPrimarySecondFactorDevices.map((device, idx) => ({ title: device.sms_phone_number, value: idx, })); if (hasAuthenticatorSecondFactorDevice) { deviceChoices.push({ title: 'Authenticator', value: authenticatorChoiceSentinel, }); } deviceChoices.push({ title: 'Cancel', value: cancelChoiceSentinel, }); const selectedValue = await (0, prompts_1.selectAsync)('Select a second-factor device:', deviceChoices); if (selectedValue === cancelChoiceSentinel) { return null; } else if (selectedValue === authenticatorChoiceSentinel) { return await this.promptForOTPAsync('cancel'); } const device = smsNonPrimarySecondFactorDevices[selectedValue]; // this is a logged-out endpoint const apiV2Client = new api_1.ApiV2Client({ accessToken: null, sessionSecret: null }); await apiV2Client.postAsync('auth/send-sms-otp', { body: { username, password, secondFactorDeviceID: device.id, }, }); return await this.promptForOTPAsync('cancel'); } /** * Handle the special case error indicating that a second-factor is required for * authentication. * * There are three cases we need to handle: * 1. User's primary second-factor device was SMS, OTP was automatically sent by the server to that * device already. In this case we should just prompt for the SMS OTP (or backup code), which the * user should be receiving shortly. We should give the user a way to cancel and the prompt and move * to case 3 below. * 2. User's primary second-factor device is authenticator. In this case we should prompt for authenticator * OTP (or backup code) and also give the user a way to cancel and move to case 3 below. * 3. User doesn't have a primary device or doesn't have access to their primary device. In this case * we should show a picker of the SMS devices that they can have an OTP code sent to, and when * the user picks one we show a prompt() for the sent OTP. */ async retryUsernamePasswordAuthWithOTPAsync(username, password, metadata) { const { secondFactorDevices, smsAutomaticallySent } = metadata; (0, assert_1.default)(secondFactorDevices !== undefined && smsAutomaticallySent !== undefined, `Malformed OTP error metadata: ${metadata}`); const primaryDevice = secondFactorDevices.find(device => device.is_primary); let otp = null; if (smsAutomaticallySent) { (0, assert_1.default)(primaryDevice, 'OTP should only automatically be sent when there is a primary device'); log_1.default.log(`One-time password was sent to the phone number ending in ${primaryDevice.sms_phone_number}.`); otp = await this.promptForOTPAsync('menu'); } if (primaryDevice?.method === UserSecondFactorDeviceMethod.AUTHENTICATOR) { log_1.default.log('One-time password from authenticator required.'); otp = await this.promptForOTPAsync('menu'); } // user bailed on case 1 or 2, wants to move to case 3 if (!otp) { otp = await this.promptForBackupOTPAsync(username, password, secondFactorDevices); } if (!otp) { throw new Error('Cancelled login'); } await this.loginAsync({ username, password, otp, }); } } exports.default = SessionManager;