Skip to content

Commit fa7f2ec

Browse files
Populates UserRecord with multi-factor related info (#681)
Updates UserRecord to parse multi-factor related information from the GetAccountInfo server response. First step in supporting multi-factor authentication with SMS as a second factor.
1 parent 0f91e02 commit fa7f2ec

File tree

2 files changed

+612
-39
lines changed

2 files changed

+612
-39
lines changed

src/auth/user-record.ts

Lines changed: 221 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
import {deepCopy} from '../utils/deep-copy';
18+
import {isNonNullObject} from '../utils/validator';
1819
import * as utils from '../utils';
1920
import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error';
2021

@@ -26,8 +27,8 @@ const B64_REDACTED = Buffer.from('REDACTED').toString('base64');
2627
/**
2728
* Parses a time stamp string or number and returns the corresponding date if valid.
2829
*
29-
* @param {any} time The unix timestamp string or number in milliseconds.
30-
* @return {string} The corresponding date as a UTC string, if valid.
30+
* @param time The unix timestamp string or number in milliseconds.
31+
* @return The corresponding date as a UTC string, if valid.
3132
*/
3233
function parseDate(time: any): string {
3334
try {
@@ -57,19 +58,219 @@ export interface CreateRequest extends UpdateRequest {
5758
uid?: string;
5859
}
5960

61+
export interface AuthFactorInfo {
62+
mfaEnrollmentId: string;
63+
displayName?: string;
64+
phoneInfo?: string;
65+
enrolledAt?: string;
66+
[key: string]: any;
67+
}
68+
69+
export interface ProviderUserInfo {
70+
rawId: string;
71+
displayName?: string;
72+
email?: string;
73+
photoUrl?: string;
74+
phoneNumber?: string;
75+
providerId: string;
76+
federatedId?: string;
77+
}
78+
79+
export interface GetAccountInfoUserResponse {
80+
localId: string;
81+
email?: string;
82+
emailVerified?: boolean;
83+
phoneNumber?: string;
84+
displayName?: string;
85+
photoUrl?: string;
86+
disabled?: boolean;
87+
passwordHash?: string;
88+
salt?: string;
89+
customAttributes?: string;
90+
validSince?: string;
91+
tenantId?: string;
92+
providerUserInfo?: ProviderUserInfo[];
93+
mfaInfo?: AuthFactorInfo[];
94+
createdAt?: string;
95+
lastLoginAt?: string;
96+
[key: string]: any;
97+
}
98+
99+
/** Enums for multi-factor identifiers. */
100+
export enum MultiFactorId {
101+
Phone = 'phone',
102+
}
103+
104+
/**
105+
* Abstract class representing a multi-factor info interface.
106+
*/
107+
export abstract class MultiFactorInfo {
108+
public readonly uid: string;
109+
public readonly displayName: string | null;
110+
public readonly factorId: MultiFactorId;
111+
public readonly enrollmentTime: string;
112+
113+
/**
114+
* Initializes the MultiFactorInfo associated subclass using the server side.
115+
* If no MultiFactorInfo is associated with the response, null is returned.
116+
*
117+
* @param response The server side response.
118+
* @constructor
119+
*/
120+
public static initMultiFactorInfo(response: AuthFactorInfo): MultiFactorInfo | null {
121+
let multiFactorInfo: MultiFactorInfo | null = null;
122+
// Only PhoneMultiFactorInfo currently available.
123+
try {
124+
multiFactorInfo = new PhoneMultiFactorInfo(response);
125+
} catch (e) {
126+
// Ignore error.
127+
}
128+
return multiFactorInfo;
129+
}
130+
131+
/**
132+
* Initializes the MultiFactorInfo object using the server side response.
133+
*
134+
* @param response The server side response.
135+
* @constructor
136+
*/
137+
constructor(response: AuthFactorInfo) {
138+
this.initFromServerResponse(response);
139+
}
140+
141+
/** @return The plain object representation. */
142+
public toJSON(): any {
143+
return {
144+
uid: this.uid,
145+
displayName: this.displayName,
146+
factorId: this.factorId,
147+
enrollmentTime: this.enrollmentTime,
148+
};
149+
}
150+
151+
/**
152+
* Returns the factor ID based on the response provided.
153+
*
154+
* @param response The server side response.
155+
* @return The multi-factor ID associated with the provided response. If the response is
156+
* not associated with any known multi-factor ID, null is returned.
157+
*/
158+
protected abstract getFactorId(response: AuthFactorInfo): MultiFactorId | null;
159+
160+
/**
161+
* Initializes the MultiFactorInfo object using the provided server response.
162+
*
163+
* @param response The server side response.
164+
*/
165+
private initFromServerResponse(response: AuthFactorInfo) {
166+
const factorId = response && this.getFactorId(response);
167+
if (!factorId || !response || !response.mfaEnrollmentId) {
168+
throw new FirebaseAuthError(
169+
AuthClientErrorCode.INTERNAL_ERROR,
170+
'INTERNAL ASSERT FAILED: Invalid multi-factor info response');
171+
}
172+
utils.addReadonlyGetter(this, 'uid', response.mfaEnrollmentId);
173+
utils.addReadonlyGetter(this, 'factorId', factorId);
174+
utils.addReadonlyGetter(this, 'displayName', response.displayName || null);
175+
// Encoded using [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format.
176+
// For example, "2017-01-15T01:30:15.01Z".
177+
// This can be parsed directly via Date constructor.
178+
// This can be computed using Data.prototype.toISOString.
179+
if (response.enrolledAt) {
180+
utils.addReadonlyGetter(
181+
this, 'enrollmentTime', new Date(response.enrolledAt).toUTCString());
182+
} else {
183+
utils.addReadonlyGetter(this, 'enrollmentTime', null);
184+
}
185+
}
186+
}
187+
188+
/** Class representing a phone MultiFactorInfo object. */
189+
export class PhoneMultiFactorInfo extends MultiFactorInfo {
190+
public readonly phoneNumber: string;
191+
192+
/**
193+
* Initializes the PhoneMultiFactorInfo object using the server side response.
194+
*
195+
* @param response The server side response.
196+
* @constructor
197+
*/
198+
constructor(response: AuthFactorInfo) {
199+
super(response);
200+
utils.addReadonlyGetter(this, 'phoneNumber', response.phoneInfo);
201+
}
202+
203+
/** @return The plain object representation. */
204+
public toJSON(): any {
205+
return Object.assign(
206+
super.toJSON(),
207+
{
208+
phoneNumber: this.phoneNumber,
209+
});
210+
}
211+
212+
/**
213+
* Returns the factor ID based on the response provided.
214+
*
215+
* @param response The server side response.
216+
* @return The multi-factor ID associated with the provided response. If the response is
217+
* not associated with any known multi-factor ID, null is returned.
218+
*/
219+
protected getFactorId(response: AuthFactorInfo): MultiFactorId | null {
220+
return !!(response && response.phoneInfo) ? MultiFactorId.Phone : null;
221+
}
222+
}
223+
224+
/** Class representing multi-factor related properties of a user. */
225+
export class MultiFactor {
226+
public readonly enrolledFactors: ReadonlyArray<MultiFactorInfo>;
227+
228+
/**
229+
* Initializes the MultiFactor object using the server side or JWT format response.
230+
*
231+
* @param response The server side response.
232+
* @constructor
233+
*/
234+
constructor(response: GetAccountInfoUserResponse) {
235+
const parsedEnrolledFactors: MultiFactorInfo[] = [];
236+
if (!isNonNullObject(response)) {
237+
throw new FirebaseAuthError(
238+
AuthClientErrorCode.INTERNAL_ERROR,
239+
'INTERNAL ASSERT FAILED: Invalid multi-factor response');
240+
} else if (response.mfaInfo) {
241+
response.mfaInfo.forEach((factorResponse) => {
242+
const multiFactorInfo = MultiFactorInfo.initMultiFactorInfo(factorResponse);
243+
if (multiFactorInfo) {
244+
parsedEnrolledFactors.push(multiFactorInfo);
245+
}
246+
});
247+
}
248+
// Make enrolled factors immutable.
249+
utils.addReadonlyGetter(
250+
this, 'enrolledFactors', Object.freeze(parsedEnrolledFactors));
251+
}
252+
253+
/** @return The plain object representation. */
254+
public toJSON(): any {
255+
return {
256+
enrolledFactors: this.enrolledFactors.map((info) => info.toJSON()),
257+
};
258+
}
259+
}
260+
60261
/**
61262
* User metadata class that provides metadata information like user account creation
62263
* and last sign in time.
63264
*
64-
* @param {object} response The server side response returned from the getAccountInfo
265+
* @param response The server side response returned from the getAccountInfo
65266
* endpoint.
66267
* @constructor
67268
*/
68269
export class UserMetadata {
69270
public readonly creationTime: string;
70271
public readonly lastSignInTime: string;
71272

72-
constructor(response: any) {
273+
constructor(response: GetAccountInfoUserResponse) {
73274
// Creation date should always be available but due to some backend bugs there
74275
// were cases in the past where users did not have creation date properly set.
75276
// This included legacy Firebase migrating project users and some anonymous users.
@@ -78,7 +279,7 @@ export class UserMetadata {
78279
utils.addReadonlyGetter(this, 'lastSignInTime', parseDate(response.lastLoginAt));
79280
}
80281

81-
/** @return {object} The plain object representation of the user's metadata. */
282+
/** @return The plain object representation of the user's metadata. */
82283
public toJSON(): object {
83284
return {
84285
lastSignInTime: this.lastSignInTime,
@@ -91,7 +292,7 @@ export class UserMetadata {
91292
* User info class that provides provider user information for different
92293
* Firebase providers like google.com, facebook.com, password, etc.
93294
*
94-
* @param {object} response The server side response returned from the getAccountInfo
295+
* @param response The server side response returned from the getAccountInfo
95296
* endpoint.
96297
* @constructor
97298
*/
@@ -103,7 +304,7 @@ export class UserInfo {
103304
public readonly providerId: string;
104305
public readonly phoneNumber: string;
105306

106-
constructor(response: any) {
307+
constructor(response: ProviderUserInfo) {
107308
// Provider user id and provider id are required.
108309
if (!response.rawId || !response.providerId) {
109310
throw new FirebaseAuthError(
@@ -119,7 +320,7 @@ export class UserInfo {
119320
utils.addReadonlyGetter(this, 'phoneNumber', response.phoneNumber);
120321
}
121322

122-
/** @return {object} The plain object representation of the current provider data. */
323+
/** @return The plain object representation of the current provider data. */
123324
public toJSON(): object {
124325
return {
125326
uid: this.uid,
@@ -136,7 +337,7 @@ export class UserInfo {
136337
* User record class that defines the Firebase user object populated from
137338
* the Firebase Auth getAccountInfo response.
138339
*
139-
* @param {any} response The server side response returned from the getAccountInfo
340+
* @param response The server side response returned from the getAccountInfo
140341
* endpoint.
141342
* @constructor
142343
*/
@@ -155,8 +356,9 @@ export class UserRecord {
155356
public readonly customClaims: object;
156357
public readonly tenantId?: string | null;
157358
public readonly tokensValidAfterTime?: string;
359+
public readonly multiFactor?: MultiFactor;
158360

159-
constructor(response: any) {
361+
constructor(response: GetAccountInfoUserResponse) {
160362
// The Firebase user id is required.
161363
if (!response.localId) {
162364
throw new FirebaseAuthError(
@@ -199,13 +401,17 @@ export class UserRecord {
199401
let validAfterTime: string = null;
200402
// Convert validSince first to UTC milliseconds and then to UTC date string.
201403
if (typeof response.validSince !== 'undefined') {
202-
validAfterTime = parseDate(response.validSince * 1000);
404+
validAfterTime = parseDate(parseInt(response.validSince, 10) * 1000);
203405
}
204406
utils.addReadonlyGetter(this, 'tokensValidAfterTime', validAfterTime || undefined);
205407
utils.addReadonlyGetter(this, 'tenantId', response.tenantId);
408+
const multiFactor = new MultiFactor(response);
409+
if (multiFactor.enrolledFactors.length > 0) {
410+
utils.addReadonlyGetter(this, 'multiFactor', multiFactor);
411+
}
206412
}
207413

208-
/** @return {object} The plain object representation of the user record. */
414+
/** @return The plain object representation of the user record. */
209415
public toJSON(): object {
210416
const json: any = {
211417
uid: this.uid,
@@ -223,6 +429,9 @@ export class UserRecord {
223429
tokensValidAfterTime: this.tokensValidAfterTime,
224430
tenantId: this.tenantId,
225431
};
432+
if (this.multiFactor) {
433+
json.multiFactor = this.multiFactor.toJSON();
434+
}
226435
json.providerData = [];
227436
for (const entry of this.providerData) {
228437
// Convert each provider data to json.

0 commit comments

Comments
 (0)