From ebbf3975d48c693ed1d97f27e196852a4b8e816a Mon Sep 17 00:00:00 2001 From: bhparijat Date: Wed, 31 Aug 2022 13:14:06 -0700 Subject: [PATCH 01/26] adding support TOTP MFA (#6547) Co-authored-by: Parijat Bhatt --- common/api-review/auth.api.md | 5 ++ packages/auth/src/mfa/assertions/totp.ts | 78 +++++++++++++++++++ packages/auth/src/model/enum_maps.ts | 3 +- packages/auth/src/model/public_types.ts | 12 ++- .../platform_browser/mfa/assertions/totp.ts | 50 ++++++++++++ 5 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 packages/auth/src/mfa/assertions/totp.ts create mode 100644 packages/auth/src/platform_browser/mfa/assertions/totp.ts diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index 045b64bcf19..553feb40f20 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -361,6 +361,7 @@ export class FacebookAuthProvider extends BaseOAuthProvider { // @public export const FactorId: { readonly PHONE: "phone"; + readonly TOTP: "totp"; }; // @public @@ -745,6 +746,10 @@ export function signInWithRedirect(auth: Auth, provider: AuthProvider, resolver? // @public export function signOut(auth: Auth): Promise; +// @public +export interface TotpMultiFactorAssertion extends MultiFactorAssertion { +} + // @public export class TwitterAuthProvider extends BaseOAuthProvider { constructor(); diff --git a/packages/auth/src/mfa/assertions/totp.ts b/packages/auth/src/mfa/assertions/totp.ts new file mode 100644 index 00000000000..8fb437cef35 --- /dev/null +++ b/packages/auth/src/mfa/assertions/totp.ts @@ -0,0 +1,78 @@ +/** + * @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 { TotpSecret } from '../../platform_browser/mfa/assertions/totp'; +import { + TotpMultiFactorAssertion, + MultiFactorSession, + FactorId +} from '../../model/public_types'; +/** + * Provider for generating a {@link TotpMultiFactorAssertion}. + * + * @public + */ +export class TotpMultiFactorGenerator { + /** + * Provides a {@link TotpMultiFactorAssertion} to confirm ownership of + * the totp(Time-based One Time Password) second factor. + * This assertion is used to complete enrollment in TOTP second factor. + * + * @param secret {@link TotpSecret}. + * @param oneTimePassword One-time password from TOTP App. + * @returns A {@link TotpMultiFactorAssertion} which can be used with + * {@link MultiFactorUser.enroll}. + */ + static assertionForEnrollment( + _secret: TotpSecret, + _oneTimePassword: string + ): TotpMultiFactorAssertion { + throw new Error('Unimplemented'); + } + /** + * Provides a {@link TotpMultiFactorAssertion} to confirm ownership of the totp second factor. + * This assertion is used to complete signIn with TOTP as the second factor. + * + * @param enrollmentId identifies the enrolled TOTP second factor. + * @param otp One-time password from TOTP App. + * @returns A {@link TotpMultiFactorAssertion} which can be used with + * {@link MultiFactorResolver.resolveSignIn}. + */ + static assertionForSignIn( + _enrollmentId: string, + _otp: string + ): TotpMultiFactorAssertion { + throw new Error('Unimplemented'); + } + /** + * Returns a promise to {@link TOTPSecret} which contains the TOTP shared secret key and other parameters. + * Creates a TOTP secret as part of enrolling a TOTP second factor. + * Used for generating a QRCode URL or inputting into a TOTP App. + * This method uses the auth instance corresponding to the user in the multiFactorSession. + * + * @param session A link to {@MultiFactorSession}. + * @returns A promise to {@link TotpSecret}. + */ + static async generateSecret( + _session: MultiFactorSession + ): Promise { + throw new Error('Unimplemented'); + } + /** + * The identifier of the TOTP second factor: `totp`. + */ + static FACTOR_ID = FactorId.TOTP; +} diff --git a/packages/auth/src/model/enum_maps.ts b/packages/auth/src/model/enum_maps.ts index 4d3e3f4a59c..e0ffec60b7e 100644 --- a/packages/auth/src/model/enum_maps.ts +++ b/packages/auth/src/model/enum_maps.ts @@ -22,7 +22,8 @@ */ export const FactorId = { /** Phone as second factor */ - PHONE: 'phone' + PHONE: 'phone', + TOTP: 'totp' } as const; /** diff --git a/packages/auth/src/model/public_types.ts b/packages/auth/src/model/public_types.ts index fdef53e6850..31437ec5dea 100644 --- a/packages/auth/src/model/public_types.ts +++ b/packages/auth/src/model/public_types.ts @@ -545,7 +545,8 @@ export interface AuthProvider { */ export const enum FactorId { /** Phone as second factor */ - PHONE = 'phone' + PHONE = 'phone', + TOTP = 'totp' } /** @@ -1229,3 +1230,12 @@ export interface Dependencies { */ errorMap?: AuthErrorMap; } + +/** + * The class for asserting ownership of a totp second factor. Provided by + * {@link TotpMultiFactorGenerator.assertion}. + * + * @public + */ + +export interface TotpMultiFactorAssertion extends MultiFactorAssertion {} diff --git a/packages/auth/src/platform_browser/mfa/assertions/totp.ts b/packages/auth/src/platform_browser/mfa/assertions/totp.ts new file mode 100644 index 00000000000..d62813d8d44 --- /dev/null +++ b/packages/auth/src/platform_browser/mfa/assertions/totp.ts @@ -0,0 +1,50 @@ +/** + * @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. + */ + +/** + * Stores the shared secret key and other parameters to generate time-based OTPs. + * Implements methods to retrieve the shared secret key, generate a QRCode URL. + * @public + */ +export class TotpSecret { + /** + * Constructor for TotpSecret. + * @param secretKey - Shared secret key/seed used for enrolling in TOTP MFA and generating otps. + * @param hashingAlgorithm - Hashing algorithm used. + * @param codeLength - Length of the one-time passwords to be generated. + * @param codeIntervalSeconds - The interval (in seconds) when the OTP codes should change. + */ + constructor( + readonly secretKey: string, + readonly hashingAlgorithm: string, + readonly codeLength: number, + readonly codeIntervalSeconds: number + ) {} + /** + * Returns a QRCode URL as described in + * https://github.com/google/google-authenticator/wiki/Key-Uri-Format + * This can be displayed to the user as a QRCode to be scanned into a TOTP App like Google Authenticator. + * If the optional parameters are unspecified, an accountName of ": and issuer of are used. + * + * @param accountName the name of the account/app along with a user identifier. + * @param issuer issuer of the TOTP(likely the app name). + * @returns A QRCode URL string. + */ + generateQrCodeUrl(_accountName?: string, _issuer?: string): string { + throw new Error('Unimplemented'); + } +} From c79e7fb4139f3770db80a35facbd29dbe12382c5 Mon Sep 17 00:00:00 2001 From: prameshj Date: Wed, 14 Sep 2022 15:33:31 -0700 Subject: [PATCH 02/26] Implement TOTP MFA enrollment flows (#6598) * Implement TOTP MFA enrollment. This includes changes to mfa_info, addition of TotpMultiFactorImpl and unit tests. * move all TOTP implementation into core/mfa/assertions. We do not need to restrict this to platform_browser. SMS mfa is in platform_browser since it requires a recaptcha step. * Include a reference to Auth in TotpSecret This is cleaner than looking up the app and auth instance with getApp and getAuth. * addressed review comments, added totp subdirectory. --- common/api-review/auth.api.md | 4 + .../src/api/account_management/mfa.test.ts | 137 ++++++++- .../auth/src/api/account_management/mfa.ts | 73 ++++- packages/auth/src/mfa/assertions/totp.ts | 78 ----- .../auth/src/mfa/assertions/totp/totp.test.ts | 282 ++++++++++++++++++ packages/auth/src/mfa/assertions/totp/totp.ts | 243 +++++++++++++++ packages/auth/src/mfa/mfa_info.test.ts | 51 +++- packages/auth/src/mfa/mfa_info.ts | 26 +- packages/auth/src/model/public_types.ts | 7 + .../platform_browser/mfa/assertions/totp.ts | 50 ---- 10 files changed, 812 insertions(+), 139 deletions(-) delete mode 100644 packages/auth/src/mfa/assertions/totp.ts create mode 100644 packages/auth/src/mfa/assertions/totp/totp.test.ts create mode 100644 packages/auth/src/mfa/assertions/totp/totp.ts delete mode 100644 packages/auth/src/platform_browser/mfa/assertions/totp.ts diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index 553feb40f20..b9d192e01cb 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -750,6 +750,10 @@ export function signOut(auth: Auth): Promise; export interface TotpMultiFactorAssertion extends MultiFactorAssertion { } +// @public +export interface TotpMultiFactorInfo extends MultiFactorInfo { +} + // @public export class TwitterAuthProvider extends BaseOAuthProvider { constructor(); diff --git a/packages/auth/src/api/account_management/mfa.test.ts b/packages/auth/src/api/account_management/mfa.test.ts index 83eda69416a..9f036471087 100644 --- a/packages/auth/src/api/account_management/mfa.test.ts +++ b/packages/auth/src/api/account_management/mfa.test.ts @@ -27,7 +27,9 @@ import * as mockFetch from '../../../test/helpers/mock_fetch'; import { ServerError } from '../errors'; import { finalizeEnrollPhoneMfa, + finalizeEnrollTotpMfa, startEnrollPhoneMfa, + startEnrollTotpMfa, withdrawMfa } from './mfa'; @@ -89,7 +91,7 @@ describe('api/account_management/startEnrollPhoneMfa', () => { await expect(startEnrollPhoneMfa(auth, request)).to.be.rejectedWith( FirebaseError, - "Firebase: This user's credential isn't valid for this project. This can happen if the user's token has been tampered with, or if the user isn't for the project associated with this API key. (auth/invalid-user-token)." + 'auth/invalid-user-token' ); expect(mock.calls[0].request).to.eql(request); }); @@ -152,6 +154,139 @@ describe('api/account_management/finalizeEnrollPhoneMfa', () => { ); await expect(finalizeEnrollPhoneMfa(auth, request)).to.be.rejectedWith( + FirebaseError, + 'auth/invalid-verification-id' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); + +describe('api/account_management/startEnrollTotpMfa', () => { + const request = { + idToken: 'id-token', + totpEnrollmentInfo: {} + }; + + let auth: TestAuth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const currentTime = new Date().toISOString(); + const mock = mockEndpoint(Endpoint.START_MFA_ENROLLMENT, { + totpSessionInfo: { + sharedSecretKey: 'key123', + verificationCodeLength: 6, + hashingAlgorithm: 'SHA256', + periodSec: 30, + sessionInfo: 'session-info', + finalizeEnrollmentTime: currentTime + } + }); + + const response = await startEnrollTotpMfa(auth, request); + expect(response.totpSessionInfo.sharedSecretKey).to.eq('key123'); + expect(response.totpSessionInfo.verificationCodeLength).to.eq(6); + expect(response.totpSessionInfo.hashingAlgorithm).to.eq('SHA256'); + expect(response.totpSessionInfo.periodSec).to.eq(30); + expect(response.totpSessionInfo.sessionInfo).to.eq('session-info'); + expect(response.totpSessionInfo.finalizeEnrollmentTime).to.eq(currentTime); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq( + 'application/json' + ); + expect(mock.calls[0].headers!.get(HttpHeader.X_CLIENT_VERSION)).to.eq( + 'testSDK/0.0.0' + ); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.START_MFA_ENROLLMENT, + { + error: { + code: 400, + message: ServerError.INVALID_ID_TOKEN, + errors: [ + { + message: ServerError.INVALID_ID_TOKEN + } + ] + } + }, + 400 + ); + + await expect(startEnrollTotpMfa(auth, request)).to.be.rejectedWith( + FirebaseError, + 'auth/invalid-user-token' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); + +describe('api/account_management/finalizeEnrollTotpMfa', () => { + const request = { + idToken: 'id-token', + displayName: 'my-otp-app', + totpVerificationInfo: { + sessionInfo: 'session-info', + verificationCode: 'code' + } + }; + + let auth: TestAuth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.FINALIZE_MFA_ENROLLMENT, { + idToken: 'id-token', + refreshToken: 'refresh-token' + }); + + const response = await finalizeEnrollTotpMfa(auth, request); + expect(response.idToken).to.eq('id-token'); + expect(response.refreshToken).to.eq('refresh-token'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq( + 'application/json' + ); + expect(mock.calls[0].headers!.get(HttpHeader.X_CLIENT_VERSION)).to.eq( + 'testSDK/0.0.0' + ); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.FINALIZE_MFA_ENROLLMENT, + { + error: { + code: 400, + message: ServerError.INVALID_SESSION_INFO, + errors: [ + { + message: ServerError.INVALID_SESSION_INFO + } + ] + } + }, + 400 + ); + + await expect(finalizeEnrollTotpMfa(auth, request)).to.be.rejectedWith( FirebaseError, 'Firebase: The verification ID used to create the phone auth credential is invalid. (auth/invalid-verification-id).' ); diff --git a/packages/auth/src/api/account_management/mfa.ts b/packages/auth/src/api/account_management/mfa.ts index a30a363a832..db9a4120d3d 100644 --- a/packages/auth/src/api/account_management/mfa.ts +++ b/packages/auth/src/api/account_management/mfa.ts @@ -26,7 +26,7 @@ import { FinalizeMfaResponse } from '../authentication/mfa'; import { AuthInternal } from '../../model/auth'; /** - * MFA Info as returned by the API + * MFA Info as returned by the API. */ interface BaseMfaEnrollment { mfaEnrollmentId: string; @@ -35,16 +35,21 @@ interface BaseMfaEnrollment { } /** - * An MFA provided by SMS verification + * An MFA provided by SMS verification. */ export interface PhoneMfaEnrollment extends BaseMfaEnrollment { phoneInfo: string; } /** - * MfaEnrollment can be any subtype of BaseMfaEnrollment, currently only PhoneMfaEnrollment is supported + * An MFA provided by TOTP (Time-based One Time Password). */ -export type MfaEnrollment = PhoneMfaEnrollment; +export interface TotpMfaEnrollment extends BaseMfaEnrollment {} + +/** + * MfaEnrollment can be any subtype of BaseMfaEnrollment, currently only PhoneMfaEnrollment and TotpMfaEnrollment are supported. + */ +export type MfaEnrollment = PhoneMfaEnrollment | TotpMfaEnrollment; export interface StartPhoneMfaEnrollmentRequest { idToken: string; @@ -100,6 +105,66 @@ export function finalizeEnrollPhoneMfa( _addTidIfNecessary(auth, request) ); } +export interface StartTotpMfaEnrollmentRequest { + idToken: string; + totpEnrollmentInfo: {}; + tenantId?: string; +} + +export interface StartTotpMfaEnrollmentResponse { + totpSessionInfo: { + sharedSecretKey: string; + verificationCodeLength: number; + hashingAlgorithm: string; + periodSec: number; + sessionInfo: string; + finalizeEnrollmentTime: number; + }; +} + +export function startEnrollTotpMfa( + auth: AuthInternal, + request: StartTotpMfaEnrollmentRequest +): Promise { + return _performApiRequest< + StartTotpMfaEnrollmentRequest, + StartTotpMfaEnrollmentResponse + >( + auth, + HttpMethod.POST, + Endpoint.START_MFA_ENROLLMENT, + _addTidIfNecessary(auth, request) + ); +} + +export interface TotpVerificationInfo { + sessionInfo: string; + verificationCode: string; +} +export interface FinalizeTotpMfaEnrollmentRequest { + idToken: string; + totpVerificationInfo: TotpVerificationInfo; + displayName?: string | null; + tenantId?: string; +} + +export interface FinalizeTotpMfaEnrollmentResponse + extends FinalizeMfaResponse {} + +export function finalizeEnrollTotpMfa( + auth: AuthInternal, + request: FinalizeTotpMfaEnrollmentRequest +): Promise { + return _performApiRequest< + FinalizeTotpMfaEnrollmentRequest, + FinalizeTotpMfaEnrollmentResponse + >( + auth, + HttpMethod.POST, + Endpoint.FINALIZE_MFA_ENROLLMENT, + _addTidIfNecessary(auth, request) + ); +} export interface WithdrawMfaRequest { idToken: string; diff --git a/packages/auth/src/mfa/assertions/totp.ts b/packages/auth/src/mfa/assertions/totp.ts deleted file mode 100644 index 8fb437cef35..00000000000 --- a/packages/auth/src/mfa/assertions/totp.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * @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 { TotpSecret } from '../../platform_browser/mfa/assertions/totp'; -import { - TotpMultiFactorAssertion, - MultiFactorSession, - FactorId -} from '../../model/public_types'; -/** - * Provider for generating a {@link TotpMultiFactorAssertion}. - * - * @public - */ -export class TotpMultiFactorGenerator { - /** - * Provides a {@link TotpMultiFactorAssertion} to confirm ownership of - * the totp(Time-based One Time Password) second factor. - * This assertion is used to complete enrollment in TOTP second factor. - * - * @param secret {@link TotpSecret}. - * @param oneTimePassword One-time password from TOTP App. - * @returns A {@link TotpMultiFactorAssertion} which can be used with - * {@link MultiFactorUser.enroll}. - */ - static assertionForEnrollment( - _secret: TotpSecret, - _oneTimePassword: string - ): TotpMultiFactorAssertion { - throw new Error('Unimplemented'); - } - /** - * Provides a {@link TotpMultiFactorAssertion} to confirm ownership of the totp second factor. - * This assertion is used to complete signIn with TOTP as the second factor. - * - * @param enrollmentId identifies the enrolled TOTP second factor. - * @param otp One-time password from TOTP App. - * @returns A {@link TotpMultiFactorAssertion} which can be used with - * {@link MultiFactorResolver.resolveSignIn}. - */ - static assertionForSignIn( - _enrollmentId: string, - _otp: string - ): TotpMultiFactorAssertion { - throw new Error('Unimplemented'); - } - /** - * Returns a promise to {@link TOTPSecret} which contains the TOTP shared secret key and other parameters. - * Creates a TOTP secret as part of enrolling a TOTP second factor. - * Used for generating a QRCode URL or inputting into a TOTP App. - * This method uses the auth instance corresponding to the user in the multiFactorSession. - * - * @param session A link to {@MultiFactorSession}. - * @returns A promise to {@link TotpSecret}. - */ - static async generateSecret( - _session: MultiFactorSession - ): Promise { - throw new Error('Unimplemented'); - } - /** - * The identifier of the TOTP second factor: `totp`. - */ - static FACTOR_ID = FactorId.TOTP; -} diff --git a/packages/auth/src/mfa/assertions/totp/totp.test.ts b/packages/auth/src/mfa/assertions/totp/totp.test.ts new file mode 100644 index 00000000000..c67a091a957 --- /dev/null +++ b/packages/auth/src/mfa/assertions/totp/totp.test.ts @@ -0,0 +1,282 @@ +/** + * @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 { mockEndpoint } from '../../../../test/helpers/api/helper'; +import { + testAuth, + TestAuth, + testUser +} from '../../../../test/helpers/mock_auth'; +import * as mockFetch from '../../../../test/helpers/mock_fetch'; +import { Endpoint } from '../../../api'; +import { MultiFactorSessionImpl } from '../../../mfa/mfa_session'; +import { StartTotpMfaEnrollmentResponse } from '../../../api/account_management/mfa'; +import { FinalizeMfaResponse } from '../../../api/authentication/mfa'; +import { + TotpMultiFactorAssertionImpl, + TotpMultiFactorGenerator, + TotpSecret +} from './totp'; +import { FactorId } from '../../../model/public_types'; +import { AuthErrorCode } from '../../../core/errors'; +import { AppName } from '../../../model/auth'; +import { _castAuth } from '../../../core/auth/auth_impl'; + +use(chaiAsPromised); + +describe('core/mfa/assertions/totp/TotpMultiFactorGenerator', () => { + let auth: TestAuth; + let session: MultiFactorSessionImpl; + const startEnrollmentResponse: StartTotpMfaEnrollmentResponse = { + totpSessionInfo: { + sharedSecretKey: 'key123', + verificationCodeLength: 6, + hashingAlgorithm: 'SHA1', + periodSec: 30, + sessionInfo: 'verification-id', + finalizeEnrollmentTime: 1662586196 + } + }; + describe('assertionForEnrollment', () => { + it('should generate a valid TOTP assertion for enrollment', async () => { + auth = await testAuth(); + const secret = TotpSecret._fromStartTotpMfaEnrollmentResponse( + startEnrollmentResponse, + auth + ); + const assertion = TotpMultiFactorGenerator.assertionForEnrollment( + secret, + '123456' + ); + expect(assertion.factorId).to.eql(FactorId.TOTP); + }); + }); + + describe('assertionForSignIn', () => { + it('should generate a valid TOTP assertion for sign in', () => { + const assertion = TotpMultiFactorGenerator.assertionForSignIn( + 'enrollmentId', + '123456' + ); + expect(assertion.factorId).to.eql(FactorId.TOTP); + }); + }); + + describe('generateSecret', () => { + beforeEach(async () => { + mockFetch.setUp(); + }); + afterEach(mockFetch.tearDown); + + it('should throw error if auth instance is not found in mfaSession', async () => { + try { + session = MultiFactorSessionImpl._fromIdtoken( + 'enrollment-id-token', + undefined + ); + await TotpMultiFactorGenerator.generateSecret(session); + } catch (e) { + expect(e.code).to.eql(`auth/${AuthErrorCode.INTERNAL_ERROR}`); + } + }); + it('generateSecret should generate a valid secret by starting enrollment', async () => { + const mock = mockEndpoint( + Endpoint.START_MFA_ENROLLMENT, + startEnrollmentResponse + ); + + auth = await testAuth(); + session = MultiFactorSessionImpl._fromIdtoken( + 'enrollment-id-token', + auth + ); + const secret = await TotpMultiFactorGenerator.generateSecret(session); + expect(mock.calls[0].request).to.eql({ + idToken: 'enrollment-id-token', + totpEnrollmentInfo: {} + }); + expect(secret.secretKey).to.eql( + startEnrollmentResponse.totpSessionInfo.sharedSecretKey + ); + expect(secret.codeIntervalSeconds).to.eq( + startEnrollmentResponse.totpSessionInfo.periodSec + ); + expect(secret.codeLength).to.eq( + startEnrollmentResponse.totpSessionInfo.verificationCodeLength + ); + expect(secret.hashingAlgorithm).to.eq( + startEnrollmentResponse.totpSessionInfo.hashingAlgorithm + ); + }); + }); +}); + +describe('core/mfa/totp/assertions/TotpMultiFactorAssertionImpl', () => { + let auth: TestAuth; + let assertion: TotpMultiFactorAssertionImpl; + let session: MultiFactorSessionImpl; + let secret: TotpSecret; + + const serverResponse: FinalizeMfaResponse = { + idToken: 'final-id-token', + refreshToken: 'refresh-token' + }; + + const startEnrollmentResponse: StartTotpMfaEnrollmentResponse = { + totpSessionInfo: { + sharedSecretKey: 'key123', + verificationCodeLength: 6, + hashingAlgorithm: 'SHA1', + periodSec: 30, + sessionInfo: 'verification-id', + finalizeEnrollmentTime: 1662586196 + } + }; + + beforeEach(async () => { + mockFetch.setUp(); + auth = await testAuth(); + secret = TotpSecret._fromStartTotpMfaEnrollmentResponse( + startEnrollmentResponse, + auth + ); + assertion = TotpMultiFactorAssertionImpl._fromSecret(secret, '123456'); + }); + afterEach(mockFetch.tearDown); + + describe('enroll', () => { + beforeEach(() => { + session = MultiFactorSessionImpl._fromIdtoken( + 'enrollment-id-token', + auth + ); + }); + + it('should finalize the MFA enrollment', async () => { + const mock = mockEndpoint( + Endpoint.FINALIZE_MFA_ENROLLMENT, + serverResponse + ); + const response = await assertion._process(auth, session); + expect(response).to.eql(serverResponse); + expect(mock.calls[0].request).to.eql({ + idToken: 'enrollment-id-token', + totpVerificationInfo: { + verificationCode: '123456', + sessionInfo: 'verification-id' + } + }); + expect(session.auth).to.eql(auth); + }); + + context('with display name', () => { + it('should set the display name', async () => { + const mock = mockEndpoint( + Endpoint.FINALIZE_MFA_ENROLLMENT, + serverResponse + ); + const response = await assertion._process( + auth, + session, + 'display-name' + ); + expect(response).to.eql(serverResponse); + expect(mock.calls[0].request).to.eql({ + idToken: 'enrollment-id-token', + displayName: 'display-name', + totpVerificationInfo: { + verificationCode: '123456', + sessionInfo: 'verification-id' + } + }); + expect(session.auth).to.eql(auth); + }); + }); + }); +}); + +describe('core/mfa/assertions/totp/TotpSecret', async () => { + const serverResponse: StartTotpMfaEnrollmentResponse = { + totpSessionInfo: { + sharedSecretKey: 'key123', + verificationCodeLength: 6, + hashingAlgorithm: 'SHA1', + periodSec: 30, + sessionInfo: 'verification-id', + finalizeEnrollmentTime: 1662586196 + } + }; + // this is the name used by the fake app in testAuth(). + const fakeAppName: AppName = 'test-app'; + const fakeEmail: string = 'user@email'; + const auth = await testAuth(); + const secret = TotpSecret._fromStartTotpMfaEnrollmentResponse( + serverResponse, + auth + ); + + describe('fromStartTotpMfaEnrollmentResponse', () => { + it('fields from the response are parsed correctly', () => { + expect(secret.secretKey).to.eq('key123'); + expect(secret.codeIntervalSeconds).to.eq(30); + expect(secret.codeLength).to.eq(6); + expect(secret.hashingAlgorithm).to.eq('SHA1'); + }); + }); + describe('generateQrCodeUrl', () => { + beforeEach(async () => { + await auth.updateCurrentUser( + testUser(_castAuth(auth), 'uid', fakeEmail, true) + ); + }); + + it('with account name and issuer provided', () => { + const url = secret.generateQrCodeUrl('user@myawesomeapp', 'myawesomeapp'); + expect(url).to.eq( + 'otpauth://totp/myawesomeapp:user@myawesomeapp?secret=key123&issuer=myawesomeapp&algorithm=SHA1&digits=6' + ); + }); + it('only accountName provided', () => { + const url = secret.generateQrCodeUrl('user@myawesomeapp', ''); + expect(url).to.eq( + `otpauth://totp/${fakeAppName}:user@myawesomeapp?secret=key123&issuer=${fakeAppName}&algorithm=SHA1&digits=6` + ); + }); + it('only issuer provided', () => { + const url = secret.generateQrCodeUrl('', 'myawesomeapp'); + expect(url).to.eq( + `otpauth://totp/myawesomeapp:${fakeEmail}?secret=key123&issuer=myawesomeapp&algorithm=SHA1&digits=6` + ); + }); + it('with defaults', () => { + const url = secret.generateQrCodeUrl(); + expect(url).to.eq( + `otpauth://totp/${fakeAppName}:${fakeEmail}?secret=key123&issuer=${fakeAppName}&algorithm=SHA1&digits=6` + ); + }); + it('with defaults, without currentUser', async () => { + await auth.updateCurrentUser(null); + const url = secret.generateQrCodeUrl(); + expect(url).to.eq( + `otpauth://totp/${fakeAppName}:unknownuser?secret=key123&issuer=${fakeAppName}&algorithm=SHA1&digits=6` + ); + }); + }); +}); diff --git a/packages/auth/src/mfa/assertions/totp/totp.ts b/packages/auth/src/mfa/assertions/totp/totp.ts new file mode 100644 index 00000000000..ec04908b058 --- /dev/null +++ b/packages/auth/src/mfa/assertions/totp/totp.ts @@ -0,0 +1,243 @@ +/** + * @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 { + TotpMultiFactorAssertion, + MultiFactorSession, + FactorId +} from '../../../model/public_types'; +import { AuthInternal } from '../../../model/auth'; +import { + finalizeEnrollTotpMfa, + startEnrollTotpMfa, + StartTotpMfaEnrollmentResponse, + TotpVerificationInfo +} from '../../../api/account_management/mfa'; +import { FinalizeMfaResponse } from '../../../api/authentication/mfa'; +import { MultiFactorAssertionImpl } from '../../../mfa/mfa_assertion'; +import { MultiFactorSessionImpl } from '../../mfa_session'; +import { AuthErrorCode } from '../../../core/errors'; +import { _assert } from '../../../core/util/assert'; + +/** + * Provider for generating a {@link TotpMultiFactorAssertion}. + * + * @public + */ +export class TotpMultiFactorGenerator { + /** + * Provides a {@link TotpMultiFactorAssertion} to confirm ownership of + * the totp(Time-based One Time Password) second factor. + * This assertion is used to complete enrollment in TOTP second factor. + * + * @param secret {@link TotpSecret}. + * @param oneTimePassword One-time password from TOTP App. + * @returns A {@link TotpMultiFactorAssertion} which can be used with + * {@link MultiFactorUser.enroll}. + */ + static assertionForEnrollment( + secret: TotpSecret, + oneTimePassword: string + ): TotpMultiFactorAssertion { + return TotpMultiFactorAssertionImpl._fromSecret(secret, oneTimePassword); + } + + /** + * Provides a {@link TotpMultiFactorAssertion} to confirm ownership of the totp second factor. + * This assertion is used to complete signIn with TOTP as the second factor. + * + * @param enrollmentId identifies the enrolled TOTP second factor. + * @param oneTimePassword One-time password from TOTP App. + * @returns A {@link TotpMultiFactorAssertion} which can be used with + * {@link MultiFactorResolver.resolveSignIn}. + */ + static assertionForSignIn( + enrollmentId: string, + oneTimePassword: string + ): TotpMultiFactorAssertion { + return TotpMultiFactorAssertionImpl._fromEnrollmentId( + enrollmentId, + oneTimePassword + ); + } + + /** + * Returns a promise to {@link TOTPSecret} which contains the TOTP shared secret key and other parameters. + * Creates a TOTP secret as part of enrolling a TOTP second factor. + * Used for generating a QRCode URL or inputting into a TOTP App. + * This method uses the auth instance corresponding to the user in the multiFactorSession. + * + * @param session A link to {@MultiFactorSession}. + * @returns A promise to {@link TotpSecret}. + */ + static async generateSecret( + session: MultiFactorSession + ): Promise { + const mfaSession = session as MultiFactorSessionImpl; + _assert( + typeof mfaSession.auth !== 'undefined', + AuthErrorCode.INTERNAL_ERROR + ); + const response = await startEnrollTotpMfa(mfaSession.auth, { + idToken: mfaSession.credential, + totpEnrollmentInfo: {} + }); + return TotpSecret._fromStartTotpMfaEnrollmentResponse( + response, + mfaSession.auth + ); + } + + /** + * The identifier of the TOTP second factor: `totp`. + */ + static FACTOR_ID = FactorId.TOTP; +} + +export class TotpMultiFactorAssertionImpl + extends MultiFactorAssertionImpl + implements TotpMultiFactorAssertion +{ + constructor( + readonly otp: string, + readonly enrollmentId?: string, + readonly secret?: TotpSecret + ) { + super(FactorId.TOTP); + } + + /** @internal */ + static _fromSecret( + secret: TotpSecret, + otp: string + ): TotpMultiFactorAssertionImpl { + return new TotpMultiFactorAssertionImpl(otp, undefined, secret); + } + + /** @internal */ + static _fromEnrollmentId( + enrollmentId: string, + otp: string + ): TotpMultiFactorAssertionImpl { + return new TotpMultiFactorAssertionImpl(otp, enrollmentId); + } + + /** @internal */ + _finalizeEnroll( + auth: AuthInternal, + idToken: string, + displayName?: string | null + ): Promise { + _assert( + typeof this.secret !== 'undefined', + auth, + AuthErrorCode.ARGUMENT_ERROR + ); + return finalizeEnrollTotpMfa(auth, { + idToken, + displayName, + totpVerificationInfo: this.secret._makeTotpVerificationInfo(this.otp) + }); + } + + /** @internal */ + _finalizeSignIn( + _auth: AuthInternal, + _mfaPendingCredential: string + ): Promise { + throw new Error('method not implemented'); + } +} + +/** + * Provider for generating a {@link TotpMultiFactorAssertion}. + * + * Stores the shared secret key and other parameters to generate time-based OTPs. + * Implements methods to retrieve the shared secret key, generate a QRCode URL. + * @public + */ +export class TotpSecret { + /** + * Constructor for TotpSecret. + * @param secretKey - Shared secret key/seed used for enrolling in TOTP MFA and generating otps. + * @param hashingAlgorithm - Hashing algorithm used. + * @param codeLength - Length of the one-time passwords to be generated. + * @param codeIntervalSeconds - The interval (in seconds) when the OTP codes should change. + */ + private constructor( + readonly secretKey: string, + readonly hashingAlgorithm: string, + readonly codeLength: number, + readonly codeIntervalSeconds: number, + // TODO(prameshj) - make this public after API review. + // This can be used by callers to show a countdown of when to enter OTP code by. + private readonly finalizeEnrollmentBy: string, + private readonly sessionInfo: string, + private readonly auth: AuthInternal + ) {} + + /** @internal */ + static _fromStartTotpMfaEnrollmentResponse( + response: StartTotpMfaEnrollmentResponse, + auth: AuthInternal + ): TotpSecret { + return new TotpSecret( + response.totpSessionInfo.sharedSecretKey, + response.totpSessionInfo.hashingAlgorithm, + response.totpSessionInfo.verificationCodeLength, + response.totpSessionInfo.periodSec, + new Date(response.totpSessionInfo.finalizeEnrollmentTime).toUTCString(), + response.totpSessionInfo.sessionInfo, + auth + ); + } + + /** @internal */ + _makeTotpVerificationInfo(otp: string): TotpVerificationInfo { + return { sessionInfo: this.sessionInfo, verificationCode: otp }; + } + + /** + * Returns a QRCode URL as described in + * https://github.com/google/google-authenticator/wiki/Key-Uri-Format + * This can be displayed to the user as a QRCode to be scanned into a TOTP App like Google Authenticator. + * If the optional parameters are unspecified, an accountName of and issuer of are used. + * + * @param accountName the name of the account/app along with a user identifier. + * @param issuer issuer of the TOTP(likely the app name). + * @returns A QRCode URL string. + */ + generateQrCodeUrl(accountName?: string, issuer?: string): string { + let useDefaults = false; + if (_isEmptyString(accountName) || _isEmptyString(issuer)) { + useDefaults = true; + } + if (useDefaults) { + if (_isEmptyString(accountName)) { + accountName = this.auth.currentUser?.email || 'unknownuser'; + } + if (_isEmptyString(issuer)) { + issuer = this.auth.name; + } + } + return `otpauth://totp/${issuer}:${accountName}?secret=${this.secretKey}&issuer=${issuer}&algorithm=${this.hashingAlgorithm}&digits=${this.codeLength}`; + } +} + +/** @internal */ +function _isEmptyString(input?: string): boolean { + return typeof input === 'undefined' || input?.length === 0; +} diff --git a/packages/auth/src/mfa/mfa_info.test.ts b/packages/auth/src/mfa/mfa_info.test.ts index 91f0c6175e0..47df7f53a0f 100644 --- a/packages/auth/src/mfa/mfa_info.test.ts +++ b/packages/auth/src/mfa/mfa_info.test.ts @@ -18,7 +18,7 @@ import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; -import { ProviderId } from '../model/enums'; +import { FactorId } from '../model/public_types'; import { FirebaseError } from '@firebase/util'; import { testAuth, TestAuth } from '../../test/helpers/mock_auth'; @@ -27,7 +27,7 @@ import { MultiFactorInfoImpl } from './mfa_info'; use(chaiAsPromised); -describe('core/mfa/mfa_info/MultiFactorInfo', () => { +describe('core/mfa/mfa_info/MultiFactorInfo for Phone MFA', () => { let auth: TestAuth; beforeEach(async () => { @@ -49,7 +49,7 @@ describe('core/mfa/mfa_info/MultiFactorInfo', () => { auth, enrollmentInfo ); - expect(mfaInfo.factorId).to.eq(ProviderId.PHONE); + expect(mfaInfo.factorId).to.eq(FactorId.PHONE); expect(mfaInfo.uid).to.eq('uid'); expect(mfaInfo.enrollmentTime).to.eq(new Date(date).toUTCString()); expect(mfaInfo.displayName).to.eq('display-name'); @@ -74,3 +74,48 @@ describe('core/mfa/mfa_info/MultiFactorInfo', () => { }); }); }); + +describe('core/mfa/mfa_info/MultiFactorInfo for TOTP MFA', () => { + let auth: TestAuth; + + beforeEach(async () => { + auth = await testAuth(); + }); + + describe('_fromServerResponse', () => { + context('TOTP enrollment', () => { + const date = Date.now(); + const enrollmentInfo = { + mfaEnrollmentId: 'uid', + enrolledAt: date, + displayName: 'display-name', + totpInfo: {} + }; + + it('should create a valid MfaInfo', () => { + const mfaInfo = MultiFactorInfoImpl._fromServerResponse( + auth, + enrollmentInfo + ); + expect(mfaInfo.factorId).to.eq(FactorId.TOTP); + expect(mfaInfo.uid).to.eq('uid'); + expect(mfaInfo.enrollmentTime).to.eq(new Date(date).toUTCString()); + expect(mfaInfo.displayName).to.eq('display-name'); + }); + }); + + context('Invalid enrollment, no totp or phone info found', () => { + const enrollmentInfo = { + mfaEnrollmentId: 'uid', + enrolledAt: Date.now(), + displayName: 'display-name' + }; + + it('should throw an error', () => { + expect(() => + MultiFactorInfoImpl._fromServerResponse(auth, enrollmentInfo) + ).to.throw(FirebaseError, 'auth/internal-error'); + }); + }); + }); +}); diff --git a/packages/auth/src/mfa/mfa_info.ts b/packages/auth/src/mfa/mfa_info.ts index a1cb41b12af..4059a234be4 100644 --- a/packages/auth/src/mfa/mfa_info.ts +++ b/packages/auth/src/mfa/mfa_info.ts @@ -18,11 +18,13 @@ import { FactorId, MultiFactorInfo, - PhoneMultiFactorInfo + PhoneMultiFactorInfo, + TotpMultiFactorInfo } from '../model/public_types'; import { PhoneMfaEnrollment, - MfaEnrollment + MfaEnrollment, + TotpMfaEnrollment } from '../api/account_management/mfa'; import { AuthErrorCode } from '../core/errors'; import { _fail } from '../core/util/assert'; @@ -45,6 +47,9 @@ export abstract class MultiFactorInfoImpl implements MultiFactorInfo { ): MultiFactorInfoImpl { if ('phoneInfo' in enrollment) { return PhoneMultiFactorInfoImpl._fromServerResponse(auth, enrollment); + } else if ('totpInfo' in enrollment) { + // TODO(prameshj) ensure that this field is set by the backend once the tracking bug is fixed. + return TotpMultiFactorInfoImpl._fromServerResponse(auth, enrollment); } return _fail(auth, AuthErrorCode.INTERNAL_ERROR); } @@ -65,6 +70,21 @@ export class PhoneMultiFactorInfoImpl _auth: AuthInternal, enrollment: MfaEnrollment ): PhoneMultiFactorInfoImpl { - return new PhoneMultiFactorInfoImpl(enrollment); + return new PhoneMultiFactorInfoImpl(enrollment as PhoneMfaEnrollment); + } +} +export class TotpMultiFactorInfoImpl + extends MultiFactorInfoImpl + implements TotpMultiFactorInfo +{ + private constructor(response: TotpMfaEnrollment) { + super(FactorId.TOTP, response); + } + + static _fromServerResponse( + _auth: AuthInternal, + enrollment: MfaEnrollment + ): TotpMultiFactorInfoImpl { + return new TotpMultiFactorInfoImpl(enrollment as TotpMfaEnrollment); } } diff --git a/packages/auth/src/model/public_types.ts b/packages/auth/src/model/public_types.ts index 31437ec5dea..d65fcf840bb 100644 --- a/packages/auth/src/model/public_types.ts +++ b/packages/auth/src/model/public_types.ts @@ -659,6 +659,13 @@ export interface PhoneMultiFactorInfo extends MultiFactorInfo { readonly phoneNumber: string; } +/** + * The subclass of the {@link MultiFactorInfo} interface for TOTP + * second factors. The `factorId` of this second factor is {@link FactorId}.TOTP. + * @public + */ +export interface TotpMultiFactorInfo extends MultiFactorInfo {} + /** * The class used to facilitate recovery from {@link MultiFactorError} when a user needs to * provide a second factor to sign in. diff --git a/packages/auth/src/platform_browser/mfa/assertions/totp.ts b/packages/auth/src/platform_browser/mfa/assertions/totp.ts deleted file mode 100644 index d62813d8d44..00000000000 --- a/packages/auth/src/platform_browser/mfa/assertions/totp.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @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. - */ - -/** - * Stores the shared secret key and other parameters to generate time-based OTPs. - * Implements methods to retrieve the shared secret key, generate a QRCode URL. - * @public - */ -export class TotpSecret { - /** - * Constructor for TotpSecret. - * @param secretKey - Shared secret key/seed used for enrolling in TOTP MFA and generating otps. - * @param hashingAlgorithm - Hashing algorithm used. - * @param codeLength - Length of the one-time passwords to be generated. - * @param codeIntervalSeconds - The interval (in seconds) when the OTP codes should change. - */ - constructor( - readonly secretKey: string, - readonly hashingAlgorithm: string, - readonly codeLength: number, - readonly codeIntervalSeconds: number - ) {} - /** - * Returns a QRCode URL as described in - * https://github.com/google/google-authenticator/wiki/Key-Uri-Format - * This can be displayed to the user as a QRCode to be scanned into a TOTP App like Google Authenticator. - * If the optional parameters are unspecified, an accountName of ": and issuer of are used. - * - * @param accountName the name of the account/app along with a user identifier. - * @param issuer issuer of the TOTP(likely the app name). - * @returns A QRCode URL string. - */ - generateQrCodeUrl(_accountName?: string, _issuer?: string): string { - throw new Error('Unimplemented'); - } -} From e11ed0b6bea14fe15d42a9a32acfe3e66d616c8a Mon Sep 17 00:00:00 2001 From: prameshj Date: Fri, 16 Sep 2022 14:16:41 -0700 Subject: [PATCH 03/26] restore totp*.ts into mfa/assertions directory. (#6602) The same pattern is followed in rest of the auth codebase. (ex- src/platform_browser/mfa/assertions/) --- .../mfa/assertions/{totp => }/totp.test.ts | 26 ++++++++----------- .../src/mfa/assertions/{totp => }/totp.ts | 16 ++++++------ 2 files changed, 19 insertions(+), 23 deletions(-) rename packages/auth/src/mfa/assertions/{totp => }/totp.test.ts (92%) rename packages/auth/src/mfa/assertions/{totp => }/totp.ts (94%) diff --git a/packages/auth/src/mfa/assertions/totp/totp.test.ts b/packages/auth/src/mfa/assertions/totp.test.ts similarity index 92% rename from packages/auth/src/mfa/assertions/totp/totp.test.ts rename to packages/auth/src/mfa/assertions/totp.test.ts index c67a091a957..17c8f323f08 100644 --- a/packages/auth/src/mfa/assertions/totp/totp.test.ts +++ b/packages/auth/src/mfa/assertions/totp.test.ts @@ -18,26 +18,22 @@ import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; -import { mockEndpoint } from '../../../../test/helpers/api/helper'; -import { - testAuth, - TestAuth, - testUser -} from '../../../../test/helpers/mock_auth'; -import * as mockFetch from '../../../../test/helpers/mock_fetch'; -import { Endpoint } from '../../../api'; -import { MultiFactorSessionImpl } from '../../../mfa/mfa_session'; -import { StartTotpMfaEnrollmentResponse } from '../../../api/account_management/mfa'; -import { FinalizeMfaResponse } from '../../../api/authentication/mfa'; +import { mockEndpoint } from '../../../test/helpers/api/helper'; +import { testAuth, TestAuth, testUser } from '../../../test/helpers/mock_auth'; +import * as mockFetch from '../../../test/helpers/mock_fetch'; +import { Endpoint } from '../../api'; +import { MultiFactorSessionImpl } from '../../mfa/mfa_session'; +import { StartTotpMfaEnrollmentResponse } from '../../api/account_management/mfa'; +import { FinalizeMfaResponse } from '../../api/authentication/mfa'; import { TotpMultiFactorAssertionImpl, TotpMultiFactorGenerator, TotpSecret } from './totp'; -import { FactorId } from '../../../model/public_types'; -import { AuthErrorCode } from '../../../core/errors'; -import { AppName } from '../../../model/auth'; -import { _castAuth } from '../../../core/auth/auth_impl'; +import { FactorId } from '../../model/public_types'; +import { AuthErrorCode } from '../../core/errors'; +import { AppName } from '../../model/auth'; +import { _castAuth } from '../../core/auth/auth_impl'; use(chaiAsPromised); diff --git a/packages/auth/src/mfa/assertions/totp/totp.ts b/packages/auth/src/mfa/assertions/totp.ts similarity index 94% rename from packages/auth/src/mfa/assertions/totp/totp.ts rename to packages/auth/src/mfa/assertions/totp.ts index ec04908b058..39cc18376e5 100644 --- a/packages/auth/src/mfa/assertions/totp/totp.ts +++ b/packages/auth/src/mfa/assertions/totp.ts @@ -18,19 +18,19 @@ import { TotpMultiFactorAssertion, MultiFactorSession, FactorId -} from '../../../model/public_types'; -import { AuthInternal } from '../../../model/auth'; +} from '../../model/public_types'; +import { AuthInternal } from '../../model/auth'; import { finalizeEnrollTotpMfa, startEnrollTotpMfa, StartTotpMfaEnrollmentResponse, TotpVerificationInfo -} from '../../../api/account_management/mfa'; -import { FinalizeMfaResponse } from '../../../api/authentication/mfa'; -import { MultiFactorAssertionImpl } from '../../../mfa/mfa_assertion'; -import { MultiFactorSessionImpl } from '../../mfa_session'; -import { AuthErrorCode } from '../../../core/errors'; -import { _assert } from '../../../core/util/assert'; +} from '../../api/account_management/mfa'; +import { FinalizeMfaResponse } from '../../api/authentication/mfa'; +import { MultiFactorAssertionImpl } from '../../mfa/mfa_assertion'; +import { MultiFactorSessionImpl } from '../mfa_session'; +import { AuthErrorCode } from '../../core/errors'; +import { _assert } from '../../core/util/assert'; /** * Provider for generating a {@link TotpMultiFactorAssertion}. From 524f1d129ec1da61bebe85b8c36c4be14886abef Mon Sep 17 00:00:00 2001 From: bhparijat Date: Wed, 28 Sep 2022 16:06:52 -0700 Subject: [PATCH 04/26] Demo-app readme fix (#6630) * updated readme --- packages/auth/demo/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/auth/demo/README.md b/packages/auth/demo/README.md index beb5a800bc5..f29127ab074 100644 --- a/packages/auth/demo/README.md +++ b/packages/auth/demo/README.md @@ -45,12 +45,21 @@ in the `config.js` file. Before deploying, you may need to build the auth package: ```bash +cd auth/demo yarn yarn build:deps ``` This can take some time, and you only need to do it if you've modified the auth package. +You can optionally clear the cache and rebuild using: + +```bash +cd auth/demo +rm -rf node_modules yarn.lock +yarn build:deps +``` + To run the app locally, simply issue the following command in the `auth/demo` directory: ```bash From b6c736f1a8db3137d04e245d3ff04122034d1ca3 Mon Sep 17 00:00:00 2001 From: prameshj Date: Wed, 5 Oct 2022 13:38:56 -0700 Subject: [PATCH 05/26] Mfa totp demoapp (#6629) * Export TOTP symbols to be picked up by demo app. * Update the demo app to support TOTP enrollment, use local firebase auth version. The QR code image is generated using the qrserver api at https://goqr.me/api/doc/ --- common/api-review/auth.api.md | 26 ++++++++++++ packages/auth/demo/public/index.html | 28 ++++++++++++- packages/auth/demo/public/style.css | 11 +++++ packages/auth/demo/src/index.js | 51 ++++++++++++++++++++++++ packages/auth/index.ts | 6 +++ packages/auth/src/mfa/assertions/totp.ts | 44 ++++++++++++++------ 6 files changed, 152 insertions(+), 14 deletions(-) diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index b9d192e01cb..738c72048bb 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -750,10 +750,36 @@ export function signOut(auth: Auth): Promise; export interface TotpMultiFactorAssertion extends MultiFactorAssertion { } +// @public +export class TotpMultiFactorGenerator { + static assertionForEnrollment(secret: TotpSecret, oneTimePassword: string): TotpMultiFactorAssertion; + static assertionForSignIn(enrollmentId: string, oneTimePassword: string): TotpMultiFactorAssertion; + // Warning: (ae-forgotten-export) The symbol "FactorId" needs to be exported by the entry point index.d.ts + static FACTOR_ID: FactorId_2; + static generateSecret(session: MultiFactorSession): Promise; +} + // @public export interface TotpMultiFactorInfo extends MultiFactorInfo { } +// @public +export class TotpSecret { + readonly codeIntervalSeconds: number; + readonly codeLength: number; + // Warning: (ae-forgotten-export) The symbol "StartTotpMfaEnrollmentResponse" needs to be exported by the entry point index.d.ts + // + // @internal (undocumented) + static _fromStartTotpMfaEnrollmentResponse(response: StartTotpMfaEnrollmentResponse, auth: AuthInternal): TotpSecret; + generateQrCodeUrl(accountName?: string, issuer?: string): string; + readonly hashingAlgorithm: string; + // Warning: (ae-forgotten-export) The symbol "TotpVerificationInfo" needs to be exported by the entry point index.d.ts + // + // @internal (undocumented) + _makeTotpVerificationInfo(otp: string): TotpVerificationInfo; + readonly secretKey: string; + } + // @public export class TwitterAuthProvider extends BaseOAuthProvider { constructor(); diff --git a/packages/auth/demo/public/index.html b/packages/auth/demo/public/index.html index 624b6e3c0e7..86290860583 100644 --- a/packages/auth/demo/public/index.html +++ b/packages/auth/demo/public/index.html @@ -487,6 +487,12 @@ Phone +
  • + + TOTP + +
  • @@ -504,7 +510,27 @@ class="form-control" placeholder="Display Name" /> + + +
    +
    +
    + +
    + +
    + +
    + + +
    diff --git a/packages/auth/demo/public/style.css b/packages/auth/demo/public/style.css index cdd999f8e8b..84750c29ea1 100644 --- a/packages/auth/demo/public/style.css +++ b/packages/auth/demo/public/style.css @@ -177,6 +177,17 @@ input + .form, margin-right: 10px; } +.totp-text { + font-family: 'Courier New', Courier; +} +.totp-qr-image { + height: 120px; + margin-right: 10px; + border: 1px solid #ddd; + border-radius: 4px; + width: 120px; +} + .profile-email-not-verified { color: #d9534f; } diff --git a/packages/auth/demo/src/index.js b/packages/auth/demo/src/index.js index 8b924a57079..40701616293 100644 --- a/packages/auth/demo/src/index.js +++ b/packages/auth/demo/src/index.js @@ -49,6 +49,8 @@ import { signInWithCredential, signInWithCustomToken, signInWithEmailAndPassword, + TotpMultiFactorGenerator, + TotpSecret, unlink, updateEmail, updatePassword, @@ -97,6 +99,7 @@ let multiFactorErrorResolver = null; let selectedMultiFactorHint = null; let recaptchaSize = 'normal'; let webWorker = null; +let totpSecret = null; // The corresponding Font Awesome icons for each provider. const providersIcons = { @@ -687,6 +690,50 @@ function onFinalizeEnrollWithPhoneMultiFactor() { }, onAuthError); } +async function onStartEnrollWithTotpMultiFactor() { + console.log('Starting TOTP enrollment!'); + if (!activeUser()) { + alertError('No active user found.'); + return; + } + try { + multiFactorSession = await multiFactor(activeUser()).getSession(); + totpSecret = await TotpMultiFactorGenerator.generateSecret( + multiFactorSession + ); + const url = totpSecret.generateQrCodeUrl('test', 'testissuer'); + console.log('TOTP URL is ' + url); + // Use the QRServer API documented at https://goqr.me/api/doc/ + const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?data=${url}&size=30x30`; + $('img.totp-qr-image').attr('src', qrCodeUrl).show(); + $('p.totp-text').show(); + } catch (e) { + onAuthError(e); + } +} + +async function onFinalizeEnrollWithTotpMultiFactor() { + const verificationCode = $('#enroll-mfa-totp-verification-code').val(); + if (!activeUser() || !totpSecret || !verificationCode) { + alertError(' Missing active user OR TOTP secret OR verification code.'); + return; + } + + const multiFactorAssertion = TotpMultiFactorGenerator.assertionForEnrollment( + totpSecret, + verificationCode + ); + const displayName = $('#enroll-mfa-totp-display-name').val() || undefined; + + try { + await multiFactor(activeUser()).enroll(multiFactorAssertion, displayName); + refreshUserData(); + alertSuccess('TOTP MFA enrolled!'); + } catch (e) { + onAuthError(e); + } +} + /** * Signs in or links a provider's credential, based on current tab opened. * @param {!AuthCredential} credential The provider's credential. @@ -2005,6 +2052,10 @@ function initApp() { $('#enroll-mfa-confirm-phone-verification').click( onFinalizeEnrollWithPhoneMultiFactor ); + // Starts multi-factor enrollment with TOTP. + $('#enroll-mfa-totp-start').click(onStartEnrollWithTotpMultiFactor); + // Completes multi-factor enrollment with supplied OTP(One-Time Password). + $('#enroll-mfa-totp-finalize').click(onFinalizeEnrollWithTotpMultiFactor); } $(initApp); diff --git a/packages/auth/index.ts b/packages/auth/index.ts index 6e0338435d1..6ed92b361ac 100644 --- a/packages/auth/index.ts +++ b/packages/auth/index.ts @@ -73,6 +73,10 @@ import { browserPopupRedirectResolver } from './src/platform_browser/popup_redir // MFA import { PhoneMultiFactorGenerator } from './src/platform_browser/mfa/assertions/phone'; +import { + TotpMultiFactorGenerator, + TotpSecret +} from './src/mfa/assertions/totp'; // Initialization and registration of Auth import { getAuth } from './src/platform_browser'; @@ -96,5 +100,7 @@ export { RecaptchaVerifier, browserPopupRedirectResolver, PhoneMultiFactorGenerator, + TotpMultiFactorGenerator, + TotpSecret, getAuth }; diff --git a/packages/auth/src/mfa/assertions/totp.ts b/packages/auth/src/mfa/assertions/totp.ts index 39cc18376e5..8ab819c4c62 100644 --- a/packages/auth/src/mfa/assertions/totp.ts +++ b/packages/auth/src/mfa/assertions/totp.ts @@ -171,23 +171,41 @@ export class TotpMultiFactorAssertionImpl */ export class TotpSecret { /** - * Constructor for TotpSecret. - * @param secretKey - Shared secret key/seed used for enrolling in TOTP MFA and generating otps. - * @param hashingAlgorithm - Hashing algorithm used. - * @param codeLength - Length of the one-time passwords to be generated. - * @param codeIntervalSeconds - The interval (in seconds) when the OTP codes should change. + * Shared secret key/seed used for enrolling in TOTP MFA and generating otps. */ + readonly secretKey: string; + /** + * Hashing algorithm used. + */ + readonly hashingAlgorithm: string; + /** + * Length of the one-time passwords to be generated. + */ + readonly codeLength: number; + /** + * The interval (in seconds) when the OTP codes should change. + */ + readonly codeIntervalSeconds: number; + // TODO(prameshj) - make this public after API review. + // This can be used by callers to show a countdown of when to enter OTP code by. + private readonly finalizeEnrollmentBy: string; + + // The public members are declared outside the constructor so the docs can be generated. private constructor( - readonly secretKey: string, - readonly hashingAlgorithm: string, - readonly codeLength: number, - readonly codeIntervalSeconds: number, - // TODO(prameshj) - make this public after API review. - // This can be used by callers to show a countdown of when to enter OTP code by. - private readonly finalizeEnrollmentBy: string, + secretKey: string, + hashingAlgorithm: string, + codeLength: number, + codeIntervalSeconds: number, + finalizeEnrollmentBy: string, private readonly sessionInfo: string, private readonly auth: AuthInternal - ) {} + ) { + this.secretKey = secretKey; + this.hashingAlgorithm = hashingAlgorithm; + this.codeLength = codeLength; + this.codeIntervalSeconds = codeIntervalSeconds; + this.finalizeEnrollmentBy = finalizeEnrollmentBy; + } /** @internal */ static _fromStartTotpMfaEnrollmentResponse( From e3f2bea36d9e698c607ad1253e85f2322b6797ba Mon Sep 17 00:00:00 2001 From: bhparijat Date: Thu, 13 Oct 2022 12:05:03 -0700 Subject: [PATCH 06/26] sign-in flow for totp (#6626) * Export TOTP symbols to be picked up by demo app. * adding sign-in flow for totp * using only verification code for sign-in * added startSignInTotp method * modified verification code usage in object signin * added mfa enrollment id to finalize signin method: * adding singin for totp in demoapp * made enrollmentId to not be optional * reverting changes in authapi.md * removed unnecessary check and fixed spelling * added back otp check * made _finalizeEnroll && and _finalizeSignin to be async Co-authored-by: Pavithra Ramesh --- packages/auth/demo/public/index.html | 11 +++++ packages/auth/demo/src/index.js | 39 ++++++++++++++- packages/auth/src/api/authentication/mfa.ts | 53 +++++++++++++++++++++ packages/auth/src/mfa/assertions/totp.ts | 25 +++++++--- 4 files changed, 121 insertions(+), 7 deletions(-) diff --git a/packages/auth/demo/public/index.html b/packages/auth/demo/public/index.html index 86290860583..0492d810ab5 100644 --- a/packages/auth/demo/public/index.html +++ b/packages/auth/demo/public/index.html @@ -759,6 +759,17 @@
    + +