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