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