Skip to content
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

fix(auth): Add TotpInfo field to UserRecord #2197

Merged
merged 9 commits into from
Jun 27, 2023
75 changes: 70 additions & 5 deletions src/auth/user-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,15 @@ export interface MultiFactorInfoResponse {
mfaEnrollmentId: string;
displayName?: string;
phoneInfo?: string;
totpInfo?: TotpInfoResponse;
enrolledAt?: string;
[key: string]: any;
}

export interface TotpInfoResponse {
[key: string]: any;
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved
}

export interface ProviderUserInfoResponse {
rawId: string;
displayName?: string;
Expand Down Expand Up @@ -84,6 +89,7 @@ export interface GetAccountInfoUserResponse {

enum MultiFactorId {
Phone = 'phone',
Totp = 'totp',
}

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

/**
* The type identifier of the second factor. For SMS second factors, this is `phone`.
* The type identifier of the second factor.
* For SMS second factors, this is `phone`.
* For TOTP second factors, this is `totp`.
*/
public readonly factorId: string;

Expand All @@ -120,9 +128,13 @@ export abstract class MultiFactorInfo {
*/
public static initMultiFactorInfo(response: MultiFactorInfoResponse): MultiFactorInfo | null {
let multiFactorInfo: MultiFactorInfo | null = null;
// Only PhoneMultiFactorInfo currently available.
// PhoneMultiFactorInfo, TotpMultiFactorInfo currently available.
try {
multiFactorInfo = new PhoneMultiFactorInfo(response);
if (response.phoneInfo !== undefined) {
multiFactorInfo = new PhoneMultiFactorInfo(response);
} else if (response.totpInfo !== undefined) {
multiFactorInfo = new TotpMultiFactorInfo(response);
}
} catch (e) {
// Ignore error.
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved
}
Expand Down Expand Up @@ -225,7 +237,6 @@ export class PhoneMultiFactorInfo extends MultiFactorInfo {
phoneNumber: this.phoneNumber,
});
}

/**
* Returns the factor ID based on the response provided.
*
Expand All @@ -240,14 +251,68 @@ export class PhoneMultiFactorInfo extends MultiFactorInfo {
}
}

/**
* TotpInfo struct associated with a second factor
*/
export class TotpInfo {
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved

}

/**
* Interface representing a TOTP specific user-enrolled second factor.
*/
export class TotpMultiFactorInfo extends MultiFactorInfo {

/**
* TotpInfo struct associated with a second factor
*/
public readonly totpInfo: TotpInfo;

/**
* Initializes the TotpMultiFactorInfo object using the server side response.
*
* @param response - The server side response.
* @constructor
* @internal
*/
constructor(response: MultiFactorInfoResponse) {
super(response);
utils.addReadonlyGetter(this, 'totpInfo', response.totpInfo);
}

/**
* {@inheritdoc MultiFactorInfo.toJSON}
*/
public toJSON(): object {
return Object.assign(
super.toJSON(),
{
totpInfo: this.totpInfo,
});
}

/**
* Returns the factor ID based on the response provided.
*
* @param response - The server side response.
* @returns The multi-factor ID associated with the provided response. If the response is
* not associated with any known multi-factor ID, null is returned.
*
* @internal
*/
protected getFactorId(response: MultiFactorInfoResponse): string | null {
return (response && response.totpInfo) ? MultiFactorId.Totp : null;
}
}

/**
* The multi-factor related user settings.
*/
export class MultiFactorSettings {

/**
* List of second factors enrolled with the current user.
* Currently only phone second factors are supported.
* Currently only phone and totp second factors are supported.
*/
public enrolledFactors: MultiFactorInfo[];

Expand Down
163 changes: 158 additions & 5 deletions test/unit/auth/user-record.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import * as chaiAsPromised from 'chai-as-promised';

import { deepCopy } from '../../../src/utils/deep-copy';
import {
GetAccountInfoUserResponse, ProviderUserInfoResponse, MultiFactorInfoResponse,
GetAccountInfoUserResponse, ProviderUserInfoResponse, MultiFactorInfoResponse, TotpMultiFactorInfo,
} from '../../../src/auth/user-record';
import {
UserInfo, UserMetadata, UserRecord, MultiFactorSettings, MultiFactorInfo, PhoneMultiFactorInfo,
Expand Down Expand Up @@ -379,18 +379,157 @@ describe('PhoneMultiFactorInfo', () => {
});
});

describe('MultiFactorInfo', () => {
describe('TotpMultiFactorInfo', () => {
const serverResponse: MultiFactorInfoResponse = {
mfaEnrollmentId: 'enrollmentId1',
displayName: 'displayName1',
enrolledAt: now.toISOString(),
totpInfo: {},
};
const totpMultiFactorInfo = new TotpMultiFactorInfo(serverResponse);
const totpMultiFactorInfoMissingFields = new TotpMultiFactorInfo({
mfaEnrollmentId: serverResponse.mfaEnrollmentId,
totpInfo: serverResponse.totpInfo,
});

describe('constructor', () => {
it('should throw when an empty object is provided', () => {
expect(() => {
return new TotpMultiFactorInfo({} as any);
}).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response');
});

it('should throw when an undefined response is provided', () => {
expect(() => {
return new TotpMultiFactorInfo(undefined as any);
}).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response');
});

it('should succeed when mfaEnrollmentId and totpInfo are both provided', () => {
expect(() => {
return new TotpMultiFactorInfo({
mfaEnrollmentId: 'enrollmentId1',
totpInfo: {},
});
}).not.to.throw(Error);
});

it('should throw when only mfaEnrollmentId is provided', () => {
expect(() => {
return new TotpMultiFactorInfo({
mfaEnrollmentId: 'enrollmentId1',
} as any);
}).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response');
});

it('should throw when only totpInfo is provided', () => {
expect(() => {
return new TotpMultiFactorInfo({
totpInfo: {},
} as any);
}).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response');
});
});

describe('getters', () => {
it('should set missing optional fields to null', () => {
expect(totpMultiFactorInfoMissingFields.uid).to.equal(serverResponse.mfaEnrollmentId);
expect(totpMultiFactorInfoMissingFields.displayName).to.be.undefined;
expect(totpMultiFactorInfoMissingFields.totpInfo).to.equal(serverResponse.totpInfo);
expect(totpMultiFactorInfoMissingFields.enrollmentTime).to.be.null;
expect(totpMultiFactorInfoMissingFields.factorId).to.equal('totp');
});

it('should return expected factorId', () => {
expect(totpMultiFactorInfo.factorId).to.equal('totp');
});

it('should throw when modifying readonly factorId property', () => {
expect(() => {
(totpMultiFactorInfo as any).factorId = 'other';
}).to.throw(Error);
});

it('should return expected displayName', () => {
expect(totpMultiFactorInfo.displayName).to.equal(serverResponse.displayName);
});

it('should throw when modifying readonly displayName property', () => {
expect(() => {
(totpMultiFactorInfo as any).displayName = 'Modified';
}).to.throw(Error);
});

it('should return expected totpInfo object', () => {
expect(totpMultiFactorInfo.totpInfo).to.equal(serverResponse.totpInfo);
});

it('should return expected uid', () => {
expect(totpMultiFactorInfo.uid).to.equal(serverResponse.mfaEnrollmentId);
});

it('should throw when modifying readonly uid property', () => {
expect(() => {
(totpMultiFactorInfo as any).uid = 'modifiedEnrollmentId';
}).to.throw(Error);
});

it('should return expected enrollmentTime', () => {
expect(totpMultiFactorInfo.enrollmentTime).to.equal(now.toUTCString());
});

it('should throw when modifying readonly uid property', () => {
expect(() => {
(totpMultiFactorInfo as any).enrollmentTime = new Date().toISOString();
}).to.throw(Error);
});
});

describe('toJSON', () => {
it('should return expected JSON object', () => {
expect(totpMultiFactorInfo.toJSON()).to.deep.equal({
uid: 'enrollmentId1',
displayName: 'displayName1',
enrollmentTime: now.toUTCString(),
totpInfo: {},
factorId: 'totp',
});
});

it('should return expected JSON object with missing fields set to null', () => {
expect(totpMultiFactorInfoMissingFields.toJSON()).to.deep.equal({
uid: 'enrollmentId1',
displayName: undefined,
enrollmentTime: null,
totpInfo: {},
factorId: 'totp',
});
});
});
});

describe('MultiFactorInfo', () => {
const phoneServerResponse: MultiFactorInfoResponse = {
mfaEnrollmentId: 'enrollmentId1',
displayName: 'displayName1',
enrolledAt: now.toISOString(),
phoneInfo: '+16505551234',
};
const phoneMultiFactorInfo = new PhoneMultiFactorInfo(serverResponse);
const phoneMultiFactorInfo = new PhoneMultiFactorInfo(phoneServerResponse);
const totpServerResponse: MultiFactorInfoResponse = {
mfaEnrollmentId: 'enrollmentId1',
displayName: 'displayName1',
enrolledAt: now.toISOString(),
totpInfo: {},
};
const totpMultiFactorInfo = new TotpMultiFactorInfo(totpServerResponse);

describe('initMultiFactorInfo', () => {
it('should return expected PhoneMultiFactorInfo', () => {
expect(MultiFactorInfo.initMultiFactorInfo(serverResponse)).to.deep.equal(phoneMultiFactorInfo);
expect(MultiFactorInfo.initMultiFactorInfo(phoneServerResponse)).to.deep.equal(phoneMultiFactorInfo);
});
it('should return expected TotpMultiFactorInfo', () => {
expect(MultiFactorInfo.initMultiFactorInfo(totpServerResponse)).to.deep.equal(totpMultiFactorInfo);
});

it('should return null for invalid MultiFactorInfo', () => {
Expand Down Expand Up @@ -425,6 +564,12 @@ describe('MultiFactorSettings', () => {
enrolledAt: now.toISOString(),
secretKey: 'SECRET_KEY',
},
{
mfaEnrollmentId: 'enrollmentId5',
displayName: 'displayName1',
enrolledAt: now.toISOString(),
totpInfo: {},
},
],
};
const expectedMultiFactorInfo = [
Expand All @@ -439,6 +584,12 @@ describe('MultiFactorSettings', () => {
enrolledAt: now.toISOString(),
phoneInfo: '+16505556789',
}),
new TotpMultiFactorInfo({
mfaEnrollmentId: 'enrollmentId5',
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved
displayName: 'displayName1',
enrolledAt: now.toISOString(),
totpInfo: {},
})
];

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

expect(multiFactor.enrolledFactors.length).to.equal(2);
expect(multiFactor.enrolledFactors.length).to.equal(3);
expect(multiFactor.enrolledFactors[0]).to.deep.equal(expectedMultiFactorInfo[0]);
expect(multiFactor.enrolledFactors[1]).to.deep.equal(expectedMultiFactorInfo[1]);
expect(multiFactor.enrolledFactors[2]).to.deep.equal(expectedMultiFactorInfo[2]);
});
});

Expand Down Expand Up @@ -504,6 +656,7 @@ describe('MultiFactorSettings', () => {
enrolledFactors: [
expectedMultiFactorInfo[0].toJSON(),
expectedMultiFactorInfo[1].toJSON(),
expectedMultiFactorInfo[2].toJSON(),
],
});
});
Expand Down