xref: /expo/packages/@expo/cli/src/api/user/otp.ts (revision 8a424beb)
18d307f52SEvan Baconimport assert from 'assert';
28d307f52SEvan Baconimport chalk from 'chalk';
38d307f52SEvan Bacon
4*8a424bebSJames Ideimport { loginAsync } from './user';
58d307f52SEvan Baconimport * as Log from '../../log';
68d307f52SEvan Baconimport { AbortCommandError, CommandError } from '../../utils/errors';
78d307f52SEvan Baconimport { learnMore } from '../../utils/link';
88d307f52SEvan Baconimport { promptAsync, selectAsync } from '../../utils/prompts';
98d307f52SEvan Baconimport { fetchAsync } from '../rest/client';
108d307f52SEvan Bacon
118d307f52SEvan Baconexport enum UserSecondFactorDeviceMethod {
128d307f52SEvan Bacon  AUTHENTICATOR = 'authenticator',
138d307f52SEvan Bacon  SMS = 'sms',
148d307f52SEvan Bacon}
158d307f52SEvan Bacon
168d307f52SEvan Bacon/** Device properties for 2FA */
178d307f52SEvan Baconexport type SecondFactorDevice = {
188d307f52SEvan Bacon  id: string;
198d307f52SEvan Bacon  method: UserSecondFactorDeviceMethod;
208d307f52SEvan Bacon  sms_phone_number: string | null;
218d307f52SEvan Bacon  is_primary: boolean;
228d307f52SEvan Bacon};
238d307f52SEvan Bacon
248d307f52SEvan Baconconst nonInteractiveHelp = `Use the EXPO_TOKEN environment variable to authenticate in CI (${learnMore(
258d307f52SEvan Bacon  'https://docs.expo.dev/accounts/programmatic-access/'
268d307f52SEvan Bacon)})`;
278d307f52SEvan Bacon
288d307f52SEvan Bacon/**
298d307f52SEvan Bacon * Prompt for an OTP with the option to cancel the question by answering empty (pressing return key).
308d307f52SEvan Bacon */
318d307f52SEvan Baconasync function promptForOTPAsync(cancelBehavior: 'cancel' | 'menu'): Promise<string | null> {
328d307f52SEvan Bacon  const enterMessage =
338d307f52SEvan Bacon    cancelBehavior === 'cancel'
348d307f52SEvan Bacon      ? chalk`press {bold Enter} to cancel`
358d307f52SEvan Bacon      : chalk`press {bold Enter} for more options`;
368d307f52SEvan Bacon  const { otp } = await promptAsync(
378d307f52SEvan Bacon    {
388d307f52SEvan Bacon      type: 'text',
398d307f52SEvan Bacon      name: 'otp',
408d307f52SEvan Bacon      message: `One-time password or backup code (${enterMessage}):`,
418d307f52SEvan Bacon    },
428d307f52SEvan Bacon    { nonInteractiveHelp }
438d307f52SEvan Bacon  );
448d307f52SEvan Bacon  return otp || null;
458d307f52SEvan Bacon}
468d307f52SEvan Bacon
478d307f52SEvan Bacon/**
488d307f52SEvan Bacon * Prompt for user to choose a backup OTP method. If selected method is SMS, a request
498d307f52SEvan Bacon * for a new OTP will be sent to that method. Then, prompt for the OTP, and retry the user login.
508d307f52SEvan Bacon */
518d307f52SEvan Baconasync function promptForBackupOTPAsync(
528d307f52SEvan Bacon  username: string,
538d307f52SEvan Bacon  password: string,
548d307f52SEvan Bacon  secondFactorDevices: SecondFactorDevice[]
558d307f52SEvan Bacon): Promise<string | null> {
568d307f52SEvan Bacon  const nonPrimarySecondFactorDevices = secondFactorDevices.filter((device) => !device.is_primary);
578d307f52SEvan Bacon
588d307f52SEvan Bacon  if (nonPrimarySecondFactorDevices.length === 0) {
598d307f52SEvan Bacon    throw new CommandError(
608d307f52SEvan Bacon      'No other second-factor devices set up. Ensure you have set up and certified a backup device.'
618d307f52SEvan Bacon    );
628d307f52SEvan Bacon  }
638d307f52SEvan Bacon
648d307f52SEvan Bacon  const hasAuthenticatorSecondFactorDevice = nonPrimarySecondFactorDevices.find(
658d307f52SEvan Bacon    (device) => device.method === UserSecondFactorDeviceMethod.AUTHENTICATOR
668d307f52SEvan Bacon  );
678d307f52SEvan Bacon
688d307f52SEvan Bacon  const smsNonPrimarySecondFactorDevices = nonPrimarySecondFactorDevices.filter(
698d307f52SEvan Bacon    (device) => device.method === UserSecondFactorDeviceMethod.SMS
708d307f52SEvan Bacon  );
718d307f52SEvan Bacon
728d307f52SEvan Bacon  const authenticatorChoiceSentinel = -1;
738d307f52SEvan Bacon  const cancelChoiceSentinel = -2;
748d307f52SEvan Bacon
758d307f52SEvan Bacon  const deviceChoices = smsNonPrimarySecondFactorDevices.map((device, idx) => ({
768d307f52SEvan Bacon    title: device.sms_phone_number!,
778d307f52SEvan Bacon    value: idx,
788d307f52SEvan Bacon  }));
798d307f52SEvan Bacon
808d307f52SEvan Bacon  if (hasAuthenticatorSecondFactorDevice) {
818d307f52SEvan Bacon    deviceChoices.push({
828d307f52SEvan Bacon      title: 'Authenticator',
838d307f52SEvan Bacon      value: authenticatorChoiceSentinel,
848d307f52SEvan Bacon    });
858d307f52SEvan Bacon  }
868d307f52SEvan Bacon
878d307f52SEvan Bacon  deviceChoices.push({
888d307f52SEvan Bacon    title: 'Cancel',
898d307f52SEvan Bacon    value: cancelChoiceSentinel,
908d307f52SEvan Bacon  });
918d307f52SEvan Bacon
928d307f52SEvan Bacon  const selectedValue = await selectAsync('Select a second-factor device:', deviceChoices, {
938d307f52SEvan Bacon    nonInteractiveHelp,
948d307f52SEvan Bacon  });
958d307f52SEvan Bacon  if (selectedValue === cancelChoiceSentinel) {
968d307f52SEvan Bacon    return null;
978d307f52SEvan Bacon  } else if (selectedValue === authenticatorChoiceSentinel) {
988d307f52SEvan Bacon    return await promptForOTPAsync('cancel');
998d307f52SEvan Bacon  }
1008d307f52SEvan Bacon
1018d307f52SEvan Bacon  const device = smsNonPrimarySecondFactorDevices[selectedValue];
1028d307f52SEvan Bacon
1038d307f52SEvan Bacon  await fetchAsync('auth/send-sms-otp', {
1048d307f52SEvan Bacon    method: 'POST',
1058d307f52SEvan Bacon    body: JSON.stringify({
1068d307f52SEvan Bacon      username,
1078d307f52SEvan Bacon      password,
1088d307f52SEvan Bacon      secondFactorDeviceID: device.id,
1098d307f52SEvan Bacon    }),
1108d307f52SEvan Bacon  });
1118d307f52SEvan Bacon
1128d307f52SEvan Bacon  return await promptForOTPAsync('cancel');
1138d307f52SEvan Bacon}
1148d307f52SEvan Bacon
1158d307f52SEvan Bacon/**
1168d307f52SEvan Bacon * Handle the special case error indicating that a second-factor is required for
1178d307f52SEvan Bacon * authentication.
1188d307f52SEvan Bacon *
1198d307f52SEvan Bacon * There are three cases we need to handle:
1208d307f52SEvan Bacon * 1. User's primary second-factor device was SMS, OTP was automatically sent by the server to that
1218d307f52SEvan Bacon *    device already. In this case we should just prompt for the SMS OTP (or backup code), which the
1228d307f52SEvan Bacon *    user should be receiving shortly. We should give the user a way to cancel and the prompt and move
1238d307f52SEvan Bacon *    to case 3 below.
1248d307f52SEvan Bacon * 2. User's primary second-factor device is authenticator. In this case we should prompt for authenticator
1258d307f52SEvan Bacon *    OTP (or backup code) and also give the user a way to cancel and move to case 3 below.
1268d307f52SEvan Bacon * 3. User doesn't have a primary device or doesn't have access to their primary device. In this case
1278d307f52SEvan Bacon *    we should show a picker of the SMS devices that they can have an OTP code sent to, and when
1288d307f52SEvan Bacon *    the user picks one we show a prompt() for the sent OTP.
1298d307f52SEvan Bacon */
1308d307f52SEvan Baconexport async function retryUsernamePasswordAuthWithOTPAsync(
1318d307f52SEvan Bacon  username: string,
1328d307f52SEvan Bacon  password: string,
1338d307f52SEvan Bacon  metadata: {
1348d307f52SEvan Bacon    secondFactorDevices?: SecondFactorDevice[];
1358d307f52SEvan Bacon    smsAutomaticallySent?: boolean;
1368d307f52SEvan Bacon  }
1378d307f52SEvan Bacon): Promise<void> {
1388d307f52SEvan Bacon  const { secondFactorDevices, smsAutomaticallySent } = metadata;
1398d307f52SEvan Bacon  assert(
1408d307f52SEvan Bacon    secondFactorDevices !== undefined && smsAutomaticallySent !== undefined,
1418d307f52SEvan Bacon    `Malformed OTP error metadata: ${metadata}`
1428d307f52SEvan Bacon  );
1438d307f52SEvan Bacon
1448d307f52SEvan Bacon  const primaryDevice = secondFactorDevices.find((device) => device.is_primary);
1458d307f52SEvan Bacon  let otp: string | null = null;
1468d307f52SEvan Bacon
1478d307f52SEvan Bacon  if (smsAutomaticallySent) {
1488d307f52SEvan Bacon    assert(primaryDevice, 'OTP should only automatically be sent when there is a primary device');
1498d307f52SEvan Bacon    Log.log(
1508d307f52SEvan Bacon      `One-time password was sent to the phone number ending in ${primaryDevice.sms_phone_number}.`
1518d307f52SEvan Bacon    );
1528d307f52SEvan Bacon    otp = await promptForOTPAsync('menu');
1538d307f52SEvan Bacon  }
1548d307f52SEvan Bacon
1558d307f52SEvan Bacon  if (primaryDevice?.method === UserSecondFactorDeviceMethod.AUTHENTICATOR) {
1568d307f52SEvan Bacon    Log.log('One-time password from authenticator required.');
1578d307f52SEvan Bacon    otp = await promptForOTPAsync('menu');
1588d307f52SEvan Bacon  }
1598d307f52SEvan Bacon
1608d307f52SEvan Bacon  // user bailed on case 1 or 2, wants to move to case 3
1618d307f52SEvan Bacon  if (!otp) {
1628d307f52SEvan Bacon    otp = await promptForBackupOTPAsync(username, password, secondFactorDevices);
1638d307f52SEvan Bacon  }
1648d307f52SEvan Bacon
1658d307f52SEvan Bacon  if (!otp) {
1668d307f52SEvan Bacon    throw new AbortCommandError();
1678d307f52SEvan Bacon  }
1688d307f52SEvan Bacon
1698d307f52SEvan Bacon  await loginAsync({
1708d307f52SEvan Bacon    username,
1718d307f52SEvan Bacon    password,
1728d307f52SEvan Bacon    otp,
1738d307f52SEvan Bacon  });
1748d307f52SEvan Bacon}
175