diff --git a/packages/auth-compat/test/helpers/helpers.ts b/packages/auth-compat/test/helpers/helpers.ts index 18e0cc2c571..1b1d6ed6aff 100644 --- a/packages/auth-compat/test/helpers/helpers.ts +++ b/packages/auth-compat/test/helpers/helpers.ts @@ -19,19 +19,27 @@ import * as sinon from 'sinon'; import firebase from '@firebase/app-compat'; import '../..'; -import * as exp from '@firebase/auth/internal'; import { getAppConfig, getEmulatorUrl } from '../../../auth/test/helpers/integration/settings'; import { resetEmulator } from '../../../auth/test/helpers/integration/emulator_rest_helpers'; -export { createNewTenant } from '../../../auth/test/helpers/integration/emulator_rest_helpers'; +export { + createNewTenant, + getOobCodes, + getPhoneVerificationCodes, + OobCodeSession +} from '../../../auth/test/helpers/integration/emulator_rest_helpers'; +export { + randomEmail, + randomPhone +} from '../../../auth/test/helpers/integration/helpers'; export function initializeTestInstance(): void { firebase.initializeApp(getAppConfig()); - const stub = stubConsoleToSilenceEmulatorWarnings(); - firebase.auth().useEmulator(getEmulatorUrl()!); - stub.restore(); + firebase.auth().useEmulator(getEmulatorUrl()!, { + disableWarnings: true + }); } export async function cleanUpTestInstance(): Promise { @@ -41,20 +49,3 @@ export async function cleanUpTestInstance(): Promise { } await resetEmulator(); } - -export function randomEmail(): string { - return `${exp._generateEventId('test.email.')}@integration.test`; -} - -function stubConsoleToSilenceEmulatorWarnings(): sinon.SinonStub { - const originalConsoleInfo = console.info.bind(console); - return sinon.stub(console, 'info').callsFake((...args: unknown[]) => { - if ( - !JSON.stringify(args[0]).includes( - 'WARNING: You are using the Auth Emulator' - ) - ) { - originalConsoleInfo(...args); - } - }); -} diff --git a/packages/auth-compat/test/integration/flows/mfa.test.ts b/packages/auth-compat/test/integration/flows/mfa.test.ts new file mode 100644 index 00000000000..f8a657ad259 --- /dev/null +++ b/packages/auth-compat/test/integration/flows/mfa.test.ts @@ -0,0 +1,455 @@ +/** + * @license + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import firebase from '@firebase/app-compat'; +import { + AuthError, + ConfirmationResult, + MultiFactorError, + MultiFactorResolver, + RecaptchaVerifier, + User +} from '@firebase/auth-types'; +import { assert, expect, use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import { + createNewTenant, + initializeTestInstance, + cleanUpTestInstance, + randomEmail, + randomPhone, + getOobCodes, + OobCodeSession, + getPhoneVerificationCodes +} from '../../helpers/helpers'; + +use(chaiAsPromised); + +describe('Integration test: multi-factor', () => { + let email: string; + let phone: string; + let fakeRecaptchaContainer: HTMLElement; + let verifier: RecaptchaVerifier; + + beforeEach(() => { + initializeTestInstance(); + email = randomEmail(); + phone = randomPhone(); + fakeRecaptchaContainer = document.createElement('div'); + document.body.appendChild(fakeRecaptchaContainer); + verifier = new firebase.auth.RecaptchaVerifier( + fakeRecaptchaContainer, + undefined as any + ); + }); + + /** If in the emulator, search for the code in the API */ + async function phoneCode( + crOrId: ConfirmationResult | string, + tenantId?: string + ): Promise { + const codes = await getPhoneVerificationCodes(tenantId); + const vid = typeof crOrId === 'string' ? crOrId : crOrId.verificationId; + return codes[vid].code; + } + + afterEach(async () => { + await cleanUpTestInstance(); + document.body.removeChild(fakeRecaptchaContainer); + }); + + function resetVerifier(): void { + verifier.clear(); + verifier = new firebase.auth.RecaptchaVerifier( + fakeRecaptchaContainer, + undefined as any + ); + } + + async function enroll( + user: User, + phoneNumber: string, + displayName: string + ): Promise { + const mfaUser = user.multiFactor; + const mfaSession = await mfaUser.getSession(); + + // Send verification code. + const phoneAuthProvider = new firebase.auth.PhoneAuthProvider(); + const phoneInfoOptions = { + phoneNumber, + session: mfaSession + }; + const verificationId = await phoneAuthProvider.verifyPhoneNumber( + phoneInfoOptions, + verifier + ); + const phoneAuthCredential = firebase.auth.PhoneAuthProvider.credential( + verificationId, + await phoneCode(verificationId, user.tenantId || undefined) + ); + const multiFactorAssertion = + firebase.auth.PhoneMultiFactorGenerator.assertion(phoneAuthCredential); + await mfaUser.enroll(multiFactorAssertion, displayName); + } + + context('with email/password', () => { + const password = 'password'; + let user: User; + + beforeEach(async () => { + user = ( + await firebase.auth().createUserWithEmailAndPassword(email, password) + ).user!; + }); + + async function oobCode( + toEmail: string, + tenant?: string + ): Promise { + const codes = await getOobCodes(tenant); + return codes.reverse().find(({ email }) => email === toEmail)!; + } + + async function verify() { + await user.sendEmailVerification(); + // Apply the email verification code + await firebase + .auth() + .applyActionCode( + ( + await oobCode(email, user.tenantId || undefined) + ).oobCode + ); + await user.reload(); + } + + it('allows enrollment, sign in, and unenrollment', async () => { + await verify(); + + await enroll(user, phone, 'Display name'); + + // Log out and try logging in + await firebase.auth().signOut(); + let resolver!: MultiFactorResolver; + try { + await firebase.auth().signInWithEmailAndPassword(email, password); + // Previous line should throw an error. + assert.fail('Multi factor check not triggered'); + } catch (e) { + if ((e as AuthError).code == 'auth/multi-factor-auth-required') { + resolver = (e as MultiFactorError).resolver; + } else { + throw e; + } + } + + // Check the resolver hints and reverify + expect(resolver.hints.length).to.eq(1); + expect(resolver.hints[0].displayName).to.eq('Display name'); + resetVerifier(); + const verificationId = + await new firebase.auth.PhoneAuthProvider().verifyPhoneNumber( + { + multiFactorUid: resolver.hints[0].uid, + session: resolver.session + }, + verifier + ); + + const phoneAuthCredential = firebase.auth.PhoneAuthProvider.credential( + verificationId, + await phoneCode(verificationId) + ); + const multiFactorAssertion = + firebase.auth.PhoneMultiFactorGenerator.assertion(phoneAuthCredential); + const userCredential = await resolver.resolveSignIn(multiFactorAssertion); + expect(userCredential.operationType).to.eq('signIn'); + expect(userCredential.user).to.eq(firebase.auth().currentUser); + + // Now unenroll and try again + const mfaUser = firebase.auth().currentUser!.multiFactor; + await mfaUser.unenroll(resolver.hints[0].uid); + + // Sign in should happen without MFA + user = (await firebase.auth().signInWithEmailAndPassword(email, password)) + .user!; + expect(user).to.eq(firebase.auth().currentUser); + }); + + it('multiple factors can be enrolled', async () => { + await verify(); + + const secondaryPhone = randomPhone(); + + await enroll(user, phone, 'Main phone'); + resetVerifier(); + await enroll(user, secondaryPhone, 'Backup phone'); + + // Log out and try logging in + await firebase.auth().signOut(); + let resolver!: MultiFactorResolver; + try { + await firebase.auth().signInWithEmailAndPassword(email, password); + // Previous line should throw an error. + assert.fail('Multi factor check not triggered'); + } catch (e) { + if ((e as AuthError).code == 'auth/multi-factor-auth-required') { + resolver = (e as MultiFactorError).resolver; + } else { + throw e; + } + } + + // Use the primary phone + let hint = resolver.hints.find(h => h.displayName === 'Main phone')!; + resetVerifier(); + let verificationId = + await new firebase.auth.PhoneAuthProvider().verifyPhoneNumber( + { + multiFactorHint: hint, + session: resolver.session + }, + verifier + ); + let phoneAuthCredential = firebase.auth.PhoneAuthProvider.credential( + verificationId, + await phoneCode(verificationId) + ); + let multiFactorAssertion = + firebase.auth.PhoneMultiFactorGenerator.assertion(phoneAuthCredential); + let userCredential = await resolver.resolveSignIn(multiFactorAssertion); + expect(userCredential.operationType).to.eq('signIn'); + expect(userCredential.user).to.eq(firebase.auth().currentUser); + + // Now unenroll primary phone and try again + const mfaUser = firebase.auth().currentUser!.multiFactor; + await mfaUser.unenroll(hint.uid); + + // Sign in should still trigger MFA + try { + await firebase.auth().signInWithEmailAndPassword(email, password); + // Previous line should throw an error. + assert.fail('Multi factor check not triggered'); + } catch (e) { + if ((e as AuthError).code == 'auth/multi-factor-auth-required') { + resolver = (e as MultiFactorError).resolver; + } else { + throw e; + } + } + + // Use the secondary phone now + hint = resolver.hints.find(h => h.displayName === 'Backup phone')!; + resetVerifier(); + verificationId = + await new firebase.auth.PhoneAuthProvider().verifyPhoneNumber( + { + multiFactorHint: hint, + session: resolver.session + }, + verifier + ); + + phoneAuthCredential = firebase.auth.PhoneAuthProvider.credential( + verificationId, + await phoneCode(verificationId) + ); + multiFactorAssertion = + firebase.auth.PhoneMultiFactorGenerator.assertion(phoneAuthCredential); + userCredential = await resolver.resolveSignIn(multiFactorAssertion); + expect(userCredential.operationType).to.eq('signIn'); + expect(userCredential.user).to.eq(firebase.auth().currentUser); + }); + + it('fails if the email is not verified', async () => { + await expect(enroll(user, phone, 'nope')).to.be.rejectedWith( + 'auth/unverified-email' + ); + }); + + it('fails reauth if wrong code given', async () => { + await verify(); + await enroll(user, phone, 'Display name'); + let resolver!: MultiFactorResolver; + + try { + await firebase.auth().signInWithEmailAndPassword(email, password); + // Previous line should throw an error. + assert.fail('Multi factor check not triggered'); + } catch (e) { + if ((e as AuthError).code == 'auth/multi-factor-auth-required') { + resolver = (e as MultiFactorError).resolver; + } else { + throw e; + } + } + + expect(resolver.hints.length).to.eq(1); + expect(resolver.hints[0].displayName).to.eq('Display name'); + resetVerifier(); + const verificationId = + await new firebase.auth.PhoneAuthProvider().verifyPhoneNumber( + { + multiFactorUid: resolver.hints[0].uid, + session: resolver.session + }, + verifier + ); + + const phoneAuthCredential = firebase.auth.PhoneAuthProvider.credential( + verificationId, + 'not-code' + ); + const multiFactorAssertion = + firebase.auth.PhoneMultiFactorGenerator.assertion(phoneAuthCredential); + await expect( + resolver.resolveSignIn(multiFactorAssertion) + ).to.be.rejectedWith('auth/invalid-verification-code'); + }); + + it('works in a multi-tenant context', async () => { + const tenantId = await createNewTenant(); + firebase.auth().tenantId = tenantId; + // Need to create a new user for this + user = ( + await firebase.auth().createUserWithEmailAndPassword(email, password) + ).user!; + await verify(); + + await enroll(user, phone, 'Display name'); + + // Log out and try logging in + await firebase.auth().signOut(); + let resolver!: MultiFactorResolver; + try { + await firebase.auth().signInWithEmailAndPassword(email, password); + // Previous line should throw an error. + assert.fail('Multi factor check not triggered'); + } catch (e) { + if ((e as AuthError).code == 'auth/multi-factor-auth-required') { + resolver = (e as MultiFactorError).resolver; + } else { + throw e; + } + } + + // Check the resolver hints and reverify + expect(resolver.hints.length).to.eq(1); + expect(resolver.hints[0].displayName).to.eq('Display name'); + resetVerifier(); + const verificationId = + await new firebase.auth.PhoneAuthProvider().verifyPhoneNumber( + { + multiFactorUid: resolver.hints[0].uid, + session: resolver.session + }, + verifier + ); + + const phoneAuthCredential = firebase.auth.PhoneAuthProvider.credential( + verificationId, + await phoneCode(verificationId, tenantId) + ); + const multiFactorAssertion = + firebase.auth.PhoneMultiFactorGenerator.assertion(phoneAuthCredential); + const userCredential = await resolver.resolveSignIn(multiFactorAssertion); + expect(userCredential.operationType).to.eq('signIn'); + expect(userCredential.user).to.eq(firebase.auth().currentUser); + + // Now unenroll and try again + const mfaUser = firebase.auth().currentUser!.multiFactor; + await mfaUser.unenroll(resolver.hints[0].uid); + + // Sign in should happen without MFA + user = (await firebase.auth().signInWithEmailAndPassword(email, password)) + .user!; + expect(user).to.eq(firebase.auth().currentUser); + expect(user.tenantId).to.eq(tenantId); + }); + }); + + context('OAuth', () => { + it('allows enroll and sign in', async () => { + const oauthIdToken = JSON.stringify({ + email, + 'email_verified': true, + sub: `oauthidp--${email}--oauthidp` + }); + let { user } = await firebase + .auth() + .signInWithCredential( + firebase.auth.GoogleAuthProvider.credential(oauthIdToken) + ); + await enroll(user!, phone, 'Display name'); + + // Log out and try logging in + await firebase.auth().signOut(); + let resolver!: MultiFactorResolver; + try { + await firebase + .auth() + .signInWithCredential( + firebase.auth.GoogleAuthProvider.credential(oauthIdToken) + ); + // Previous line should throw an error. + assert.fail('Multi factor check not triggered'); + } catch (e) { + if ((e as AuthError).code == 'auth/multi-factor-auth-required') { + resolver = (e as MultiFactorError).resolver; + } else { + throw e; + } + } + + // Check the resolver hints and reverify + expect(resolver.hints.length).to.eq(1); + expect(resolver.hints[0].displayName).to.eq('Display name'); + resetVerifier(); + const verificationId = + await new firebase.auth.PhoneAuthProvider().verifyPhoneNumber( + { + multiFactorUid: resolver.hints[0].uid, + session: resolver.session + }, + verifier + ); + + const phoneAuthCredential = firebase.auth.PhoneAuthProvider.credential( + verificationId, + await phoneCode(verificationId) + ); + const multiFactorAssertion = + firebase.auth.PhoneMultiFactorGenerator.assertion(phoneAuthCredential); + const userCredential = await resolver.resolveSignIn(multiFactorAssertion); + expect(userCredential.operationType).to.eq('signIn'); + expect(userCredential.user).to.eq(firebase.auth().currentUser); + + // Now unenroll and try again + const mfaUser = firebase.auth().currentUser!.multiFactor; + await mfaUser.unenroll(resolver.hints[0].uid); + + // Sign in should happen without MFA + ({ user } = await firebase + .auth() + .signInWithCredential( + firebase.auth.GoogleAuthProvider.credential(oauthIdToken) + )); + expect(user).to.eq(firebase.auth().currentUser); + }); + }); +}); diff --git a/packages/auth-compat/test/integration/flows/oob.test.ts b/packages/auth-compat/test/integration/flows/oob.test.ts index a36cb78db63..d470fc7e64c 100644 --- a/packages/auth-compat/test/integration/flows/oob.test.ts +++ b/packages/auth-compat/test/integration/flows/oob.test.ts @@ -19,14 +19,12 @@ import { FirebaseError } from '@firebase/util'; import { expect, use } from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; import firebase from '@firebase/app-compat'; -import { - getOobCodes, - OobCodeSession -} from '../../../../auth/test/helpers/integration/emulator_rest_helpers'; import { cleanUpTestInstance, initializeTestInstance, - randomEmail + randomEmail, + getOobCodes, + OobCodeSession } from '../../helpers/helpers'; import { ActionCodeSettings } from '@firebase/auth-types'; @@ -90,10 +88,9 @@ describe('Integration test: oob codes', () => { email, oobSession.oobLink ); - const { - user, - operationType - } = await firebase.auth().signInWithCredential(cred); + const { user, operationType } = await firebase + .auth() + .signInWithCredential(cred); expect(operationType).to.eq('signIn'); expect(user).to.eq(firebase.auth().currentUser); @@ -117,10 +114,8 @@ describe('Integration test: oob codes', () => { email, reauthSession.oobLink ); - const { - user: newUser, - operationType - } = await oldUser!.reauthenticateWithCredential(cred); + const { user: newUser, operationType } = + await oldUser!.reauthenticateWithCredential(cred); expect(newUser!.uid).to.eq(oldUser!.uid); expect(operationType).to.eq('reauthenticate'); @@ -177,10 +172,8 @@ describe('Integration test: oob codes', () => { const { user: original } = await firebase.auth().signInAnonymously(); expect(original!.isAnonymous).to.be.true; - const { - user: linked, - operationType - } = await original!.linkWithCredential(cred); + const { user: linked, operationType } = + await original!.linkWithCredential(cred); expect(operationType).to.eq('link'); expect(linked!.uid).to.eq(original!.uid); @@ -275,9 +268,9 @@ describe('Integration test: oob codes', () => { }); it('can be used to initiate password reset', async () => { - const { - user: original - } = await firebase.auth().createUserWithEmailAndPassword(email, 'password'); + const { user: original } = await firebase + .auth() + .createUserWithEmailAndPassword(email, 'password'); await original!.sendEmailVerification(); // Can only reset verified user emails await firebase.auth().applyActionCode((await code(email)).oobCode); @@ -326,9 +319,7 @@ describe('Integration test: oob codes', () => { await expect( firebase.auth().signInWithEmailAndPassword(email, 'password') ).to.be.rejectedWith(FirebaseError, 'auth/alskdjf'); - const { - user: newSignIn - } = await firebase + const { user: newSignIn } = await firebase .auth() .signInWithEmailAndPassword(updatedEmail, 'password'); expect(newSignIn!.uid).to.eq(user!.uid); diff --git a/packages/auth-compat/test/integration/flows/phone.test.ts b/packages/auth-compat/test/integration/flows/phone.test.ts index fe50494d633..bf59a5068ec 100644 --- a/packages/auth-compat/test/integration/flows/phone.test.ts +++ b/packages/auth-compat/test/integration/flows/phone.test.ts @@ -21,9 +21,9 @@ import { FirebaseError } from '@firebase/util'; import firebase from '@firebase/app-compat'; import { cleanUpTestInstance, - initializeTestInstance + initializeTestInstance, + getPhoneVerificationCodes } from '../../helpers/helpers'; -import { getPhoneVerificationCodes } from '../../../../auth/test/helpers/integration/emulator_rest_helpers'; import { ConfirmationResult, RecaptchaVerifier, diff --git a/packages/auth/test/helpers/integration/emulator_rest_helpers.ts b/packages/auth/test/helpers/integration/emulator_rest_helpers.ts index 48d57563166..c8478fe50cb 100644 --- a/packages/auth/test/helpers/integration/emulator_rest_helpers.ts +++ b/packages/auth/test/helpers/integration/emulator_rest_helpers.ts @@ -39,10 +39,10 @@ interface OobCodesResponse { oobCodes: OobCodeSession[]; } -export async function getPhoneVerificationCodes(): Promise< +export async function getPhoneVerificationCodes(tenantId?: string): Promise< Record > { - const url = buildEmulatorUrlForPath('verificationCodes'); + const url = buildEmulatorUrlForPath('verificationCodes', tenantId); const response: VerificationCodesResponse = await (await doFetch(url)).json(); return response.verificationCodes.reduce((accum, session) => { diff --git a/packages/auth/test/helpers/integration/helpers.ts b/packages/auth/test/helpers/integration/helpers.ts index 4f9518a6717..499efc823d5 100644 --- a/packages/auth/test/helpers/integration/helpers.ts +++ b/packages/auth/test/helpers/integration/helpers.ts @@ -29,7 +29,13 @@ interface IntegrationTestAuth extends Auth { } export function randomEmail(): string { - return `${_generateEventId('test.email.')}@test.com`; + return `${_generateEventId('test.email.')}@sdk.test`; +} + +export function randomPhone(): string { + const area = Math.floor(Math.random() * 999).toString().padStart(3, '0'); + const num = Math.floor(Math.random() * 9999).toString().padStart(4, '0'); + return `+1${area}555${num}`; } export function getTestInstance(requireEmulator = false): Auth { diff --git a/packages/auth/test/integration/flows/mfa.local.test.ts b/packages/auth/test/integration/flows/mfa.local.test.ts new file mode 100644 index 00000000000..967faedc397 --- /dev/null +++ b/packages/auth/test/integration/flows/mfa.local.test.ts @@ -0,0 +1,356 @@ +/** + * @license + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import { + applyActionCode, + Auth, AuthError, ConfirmationResult, createUserWithEmailAndPassword, getMultiFactorResolver, GoogleAuthProvider, multiFactor, MultiFactorError, MultiFactorResolver, OperationType, PhoneAuthProvider, PhoneMultiFactorGenerator, RecaptchaVerifier, sendEmailVerification, signInWithCredential, signInWithCustomToken, signInWithEmailAndPassword, User, +} from '@firebase/auth'; +import { createNewTenant, getOobCodes, getPhoneVerificationCodes, OobCodeSession } from '../../helpers/integration/emulator_rest_helpers'; +import { cleanUpTestInstance, getTestInstance, randomEmail, randomPhone } from '../../helpers/integration/helpers'; +import { expect, use, assert } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; + +use(chaiAsPromised); + +describe('Integration test: multi-factor', () => { + let auth: Auth; + let email: string; + let phone: string; + let fakeRecaptchaContainer: HTMLElement; + let verifier: RecaptchaVerifier; + + beforeEach(() => { + auth = getTestInstance(/** requireTestInstance */ true); + email = randomEmail(); + phone = randomPhone(); + fakeRecaptchaContainer = document.createElement('div'); + document.body.appendChild(fakeRecaptchaContainer); + verifier = new RecaptchaVerifier( + fakeRecaptchaContainer, + undefined as any, + auth + ); + }); + + /** If in the emulator, search for the code in the API */ + async function phoneCode( + crOrId: ConfirmationResult | string, + tenantId?: string + ): Promise { + const codes = await getPhoneVerificationCodes(tenantId); + const vid = typeof crOrId === 'string' ? crOrId : crOrId.verificationId; + return codes[vid].code; + } + + afterEach(async () => { + await cleanUpTestInstance(auth); + document.body.removeChild(fakeRecaptchaContainer); + }); + + function resetVerifier(): void { + verifier.clear(); + verifier = new RecaptchaVerifier( + fakeRecaptchaContainer, + undefined as any, + auth + ); + } + + async function enroll(user: User, phoneNumber: string, displayName: string): Promise { + const mfaUser = multiFactor(user); + const mfaSession = await mfaUser.getSession(); + + // Send verification code. + const phoneAuthProvider = new PhoneAuthProvider(auth); + const phoneInfoOptions = { + phoneNumber, + session: mfaSession + }; + const verificationId = await phoneAuthProvider.verifyPhoneNumber(phoneInfoOptions, verifier); + const phoneAuthCredential = PhoneAuthProvider.credential(verificationId, await phoneCode(verificationId, user.tenantId || undefined)); + const multiFactorAssertion = PhoneMultiFactorGenerator.assertion(phoneAuthCredential); + await mfaUser.enroll(multiFactorAssertion, displayName); + } + + context('with email/password', () => { + const password = 'password'; + let user: User; + + beforeEach(async () => { + ({user} = await createUserWithEmailAndPassword(auth, email, password)); + }); + + async function oobCode(toEmail: string, tenant?: string): Promise { + const codes = await getOobCodes(tenant); + return codes.reverse().find(({ email }) => email === toEmail)!; + } + + async function verify() { + await sendEmailVerification(user); + // Apply the email verification code + await applyActionCode(auth, (await oobCode(email, user.tenantId || undefined)).oobCode); + await user.reload(); + } + + it('allows enrollment, sign in, and unenrollment', async () => { + await verify(); + + await enroll(user, phone, 'Display name'); + + // Log out and try logging in + await auth.signOut(); + let resolver!: MultiFactorResolver; + try { + await signInWithEmailAndPassword(auth, email, password); + // Previous line should throw an error. + assert.fail('Multi factor check not triggered'); + } catch (e) { + if ((e as AuthError).code == 'auth/multi-factor-auth-required') { + resolver = getMultiFactorResolver(auth, (e as MultiFactorError)); + } else { + throw e; + } + } + + // Check the resolver hints and reverify + expect(resolver.hints.length).to.eq(1); + expect(resolver.hints[0].displayName).to.eq('Display name'); + resetVerifier(); + const verificationId = await new PhoneAuthProvider(auth).verifyPhoneNumber({ + multiFactorUid: resolver.hints[0].uid, + session: resolver.session + }, verifier); + + const phoneAuthCredential = PhoneAuthProvider.credential(verificationId, await phoneCode(verificationId)); + const multiFactorAssertion = PhoneMultiFactorGenerator.assertion(phoneAuthCredential); + const userCredential = await resolver.resolveSignIn(multiFactorAssertion); + expect(userCredential.operationType).to.eq(OperationType.SIGN_IN); + expect(userCredential.user).to.eq(auth.currentUser); + + // Now unenroll and try again + const mfaUser = multiFactor(auth.currentUser!); + await mfaUser.unenroll(resolver.hints[0].uid); + + // Sign in should happen without MFA + ({user} = await signInWithEmailAndPassword(auth, email, password)); + expect(user).to.eq(auth.currentUser); + }); + + it('multiple factors can be enrolled', async () => { + await verify(); + + const secondaryPhone = randomPhone(); + + await enroll(user, phone, 'Main phone'); + resetVerifier(); + await enroll(user, secondaryPhone, 'Backup phone'); + + // Log out and try logging in + await auth.signOut(); + let resolver!: MultiFactorResolver; + try { + await signInWithEmailAndPassword(auth, email, password); + // Previous line should throw an error. + assert.fail('Multi factor check not triggered'); + } catch (e) { + if ((e as AuthError).code == 'auth/multi-factor-auth-required') { + resolver = getMultiFactorResolver(auth, (e as MultiFactorError)); + } else { + throw e; + } + } + + // Use the primary phone + let hint = resolver.hints.find(h => h.displayName === 'Main phone')!; + resetVerifier(); + let verificationId = await new PhoneAuthProvider(auth).verifyPhoneNumber({ + multiFactorHint: hint, + session: resolver.session + }, verifier); + let phoneAuthCredential = PhoneAuthProvider.credential(verificationId, await phoneCode(verificationId)); + let multiFactorAssertion = PhoneMultiFactorGenerator.assertion(phoneAuthCredential); + let userCredential = await resolver.resolveSignIn(multiFactorAssertion); + expect(userCredential.operationType).to.eq(OperationType.SIGN_IN); + expect(userCredential.user).to.eq(auth.currentUser); + + // Now unenroll primary phone and try again + const mfaUser = multiFactor(auth.currentUser!); + await mfaUser.unenroll(hint.uid); + + // Sign in should still trigger MFA + try { + await signInWithEmailAndPassword(auth, email, password); + // Previous line should throw an error. + assert.fail('Multi factor check not triggered'); + } catch (e) { + if ((e as AuthError).code == 'auth/multi-factor-auth-required') { + resolver = getMultiFactorResolver(auth, (e as MultiFactorError)); + } else { + throw e; + } + } + + // Use the secondary phone now + hint = resolver.hints.find(h => h.displayName === 'Backup phone')!; + resetVerifier(); + verificationId = await new PhoneAuthProvider(auth).verifyPhoneNumber({ + multiFactorHint: hint, + session: resolver.session + }, verifier); + + phoneAuthCredential = PhoneAuthProvider.credential(verificationId, await phoneCode(verificationId)); + multiFactorAssertion = PhoneMultiFactorGenerator.assertion(phoneAuthCredential); + userCredential = await resolver.resolveSignIn(multiFactorAssertion); + expect(userCredential.operationType).to.eq(OperationType.SIGN_IN); + expect(userCredential.user).to.eq(auth.currentUser); + }); + + it('fails if the email is not verified', async () => { + await expect(enroll(user, phone, 'nope')).to.be.rejectedWith('auth/unverified-email'); + }); + + it('fails reauth if wrong code given', async () => { + await verify(); + await enroll(user, phone, 'Display name'); + let resolver!: MultiFactorResolver; + + try { + await signInWithEmailAndPassword(auth, email, password); + // Previous line should throw an error. + assert.fail('Multi factor check not triggered'); + } catch (e) { + if ((e as AuthError).code == 'auth/multi-factor-auth-required') { + resolver = getMultiFactorResolver(auth, (e as MultiFactorError)); + } else { + throw e; + } + } + + expect(resolver.hints.length).to.eq(1); + expect(resolver.hints[0].displayName).to.eq('Display name'); + resetVerifier(); + const verificationId = await new PhoneAuthProvider(auth).verifyPhoneNumber({ + multiFactorUid: resolver.hints[0].uid, + session: resolver.session + }, verifier); + + const phoneAuthCredential = PhoneAuthProvider.credential(verificationId, 'not-code'); + const multiFactorAssertion = PhoneMultiFactorGenerator.assertion(phoneAuthCredential); + await expect(resolver.resolveSignIn(multiFactorAssertion)).to.be.rejectedWith('auth/invalid-verification-code'); + }); + + it('works in a multi-tenant context', async () => { + const tenantId = await createNewTenant(); + auth.tenantId = tenantId; + // Need to create a new user for this + ({user} = await createUserWithEmailAndPassword(auth, email, password)); + await verify(); + + await enroll(user, phone, 'Display name'); + + // Log out and try logging in + await auth.signOut(); + let resolver!: MultiFactorResolver; + try { + await signInWithEmailAndPassword(auth, email, password); + // Previous line should throw an error. + assert.fail('Multi factor check not triggered'); + } catch (e) { + if ((e as AuthError).code == 'auth/multi-factor-auth-required') { + resolver = getMultiFactorResolver(auth, (e as MultiFactorError)); + } else { + throw e; + } + } + + // Check the resolver hints and reverify + expect(resolver.hints.length).to.eq(1); + expect(resolver.hints[0].displayName).to.eq('Display name'); + resetVerifier(); + const verificationId = await new PhoneAuthProvider(auth).verifyPhoneNumber({ + multiFactorUid: resolver.hints[0].uid, + session: resolver.session + }, verifier); + + const phoneAuthCredential = PhoneAuthProvider.credential(verificationId, await phoneCode(verificationId, tenantId)); + const multiFactorAssertion = PhoneMultiFactorGenerator.assertion(phoneAuthCredential); + const userCredential = await resolver.resolveSignIn(multiFactorAssertion); + expect(userCredential.operationType).to.eq(OperationType.SIGN_IN); + expect(userCredential.user).to.eq(auth.currentUser); + + // Now unenroll and try again + const mfaUser = multiFactor(auth.currentUser!); + await mfaUser.unenroll(resolver.hints[0].uid); + + // Sign in should happen without MFA + ({user} = await signInWithEmailAndPassword(auth, email, password)); + expect(user).to.eq(auth.currentUser); + expect(user.tenantId).to.eq(tenantId); + }); + }); + + context('OAuth', () => { + it('allows enroll and sign in', async () => { + const oauthIdToken = JSON.stringify({ + email, + 'email_verified': true, + sub: `oauthidp--${email}--oauthidp` + }); + let {user} = await signInWithCredential(auth, GoogleAuthProvider.credential(oauthIdToken)); + await enroll(user, phone, 'Display name'); + + // Log out and try logging in + await auth.signOut(); + let resolver!: MultiFactorResolver; + try { + await signInWithCredential(auth, GoogleAuthProvider.credential(oauthIdToken)); + // Previous line should throw an error. + assert.fail('Multi factor check not triggered'); + } catch (e) { + if ((e as AuthError).code == 'auth/multi-factor-auth-required') { + resolver = getMultiFactorResolver(auth, (e as MultiFactorError)); + } else { + throw e; + } + } + + // Check the resolver hints and reverify + expect(resolver.hints.length).to.eq(1); + expect(resolver.hints[0].displayName).to.eq('Display name'); + resetVerifier(); + const verificationId = await new PhoneAuthProvider(auth).verifyPhoneNumber({ + multiFactorUid: resolver.hints[0].uid, + session: resolver.session + }, verifier); + + const phoneAuthCredential = PhoneAuthProvider.credential(verificationId, await phoneCode(verificationId)); + const multiFactorAssertion = PhoneMultiFactorGenerator.assertion(phoneAuthCredential); + const userCredential = await resolver.resolveSignIn(multiFactorAssertion); + expect(userCredential.operationType).to.eq(OperationType.SIGN_IN); + expect(userCredential.user).to.eq(auth.currentUser); + + // Now unenroll and try again + const mfaUser = multiFactor(auth.currentUser!); + await mfaUser.unenroll(resolver.hints[0].uid); + + // Sign in should happen without MFA + ({user} = await signInWithCredential(auth, GoogleAuthProvider.credential(oauthIdToken))); + expect(user).to.eq(auth.currentUser); + }); + }); +}); \ No newline at end of file