xref: /expo/packages/@expo/cli/src/api/user/otp.ts (revision 8a424beb)
1import assert from 'assert';
2import chalk from 'chalk';
3
4import { loginAsync } from './user';
5import * as Log from '../../log';
6import { AbortCommandError, CommandError } from '../../utils/errors';
7import { learnMore } from '../../utils/link';
8import { promptAsync, selectAsync } from '../../utils/prompts';
9import { fetchAsync } from '../rest/client';
10
11export enum UserSecondFactorDeviceMethod {
12  AUTHENTICATOR = 'authenticator',
13  SMS = 'sms',
14}
15
16/** Device properties for 2FA */
17export type SecondFactorDevice = {
18  id: string;
19  method: UserSecondFactorDeviceMethod;
20  sms_phone_number: string | null;
21  is_primary: boolean;
22};
23
24const nonInteractiveHelp = `Use the EXPO_TOKEN environment variable to authenticate in CI (${learnMore(
25  'https://docs.expo.dev/accounts/programmatic-access/'
26)})`;
27
28/**
29 * Prompt for an OTP with the option to cancel the question by answering empty (pressing return key).
30 */
31async function promptForOTPAsync(cancelBehavior: 'cancel' | 'menu'): Promise<string | null> {
32  const enterMessage =
33    cancelBehavior === 'cancel'
34      ? chalk`press {bold Enter} to cancel`
35      : chalk`press {bold Enter} for more options`;
36  const { otp } = await promptAsync(
37    {
38      type: 'text',
39      name: 'otp',
40      message: `One-time password or backup code (${enterMessage}):`,
41    },
42    { nonInteractiveHelp }
43  );
44  return otp || null;
45}
46
47/**
48 * Prompt for user to choose a backup OTP method. If selected method is SMS, a request
49 * for a new OTP will be sent to that method. Then, prompt for the OTP, and retry the user login.
50 */
51async function promptForBackupOTPAsync(
52  username: string,
53  password: string,
54  secondFactorDevices: SecondFactorDevice[]
55): Promise<string | null> {
56  const nonPrimarySecondFactorDevices = secondFactorDevices.filter((device) => !device.is_primary);
57
58  if (nonPrimarySecondFactorDevices.length === 0) {
59    throw new CommandError(
60      'No other second-factor devices set up. Ensure you have set up and certified a backup device.'
61    );
62  }
63
64  const hasAuthenticatorSecondFactorDevice = nonPrimarySecondFactorDevices.find(
65    (device) => device.method === UserSecondFactorDeviceMethod.AUTHENTICATOR
66  );
67
68  const smsNonPrimarySecondFactorDevices = nonPrimarySecondFactorDevices.filter(
69    (device) => device.method === UserSecondFactorDeviceMethod.SMS
70  );
71
72  const authenticatorChoiceSentinel = -1;
73  const cancelChoiceSentinel = -2;
74
75  const deviceChoices = smsNonPrimarySecondFactorDevices.map((device, idx) => ({
76    title: device.sms_phone_number!,
77    value: idx,
78  }));
79
80  if (hasAuthenticatorSecondFactorDevice) {
81    deviceChoices.push({
82      title: 'Authenticator',
83      value: authenticatorChoiceSentinel,
84    });
85  }
86
87  deviceChoices.push({
88    title: 'Cancel',
89    value: cancelChoiceSentinel,
90  });
91
92  const selectedValue = await selectAsync('Select a second-factor device:', deviceChoices, {
93    nonInteractiveHelp,
94  });
95  if (selectedValue === cancelChoiceSentinel) {
96    return null;
97  } else if (selectedValue === authenticatorChoiceSentinel) {
98    return await promptForOTPAsync('cancel');
99  }
100
101  const device = smsNonPrimarySecondFactorDevices[selectedValue];
102
103  await fetchAsync('auth/send-sms-otp', {
104    method: 'POST',
105    body: JSON.stringify({
106      username,
107      password,
108      secondFactorDeviceID: device.id,
109    }),
110  });
111
112  return await promptForOTPAsync('cancel');
113}
114
115/**
116 * Handle the special case error indicating that a second-factor is required for
117 * authentication.
118 *
119 * There are three cases we need to handle:
120 * 1. User's primary second-factor device was SMS, OTP was automatically sent by the server to that
121 *    device already. In this case we should just prompt for the SMS OTP (or backup code), which the
122 *    user should be receiving shortly. We should give the user a way to cancel and the prompt and move
123 *    to case 3 below.
124 * 2. User's primary second-factor device is authenticator. In this case we should prompt for authenticator
125 *    OTP (or backup code) and also give the user a way to cancel and move to case 3 below.
126 * 3. User doesn't have a primary device or doesn't have access to their primary device. In this case
127 *    we should show a picker of the SMS devices that they can have an OTP code sent to, and when
128 *    the user picks one we show a prompt() for the sent OTP.
129 */
130export async function retryUsernamePasswordAuthWithOTPAsync(
131  username: string,
132  password: string,
133  metadata: {
134    secondFactorDevices?: SecondFactorDevice[];
135    smsAutomaticallySent?: boolean;
136  }
137): Promise<void> {
138  const { secondFactorDevices, smsAutomaticallySent } = metadata;
139  assert(
140    secondFactorDevices !== undefined && smsAutomaticallySent !== undefined,
141    `Malformed OTP error metadata: ${metadata}`
142  );
143
144  const primaryDevice = secondFactorDevices.find((device) => device.is_primary);
145  let otp: string | null = null;
146
147  if (smsAutomaticallySent) {
148    assert(primaryDevice, 'OTP should only automatically be sent when there is a primary device');
149    Log.log(
150      `One-time password was sent to the phone number ending in ${primaryDevice.sms_phone_number}.`
151    );
152    otp = await promptForOTPAsync('menu');
153  }
154
155  if (primaryDevice?.method === UserSecondFactorDeviceMethod.AUTHENTICATOR) {
156    Log.log('One-time password from authenticator required.');
157    otp = await promptForOTPAsync('menu');
158  }
159
160  // user bailed on case 1 or 2, wants to move to case 3
161  if (!otp) {
162    otp = await promptForBackupOTPAsync(username, password, secondFactorDevices);
163  }
164
165  if (!otp) {
166    throw new AbortCommandError();
167  }
168
169  await loginAsync({
170    username,
171    password,
172    otp,
173  });
174}
175