packages/fxa-auth-client/lib/client.ts (2,088 lines of code) (raw):

/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as crypto from './crypto'; import { Credentials } from './crypto'; import * as hawk from './hawk'; import { SaltVersion, createSaltV2 } from './salt'; import * as Sentry from '@sentry/browser'; import { MetricsContext } from '@fxa/shared/metrics/glean'; enum ERRORS { INVALID_TIMESTAMP = 111, INCORRECT_EMAIL_CASE = 120, } enum tokenType { sessionToken = 'sessionToken', passwordForgotToken = 'passwordForgotToken', keyFetchToken = 'keyFetchToken', accountResetToken = 'accountResetToken', passwordChangeToken = 'passwordChangeToken', } export enum AUTH_PROVIDER { GOOGLE = 'google', APPLE = 'apple', } export type BoolString = 'true' | 'false' | 'yes' | 'no'; export type CredentialsV1 = Credentials; export type CredentialsV2 = Credentials & { clientSalt: string; }; export type CredentialSet = { upgradeNeeded: boolean; v1: CredentialsV1; v2?: CredentialsV2; }; export type CredentialStatus = { upgradeNeeded: boolean; currentVersion?: 'v1' | 'v2'; clientSalt?: string; }; export type SignUpOptions = { keys?: boolean; service?: string; redirectTo?: string; preVerified?: BoolString; resume?: string; lang?: string; style?: string; verificationMethod?: string; metricsContext?: MetricsContext; }; export type SignedUpAccountData = { uid: hexstring; sessionToken: hexstring; keyFetchToken?: hexstring; authAt: number; verificationMethod?: string; unwrapBKey?: hexstring; }; export type SignInOptions = { keys?: boolean; skipCaseError?: boolean; service?: string; reason?: string; redirectTo?: string; resume?: string; originalLoginEmail?: string; verificationMethod?: string; unblockCode?: string; metricsContext?: MetricsContext; postPasswordUpgrade?: boolean; skipPasswordUpgrade?: boolean; }; export type SignedInAccountData = { uid: hexstring; sessionToken: hexstring; verified: boolean; authAt: number; metricsEnabled: boolean; keyFetchToken?: hexstring; verificationMethod?: string; verificationReason?: string; unwrapBKey?: hexstring; }; export type PasswordChangePayload = { authPW: string; wrapKb: string; sessionToken?: string; wrapKbVersion2?: string; authPWVersion2?: string; clientSalt?: string; }; export type SessionReauthOptions = SignInOptions; export type SessionReauthedAccountData = Omit< SignedInAccountData, 'sessionToken' >; export type AuthServerError = Error & { error?: string; errno?: number; message?: string; code?: number; retryAfter?: number; retryAfterLocalized?: string; }; export type VerificationMethod = | 'email' | 'email-otp' | 'email-2fa' | 'email-captcha' | 'totp-2fa'; function createHeaders( headers?: Headers | undefined, options?: Record<string, any> & { lang?: string } ) { if (headers === undefined) { headers = new Headers(); } if (options?.lang) { headers.set('Accept-Language', options.lang); } return headers; } function pathWithKeys(path: string, keys?: boolean) { return `${path}${keys ? '?keys=true' : ''}`; } async function fetchOrTimeout( input: RequestInfo, init: RequestInit = {}, timeout: number ) { let id = 0; if (typeof AbortController !== 'undefined') { const aborter = new AbortController(); init.signal = aborter.signal; id = setTimeout((() => aborter.abort()) as TimerHandler, timeout); } try { return await fetch(input, init); } finally { if (id) { clearTimeout(id); } } } function cleanStringify(value: any) { // remove keys with null values return JSON.stringify(value, (_, v) => (v == null ? undefined : v)); } export class AuthClientError extends Error { code: number; errno: number; error: string; constructor(error: string, message: string, errno: number, code: number) { super(message); this.code = code; this.errno = errno; this.error = error; } } export type AuthClientOptions = { timeout?: number; keyStretchVersion?: SaltVersion; }; export default class AuthClient { static VERSION = 'v1'; private uri: string; private localtimeOffsetMsec: number; private timeout: number; private keyStretchVersion: SaltVersion; private requireHeaders: boolean; constructor( authServerUri: string, options: { timeout?: number; keyStretchVersion?: SaltVersion; requireHeaders?: boolean; } = {} ) { if (new RegExp(`/${AuthClient.VERSION}$`).test(authServerUri)) { this.uri = authServerUri; } else { this.uri = `${authServerUri}/${AuthClient.VERSION}`; } this.keyStretchVersion = options.keyStretchVersion || 1; this.localtimeOffsetMsec = 0; this.timeout = options.timeout || 30000; this.requireHeaders = options.requireHeaders === true; } static async create(authServerUri: string, options?: AuthClientOptions) { if (typeof TextEncoder === 'undefined') { await import( // @ts-ignore /* webpackChunkName: "fast-text-encoding" */ 'fast-text-encoding' ); } await crypto.checkWebCrypto(); return new AuthClient(authServerUri, options); } private url(path: string) { return `${this.uri}${path}`; } private async request( method: string, path: string, payload: object | null, extraHeaders: Headers | undefined ) { if (extraHeaders === undefined) { if (this.requireHeaders) { throw new Error('extraHeaders missing!'); } else { extraHeaders = new Headers(); } } extraHeaders.set('Content-Type', 'application/json'); const response = await fetchOrTimeout( this.url(path), { method, headers: extraHeaders, body: cleanStringify(payload), }, this.timeout ); let result: any = await response.text(); try { result = JSON.parse(result); } catch (e) {} if (result.errno) { throw result; } if (!response.ok) { throw new AuthClientError('Unknown error', result, 999, response.status); } return result; } private async hawkRequest( method: string, path: string, token: hexstring, kind: tokenType, payload: object | null, extraHeaders: Headers | undefined ) { if (extraHeaders === undefined) { if (this.requireHeaders) { throw new Error('extraHeaders missing!'); } else { extraHeaders = new Headers(); } } const makeHeaders = async () => { const headers = await hawk.header(method, this.url(path), token, kind, { payload: cleanStringify(payload), contentType: 'application/json', localtimeOffsetMsec: this.localtimeOffsetMsec, }); if (extraHeaders) { for (const [name, value] of extraHeaders) { headers.set(name, value); } } return headers; }; try { return await this.request(method, path, payload, await makeHeaders()); } catch (e: any) { if (e.errno === ERRORS.INVALID_TIMESTAMP) { const serverTime = e.serverTime * 1000 || Date.now(); this.localtimeOffsetMsec = serverTime - Date.now(); return this.request(method, path, payload, await makeHeaders()); } throw e; } } private async sessionGet( path: string, sessionToken: hexstring, headers?: Headers ) { return this.hawkRequest( 'GET', path, sessionToken, tokenType.sessionToken, null, headers ); } private async sessionPost( path: string, sessionToken: hexstring, payload: object, headers?: Headers ) { return this.hawkRequest( 'POST', path, sessionToken, tokenType.sessionToken, payload, headers ); } private async sessionPut( path: string, sessionToken: hexstring, payload: object, headers?: Headers ) { return this.hawkRequest( 'PUT', path, sessionToken, tokenType.sessionToken, payload, headers ); } private async sessionDelete( path: string, sessionToken: hexstring, payload: object, headers?: Headers ) { return this.hawkRequest( 'DELETE', path, sessionToken, tokenType.sessionToken, payload, headers ); } /** * Allows us to toggle the key stretch version. * @param version */ setKeyStretchVersion(version: 1 | 2) { this.keyStretchVersion = version; } /** * Used for sign up on clients with direct access to the plaintext password. */ async signUp( email: string, password: string, options: SignUpOptions = {}, headers?: Headers ): Promise<SignedUpAccountData> { const credentialsV1 = await crypto.getCredentials(email, password); let credentialsV2 = undefined; if (this.keyStretchVersion === 2) { const clientSalt = await createSaltV2(); credentialsV2 = await crypto.getCredentialsV2({ password, clientSalt }); } const v2Payload = await this.getPayloadV2({ v1: credentialsV1, v2: credentialsV2, }); const accountData = (await this.signUpWithAuthPW( email, credentialsV1.authPW, v2Payload, options, createHeaders(headers, options) )) as SignedUpAccountData; if (options?.keys) { if (credentialsV2) { accountData.unwrapBKey = credentialsV2.unwrapBKey; } else { accountData.unwrapBKey = credentialsV1.unwrapBKey; } } return accountData; } /** * This function is intended for a service that will proxy the sign-up * request. When signing up from a client with access to the plaintext * password, use `signUp` above. */ async signUpWithAuthPW( email: string, authPW: string, v2: | { wrapKb: string; authPWVersion2: string; wrapKbVersion2: string; clientSalt: string; } | {}, options: SignUpOptions, headers?: Headers ): Promise<Omit<SignedUpAccountData, 'unwrapBKey'>> { const payloadOptions = ({ keys, lang, ...rest }: SignUpOptions) => rest; const payload = { email, authPW, ...v2, ...payloadOptions(options), }; const accountData = await this.request( 'POST', pathWithKeys('/account/create', options.keys), payload, createHeaders(headers, options) ); if (v2) { if (accountData.keyFetchTokenVersion2) { accountData.keyFetchToken = accountData.keyFetchTokenVersion2; delete accountData.keyFetchTokenVersion2; } } return accountData; } /** * Used for authentication on clients with direct access to the plaintext * password. */ async signIn( email: string, password: string, options: SignInOptions = {}, headers?: Headers ): Promise<SignedInAccountData> { let credentials = await this.getCredentialSet({ email, password }, headers); try { let accountData: SignedInAccountData; if (this.keyStretchVersion === 2) { if (credentials.upgradeNeeded) { // To do the password upgrade we first sign the user in with V1 creds, // then do a password change to upgrade to V2. this.keyStretchVersion = 1; accountData = await this.signInWithAuthPW( email, credentials.v1.authPW, options, createHeaders(headers, options) ); try { if ( accountData.sessionToken && options.skipPasswordUpgrade !== true ) { this.keyStretchVersion = 2; await this.passwordChange( email, password, password, accountData.sessionToken, options, headers ); } } catch (err) { Sentry.captureMessage( 'Failure to complete v2 key stretch upgrade.' ); } } else if (credentials.v2) { // Already using V2! Just sign in. accountData = await this.signInWithAuthPW( email, credentials.v2.authPW, options, createHeaders(headers, options) ); } else { throw new Error( 'Invalid state. V2 credentials not provided and no upgraded needed.' ); } } else { accountData = await this.signInWithAuthPW( email, credentials.v1.authPW, options, createHeaders(headers, options) ); } // Relay unwrapBKeys if (options.keys) { if (credentials.v2) { accountData.unwrapBKey = credentials.v2.unwrapBKey; } else { accountData.unwrapBKey = credentials.v1.unwrapBKey; } } return accountData; } catch (error: any) { if ( error && error.email && error.errno === ERRORS.INCORRECT_EMAIL_CASE && !options.skipCaseError ) { options.skipCaseError = true; options.originalLoginEmail = email; return this.signIn( error.email, password, options, createHeaders(headers, options) ); } else { throw error; } } } /** * This function is intended for a service that will proxy the authentication * request. When authenticating from a client with access to the plaintext * password, use `signIn` above, which has additional error handling. */ async signInWithAuthPW( email: string, authPW: string, options: SignInOptions = {}, headers?: Headers ): Promise<Omit<SignedInAccountData, 'unwrapBKey'>> { const payloadOptions = ({ keys, ...rest }: any) => rest; const payload = { email, authPW, ...payloadOptions(options), }; const accountData = await this.request( 'POST', pathWithKeys('/account/login', options.keys), payload, createHeaders(headers, options) ); if (accountData.keyFetchTokenVersion2) { accountData.keyFetchToken = accountData.keyFetchTokenVersion2; delete accountData.keyFetchTokenVersion2; } return accountData; } async verifyCode( uid: hexstring, code: string, options: { service?: string; reminder?: string; type?: string; marketingOptIn?: boolean; newsletters?: string[]; style?: string; } = {}, headers?: Headers ) { return this.request( 'POST', '/recovery_email/verify_code', { uid, code, ...options, }, createHeaders(headers, options) ); } async recoveryEmailStatus(sessionToken: hexstring, headers?: Headers) { return this.sessionGet('/recovery_email/status', sessionToken, headers); } async recoveryEmailResendCode( sessionToken: hexstring, options: { email?: string; service?: string; redirectTo?: string; resume?: string; type?: string; lang?: string; } = {}, headers?: Headers ) { const payloadOptions = ({ lang, ...rest }: any) => rest; return this.sessionPost( '/recovery_email/resend_code', sessionToken, payloadOptions(options), createHeaders(headers, options) ); } async passwordForgotSendOtp( email: string, options: { service?: string; metricsContext?: MetricsContext; } = {}, headers?: Headers ) { const payload = { email, ...options, }; return this.request( 'POST', '/password/forgot/send_otp', payload, createHeaders(headers, options) ); } async passwordForgotVerifyOtp( email: string, code: string, options: { metricsContext?: MetricsContext; } = {}, headers?: Headers ) { const payload = { email, code, ...options, }; return this.request( 'POST', '/password/forgot/verify_otp', payload, createHeaders(headers, options) ); } async passwordForgotSendCode( email: string, options: { service?: string; redirectTo?: string; resume?: string; lang?: string; metricsContext?: MetricsContext; } = {}, headers?: Headers ) { const payloadOptions = ({ lang, ...rest }: any) => rest; const payload = { email, ...payloadOptions(options), }; return this.request( 'POST', '/password/forgot/send_code', payload, createHeaders(headers, options) ); } async passwordForgotResendCode( email: string, passwordForgotToken: hexstring, options: { service?: string; redirectTo?: string; resume?: string; lang?: string; } = {}, headers?: Headers ) { const payloadOptions = ({ lang, ...rest }: any) => rest; const payload = { email, ...payloadOptions(options), }; return this.hawkRequest( 'POST', '/password/forgot/resend_code', passwordForgotToken, tokenType.passwordForgotToken, payload, createHeaders(headers, options) ); } async passwordForgotVerifyCode( code: string, passwordForgotToken: hexstring, options: { accountResetWithRecoveryKey?: boolean; includeRecoveryKeyPrompt?: boolean; } = {}, headers?: Headers ) { const payload = { code, ...options, }; return this.hawkRequest( 'POST', '/password/forgot/verify_code', passwordForgotToken, tokenType.passwordForgotToken, payload, headers ); } async passwordForgotStatus(passwordForgotToken: string, headers?: Headers) { return this.hawkRequest( 'GET', '/password/forgot/status', passwordForgotToken, tokenType.passwordForgotToken, null, headers ); } async passwordForgotRecoveryKeyStatus( passwordForgotToken: hexstring, headers?: Headers ) { return this.hawkRequest( 'POST', '/recoveryKey/exists', passwordForgotToken, tokenType.passwordForgotToken, null, headers ); } // TODO: Once password reset react is 100% and stable in production // we can remove this. async accountReset( email: string, newPassword: string, accountResetToken: hexstring, options: { keys?: boolean; sessionToken?: boolean; } = {}, headers?: Headers ) { const credentials = await this.getCredentialSet( { email, password: newPassword, }, headers ); // Important! This does not take kB, so the encrypted data will become // inaccessible after this operation. A new kB will be created! let v2Payload = await this.getPayloadV2(credentials); const payloadOptions = ({ keys, ...rest }: any) => rest; const payload = { authPW: credentials.v1.authPW, ...v2Payload, ...payloadOptions(options), }; const accountData = await this.hawkRequest( 'POST', pathWithKeys('/account/reset', options.keys), accountResetToken, tokenType.accountResetToken, payload, headers ); if (options.keys && accountData.keyFetchToken) { accountData.unwrapBKey = credentials.v1.unwrapBKey; accountData.unwrapBKeyVersion2 = credentials.v2?.unwrapBKey; } return accountData; } async accountResetAuthPW( authPW: string, accountResetToken: hexstring, v2Payload: | { wrapKb: string; authPWVersion2: string; wrapKbVersion2: string; clientSalt: string; } | {}, options: { // This option won't work in gql keys?: boolean; sessionToken?: boolean; } = {}, headers?: Headers ) { const payloadOptions = ({ keys, ...rest }: any) => rest; const payload = { authPW, ...v2Payload, ...payloadOptions(options), }; return await this.hawkRequest( 'POST', pathWithKeys('/account/reset', options.keys), accountResetToken, tokenType.accountResetToken, payload, headers ); } async finishSetup( token: string, email: string, newPassword: string, headers?: Headers ): Promise<{ uid: hexstring; sessionToken: hexstring; verified: boolean; }> { const credentials = await this.getCredentialSet( { email, password: newPassword, }, headers ); const v2Payload = await this.getPayloadV2(credentials); return this.finishSetupWithAuthPW( token, credentials.v1.authPW, v2Payload, headers ); } /** * This function is intended for a service that will proxy the finish setup * (setting a password of a stub account) request. When setting a password * from a client with access to the plaintext password, use `finishSetup` * above. */ async finishSetupWithAuthPW( token: string, authPW: string, v2Payload: | { wrapKb: string; authPWVersion2: string; wrapKbVersion2: string; clientSalt: string; } | {}, headers?: Headers ) { const payload = { token, authPW, ...v2Payload, }; return await this.request( 'POST', '/account/finish_setup', payload, headers ); } async verifyAccountThirdParty( code: string, provider: AUTH_PROVIDER = AUTH_PROVIDER.GOOGLE, service: string | undefined, metricsContext: MetricsContext | undefined, headers?: Headers ): Promise<{ uid: hexstring; sessionToken: hexstring; providerUid: hexstring; email: string; verificationMethod?: string; }> { metricsContext = metricsContext || {}; const payload = { code, provider, service, metricsContext, }; return await this.request( 'POST', '/linked_account/login', payload, headers ); } async unlinkThirdParty( sessionToken: hexstring, providerId: number, headers?: Headers ): Promise<{ success: boolean }> { let provider: AUTH_PROVIDER; switch (providerId) { case 2: { provider = AUTH_PROVIDER.APPLE; break; } default: { provider = AUTH_PROVIDER.GOOGLE; } } return await this.sessionPost( '/linked_account/unlink', sessionToken, { provider, }, headers ); } async accountKeys( keyFetchToken: hexstring, unwrapBKey: hexstring, headers?: Headers ): Promise<{ kA: hexstring; kB: hexstring; }> { const credentials = await hawk.deriveHawkCredentials( keyFetchToken, 'keyFetchToken' ); const keyData = await this.hawkRequest( 'GET', '/account/keys', keyFetchToken, tokenType.keyFetchToken, null, headers ); const keys = await crypto.unbundleKeyFetchResponse( credentials.bundleKey, keyData.bundle ); return { kA: keys.kA, kB: crypto.unwrapKB(keys.wrapKB, unwrapBKey), }; } async wrappedAccountKeys(keyFetchToken: hexstring, headers?: Headers) { const credentials = await hawk.deriveHawkCredentials( keyFetchToken, 'keyFetchToken' ); const keyData = await this.hawkRequest( 'GET', '/account/keys', keyFetchToken, tokenType.keyFetchToken, null, headers ); const keys = await crypto.unbundleKeyFetchResponse( credentials.bundleKey, keyData.bundle ); return { kA: keys.kA, wrapKB: keys.wrapKB, }; } async accountDestroy( email: string, password: string, options: { skipCaseError?: boolean; } = {}, sessionToken: hexstring, headers?: Headers ): Promise<any> { const credentials = await crypto.getCredentials(email, password); const payload = { email, authPW: credentials.authPW, }; try { return await this.sessionPost( '/account/destroy', sessionToken, payload, headers ); } catch (error: any) { if ( error && error.email && error.errno === ERRORS.INCORRECT_EMAIL_CASE && !options.skipCaseError ) { options.skipCaseError = true; return this.accountDestroy( error.email, password, options, sessionToken, headers ); } else { throw error; } } } async accountStatus(uid: hexstring, headers?: Headers) { return this.request('GET', `/account/status?uid=${uid}`, null, headers); } async accountStatusByEmail( email: string, options: { thirdPartyAuthStatus?: boolean } = {}, headers?: Headers ) { return this.request( 'POST', '/account/status', { email, ...options }, headers ); } async accountProfile(sessionToken: hexstring, headers?: Headers) { return this.sessionGet('/account/profile', sessionToken, headers); } async account(sessionToken: hexstring, headers?: Headers) { return this.sessionGet('/account', sessionToken, headers); } async sessionDestroy( sessionToken: hexstring, options: { customSessionToken?: string; } = {}, headers?: Headers ) { return this.sessionPost('/session/destroy', sessionToken, options, headers); } async sessionStatus( sessionToken: hexstring, headers?: Headers ): Promise<{ state: 'verified' | 'unverified'; uid: string }> { return this.sessionGet('/session/status', sessionToken, headers); } async sessionVerifyCode( sessionToken: hexstring, code: string, options: { service?: string; scopes?: string[]; marketingOptIn?: boolean; newsletters?: string[]; } = {}, headers?: Headers ): Promise<{}> { return this.sessionPost( '/session/verify_code', sessionToken, { code, ...options, }, headers ); } async sessionResendVerifyCode( sessionToken: hexstring, headers?: Headers ): Promise<{}> { return this.sessionPost('/session/resend_code', sessionToken, {}, headers); } async sessionReauth( sessionToken: hexstring, email: string, password: string, options: SessionReauthOptions = {}, headers?: Headers ): Promise<SessionReauthedAccountData> { const credentials = await crypto.getCredentials(email, password); try { const accountData = await this.sessionReauthWithAuthPW( sessionToken, email, credentials.authPW, options, headers ); if (options.keys) { accountData.unwrapBKey = credentials.unwrapBKey; } return accountData; } catch (error: any) { if ( error && error.email && error.errno === ERRORS.INCORRECT_EMAIL_CASE && !options.skipCaseError ) { options.skipCaseError = true; options.originalLoginEmail = email; return this.sessionReauth( sessionToken, error.email, password, options, headers ); } else { throw error; } } } async sessionReauthWithAuthPW( sessionToken: hexstring, email: string, authPW: string, options: Omit<SessionReauthOptions, 'skipCaseError'> = {}, headers?: Headers ): Promise<SessionReauthedAccountData> { const payloadOptions = ({ keys, ...rest }: any) => rest; const payload = { email, authPW, ...payloadOptions(options), }; const accountData = await this.sessionPost( pathWithKeys('/session/reauth', options.keys), sessionToken, payload, headers ); return accountData; } async passwordChange( email: string, oldPassword: string, newPassword: string, sessionToken: string, options: { keys?: boolean; } = {}, headers?: Headers ): Promise<SignedInAccountData> { const oldCredentials = await this.passwordChangeStart( email, oldPassword, sessionToken, undefined, headers ); const keys = await this.accountKeys( oldCredentials.keyFetchToken, oldCredentials.unwrapBKey, headers ); const newCredentials = await crypto.getCredentials( oldCredentials.email, newPassword ); const wrapKb = crypto.unwrapKB(keys.kB, newCredentials.unwrapBKey); const sessionTokenHex = sessionToken ? (await hawk.deriveHawkCredentials(sessionToken, 'sessionToken')).id : undefined; let payload: PasswordChangePayload = { authPW: newCredentials.authPW, wrapKb, sessionToken: sessionTokenHex, }; let unwrapBKeyVersion2: string | undefined; if (this.keyStretchVersion === 2) { const status = await this.getCredentialStatusV2(email, headers); const clientSalt = status.clientSalt || createSaltV2(); const newCredentialsV2 = await crypto.getCredentialsV2({ password: newPassword, clientSalt: clientSalt, }); // Important! Passing kB, ensures kB remains constant even after password upgrade. const newKeys = await crypto.getKeysV2({ kB: keys.kB, v1: newCredentials, v2: newCredentialsV2, }); if (newKeys.wrapKb !== wrapKb) { throw new Error('Sanity check failed. wrapKb should not drift!'); } unwrapBKeyVersion2 = newCredentialsV2.unwrapBKey; payload = { ...payload, authPWVersion2: newCredentialsV2.authPW, wrapKbVersion2: newKeys.wrapKbVersion2, clientSalt: clientSalt, }; } const accountData = await this.passwordChangeFinish( oldCredentials.passwordChangeToken, payload, options, headers ); if (options.keys && accountData.keyFetchToken) { accountData.unwrapBKey = newCredentials.unwrapBKey; accountData.unwrapBKeyVersion2 = unwrapBKeyVersion2; } return accountData; } public async passwordChangeStartWithAuthPW( email: string, oldAuthPW: string, sessionToken: string, options: { skipCaseError?: boolean; } = {}, headers?: Headers ): Promise<{ email: string; keyFetchToken: hexstring; passwordChangeToken: hexstring; }> { try { const passwordData = await this.sessionPost( '/password/change/start', sessionToken, { email, oldAuthPW: oldAuthPW, }, headers ); return { email: email, keyFetchToken: passwordData.keyFetchToken, passwordChangeToken: passwordData.passwordChangeToken, }; } catch (error: any) { if ( error && error.email && error.errno === ERRORS.INCORRECT_EMAIL_CASE && !options.skipCaseError ) { options.skipCaseError = true; return await this.passwordChangeStartWithAuthPW( error.email, oldAuthPW, sessionToken, options, headers ); } else { throw error; } } } private async passwordChangeStart( email: string, oldPassword: string, sessionToken: string, options: { skipCaseError?: boolean; } = {}, headers?: Headers ): Promise<{ authPW: hexstring; unwrapBKey: hexstring; email: string; keyFetchToken: hexstring; passwordChangeToken: hexstring; }> { const oldCredentials = await crypto.getCredentials(email, oldPassword); try { const passwordData = await this.sessionPost( '/password/change/start', sessionToken, { email, oldAuthPW: oldCredentials.authPW, } ); return { authPW: oldCredentials.authPW, unwrapBKey: oldCredentials.unwrapBKey, email: email, keyFetchToken: passwordData.keyFetchToken, passwordChangeToken: passwordData.passwordChangeToken, }; } catch (error: any) { if ( error && error.email && error.errno === ERRORS.INCORRECT_EMAIL_CASE && !options.skipCaseError ) { options.skipCaseError = true; return await this.passwordChangeStart( error.email, oldPassword, sessionToken, options, headers ); } else { throw error; } } } public async passwordChangeFinish( passwordChangeToken: string, payload: PasswordChangePayload, options: { keys?: boolean }, headers?: Headers ) { const response = await this.hawkRequest( 'POST', pathWithKeys('/password/change/finish', options.keys), passwordChangeToken, tokenType.passwordChangeToken, payload, headers ); return response; } async createPassword( sessionToken: string, email: string, newPassword: string, headers?: Headers ): Promise<{ passwordCreated: number; authPW: string; unwrapBKey: string }> { const { authPW, unwrapBKey } = await crypto.getCredentials( email, newPassword ); const payload = { authPW, }; const passwordCreated = await this.sessionPost( '/password/create', sessionToken, payload, headers ); return { passwordCreated, authPW, unwrapBKey, }; } async getRandomBytes(headers?: Headers) { return this.request('POST', '/get_random_bytes', null, headers); } async deviceRegister( sessionToken: hexstring, name: string, type: string, options: { deviceCallback?: string; devicePublicKey?: string; deviceAuthKey?: string; } = {}, headers?: Headers ) { const payload = { name, type, ...options, }; return this.sessionPost('/account/device', sessionToken, payload, headers); } async deviceUpdate( sessionToken: hexstring, id: string, name: string, options: { deviceCallback?: string; devicePublicKey?: string; deviceAuthKey?: string; } = {}, headers?: Headers ) { const payload = { id, name, ...options, }; return this.sessionPost('/account/device', sessionToken, payload, headers); } async deviceDestroy(sessionToken: hexstring, id: string, headers?: Headers) { return this.sessionPost( '/account/device/destroy', sessionToken, { id }, headers ); } async deviceList(sessionToken: hexstring, headers?: Headers) { return this.sessionGet('/account/devices', sessionToken, headers); } async sessions(sessionToken: hexstring, headers?: Headers) { return this.sessionGet('/account/sessions', sessionToken, headers); } async securityEvents(sessionToken: hexstring, headers?: Headers) { return this.sessionGet('/securityEvents', sessionToken, headers); } async attachedClients(sessionToken: hexstring, headers?: Headers) { return this.sessionGet('/account/attached_clients', sessionToken, headers); } async attachedClientDestroy( sessionToken: hexstring, clientInfo: any, headers?: Headers ) { return this.sessionPost( '/account/attached_client/destroy', sessionToken, { clientId: clientInfo.clientId, deviceId: clientInfo.deviceId, refreshTokenId: clientInfo.refreshTokenId, sessionTokenId: clientInfo.sessionTokenId, }, headers ); } async sendUnblockCode( email: string, options: { metricsContext?: MetricsContext; } = {}, headers?: Headers ) { return this.request( 'POST', '/account/login/send_unblock_code', { email, ...options, }, headers ); } async rejectUnblockCode( uid: hexstring, unblockCode: string, headers?: Headers ) { return this.request( 'POST', '/account/login/reject_unblock_code', { uid, unblockCode, }, headers ); } async consumeSigninCode( code: string, flowId: string, flowBeginTime: number, deviceId: string | undefined, headers?: Headers ) { return this.request( 'POST', '/signinCodes/consume', { code, metricsContext: { deviceId, flowId, flowBeginTime, }, }, headers ); } async createSigninCode(sessionToken: hexstring, headers?: Headers) { return this.sessionPost('/signinCodes', sessionToken, {}, headers); } async createCadReminder(sessionToken: hexstring, headers?: Headers) { return this.sessionPost('/emails/reminders/cad', sessionToken, {}, headers); } async recoveryEmails(sessionToken: hexstring, headers?: Headers) { return this.sessionGet('/recovery_emails', sessionToken, headers); } async recoveryEmailCreate( sessionToken: hexstring, email: string, options: { verificationMethod?: string; } = {}, headers?: Headers ) { return this.sessionPost( '/recovery_email', sessionToken, { email, ...options, }, headers ); } async recoveryEmailDestroy( sessionToken: hexstring, email: string, headers?: Headers ) { return this.sessionPost( '/recovery_email/destroy', sessionToken, { email }, headers ); } async recoveryEmailSetPrimaryEmail( sessionToken: hexstring, email: string, headers?: Headers ) { return this.sessionPost( '/recovery_email/set_primary', sessionToken, { email, }, headers ); } async recoveryEmailSecondaryVerifyCode( sessionToken: hexstring, email: string, code: string, headers?: Headers ): Promise<{}> { return this.sessionPost( '/recovery_email/secondary/verify_code', sessionToken, { email, code }, headers ); } async recoveryEmailSecondaryResendCode( sessionToken: hexstring, email: string, headers?: Headers ) { return this.sessionPost( '/recovery_email/secondary/resend_code', sessionToken, { email }, headers ); } async createTotpToken( sessionToken: hexstring, options: { metricsContext?: MetricsContext; } = {}, headers?: Headers ): Promise<{ qrCodeUrl: string; secret: string; recoveryCodes: string[]; }> { return this.sessionPost('/totp/create', sessionToken, options, headers); } async deleteTotpToken(sessionToken: hexstring, headers?: Headers) { return this.sessionPost('/totp/destroy', sessionToken, {}, headers); } async checkTotpTokenExists( sessionToken: hexstring, headers?: Headers ): Promise<{ exists: boolean; verified: boolean }> { return this.sessionGet('/totp/exists', sessionToken, headers); } async checkTotpTokenExistsWithPasswordForgotToken( token: hexstring, headers?: Headers ): Promise<{ exists: boolean; verified: boolean }> { return this.hawkRequest( 'GET', '/totp/exists', token, tokenType.passwordForgotToken, null, headers ); } async checkTotpTokenCodeWithPasswordForgotToken( token: hexstring, code: string, headers?: Headers ): Promise<{ success: boolean }> { return this.hawkRequest( 'POST', '/totp/verify', token, tokenType.passwordForgotToken, { code }, headers ); } async consumeRecoveryCodeWithPasswordForgotToken( token: hexstring, code: string, headers?: Headers ): Promise<{ success: boolean }> { return this.hawkRequest( 'POST', '/totp/verify/recoveryCode', token, tokenType.passwordForgotToken, { code }, headers ); } async sendLoginPushRequest( sessionToken: hexstring, headers?: Headers ): Promise<void> { return this.sessionPost( '/session/verify/send_push', sessionToken, {}, headers ); } async verifyLoginPushRequest( sessionToken: hexstring, tokenVerificationId: string, code: string, headers?: Headers ): Promise<void> { return this.sessionPost( '/session/verify/verify_push', sessionToken, { tokenVerificationId, code, }, headers ); } async verifyTotpCode( sessionToken: hexstring, code: string, options: { service?: string; } = {}, headers?: Headers ) { return this.sessionPost( '/session/verify/totp', sessionToken, { code, ...options, }, headers ); } async replaceRecoveryCodes( sessionToken: hexstring, headers?: Headers ): Promise<{ recoveryCodes: string[] }> { return this.sessionGet('/recoveryCodes', sessionToken, headers); } async updateRecoveryCodes( sessionToken: hexstring, recoveryCodes: string[], headers?: Headers ): Promise<{ success: boolean }> { return this.sessionPut( '/recoveryCodes', sessionToken, { recoveryCodes }, headers ); } async getRecoveryCodesExist( sessionToken: hexstring, headers?: Headers ): Promise<{ hasBackupCodes?: boolean; count?: number }> { return this.sessionGet('/recoveryCodes/exists', sessionToken, headers); } async consumeRecoveryCode( sessionToken: hexstring, code: string, headers?: Headers ) { return this.sessionPost( '/session/verify/recoveryCode', sessionToken, { code, }, headers ); } async createRecoveryKey( sessionToken: hexstring, recoveryKeyId: string, recoveryData: any, enabled: boolean = true, replaceKey: boolean = false, headers?: Headers ): Promise<{}> { return this.sessionPost( '/recoveryKey', sessionToken, { recoveryKeyId, recoveryData, enabled, replaceKey, }, headers ); } async getRecoveryKey( accountResetToken: hexstring, recoveryKeyId: string, headers?: Headers ) { return this.hawkRequest( 'GET', `/recoveryKey/${recoveryKeyId}`, accountResetToken, tokenType.accountResetToken, null, headers ); } async updateRecoveryKeyHint( sessionToken: hexstring, hint: string, headers?: Headers ): Promise<{}> { return this.sessionPost( '/recoveryKey/hint', sessionToken, { hint, }, headers ); } async resetPasswordWithRecoveryKey( accountResetToken: hexstring, email: string, newPassword: string, recoveryKeyId: string, keys: { kB: string; }, options: { keys?: boolean; sessionToken?: boolean; isFirefoxMobileClient?: boolean; } = {}, headers?: Headers ) { const credentials = await this.getCredentialSet( { email, password: newPassword, }, headers ); const newWrapKb = crypto.unwrapKB(keys.kB, credentials.v1.unwrapBKey); // We have scenario where a user with v1 credentials is trying to do a reset. Go ahead // and give them v2 credentials. if (!credentials.v2) { const clientSalt = createSaltV2(); credentials.v2 = await crypto.getCredentialsV2({ password: newPassword, clientSalt, }); } let v2Payload = await this.getPayloadV2({ ...keys, ...credentials, }); const payload = { ...v2Payload, wrapKb: newWrapKb, authPW: credentials.v1.authPW, sessionToken: options.sessionToken, recoveryKeyId, isFirefoxMobileClient: options.isFirefoxMobileClient, }; const accountData = await this.hawkRequest( 'POST', pathWithKeys('/account/reset', options.keys), accountResetToken, tokenType.accountResetToken, payload, headers ); if (options.keys && accountData.keyFetchToken) { accountData.unwrapBKey = credentials.v1.unwrapBKey; accountData.unwrapBKeyVersion2 = credentials.v2?.unwrapBKey; } return accountData; } async deleteRecoveryKey(sessionToken: hexstring, headers?: Headers) { return this.hawkRequest( 'DELETE', '/recoveryKey', sessionToken, tokenType.sessionToken, {}, headers ); } async recoveryKeyExists( sessionToken: hexstring | undefined, email: string | undefined, headers?: Headers ) { if (sessionToken) { return this.sessionPost( '/recoveryKey/exists', sessionToken, { email }, headers ); } return this.request('POST', '/recoveryKey/exists', { email }, headers); } async verifyRecoveryKey( sessionToken: hexstring, recoveryKeyId: string, headers?: Headers ) { return this.sessionPost( '/recoveryKey/verify', sessionToken, { recoveryKeyId, }, headers ); } async createOAuthCode( sessionToken: hexstring, clientId: string, state: string, options: { access_type?: string; acr_values?: string; keys_jwe?: string; redirect_uri?: string; response_type?: string; scope?: string; code_challenge_method?: string; code_challenge?: string; } = {}, headers?: Headers ) { return this.sessionPost( '/oauth/authorization', sessionToken, { access_type: options.access_type, acr_values: options.acr_values, client_id: clientId, code_challenge: options.code_challenge, code_challenge_method: options.code_challenge_method, keys_jwe: options.keys_jwe, redirect_uri: options.redirect_uri, response_type: options.response_type, scope: options.scope, state, }, headers ); } async createOAuthToken( sessionToken: hexstring, clientId: string, options: { access_type?: string; scope?: string; ttl?: number; } = {}, headers?: Headers ) { return this.sessionPost( '/oauth/token', sessionToken, { grant_type: 'fxa-credentials', access_type: options.access_type, client_id: clientId, scope: options.scope, ttl: options.ttl, }, headers ); } async getOAuthScopedKeyData( sessionToken: hexstring, clientId: string, scope: string, headers?: Headers ) { return this.sessionPost( '/account/scoped-key-data', sessionToken, { client_id: clientId, scope, }, headers ); } async getSubscriptionPlans(headers?: Headers) { return this.request('GET', '/oauth/subscriptions/plans', null, headers); } async getActiveSubscriptions(accessToken: string) { return this.request( 'GET', '/oauth/subscriptions/active', null, new Headers({ authorization: `Bearer ${accessToken}`, }) ); } async createSupportTicket( accessToken: string, supportTicket: { topic: string; subject?: string; message: string; } ) { return this.request( 'POST', '/support/ticket', supportTicket, new Headers({ authorization: `Bearer ${accessToken}`, }) ); } async updateNewsletters( sessionToken: hexstring, newsletters: string[], headers?: Headers ) { return this.sessionPost( '/newsletters', sessionToken, { newsletters, }, headers ); } async verifyIdToken( idToken: string, clientId: string, expiryGracePeriod: number | undefined, headers?: Headers ) { const payload: any = { id_token: idToken, client_id: clientId, }; if (expiryGracePeriod) { payload.expiry_grace_period = expiryGracePeriod; } return this.request('POST', '/oauth/id-token-verify', payload, headers); } async getProductInfo(productId: string, headers?: Headers) { return this.request( 'GET', `/oauth/subscriptions/productname?productId=${productId}`, null, headers ); } async sendPushLoginRequest(sessionToken: string, headers?: Headers) { return this.sessionPost( '/session/verify/send_push', sessionToken, {}, headers ); } /** * Tries to register a recovery phone number * * @param sessionToken The user's current session token * @param phoneNumber The phone number to register. Should be E.164 format * @param headers */ async recoveryPhoneCreate( sessionToken: string, phoneNumber: string, headers?: Headers ): Promise<{ nationalFormat?: string; success: boolean }> { return this.sessionPost( '/recovery_phone/create', sessionToken, { phoneNumber }, headers ); } /** * Checks to see if a recovery phone is available in the user's region. * @param sessionToken The user's current session token * @param headers */ async recoveryPhoneAvailable(sessionToken: string, headers?: Headers) { return this.sessionPost( '/recovery_phone/available', sessionToken, {}, headers ); } /** * Confirms the code sent to the recovery phone when recoveryPhoneCreate was called. * * @param sessionToken The user's current session token * @param code The otp code sent to the user's phone * @param headers */ async recoveryPhoneConfirmSetup( sessionToken: string, code: string, headers?: Headers ): Promise<{ nationalFormat?: string }> { return this.sessionPost( '/recovery_phone/confirm', sessionToken, { code, }, headers ); } /** * Sends a code to the users phone during a signin flow. * * @param sessionToken The user's current session token * @param headers */ async recoveryPhoneSigninSendCode(sessionToken: string, headers?: Headers) { return this.sessionPost( '/recovery_phone/signin/send_code', sessionToken, {}, headers ); } /** * Confirms the code sent to the recovery phone during a sign in flow. * * @param sessionToken The user's current session token * @param code The otp code sent to the user's phone * @param headers */ async recoveryPhoneSigninConfirm( sessionToken: string, code: string, headers?: Headers ) { return this.sessionPost( '/recovery_phone/signin/confirm', sessionToken, { code, }, headers ); } /** * Removes a recovery phone from the user's account * * @param sessionToken The user's current session token * @param headers */ async recoveryPhoneDelete(sessionToken: string, headers?: Headers) { return this.sessionDelete('/recovery_phone', sessionToken, {}, headers); } /** * Gets status of the recovery phone on the users account.\ * @param sessionToken The user's current session token * @param headers * @returns { exists:boolean, phoneNumber: string } */ async recoveryPhoneGet(sessionToken: string, headers?: Headers) { return this.sessionGet('/recovery_phone', sessionToken, headers); } protected async getPayloadV2({ kB, v1, v2, }: { kB?: string; v1: { authPW: string; unwrapBKey: string; }; v2?: { clientSalt: string; authPW: string; unwrapBKey: string; }; }) { if (this.keyStretchVersion === 2) { if (!v2) { throw new Error('Using key stretch version 2 requires v2 credentials.'); } // By passing in kB, we ensure wrapKbVersion2 will produce the same value const { wrapKb, wrapKbVersion2 } = await crypto.getKeysV2({ kB, v1, v2 }); // Normalize response for rest call return { wrapKb, wrapKbVersion2, authPWVersion2: v2.authPW, clientSalt: v2.clientSalt, }; } return {}; } protected async getCredentialSet( { email, password, }: { email: string; password: string; }, headers?: Headers ): Promise<CredentialSet> { const credentialsV1 = await crypto.getCredentials(email, password); if (this.keyStretchVersion === 2) { // Try to determine V2 credentials const status = await this.getCredentialStatusV2(email, headers); // Signal an upgrade is required. Status doesn't exist, or an internal state // indicates an upgrade is needed. if ( status != null && status.clientSalt != null && status.upgradeNeeded === false ) { const clientSalt = status.clientSalt; const credentialsV2 = await crypto.getCredentialsV2({ password, clientSalt, }); // V2 credentials exist and don't need upgrading. return { upgradeNeeded: false, v1: credentialsV1, v2: credentialsV2, }; } else { // V2 credentials either don't exist, or require an upgrade. return { upgradeNeeded: true, v1: credentialsV1, }; } } // In V1 mode, no upgraded needed... return { upgradeNeeded: false, v1: credentialsV1, }; } public async getCredentialStatusV2( email: string, headers?: Headers ): Promise<CredentialStatus> { try { const result = await this.request( 'POST', '/account/credentials/status', { email, }, headers ); return result; } catch (error) { if (error.errno === 102) { return { upgradeNeeded: false, }; } throw error; } } }