Skip to content

Commit b3212b3

Browse files
committed
Implement TOTP MFA enrollment.
This includes changes to mfa_info, addition of TotpMultiFactorImpl and unit tests.
1 parent b012cae commit b3212b3

File tree

10 files changed

+772
-25
lines changed

10 files changed

+772
-25
lines changed

common/api-review/auth.api.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,10 @@ export function signOut(auth: Auth): Promise<void>;
750750
export interface TotpMultiFactorAssertion extends MultiFactorAssertion {
751751
}
752752

753+
// @public
754+
export interface TotpMultiFactorInfo extends MultiFactorInfo {
755+
}
756+
753757
// @public
754758
export class TwitterAuthProvider extends BaseOAuthProvider {
755759
constructor();

packages/auth/src/api/account_management/mfa.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ import * as mockFetch from '../../../test/helpers/mock_fetch';
2727
import { ServerError } from '../errors';
2828
import {
2929
finalizeEnrollPhoneMfa,
30+
finalizeEnrollTotpMfa,
3031
startEnrollPhoneMfa,
32+
startEnrollTotpMfa,
3133
withdrawMfa
3234
} from './mfa';
3335

@@ -159,6 +161,139 @@ describe('api/account_management/finalizeEnrollPhoneMfa', () => {
159161
});
160162
});
161163

164+
describe('api/account_management/startEnrollTotpMfa', () => {
165+
const request = {
166+
idToken: 'id-token',
167+
totpEnrollmentInfo: {}
168+
};
169+
170+
let auth: TestAuth;
171+
172+
beforeEach(async () => {
173+
auth = await testAuth();
174+
mockFetch.setUp();
175+
});
176+
177+
afterEach(mockFetch.tearDown);
178+
179+
it('should POST to the correct endpoint', async () => {
180+
const currentTime = new Date().toISOString();
181+
const mock = mockEndpoint(Endpoint.START_MFA_ENROLLMENT, {
182+
totpSessionInfo: {
183+
sharedSecretKey: 'key123',
184+
verificationCodeLength: 6,
185+
hashingAlgorithm: 'SHA256',
186+
periodSec: 30,
187+
sessionInfo: 'session-info',
188+
finalizeEnrollmentTime: currentTime
189+
}
190+
});
191+
192+
const response = await startEnrollTotpMfa(auth, request);
193+
expect(response.totpSessionInfo.sharedSecretKey).to.eq('key123');
194+
expect(response.totpSessionInfo.verificationCodeLength).to.eq(6);
195+
expect(response.totpSessionInfo.hashingAlgorithm).to.eq('SHA256');
196+
expect(response.totpSessionInfo.periodSec).to.eq(30);
197+
expect(response.totpSessionInfo.sessionInfo).to.eq('session-info');
198+
expect(response.totpSessionInfo.finalizeEnrollmentTime).to.eq(currentTime);
199+
expect(mock.calls[0].request).to.eql(request);
200+
expect(mock.calls[0].method).to.eq('POST');
201+
expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq(
202+
'application/json'
203+
);
204+
expect(mock.calls[0].headers!.get(HttpHeader.X_CLIENT_VERSION)).to.eq(
205+
'testSDK/0.0.0'
206+
);
207+
});
208+
209+
it('should handle errors', async () => {
210+
const mock = mockEndpoint(
211+
Endpoint.START_MFA_ENROLLMENT,
212+
{
213+
error: {
214+
code: 400,
215+
message: ServerError.INVALID_ID_TOKEN,
216+
errors: [
217+
{
218+
message: ServerError.INVALID_ID_TOKEN
219+
}
220+
]
221+
}
222+
},
223+
400
224+
);
225+
226+
await expect(startEnrollTotpMfa(auth, request)).to.be.rejectedWith(
227+
FirebaseError,
228+
"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)."
229+
);
230+
expect(mock.calls[0].request).to.eql(request);
231+
});
232+
});
233+
234+
describe('api/account_management/finalizeEnrollTotpMfa', () => {
235+
const request = {
236+
idToken: 'id-token',
237+
displayName: 'my-otp-app',
238+
totpVerificationInfo: {
239+
sessionInfo: 'session-info',
240+
verificationCode: 'code'
241+
}
242+
};
243+
244+
let auth: TestAuth;
245+
246+
beforeEach(async () => {
247+
auth = await testAuth();
248+
mockFetch.setUp();
249+
});
250+
251+
afterEach(mockFetch.tearDown);
252+
253+
it('should POST to the correct endpoint', async () => {
254+
const mock = mockEndpoint(Endpoint.FINALIZE_MFA_ENROLLMENT, {
255+
idToken: 'id-token',
256+
refreshToken: 'refresh-token'
257+
});
258+
259+
const response = await finalizeEnrollTotpMfa(auth, request);
260+
expect(response.idToken).to.eq('id-token');
261+
expect(response.refreshToken).to.eq('refresh-token');
262+
expect(mock.calls[0].request).to.eql(request);
263+
expect(mock.calls[0].method).to.eq('POST');
264+
expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq(
265+
'application/json'
266+
);
267+
expect(mock.calls[0].headers!.get(HttpHeader.X_CLIENT_VERSION)).to.eq(
268+
'testSDK/0.0.0'
269+
);
270+
});
271+
272+
it('should handle errors', async () => {
273+
const mock = mockEndpoint(
274+
Endpoint.FINALIZE_MFA_ENROLLMENT,
275+
{
276+
error: {
277+
code: 400,
278+
message: ServerError.INVALID_SESSION_INFO,
279+
errors: [
280+
{
281+
message: ServerError.INVALID_SESSION_INFO
282+
}
283+
]
284+
}
285+
},
286+
400
287+
);
288+
289+
await expect(finalizeEnrollTotpMfa(auth, request)).to.be.rejectedWith(
290+
FirebaseError,
291+
'Firebase: The verification ID used to create the phone auth credential is invalid. (auth/invalid-verification-id).'
292+
);
293+
expect(mock.calls[0].request).to.eql(request);
294+
});
295+
});
296+
162297
describe('api/account_management/withdrawMfa', () => {
163298
const request = {
164299
idToken: 'id-token',

packages/auth/src/api/account_management/mfa.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { FinalizeMfaResponse } from '../authentication/mfa';
2626
import { AuthInternal } from '../../model/auth';
2727

2828
/**
29-
* MFA Info as returned by the API
29+
* MFA Info as returned by the API.
3030
*/
3131
interface BaseMfaEnrollment {
3232
mfaEnrollmentId: string;
@@ -35,16 +35,21 @@ interface BaseMfaEnrollment {
3535
}
3636

3737
/**
38-
* An MFA provided by SMS verification
38+
* An MFA provided by SMS verification.
3939
*/
4040
export interface PhoneMfaEnrollment extends BaseMfaEnrollment {
4141
phoneInfo: string;
4242
}
4343

4444
/**
45-
* MfaEnrollment can be any subtype of BaseMfaEnrollment, currently only PhoneMfaEnrollment is supported
45+
* An MFA provided by TOTP (Time-based One Time Password).
4646
*/
47-
export type MfaEnrollment = PhoneMfaEnrollment;
47+
export interface TotpMfaEnrollment extends BaseMfaEnrollment {}
48+
49+
/**
50+
* MfaEnrollment can be any subtype of BaseMfaEnrollment, currently only PhoneMfaEnrollment and TotpMfaEnrollment are supported.
51+
*/
52+
export type MfaEnrollment = PhoneMfaEnrollment | TotpMfaEnrollment;
4853

4954
export interface StartPhoneMfaEnrollmentRequest {
5055
idToken: string;
@@ -100,6 +105,66 @@ export function finalizeEnrollPhoneMfa(
100105
_addTidIfNecessary(auth, request)
101106
);
102107
}
108+
export interface StartTotpMfaEnrollmentRequest {
109+
idToken: string;
110+
totpEnrollmentInfo: {};
111+
tenantId?: string;
112+
}
113+
114+
export interface StartTotpMfaEnrollmentResponse {
115+
totpSessionInfo: {
116+
sharedSecretKey: string;
117+
verificationCodeLength: number;
118+
hashingAlgorithm: string;
119+
periodSec: number;
120+
sessionInfo: string;
121+
finalizeEnrollmentTime: number;
122+
};
123+
}
124+
125+
export function startEnrollTotpMfa(
126+
auth: AuthInternal,
127+
request: StartTotpMfaEnrollmentRequest
128+
): Promise<StartTotpMfaEnrollmentResponse> {
129+
return _performApiRequest<
130+
StartTotpMfaEnrollmentRequest,
131+
StartTotpMfaEnrollmentResponse
132+
>(
133+
auth,
134+
HttpMethod.POST,
135+
Endpoint.START_MFA_ENROLLMENT,
136+
_addTidIfNecessary(auth, request)
137+
);
138+
}
139+
140+
export interface TotpVerificationInfo {
141+
sessionInfo: string;
142+
verificationCode: string;
143+
}
144+
export interface FinalizeTotpMfaEnrollmentRequest {
145+
idToken: string;
146+
totpVerificationInfo: TotpVerificationInfo;
147+
displayName?: string | null;
148+
tenantId?: string;
149+
}
150+
151+
export interface FinalizeTotpMfaEnrollmentResponse
152+
extends FinalizeMfaResponse {}
153+
154+
export function finalizeEnrollTotpMfa(
155+
auth: AuthInternal,
156+
request: FinalizeTotpMfaEnrollmentRequest
157+
): Promise<FinalizeTotpMfaEnrollmentResponse> {
158+
return _performApiRequest<
159+
FinalizeTotpMfaEnrollmentRequest,
160+
FinalizeTotpMfaEnrollmentResponse
161+
>(
162+
auth,
163+
HttpMethod.POST,
164+
Endpoint.FINALIZE_MFA_ENROLLMENT,
165+
_addTidIfNecessary(auth, request)
166+
);
167+
}
103168

104169
export interface WithdrawMfaRequest {
105170
idToken: string;
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* @license
3+
* Copyright 2022 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { expect, use } from 'chai';
19+
import chaiAsPromised from 'chai-as-promised';
20+
21+
import { mockEndpoint } from '../../../test/helpers/api/helper';
22+
import { testAuth, TestAuth } from '../../../test/helpers/mock_auth';
23+
import * as mockFetch from '../../../test/helpers/mock_fetch';
24+
import { Endpoint } from '../../api';
25+
import { MultiFactorSessionImpl } from '../../mfa/mfa_session';
26+
import { StartTotpMfaEnrollmentResponse } from '../../api/account_management/mfa';
27+
import { TotpSecret } from '../../platform_browser/mfa/assertions/totp';
28+
import { TotpMultiFactorGenerator } from './totp';
29+
import { FactorId } from '../../model/public_types';
30+
import { AuthErrorCode } from '../../core/errors';
31+
32+
use(chaiAsPromised);
33+
34+
describe('core/mfa/assertions/totp/TotpMultiFactorGenerator', () => {
35+
let auth: TestAuth;
36+
let session: MultiFactorSessionImpl;
37+
const startEnrollmentResponse: StartTotpMfaEnrollmentResponse = {
38+
totpSessionInfo: {
39+
sharedSecretKey: 'key123',
40+
verificationCodeLength: 6,
41+
hashingAlgorithm: 'SHA1',
42+
periodSec: 30,
43+
sessionInfo: 'verification-id',
44+
finalizeEnrollmentTime: 1662586196
45+
}
46+
};
47+
describe('assertionForEnrollment', () => {
48+
it('should generate a valid TOTP assertion for enrollment', async () => {
49+
auth = await testAuth();
50+
const secret = TotpSecret.fromStartTotpMfaEnrollmentResponse(
51+
startEnrollmentResponse,
52+
auth.name
53+
);
54+
const assertion = TotpMultiFactorGenerator.assertionForEnrollment(
55+
secret,
56+
'123456'
57+
);
58+
expect(assertion.factorId).to.eql(FactorId.TOTP);
59+
});
60+
});
61+
62+
describe('assertionForSignIn', () => {
63+
it('should generate a valid TOTP assertion for sign in', () => {
64+
const assertion = TotpMultiFactorGenerator.assertionForSignIn(
65+
'enrollmentId',
66+
'123456'
67+
);
68+
expect(assertion.factorId).to.eql(FactorId.TOTP);
69+
});
70+
});
71+
72+
describe('generateSecret', () => {
73+
beforeEach(async () => {
74+
mockFetch.setUp();
75+
});
76+
afterEach(mockFetch.tearDown);
77+
78+
it('should throw error if auth instance is not found in mfaSession', async () => {
79+
try {
80+
session = MultiFactorSessionImpl._fromIdtoken(
81+
'enrollment-id-token',
82+
undefined
83+
);
84+
const totpSecret = await TotpMultiFactorGenerator.generateSecret(
85+
session
86+
);
87+
} catch (e) {
88+
expect(e.code).to.eql(`auth/${AuthErrorCode.INTERNAL_ERROR}`);
89+
}
90+
});
91+
it('generateSecret should generate a valid secret by starting enrollment', async () => {
92+
const mock = mockEndpoint(
93+
Endpoint.START_MFA_ENROLLMENT,
94+
startEnrollmentResponse
95+
);
96+
97+
auth = await testAuth();
98+
session = MultiFactorSessionImpl._fromIdtoken(
99+
'enrollment-id-token',
100+
auth
101+
);
102+
const secret = await TotpMultiFactorGenerator.generateSecret(session);
103+
expect(mock.calls[0].request).to.eql({
104+
idToken: 'enrollment-id-token',
105+
totpEnrollmentInfo: {}
106+
});
107+
expect(secret.secretKey).to.eql(
108+
startEnrollmentResponse.totpSessionInfo.sharedSecretKey
109+
);
110+
expect(secret.codeIntervalSeconds).to.eq(
111+
startEnrollmentResponse.totpSessionInfo.periodSec
112+
);
113+
expect(secret.codeLength).to.eq(
114+
startEnrollmentResponse.totpSessionInfo.verificationCodeLength
115+
);
116+
expect(secret.hashingAlgorithm).to.eq(
117+
startEnrollmentResponse.totpSessionInfo.hashingAlgorithm
118+
);
119+
});
120+
});
121+
});

0 commit comments

Comments
 (0)