diff --git a/src/auth/user-record.ts b/src/auth/user-record.ts index 64422c1969..3d6e690b00 100644 --- a/src/auth/user-record.ts +++ b/src/auth/user-record.ts @@ -310,15 +310,25 @@ export class MultiFactor { */ export class UserMetadata { public readonly creationTime: string; - public readonly lastSignInTime: string; + public readonly lastSignInTime: string | null; constructor(response: GetAccountInfoUserResponse) { // Creation date should always be available but due to some backend bugs there // were cases in the past where users did not have creation date properly set. // This included legacy Firebase migrating project users and some anonymous users. // These bugs have already been addressed since then. - utils.addReadonlyGetter(this, 'creationTime', parseDate(response.createdAt)); - utils.addReadonlyGetter(this, 'lastSignInTime', parseDate(response.lastLoginAt)); + const creationTime = parseDate(response.createdAt); + if (creationTime === null) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to parse createdAt time: "' + + response.createdAt + '"'); + } + this.creationTime = creationTime; + utils.enforceReadonly(this, 'creationTime'); + + this.lastSignInTime = parseDate(response.lastLoginAt); + utils.enforceReadonly(this, 'lastSignInTime'); } /** @return The plain object representation of the user's metadata. */ diff --git a/src/index.d.ts b/src/index.d.ts index a246ac1475..6f69d5890e 100755 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -489,13 +489,13 @@ declare namespace admin.auth { interface UserMetadata { /** - * The date the user last signed in, formatted as a UTC string. + * The date the user last signed in, formatted as a UTC string, or `null` + * if the user has never signed in. */ - lastSignInTime: string; + lastSignInTime: string | null; /** * The date the user was created, formatted as a UTC string. - * */ creationTime: string; diff --git a/src/utils/index.ts b/src/utils/index.ts index f6e2f41232..21014c21fe 100755 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -55,6 +55,20 @@ export function addReadonlyGetter(obj: object, prop: string, value: any): void { }); } +/** + * Marks an existing property as readonly. Unlike typescript's "readonly" + * modifier, this will take effect at runtime too (generating a TypeError if + * violated), including when called from javascript. + */ +export function enforceReadonly(obj: object, prop: string): void { + Object.defineProperty(obj, prop, { + // Make this property read-only. + writable: false, + // Include this property during enumeration of obj's properties. + enumerable: true, + }); +} + /** * Returns the Google Cloud project ID associated with a Firebase app, if it's explicitly * specified in either the Firebase app options, credentials or the local environment. diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index c3a867011f..091ce9baec 100755 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -1597,17 +1597,17 @@ AUTH_CONFIGS.forEach((testConfig) => { const maxResult = 500; const downloadAccountResponse: any = { users: [ - {localId: 'UID1'}, - {localId: 'UID2'}, - {localId: 'UID3'}, + {createdAt: '1234567890000', localId: 'UID1'}, + {createdAt: '1234567890000', localId: 'UID2'}, + {createdAt: '1234567890000', localId: 'UID3'}, ], nextPageToken: 'NEXT_PAGE_TOKEN', }; const expectedResult: any = { users: [ - new UserRecord({localId: 'UID1'}), - new UserRecord({localId: 'UID2'}), - new UserRecord({localId: 'UID3'}), + new UserRecord({createdAt: '1234567890000', localId: 'UID1'}), + new UserRecord({createdAt: '1234567890000', localId: 'UID2'}), + new UserRecord({createdAt: '1234567890000', localId: 'UID3'}), ], pageToken: 'NEXT_PAGE_TOKEN', }; diff --git a/test/unit/auth/user-record.spec.ts b/test/unit/auth/user-record.spec.ts index fce2041ec6..454bc1c252 100644 --- a/test/unit/auth/user-record.spec.ts +++ b/test/unit/auth/user-record.spec.ts @@ -23,6 +23,7 @@ import { UserInfo, UserMetadata, UserRecord, GetAccountInfoUserResponse, ProviderUserInfoResponse, MultiFactor, PhoneMultiFactorInfo, MultiFactorInfo, MultiFactorInfoResponse, } from '../../../src/auth/user-record'; +import {FirebaseAuthError} from '../../../src/utils/error'; chai.should(); @@ -633,18 +634,15 @@ describe('UserMetadata', () => { }).not.to.throw(Error); }); - it('should set creationTime and lastSignInTime to null when not provided', () => { - const metadata = new UserMetadata({} as any); - expect(metadata.creationTime).to.be.null; + it('should set lastSignInTime to null when not provided', () => { + const metadata = new UserMetadata({createdAt: '1234567890000'} as any); expect(metadata.lastSignInTime).to.be.null; }); - it('should set creationTime to null when creationTime value is invalid', () => { - const metadata = new UserMetadata({ - createdAt: 'invalid', - } as any); - expect(metadata.creationTime).to.be.null; - expect(metadata.lastSignInTime).to.be.null; + it('should throw when createdAt value is invalid', () => { + expect(() => { + new UserMetadata({createdAt: 'invalid', localId: 'uid1'}); + }).to.throw(FirebaseAuthError).with.property('code', 'auth/internal-error'); }); it('should set lastSignInTime to null when lastLoginAt value is invalid', () => { @@ -693,9 +691,9 @@ describe('UserRecord', () => { }).to.throw(Error); }); - it('should succeed when only localId is provided', () => { + it('should succeed when only createdAt and localId is provided', () => { expect(() => { - return new UserRecord({localId: '123456789'}); + return new UserRecord({createdAt: '1234567890000', localId: '123456789'}); }).not.to.throw(Error); }); }); @@ -791,6 +789,7 @@ describe('UserRecord', () => { it('should clear REDACTED passwordHash', () => { const user = new UserRecord({ localId: 'uid1', + createdAt: '1234567890000', passwordHash: Buffer.from('REDACTED').toString('base64'), });