eas-cli
Version:
EAS command line tool
269 lines (268 loc) • 11.6 kB
JavaScript
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;
;