diff --git a/packages-exp/auth-exp/test/helpers/integration/emulator_rest_helpers.ts b/packages-exp/auth-exp/test/helpers/integration/emulator_rest_helpers.ts index 7cbc4393ae3..9ec13f09b15 100644 --- a/packages-exp/auth-exp/test/helpers/integration/emulator_rest_helpers.ts +++ b/packages-exp/auth-exp/test/helpers/integration/emulator_rest_helpers.ts @@ -18,8 +18,20 @@ // eslint-disable-next-line import/no-extraneous-dependencies import { Auth } from '@firebase/auth-exp'; import { getApps } from '@firebase/app-exp'; +import { FetchProvider } from '../../../src/core/util/fetch_provider'; +import * as fetchImpl from 'node-fetch'; -interface VerificationSession { +if (typeof document !== 'undefined') { + FetchProvider.initialize(fetch); +} else { + FetchProvider.initialize( + (fetchImpl.default as unknown) as typeof fetch, + (fetchImpl.Headers as unknown) as typeof Headers, + (fetchImpl.Response as unknown) as typeof Response + ); +} + +export interface VerificationSession { code: string; phoneNumber: string; sessionInfo: string; @@ -29,12 +41,25 @@ interface VerificationCodesResponse { verificationCodes: VerificationSession[]; } +export interface OobCodeSession { + email: string; + requestType: string; + oobCode: string; + oobLink: string; +} + +interface OobCodesResponse { + oobCodes: OobCodeSession[]; +} + export async function getPhoneVerificationCodes( auth: Auth ): Promise> { assertEmulator(auth); const url = getEmulatorUrl(auth, 'verificationCodes'); - const response: VerificationCodesResponse = await (await fetch(url)).json(); + const response: VerificationCodesResponse = await ( + await FetchProvider.fetch()(url) + ).json(); return response.verificationCodes.reduce((accum, session) => { accum[session.sessionInfo] = session; @@ -42,6 +67,15 @@ export async function getPhoneVerificationCodes( }, {} as Record); } +export async function getOobCodes(auth: Auth): Promise { + assertEmulator(auth); + const url = getEmulatorUrl(auth, 'oobCodes'); + const response: OobCodesResponse = await ( + await FetchProvider.fetch()(url) + ).json(); + return response.oobCodes; +} + function getEmulatorUrl(auth: Auth, endpoint: string): string { const { host, port, protocol } = auth.emulatorConfig!; const projectId = getProjectId(auth); diff --git a/packages-exp/auth-exp/test/helpers/integration/helpers.ts b/packages-exp/auth-exp/test/helpers/integration/helpers.ts index 4b0b091483d..5378365f358 100644 --- a/packages-exp/auth-exp/test/helpers/integration/helpers.ts +++ b/packages-exp/auth-exp/test/helpers/integration/helpers.ts @@ -31,7 +31,7 @@ export function randomEmail(): string { return `${_generateEventId('test.email.')}@test.com`; } -export function getTestInstance(): Auth { +export function getTestInstance(requireEmulator = false): Auth { const app = initializeApp(getAppConfig()); const createdUsers: User[] = []; @@ -43,6 +43,9 @@ export function getTestInstance(): Auth { const stub = stubConsoleToSilenceEmulatorWarnings(); useAuthEmulator(auth, emulatorUrl, { disableWarnings: true }); stub.restore(); + } else if (requireEmulator) { + /* Emulator wasn't configured but test must use emulator */ + throw new Error('Test may only be run using the Auth Emulator!'); } auth.onAuthStateChanged(user => { diff --git a/packages-exp/auth-exp/test/integration/flows/custom.local.test.ts b/packages-exp/auth-exp/test/integration/flows/custom.local.test.ts index 08c7439fb6c..d482c8526bf 100644 --- a/packages-exp/auth-exp/test/integration/flows/custom.local.test.ts +++ b/packages-exp/auth-exp/test/integration/flows/custom.local.test.ts @@ -47,7 +47,7 @@ describe('Integration test: custom auth', () => { let uid: string; beforeEach(() => { - auth = getTestInstance(); + auth = getTestInstance(/* requireEmulator */ true); uid = randomEmail(); customToken = JSON.stringify({ uid, @@ -55,10 +55,6 @@ describe('Integration test: custom auth', () => { customClaim: 'some-claim' } }); - - if (!auth.emulatorConfig) { - throw new Error('Test can only be run against the emulator!'); - } }); afterEach(async () => { diff --git a/packages-exp/auth-exp/test/integration/flows/oob.local.test.ts b/packages-exp/auth-exp/test/integration/flows/oob.local.test.ts new file mode 100644 index 00000000000..6c699290fce --- /dev/null +++ b/packages-exp/auth-exp/test/integration/flows/oob.local.test.ts @@ -0,0 +1,358 @@ +/** + * @license + * Copyright 2020 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 { + ActionCodeSettings, + applyActionCode, + Auth, + confirmPasswordReset, + createUserWithEmailAndPassword, + deleteUser, + EmailAuthProvider, + fetchSignInMethodsForEmail, + linkWithCredential, + OperationType, + reauthenticateWithCredential, + sendEmailVerification, + sendPasswordResetEmail, + sendSignInLinkToEmail, + signInAnonymously, + SignInMethod, + signInWithCredential, + signInWithCustomToken, + signInWithEmailAndPassword, + signInWithEmailLink, + updatePassword, + verifyBeforeUpdateEmail, + verifyPasswordResetCode + // eslint-disable-next-line import/no-extraneous-dependencies +} from '@firebase/auth-exp'; +import { FirebaseError } from '@firebase/util'; +import { expect, use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import { + getOobCodes, + OobCodeSession +} from '../../helpers/integration/emulator_rest_helpers'; +import { + cleanUpTestInstance, + getTestInstance, + randomEmail +} from '../../helpers/integration/helpers'; + +use(chaiAsPromised); + +declare const xit: typeof it; + +const BASE_SETTINGS: ActionCodeSettings = { + url: 'http://localhost/action_code_return', + handleCodeInApp: true +}; + +describe('Integration test: oob codes', () => { + let auth: Auth; + let email: string; + + beforeEach(() => { + auth = getTestInstance(/* requireEmulator */ true); + email = randomEmail(); + }); + + afterEach(async () => { + await cleanUpTestInstance(auth); + }); + + async function code(toEmail: string): Promise { + const codes = await getOobCodes(auth); + return codes.reverse().find(({ email }) => email === toEmail)!; + } + + context('flows beginning with sendSignInLinkToEmail', () => { + let oobSession: OobCodeSession; + + beforeEach(async () => { + oobSession = await sendEmailLink(); + }); + + async function sendEmailLink(toEmail = email): Promise { + await sendSignInLinkToEmail(auth, toEmail, BASE_SETTINGS); + + // An email has been sent to the user. Normally you'd detect this state + // when the app redirects back. We will ask the emulator for the results + // and force the state instead. + return code(toEmail); + } + + it('allows user to sign in', async () => { + const { user, operationType } = await signInWithEmailLink( + auth, + email, + oobSession.oobLink + ); + + expect(operationType).to.eq(OperationType.SIGN_IN); + expect(user).to.eq(auth.currentUser); + expect(user.uid).to.be.a('string'); + expect(user.email).to.eq(email); + expect(user.emailVerified).to.be.true; + expect(user.isAnonymous).to.be.false; + }); + + it('sign in works with an email credential', async () => { + const cred = EmailAuthProvider.credentialWithLink( + email, + oobSession.oobLink + ); + const { user, operationType } = await signInWithCredential(auth, cred); + + expect(operationType).to.eq(OperationType.SIGN_IN); + expect(user).to.eq(auth.currentUser); + expect(user.uid).to.be.a('string'); + expect(user.email).to.eq(email); + expect(user.emailVerified).to.be.true; + expect(user.isAnonymous).to.be.false; + }); + + it('reauthenticate works with email credential', async () => { + let cred = EmailAuthProvider.credentialWithLink( + email, + oobSession.oobLink + ); + const { user: oldUser } = await signInWithCredential(auth, cred); + + const reauthSession = await sendEmailLink(); + cred = EmailAuthProvider.credentialWithLink(email, reauthSession.oobLink); + const { + user: newUser, + operationType + } = await reauthenticateWithCredential(oldUser, cred); + + expect(newUser.uid).to.eq(oldUser.uid); + expect(operationType).to.eq(OperationType.REAUTHENTICATE); + expect(auth.currentUser).to.eq(newUser); + }); + + it('reauthenticate throws with different email', async () => { + let cred = EmailAuthProvider.credentialWithLink( + email, + oobSession.oobLink + ); + const { user: oldUser } = await signInWithCredential(auth, cred); + + const newEmail = randomEmail(); + const reauthSession = await sendEmailLink(newEmail); + cred = EmailAuthProvider.credentialWithLink( + newEmail, + reauthSession.oobLink + ); + await expect( + reauthenticateWithCredential(oldUser, cred) + ).to.be.rejectedWith(FirebaseError, 'auth/user-mismatch'); + expect(auth.currentUser).to.eq(oldUser); + }); + + it('reauthenticate throws if user is deleted', async () => { + let cred = EmailAuthProvider.credentialWithLink( + email, + oobSession.oobLink + ); + const { user: oldUser } = await signInWithCredential(auth, cred); + + await deleteUser(oldUser); + const reauthSession = await sendEmailLink(email); + cred = EmailAuthProvider.credentialWithLink(email, reauthSession.oobLink); + await expect( + reauthenticateWithCredential(oldUser, cred) + ).to.be.rejectedWith(FirebaseError, 'auth/user-mismatch'); + expect(auth.currentUser).to.be.null; + }); + + it('other accounts can be linked', async () => { + const cred = EmailAuthProvider.credentialWithLink( + email, + oobSession.oobLink + ); + const { user: original } = await signInAnonymously(auth); + + expect(original.isAnonymous).to.be.true; + const { user: linked, operationType } = await linkWithCredential( + original, + cred + ); + + expect(operationType).to.eq(OperationType.LINK); + expect(linked.uid).to.eq(original.uid); + expect(linked.isAnonymous).to.be.false; + expect(auth.currentUser).to.eq(linked); + expect(linked.email).to.eq(email); + expect(linked.emailVerified).to.be.true; + }); + + it('can be linked to a custom token', async () => { + const { user: original } = await signInWithCustomToken( + auth, + JSON.stringify({ + uid: 'custom-uid' + }) + ); + + const cred = EmailAuthProvider.credentialWithLink( + email, + oobSession.oobLink + ); + const { user: linked } = await linkWithCredential(original, cred); + + expect(linked.uid).to.eq(original.uid); + expect(auth.currentUser).to.eq(linked); + expect(linked.email).to.eq(email); + expect(linked.emailVerified).to.be.true; + }); + + it('cannot link if original account is deleted', async () => { + const cred = EmailAuthProvider.credentialWithLink( + email, + oobSession.oobLink + ); + const { user } = await signInAnonymously(auth); + + expect(user.isAnonymous).to.be.true; + await deleteUser(user); + await expect(linkWithCredential(user, cred)).to.be.rejectedWith( + FirebaseError, + 'auth/user-token-expired' + ); + }); + + it('code can only be used once', async () => { + const link = oobSession.oobLink; + await signInWithEmailLink(auth, email, link); + await expect(signInWithEmailLink(auth, email, link)).to.be.rejectedWith( + FirebaseError, + 'auth/invalid-action-code' + ); + }); + + it('fetchSignInMethodsForEmail returns the correct values', async () => { + const { user } = await signInWithEmailLink( + auth, + email, + oobSession.oobLink + ); + expect(await fetchSignInMethodsForEmail(auth, email)).to.eql([ + SignInMethod.EMAIL_LINK + ]); + + await updatePassword(user, 'password'); + const updatedMethods = await fetchSignInMethodsForEmail(auth, email); + expect(updatedMethods).to.have.length(2); + expect(updatedMethods).to.include(SignInMethod.EMAIL_LINK); + expect(updatedMethods).to.include(SignInMethod.EMAIL_PASSWORD); + }); + + it('throws an error if the wrong code is provided', async () => { + const otherSession = await sendEmailLink(randomEmail()); + await expect( + signInWithEmailLink(auth, email, otherSession.oobLink) + ).to.be.rejectedWith(FirebaseError, 'auth/invalid-email'); + }); + }); + + it('can be used to verify email', async () => { + // Create an unverified user + const { user } = await createUserWithEmailAndPassword( + auth, + email, + 'password' + ); + expect(user.emailVerified).to.be.false; + expect(await fetchSignInMethodsForEmail(auth, email)).to.eql([ + SignInMethod.EMAIL_PASSWORD + ]); + await sendEmailVerification(user); + + // Apply the email verification code + await applyActionCode(auth, (await code(email)).oobCode); + await user.reload(); + expect(user.emailVerified).to.be.true; + }); + + it('can be used to initiate password reset', async () => { + const { user: original } = await createUserWithEmailAndPassword( + auth, + email, + 'password' + ); + await sendEmailVerification(original); // Can only reset verified user emails + await applyActionCode(auth, (await code(email)).oobCode); + + // Send and confirm the password reset + await sendPasswordResetEmail(auth, email); + const oobCode = (await code(email)).oobCode; + expect(await verifyPasswordResetCode(auth, oobCode)).to.eq(email); + await confirmPasswordReset(auth, oobCode, 'new-password'); + + // Make sure the new password works and the old one doesn't + const { user } = await signInWithEmailAndPassword( + auth, + email, + 'new-password' + ); + expect(user.uid).to.eq(original.uid); + expect(user.emailVerified).to.be.true; + expect(await fetchSignInMethodsForEmail(auth, email)).to.eql([ + SignInMethod.EMAIL_PASSWORD + ]); + + await expect( + signInWithEmailAndPassword(auth, email, 'password') + ).to.be.rejectedWith(FirebaseError, 'auth/wrong-password'); + }); + + // Test is ignored for now as the emulator does not currently support the + // verify-and-change-email operation. + xit('verifyBeforeUpdateEmail waits until flow completes', async () => { + const updatedEmail = randomEmail(); + + // Create an initial user with the basic email + await sendSignInLinkToEmail(auth, email, BASE_SETTINGS); + const { user } = await signInWithEmailLink( + auth, + email, + (await code(email)).oobLink + ); + await verifyBeforeUpdateEmail(user, updatedEmail, BASE_SETTINGS); + expect(user.email).to.eq(email); + + // Finish the update email flow + await applyActionCode(auth, (await code(updatedEmail)).oobCode); + await user.reload(); + expect(user.emailVerified).to.be.true; + expect(user.email).to.eq(updatedEmail); + expect(auth.currentUser).to.eq(user); + + // Old email doesn't work but new one does + await expect( + signInWithEmailAndPassword(auth, email, 'password') + ).to.be.rejectedWith(FirebaseError, 'auth/alskdjf'); + const { user: newSignIn } = await signInWithEmailAndPassword( + auth, + updatedEmail, + 'password' + ); + expect(newSignIn.uid).to.eq(user.uid); + }); +});