Skip to content

Commit f182c36

Browse files
authored
feat(auth): Add TotpInfo field to UserRecord (#2197)
* Adding TotpInfo to userRecord * Changing type from `any` to `unknown` for type safety. * Addressing feedback
1 parent 4e7e072 commit f182c36

File tree

2 files changed

+231
-10
lines changed

2 files changed

+231
-10
lines changed

src/auth/user-record.ts

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,13 @@ export interface MultiFactorInfoResponse {
4747
mfaEnrollmentId: string;
4848
displayName?: string;
4949
phoneInfo?: string;
50+
totpInfo?: TotpInfoResponse;
5051
enrolledAt?: string;
51-
[key: string]: any;
52+
[key: string]: unknown;
53+
}
54+
55+
export interface TotpInfoResponse {
56+
[key: string]: unknown;
5257
}
5358

5459
export interface ProviderUserInfoResponse {
@@ -84,6 +89,7 @@ export interface GetAccountInfoUserResponse {
8489

8590
enum MultiFactorId {
8691
Phone = 'phone',
92+
Totp = 'totp',
8793
}
8894

8995
/**
@@ -102,7 +108,9 @@ export abstract class MultiFactorInfo {
102108
public readonly displayName?: string;
103109

104110
/**
105-
* The type identifier of the second factor. For SMS second factors, this is `phone`.
111+
* The type identifier of the second factor.
112+
* For SMS second factors, this is `phone`.
113+
* For TOTP second factors, this is `totp`.
106114
*/
107115
public readonly factorId: string;
108116

@@ -120,9 +128,15 @@ export abstract class MultiFactorInfo {
120128
*/
121129
public static initMultiFactorInfo(response: MultiFactorInfoResponse): MultiFactorInfo | null {
122130
let multiFactorInfo: MultiFactorInfo | null = null;
123-
// Only PhoneMultiFactorInfo currently available.
131+
// PhoneMultiFactorInfo, TotpMultiFactorInfo currently available.
124132
try {
125-
multiFactorInfo = new PhoneMultiFactorInfo(response);
133+
if (response.phoneInfo !== undefined) {
134+
multiFactorInfo = new PhoneMultiFactorInfo(response);
135+
} else if (response.totpInfo !== undefined) {
136+
multiFactorInfo = new TotpMultiFactorInfo(response);
137+
} else {
138+
// Ignore the other SDK unsupported MFA factors to prevent blocking developers using the current SDK.
139+
}
126140
} catch (e) {
127141
// Ignore error.
128142
}
@@ -240,14 +254,68 @@ export class PhoneMultiFactorInfo extends MultiFactorInfo {
240254
}
241255
}
242256

257+
/**
258+
* TotpInfo struct associated with a second factor
259+
*/
260+
export class TotpInfo {
261+
262+
}
263+
264+
/**
265+
* Interface representing a TOTP specific user-enrolled second factor.
266+
*/
267+
export class TotpMultiFactorInfo extends MultiFactorInfo {
268+
269+
/**
270+
* TotpInfo struct associated with a second factor
271+
*/
272+
public readonly totpInfo: TotpInfo;
273+
274+
/**
275+
* Initializes the TotpMultiFactorInfo object using the server side response.
276+
*
277+
* @param response - The server side response.
278+
* @constructor
279+
* @internal
280+
*/
281+
constructor(response: MultiFactorInfoResponse) {
282+
super(response);
283+
utils.addReadonlyGetter(this, 'totpInfo', response.totpInfo);
284+
}
285+
286+
/**
287+
* {@inheritdoc MultiFactorInfo.toJSON}
288+
*/
289+
public toJSON(): object {
290+
return Object.assign(
291+
super.toJSON(),
292+
{
293+
totpInfo: this.totpInfo,
294+
});
295+
}
296+
297+
/**
298+
* Returns the factor ID based on the response provided.
299+
*
300+
* @param response - The server side response.
301+
* @returns The multi-factor ID associated with the provided response. If the response is
302+
* not associated with any known multi-factor ID, null is returned.
303+
*
304+
* @internal
305+
*/
306+
protected getFactorId(response: MultiFactorInfoResponse): string | null {
307+
return (response && response.totpInfo) ? MultiFactorId.Totp : null;
308+
}
309+
}
310+
243311
/**
244312
* The multi-factor related user settings.
245313
*/
246314
export class MultiFactorSettings {
247315

248316
/**
249317
* List of second factors enrolled with the current user.
250-
* Currently only phone second factors are supported.
318+
* Currently only phone and totp second factors are supported.
251319
*/
252320
public enrolledFactors: MultiFactorInfo[];
253321

test/unit/auth/user-record.spec.ts

Lines changed: 158 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import * as chaiAsPromised from 'chai-as-promised';
2121

2222
import { deepCopy } from '../../../src/utils/deep-copy';
2323
import {
24-
GetAccountInfoUserResponse, ProviderUserInfoResponse, MultiFactorInfoResponse,
24+
GetAccountInfoUserResponse, ProviderUserInfoResponse, MultiFactorInfoResponse, TotpMultiFactorInfo,
2525
} from '../../../src/auth/user-record';
2626
import {
2727
UserInfo, UserMetadata, UserRecord, MultiFactorSettings, MultiFactorInfo, PhoneMultiFactorInfo,
@@ -379,18 +379,157 @@ describe('PhoneMultiFactorInfo', () => {
379379
});
380380
});
381381

382-
describe('MultiFactorInfo', () => {
382+
describe('TotpMultiFactorInfo', () => {
383383
const serverResponse: MultiFactorInfoResponse = {
384+
mfaEnrollmentId: 'enrollmentId1',
385+
displayName: 'displayName1',
386+
enrolledAt: now.toISOString(),
387+
totpInfo: {},
388+
};
389+
const totpMultiFactorInfo = new TotpMultiFactorInfo(serverResponse);
390+
const totpMultiFactorInfoMissingFields = new TotpMultiFactorInfo({
391+
mfaEnrollmentId: serverResponse.mfaEnrollmentId,
392+
totpInfo: serverResponse.totpInfo,
393+
});
394+
395+
describe('constructor', () => {
396+
it('should throw when an empty object is provided', () => {
397+
expect(() => {
398+
return new TotpMultiFactorInfo({} as any);
399+
}).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response');
400+
});
401+
402+
it('should throw when an undefined response is provided', () => {
403+
expect(() => {
404+
return new TotpMultiFactorInfo(undefined as any);
405+
}).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response');
406+
});
407+
408+
it('should succeed when mfaEnrollmentId and totpInfo are both provided', () => {
409+
expect(() => {
410+
return new TotpMultiFactorInfo({
411+
mfaEnrollmentId: 'enrollmentId1',
412+
totpInfo: {},
413+
});
414+
}).not.to.throw(Error);
415+
});
416+
417+
it('should throw when only mfaEnrollmentId is provided', () => {
418+
expect(() => {
419+
return new TotpMultiFactorInfo({
420+
mfaEnrollmentId: 'enrollmentId1',
421+
} as any);
422+
}).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response');
423+
});
424+
425+
it('should throw when only totpInfo is provided', () => {
426+
expect(() => {
427+
return new TotpMultiFactorInfo({
428+
totpInfo: {},
429+
} as any);
430+
}).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response');
431+
});
432+
});
433+
434+
describe('getters', () => {
435+
it('should set missing optional fields to null', () => {
436+
expect(totpMultiFactorInfoMissingFields.uid).to.equal(serverResponse.mfaEnrollmentId);
437+
expect(totpMultiFactorInfoMissingFields.displayName).to.be.undefined;
438+
expect(totpMultiFactorInfoMissingFields.totpInfo).to.equal(serverResponse.totpInfo);
439+
expect(totpMultiFactorInfoMissingFields.enrollmentTime).to.be.null;
440+
expect(totpMultiFactorInfoMissingFields.factorId).to.equal('totp');
441+
});
442+
443+
it('should return expected factorId', () => {
444+
expect(totpMultiFactorInfo.factorId).to.equal('totp');
445+
});
446+
447+
it('should throw when modifying readonly factorId property', () => {
448+
expect(() => {
449+
(totpMultiFactorInfo as any).factorId = 'other';
450+
}).to.throw(Error);
451+
});
452+
453+
it('should return expected displayName', () => {
454+
expect(totpMultiFactorInfo.displayName).to.equal(serverResponse.displayName);
455+
});
456+
457+
it('should throw when modifying readonly displayName property', () => {
458+
expect(() => {
459+
(totpMultiFactorInfo as any).displayName = 'Modified';
460+
}).to.throw(Error);
461+
});
462+
463+
it('should return expected totpInfo object', () => {
464+
expect(totpMultiFactorInfo.totpInfo).to.equal(serverResponse.totpInfo);
465+
});
466+
467+
it('should return expected uid', () => {
468+
expect(totpMultiFactorInfo.uid).to.equal(serverResponse.mfaEnrollmentId);
469+
});
470+
471+
it('should throw when modifying readonly uid property', () => {
472+
expect(() => {
473+
(totpMultiFactorInfo as any).uid = 'modifiedEnrollmentId';
474+
}).to.throw(Error);
475+
});
476+
477+
it('should return expected enrollmentTime', () => {
478+
expect(totpMultiFactorInfo.enrollmentTime).to.equal(now.toUTCString());
479+
});
480+
481+
it('should throw when modifying readonly uid property', () => {
482+
expect(() => {
483+
(totpMultiFactorInfo as any).enrollmentTime = new Date().toISOString();
484+
}).to.throw(Error);
485+
});
486+
});
487+
488+
describe('toJSON', () => {
489+
it('should return expected JSON object', () => {
490+
expect(totpMultiFactorInfo.toJSON()).to.deep.equal({
491+
uid: 'enrollmentId1',
492+
displayName: 'displayName1',
493+
enrollmentTime: now.toUTCString(),
494+
totpInfo: {},
495+
factorId: 'totp',
496+
});
497+
});
498+
499+
it('should return expected JSON object with missing fields set to null', () => {
500+
expect(totpMultiFactorInfoMissingFields.toJSON()).to.deep.equal({
501+
uid: 'enrollmentId1',
502+
displayName: undefined,
503+
enrollmentTime: null,
504+
totpInfo: {},
505+
factorId: 'totp',
506+
});
507+
});
508+
});
509+
});
510+
511+
describe('MultiFactorInfo', () => {
512+
const phoneServerResponse: MultiFactorInfoResponse = {
384513
mfaEnrollmentId: 'enrollmentId1',
385514
displayName: 'displayName1',
386515
enrolledAt: now.toISOString(),
387516
phoneInfo: '+16505551234',
388517
};
389-
const phoneMultiFactorInfo = new PhoneMultiFactorInfo(serverResponse);
518+
const phoneMultiFactorInfo = new PhoneMultiFactorInfo(phoneServerResponse);
519+
const totpServerResponse: MultiFactorInfoResponse = {
520+
mfaEnrollmentId: 'enrollmentId1',
521+
displayName: 'displayName1',
522+
enrolledAt: now.toISOString(),
523+
totpInfo: {},
524+
};
525+
const totpMultiFactorInfo = new TotpMultiFactorInfo(totpServerResponse);
390526

391527
describe('initMultiFactorInfo', () => {
392528
it('should return expected PhoneMultiFactorInfo', () => {
393-
expect(MultiFactorInfo.initMultiFactorInfo(serverResponse)).to.deep.equal(phoneMultiFactorInfo);
529+
expect(MultiFactorInfo.initMultiFactorInfo(phoneServerResponse)).to.deep.equal(phoneMultiFactorInfo);
530+
});
531+
it('should return expected TotpMultiFactorInfo', () => {
532+
expect(MultiFactorInfo.initMultiFactorInfo(totpServerResponse)).to.deep.equal(totpMultiFactorInfo);
394533
});
395534

396535
it('should return null for invalid MultiFactorInfo', () => {
@@ -425,6 +564,12 @@ describe('MultiFactorSettings', () => {
425564
enrolledAt: now.toISOString(),
426565
secretKey: 'SECRET_KEY',
427566
},
567+
{
568+
mfaEnrollmentId: 'enrollmentId5',
569+
displayName: 'displayName1',
570+
enrolledAt: now.toISOString(),
571+
totpInfo: {},
572+
},
428573
],
429574
};
430575
const expectedMultiFactorInfo = [
@@ -439,6 +584,12 @@ describe('MultiFactorSettings', () => {
439584
enrolledAt: now.toISOString(),
440585
phoneInfo: '+16505556789',
441586
}),
587+
new TotpMultiFactorInfo({
588+
mfaEnrollmentId: 'enrollmentId5',
589+
displayName: 'displayName1',
590+
enrolledAt: now.toISOString(),
591+
totpInfo: {},
592+
})
442593
];
443594

444595
describe('constructor', () => {
@@ -457,9 +608,10 @@ describe('MultiFactorSettings', () => {
457608
it('should populate expected enrolledFactors', () => {
458609
const multiFactor = new MultiFactorSettings(serverResponse);
459610

460-
expect(multiFactor.enrolledFactors.length).to.equal(2);
611+
expect(multiFactor.enrolledFactors.length).to.equal(3);
461612
expect(multiFactor.enrolledFactors[0]).to.deep.equal(expectedMultiFactorInfo[0]);
462613
expect(multiFactor.enrolledFactors[1]).to.deep.equal(expectedMultiFactorInfo[1]);
614+
expect(multiFactor.enrolledFactors[2]).to.deep.equal(expectedMultiFactorInfo[2]);
463615
});
464616
});
465617

@@ -504,6 +656,7 @@ describe('MultiFactorSettings', () => {
504656
enrolledFactors: [
505657
expectedMultiFactorInfo[0].toJSON(),
506658
expectedMultiFactorInfo[1].toJSON(),
659+
expectedMultiFactorInfo[2].toJSON(),
507660
],
508661
});
509662
});

0 commit comments

Comments
 (0)