diff --git a/packages/auth/package.json b/packages/auth/package.json index 23eeaa5e2c9..6124c0cbe11 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -118,7 +118,8 @@ "rollup": "2.72.1", "rollup-plugin-sourcemaps": "0.6.3", "rollup-plugin-typescript2": "0.31.2", - "typescript": "4.2.2" + "typescript": "4.2.2", + "totp-generator": "0.0.14" }, "repository": { "directory": "packages/auth", diff --git a/packages/auth/test/helpers/integration/helpers.ts b/packages/auth/test/helpers/integration/helpers.ts index 4f9518a6717..a7ca9120344 100644 --- a/packages/auth/test/helpers/integration/helpers.ts +++ b/packages/auth/test/helpers/integration/helpers.ts @@ -23,7 +23,7 @@ import { getAuth, connectAuthEmulator } from '../../../'; // Use browser OR node import { _generateEventId } from '../../../src/core/util/event_id'; import { getAppConfig, getEmulatorUrl } from './settings'; import { resetEmulator } from './emulator_rest_helpers'; - +import totp from 'totp-generator'; interface IntegrationTestAuth extends Auth { cleanUp(): Promise; } @@ -62,10 +62,12 @@ export function getTestInstance(requireEmulator = false): Auth { } else { // Clear out any new users that were created in the course of the test for (const user of createdUsers) { - try { - await user.delete(); - } catch { - // Best effort. Maybe the test already deleted the user ¯\_(ツ)_/¯ + if (!user.email?.includes('donotdelete')) { + try { + await user.delete(); + } catch { + // Best effort. Maybe the test already deleted the user ¯\_(ツ)_/¯ + } } } } @@ -93,3 +95,25 @@ function stubConsoleToSilenceEmulatorWarnings(): sinon.SinonStub { } }); } + +export function getTotpCode( + sharedSecretKey: string, + periodSec: number, + verificationCodeLength: number +): string { + const token = totp(sharedSecretKey, { + period: periodSec, + digits: verificationCodeLength, + algorithm: 'SHA-1' + }); + + return token; +} + +export function delay(dt: number): Promise { + return new Promise(resolve => setTimeout(resolve, dt)); +} + +export const email = 'totpuser-donotdelete@test.com'; +//1000000 is always incorrect since it has 7 digits and we expect 6. +export const incorrectTotpCode = '1000000'; diff --git a/packages/auth/test/integration/flows/totp.test.ts b/packages/auth/test/integration/flows/totp.test.ts new file mode 100644 index 00000000000..dd2b2846055 --- /dev/null +++ b/packages/auth/test/integration/flows/totp.test.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2022 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 { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import { + Auth, + multiFactor, + signInWithEmailAndPassword, + getMultiFactorResolver +} from '@firebase/auth'; +import { FirebaseError } from '@firebase/app'; +import { + cleanUpTestInstance, + getTestInstance, + getTotpCode, + delay, + email, + incorrectTotpCode +} from '../../helpers/integration/helpers'; + +import { + TotpMultiFactorGenerator, + TotpSecret +} from '../../../src/mfa/assertions/totp'; + +use(chaiAsPromised); +use(sinonChai); + +describe(' Integration tests: Mfa TOTP', () => { + let auth: Auth; + let totpSecret: TotpSecret; + let displayName: string; + beforeEach(async () => { + auth = getTestInstance(); + displayName = 'totp-integration-test'; + }); + + afterEach(async () => { + await cleanUpTestInstance(auth); + }); + + it('should not enroll if incorrect totp supplied', async () => { + const cr = await signInWithEmailAndPassword(auth, email, 'password'); + const mfaUser = multiFactor(cr.user); + const session = await mfaUser.getSession(); + totpSecret = await TotpMultiFactorGenerator.generateSecret(session); + const multiFactorAssertion = + TotpMultiFactorGenerator.assertionForEnrollment( + totpSecret, + incorrectTotpCode + ); + + await expect( + mfaUser.enroll(multiFactorAssertion, displayName) + ).to.be.rejectedWith('auth/invalid-verification-code'); + }); + + it('should enroll using correct otp', async () => { + const cr = await signInWithEmailAndPassword(auth, email, 'password'); + + const mfaUser = multiFactor(cr.user); + + const session = await mfaUser.getSession(); + + totpSecret = await TotpMultiFactorGenerator.generateSecret(session); + + const totpVerificationCode = getTotpCode( + totpSecret.secretKey, + totpSecret.codeIntervalSeconds, + totpSecret.codeLength + ); + + const multiFactorAssertion = + TotpMultiFactorGenerator.assertionForEnrollment( + totpSecret, + totpVerificationCode + ); + await expect(mfaUser.enroll(multiFactorAssertion, displayName)).to.be + .fulfilled; + }); + + it('should not allow sign-in with incorrect totp', async () => { + let resolver; + + try { + await signInWithEmailAndPassword(auth, email, 'password'); + + throw new Error('Signin should not have been successful'); + } catch (error) { + expect(error).to.be.an.instanceOf(FirebaseError); + expect((error as any).code).to.eql('auth/multi-factor-auth-required'); + + resolver = getMultiFactorResolver(auth, error as any); + expect(resolver.hints).to.have.length(1); + + const assertion = TotpMultiFactorGenerator.assertionForSignIn( + resolver.hints[0].uid, + incorrectTotpCode + ); + + await expect(resolver.resolveSignIn(assertion)).to.be.rejectedWith( + 'auth/invalid-verification-code' + ); + } + }); + + it('should allow sign-in with for correct totp and unenroll successfully', async () => { + let resolver; + + await delay(30 * 1000); + //TODO(bhparijat) generate the otp code for the next time window by passing the appropriate + //timestamp to avoid the 30s delay. The delay is needed because the otp code used for enrollment + //cannot be reused for signing in. + try { + await signInWithEmailAndPassword(auth, email, 'password'); + + throw new Error('Signin should not have been successful'); + } catch (error) { + expect(error).to.be.an.instanceOf(FirebaseError); + expect((error as any).code).to.eql('auth/multi-factor-auth-required'); + + resolver = getMultiFactorResolver(auth, error as any); + expect(resolver.hints).to.have.length(1); + + const totpVerificationCode = getTotpCode( + totpSecret.secretKey, + totpSecret.codeIntervalSeconds, + totpSecret.codeLength + ); + const assertion = TotpMultiFactorGenerator.assertionForSignIn( + resolver.hints[0].uid, + totpVerificationCode + ); + const userCredential = await resolver.resolveSignIn(assertion); + + const mfaUser = multiFactor(userCredential.user); + + await expect(mfaUser.unenroll(resolver.hints[0].uid)).to.be.fulfilled; + } + }).timeout(32000); +}); diff --git a/packages/auth/totp-generator.d.ts b/packages/auth/totp-generator.d.ts new file mode 100644 index 00000000000..01defa03c6a --- /dev/null +++ b/packages/auth/totp-generator.d.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2022 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. + */ + +declare module 'totp-generator';