Skip to content

Implement TOTP MFA enrollment flows #6598

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Sep 14, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions common/api-review/auth.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,10 @@ export function signOut(auth: Auth): Promise<void>;
export interface TotpMultiFactorAssertion extends MultiFactorAssertion {
}

// @public
export interface TotpMultiFactorInfo extends MultiFactorInfo {
}

// @public
export class TwitterAuthProvider extends BaseOAuthProvider {
constructor();
Expand Down
135 changes: 135 additions & 0 deletions packages/auth/src/api/account_management/mfa.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ import * as mockFetch from '../../../test/helpers/mock_fetch';
import { ServerError } from '../errors';
import {
finalizeEnrollPhoneMfa,
finalizeEnrollTotpMfa,
startEnrollPhoneMfa,
startEnrollTotpMfa,
withdrawMfa
} from './mfa';

Expand Down Expand Up @@ -159,6 +161,139 @@ describe('api/account_management/finalizeEnrollPhoneMfa', () => {
});
});

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,
"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)."
);
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).'
);
expect(mock.calls[0].request).to.eql(request);
});
});

describe('api/account_management/withdrawMfa', () => {
const request = {
idToken: 'id-token',
Expand Down
73 changes: 69 additions & 4 deletions packages/auth/src/api/account_management/mfa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<StartTotpMfaEnrollmentResponse> {
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<FinalizeTotpMfaEnrollmentResponse> {
return _performApiRequest<
FinalizeTotpMfaEnrollmentRequest,
FinalizeTotpMfaEnrollmentResponse
>(
auth,
HttpMethod.POST,
Endpoint.FINALIZE_MFA_ENROLLMENT,
_addTidIfNecessary(auth, request)
);
}

export interface WithdrawMfaRequest {
idToken: string;
Expand Down
Loading