Skip to content

Commit 434952a

Browse files
committed
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.
1 parent b3212b3 commit 434952a

File tree

4 files changed

+332
-387
lines changed

4 files changed

+332
-387
lines changed

packages/auth/src/mfa/assertions/totp.test.ts

+178-4
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,24 @@ import { expect, use } from 'chai';
1919
import chaiAsPromised from 'chai-as-promised';
2020

2121
import { mockEndpoint } from '../../../test/helpers/api/helper';
22-
import { testAuth, TestAuth } from '../../../test/helpers/mock_auth';
22+
import { testAuth, TestAuth, testUser } from '../../../test/helpers/mock_auth';
2323
import * as mockFetch from '../../../test/helpers/mock_fetch';
2424
import { Endpoint } from '../../api';
2525
import { MultiFactorSessionImpl } from '../../mfa/mfa_session';
2626
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';
27+
import { FinalizeMfaResponse } from '../../api/authentication/mfa';
28+
import {
29+
TotpMultiFactorAssertionImpl,
30+
TotpMultiFactorGenerator,
31+
TotpSecret
32+
} from './totp';
33+
import { Auth, FactorId } from '../../model/public_types';
3034
import { AuthErrorCode } from '../../core/errors';
35+
import { FirebaseApp, initializeApp } from '@firebase/app';
36+
import { AppName } from '../../model/auth';
37+
import { getAuth } from '../../platform_browser';
38+
import { initializeAuth } from '../../core';
39+
import { _castAuth } from '../../core/auth/auth_impl';
3140

3241
use(chaiAsPromised);
3342

@@ -119,3 +128,168 @@ describe('core/mfa/assertions/totp/TotpMultiFactorGenerator', () => {
119128
});
120129
});
121130
});
131+
132+
describe('core/mfa/totp/assertions/TotpMultiFactorAssertionImpl', () => {
133+
let auth: TestAuth;
134+
let assertion: TotpMultiFactorAssertionImpl;
135+
let session: MultiFactorSessionImpl;
136+
let secret: TotpSecret;
137+
138+
const serverResponse: FinalizeMfaResponse = {
139+
idToken: 'final-id-token',
140+
refreshToken: 'refresh-token'
141+
};
142+
143+
const startEnrollmentResponse: StartTotpMfaEnrollmentResponse = {
144+
totpSessionInfo: {
145+
sharedSecretKey: 'key123',
146+
verificationCodeLength: 6,
147+
hashingAlgorithm: 'SHA1',
148+
periodSec: 30,
149+
sessionInfo: 'verification-id',
150+
finalizeEnrollmentTime: 1662586196
151+
}
152+
};
153+
154+
beforeEach(async () => {
155+
mockFetch.setUp();
156+
auth = await testAuth();
157+
secret = TotpSecret.fromStartTotpMfaEnrollmentResponse(
158+
startEnrollmentResponse,
159+
auth.name
160+
);
161+
assertion = TotpMultiFactorAssertionImpl._fromSecret(secret, '123456');
162+
});
163+
afterEach(mockFetch.tearDown);
164+
165+
describe('enroll', () => {
166+
beforeEach(() => {
167+
session = MultiFactorSessionImpl._fromIdtoken(
168+
'enrollment-id-token',
169+
auth
170+
);
171+
});
172+
173+
it('should finalize the MFA enrollment', async () => {
174+
const mock = mockEndpoint(
175+
Endpoint.FINALIZE_MFA_ENROLLMENT,
176+
serverResponse
177+
);
178+
const response = await assertion._process(auth, session);
179+
expect(response).to.eql(serverResponse);
180+
expect(mock.calls[0].request).to.eql({
181+
idToken: 'enrollment-id-token',
182+
totpVerificationInfo: {
183+
verificationCode: '123456',
184+
sessionInfo: 'verification-id'
185+
}
186+
});
187+
expect(session.auth).to.eql(auth);
188+
});
189+
190+
context('with display name', () => {
191+
it('should set the display name', async () => {
192+
const mock = mockEndpoint(
193+
Endpoint.FINALIZE_MFA_ENROLLMENT,
194+
serverResponse
195+
);
196+
const response = await assertion._process(
197+
auth,
198+
session,
199+
'display-name'
200+
);
201+
expect(response).to.eql(serverResponse);
202+
expect(mock.calls[0].request).to.eql({
203+
idToken: 'enrollment-id-token',
204+
displayName: 'display-name',
205+
totpVerificationInfo: {
206+
verificationCode: '123456',
207+
sessionInfo: 'verification-id'
208+
}
209+
});
210+
expect(session.auth).to.eql(auth);
211+
});
212+
});
213+
});
214+
});
215+
216+
describe('core/mfa/assertions/totp/TotpSecret', () => {
217+
const serverResponse: StartTotpMfaEnrollmentResponse = {
218+
totpSessionInfo: {
219+
sharedSecretKey: 'key123',
220+
verificationCodeLength: 6,
221+
hashingAlgorithm: 'SHA1',
222+
periodSec: 30,
223+
sessionInfo: 'verification-id',
224+
finalizeEnrollmentTime: 1662586196
225+
}
226+
};
227+
const fakeAppName: AppName = 'test-app';
228+
const fakeEmail: string = 'user@email';
229+
const secret = TotpSecret.fromStartTotpMfaEnrollmentResponse(
230+
serverResponse,
231+
fakeAppName
232+
);
233+
234+
describe('fromStartTotpMfaEnrollmentResponse', () => {
235+
it('fields from the response are parsed correctly', () => {
236+
expect(secret.secretKey).to.eq('key123');
237+
expect(secret.codeIntervalSeconds).to.eq(30);
238+
expect(secret.codeLength).to.eq(6);
239+
expect(secret.hashingAlgorithm).to.eq('SHA1');
240+
});
241+
});
242+
describe('generateQrCodeUrl', () => {
243+
let app: FirebaseApp;
244+
let auth: Auth;
245+
246+
beforeEach(async () => {
247+
app = initializeApp(
248+
{
249+
apiKey: 'fake-key',
250+
appId: 'fake-app-id',
251+
authDomain: 'fake-auth-domain'
252+
},
253+
fakeAppName
254+
);
255+
auth = initializeAuth(app);
256+
await auth.updateCurrentUser(
257+
testUser(_castAuth(auth), 'uid', fakeEmail, true)
258+
);
259+
});
260+
261+
it('with account name and issuer provided', () => {
262+
const url = secret.generateQrCodeUrl('user@myawesomeapp', 'myawesomeapp');
263+
expect(url).to.eq(
264+
'otpauth://totp/myawesomeapp:user@myawesomeapp?secret=key123&issuer=myawesomeapp&algorithm=SHA1&digits=6'
265+
);
266+
});
267+
it('only accountName provided', () => {
268+
const url = secret.generateQrCodeUrl('user@myawesomeapp', '');
269+
const auth2 = getAuth(app);
270+
console.log('Current user is ' + auth2);
271+
expect(url).to.eq(
272+
`otpauth://totp/${fakeAppName}:user@myawesomeapp?secret=key123&issuer=${fakeAppName}&algorithm=SHA1&digits=6`
273+
);
274+
});
275+
it('only issuer provided', () => {
276+
const url = secret.generateQrCodeUrl('', 'myawesomeapp');
277+
expect(url).to.eq(
278+
`otpauth://totp/myawesomeapp:${fakeEmail}?secret=key123&issuer=myawesomeapp&algorithm=SHA1&digits=6`
279+
);
280+
});
281+
it('with defaults', () => {
282+
const url = secret.generateQrCodeUrl();
283+
expect(url).to.eq(
284+
`otpauth://totp/${fakeAppName}:${fakeEmail}?secret=key123&issuer=${fakeAppName}&algorithm=SHA1&digits=6`
285+
);
286+
});
287+
it('with defaults, without currentUser', async () => {
288+
await auth.updateCurrentUser(null);
289+
const url = secret.generateQrCodeUrl();
290+
expect(url).to.eq(
291+
`otpauth://totp/${fakeAppName}:unknownuser?secret=key123&issuer=${fakeAppName}&algorithm=SHA1&digits=6`
292+
);
293+
});
294+
});
295+
});

packages/auth/src/mfa/assertions/totp.ts

+154-5
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,26 @@
1414
* See the License for the specific language governing permissions and
1515
* limitations under the License.
1616
*/
17-
import {
18-
TotpSecret,
19-
TotpMultiFactorAssertionImpl
20-
} from '../../platform_browser/mfa/assertions/totp';
2117
import {
2218
TotpMultiFactorAssertion,
2319
MultiFactorSession,
2420
FactorId
2521
} from '../../model/public_types';
26-
import { startEnrollTotpMfa } from '../../api/account_management/mfa';
22+
import { AppName, AuthInternal } from '../../model/auth';
23+
import {
24+
finalizeEnrollTotpMfa,
25+
startEnrollTotpMfa,
26+
StartTotpMfaEnrollmentResponse,
27+
TotpVerificationInfo
28+
} from '../../api/account_management/mfa';
29+
import { FinalizeMfaResponse } from '../../api/authentication/mfa';
30+
import { MultiFactorAssertionImpl } from '../../mfa/mfa_assertion';
2731
import { MultiFactorSessionImpl } from '../mfa_session';
2832
import { AuthErrorCode } from '../../core/errors';
2933
import { _assert } from '../../core/util/assert';
34+
import { getApp } from '@firebase/app';
35+
import { getAuth } from '../../platform_browser';
36+
3037
/**
3138
* Provider for generating a {@link TotpMultiFactorAssertion}.
3239
*
@@ -49,6 +56,7 @@ export class TotpMultiFactorGenerator {
4956
): TotpMultiFactorAssertion {
5057
return TotpMultiFactorAssertionImpl._fromSecret(secret, oneTimePassword);
5158
}
59+
5260
/**
5361
* Provides a {@link TotpMultiFactorAssertion} to confirm ownership of the totp second factor.
5462
* This assertion is used to complete signIn with TOTP as the second factor.
@@ -67,6 +75,7 @@ export class TotpMultiFactorGenerator {
6775
oneTimePassword
6876
);
6977
}
78+
7079
/**
7180
* Returns a promise to {@link TOTPSecret} which contains the TOTP shared secret key and other parameters.
7281
* Creates a TOTP secret as part of enrolling a TOTP second factor.
@@ -93,8 +102,148 @@ export class TotpMultiFactorGenerator {
93102
mfaSession.auth!.name
94103
);
95104
}
105+
96106
/**
97107
* The identifier of the TOTP second factor: `totp`.
98108
*/
99109
static FACTOR_ID = FactorId.TOTP;
100110
}
111+
112+
export class TotpMultiFactorAssertionImpl
113+
extends MultiFactorAssertionImpl
114+
implements TotpMultiFactorAssertion
115+
{
116+
constructor(
117+
readonly otp: string,
118+
readonly enrollmentId?: string,
119+
readonly secret?: TotpSecret
120+
) {
121+
super(FactorId.TOTP);
122+
}
123+
124+
static _fromSecret(
125+
secret: TotpSecret,
126+
otp: string
127+
): TotpMultiFactorAssertionImpl {
128+
return new TotpMultiFactorAssertionImpl(
129+
(otp = otp),
130+
undefined,
131+
(secret = secret)
132+
);
133+
}
134+
135+
static _fromEnrollmentId(
136+
enrollmentId: string,
137+
otp: string
138+
): TotpMultiFactorAssertionImpl {
139+
return new TotpMultiFactorAssertionImpl(
140+
(otp = otp),
141+
(enrollmentId = enrollmentId)
142+
);
143+
}
144+
145+
/** @internal */
146+
_finalizeEnroll(
147+
auth: AuthInternal,
148+
idToken: string,
149+
displayName?: string | null
150+
): Promise<FinalizeMfaResponse> {
151+
_assert(
152+
typeof this.secret !== 'undefined',
153+
auth,
154+
AuthErrorCode.ARGUMENT_ERROR
155+
);
156+
return finalizeEnrollTotpMfa(auth, {
157+
idToken,
158+
displayName,
159+
totpVerificationInfo: this.secret.makeTotpVerificationInfo(this.otp)
160+
});
161+
}
162+
163+
/** @internal */
164+
_finalizeSignIn(
165+
_auth: AuthInternal,
166+
_mfaPendingCredential: string
167+
): Promise<FinalizeMfaResponse> {
168+
throw new Error('method not implemented');
169+
}
170+
}
171+
172+
/**
173+
* Provider for generating a {@link TotpMultiFactorAssertion}.
174+
*
175+
* Stores the shared secret key and other parameters to generate time-based OTPs.
176+
* Implements methods to retrieve the shared secret key, generate a QRCode URL.
177+
* @public
178+
*/
179+
export class TotpSecret {
180+
/**
181+
* Constructor for TotpSecret.
182+
* @param secretKey - Shared secret key/seed used for enrolling in TOTP MFA and generating otps.
183+
* @param hashingAlgorithm - Hashing algorithm used.
184+
* @param codeLength - Length of the one-time passwords to be generated.
185+
* @param codeIntervalSeconds - The interval (in seconds) when the OTP codes should change.
186+
*/
187+
private constructor(
188+
readonly secretKey: string,
189+
readonly hashingAlgorithm: string,
190+
readonly codeLength: number,
191+
readonly codeIntervalSeconds: number,
192+
// TODO(prameshj) - make this public after API review.
193+
// This can be used by callers to show a countdown of when to enter OTP code by.
194+
private readonly finalizeEnrollmentBy: string,
195+
private readonly sessionInfo: string,
196+
private readonly appName: AppName
197+
) {}
198+
199+
static fromStartTotpMfaEnrollmentResponse(
200+
response: StartTotpMfaEnrollmentResponse,
201+
appName: AppName
202+
): TotpSecret {
203+
return new TotpSecret(
204+
response.totpSessionInfo.sharedSecretKey,
205+
response.totpSessionInfo.hashingAlgorithm,
206+
response.totpSessionInfo.verificationCodeLength,
207+
response.totpSessionInfo.periodSec,
208+
new Date(response.totpSessionInfo.finalizeEnrollmentTime).toUTCString(),
209+
response.totpSessionInfo.sessionInfo,
210+
appName
211+
);
212+
}
213+
214+
makeTotpVerificationInfo(otp: string): TotpVerificationInfo {
215+
return { sessionInfo: this.sessionInfo, verificationCode: otp };
216+
}
217+
218+
/**
219+
* Returns a QRCode URL as described in
220+
* https://github.com/google/google-authenticator/wiki/Key-Uri-Format
221+
* This can be displayed to the user as a QRCode to be scanned into a TOTP App like Google Authenticator.
222+
* If the optional parameters are unspecified, an accountName of <userEmail> and issuer of <firebaseAppName> are used.
223+
*
224+
* @param accountName the name of the account/app along with a user identifier.
225+
* @param issuer issuer of the TOTP(likely the app name).
226+
* @returns A QRCode URL string.
227+
*/
228+
generateQrCodeUrl(accountName?: string, issuer?: string): string {
229+
let useDefaults = false;
230+
if (_isEmptyString(accountName) || _isEmptyString(issuer)) {
231+
useDefaults = true;
232+
}
233+
if (useDefaults) {
234+
const app = getApp(this.appName);
235+
const auth = getAuth(app);
236+
if (_isEmptyString(accountName)) {
237+
accountName = auth.currentUser?.email || 'unknownuser';
238+
}
239+
if (_isEmptyString(issuer)) {
240+
issuer = app.name;
241+
}
242+
}
243+
return `otpauth://totp/${issuer}:${accountName}?secret=${this.secretKey}&issuer=${issuer}&algorithm=${this.hashingAlgorithm}&digits=${this.codeLength}`;
244+
}
245+
}
246+
247+
function _isEmptyString(input?: string): boolean {
248+
return typeof input === 'undefined' || input?.length === 0;
249+
}

0 commit comments

Comments
 (0)