diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 8ca6e549c6..e43f1ecb60 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -53,6 +53,7 @@ import GetUsersResult = auth.GetUsersResult; import ListUsersResult = auth.ListUsersResult; import DeleteUsersResult = auth.DeleteUsersResult; import DecodedIdToken = auth.DecodedIdToken; +import CustomTokenOptions = auth.CustomTokenOptions; import SessionCookieOptions = auth.SessionCookieOptions; import OIDCAuthProviderConfig = auth.OIDCAuthProviderConfig; import SAMLAuthProviderConfig = auth.SAMLAuthProviderConfig; @@ -100,7 +101,20 @@ export class BaseAuth implements BaseAuthI * @return {Promise} A JWT for the provided payload. */ public createCustomToken(uid: string, developerClaims?: object): Promise { - return this.tokenGenerator.createCustomToken(uid, developerClaims); + return this.createCustomTokenWithOptions(uid, { developerClaims }); + } + + /** + * Creates a new custom token that can be sent back to a client to use with + * signInWithCustomToken(). + * + * @param {string} uid The uid to use as the JWT subject. + * @param {CustomTokenOptions=} options Options to use when creating the JWT. + * + * @return {Promise} A JWT for the provided payload. + */ + public createCustomTokenWithOptions(uid: string, options?: CustomTokenOptions): Promise { + return this.tokenGenerator.createCustomToken(uid, options); } /** diff --git a/src/auth/index.ts b/src/auth/index.ts index 6193336beb..73177f5e44 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -939,6 +939,24 @@ export namespace auth { multiFactor?: MultiFactorUpdateSettings; } + /** + * Interface representing the custom token options needed for the + * {@link https://firebase.google.com/docs/reference/admin/node/admin.auth.Auth#createcustomtoken `createCustomToken()`} method. + */ + export interface CustomTokenOptions { + + /** + * Optional additional claims to include in the JWT payload. + */ + developerClaims?: { [key: string]: any }; + + /** + * The JWT expiration in milliseconds. The minimum allowed is 5 minutes and the maximum allowed is 1 hour. + * Defaults to 1 hour. + */ + expiresIn?: number; + } + /** * Interface representing the session cookie options needed for the * {@link auth.Auth.createSessionCookie `createSessionCookie()`} method. @@ -1549,6 +1567,23 @@ export namespace auth { */ createCustomToken(uid: string, developerClaims?: object): Promise; + /** + * Creates a new Firebase custom token (JWT) that can be sent back to a client + * device to use to sign in with the client SDKs' `signInWithCustomToken()` + * methods. (Tenant-aware instances will also embed the tenant ID in the + * token.) + * + * See [Create Custom Tokens](/docs/auth/admin/create-custom-tokens) for code + * samples and detailed documentation. + * + * @param uid The `uid` to use as the custom token's subject. + * @param {CustomTokenOptions=} options Options to use when creating the JWT. + * + * @return A promise fulfilled with a custom token for the + * provided `uid` and payload. + */ + createCustomTokenWithOptions(uid: string, options?: CustomTokenOptions): Promise; + /** * Creates a new user. * diff --git a/src/auth/token-generator.ts b/src/auth/token-generator.ts index 6c464ec5f2..c18726035b 100644 --- a/src/auth/token-generator.ts +++ b/src/auth/token-generator.ts @@ -27,7 +27,8 @@ import { HttpError } from '../utils/api-request'; const ALGORITHM_NONE: Algorithm = 'none' as const; -const ONE_HOUR_IN_SECONDS = 60 * 60; +const MIN_JWT_EXPIRES_IN_MS = 5 * 60 * 1000; +const ONE_HOUR_IN_MS = 60 * 60 * 1000; // List of blacklisted claims which cannot be provided when creating a custom token export const BLACKLISTED_CLAIMS = [ @@ -84,6 +85,12 @@ export class EmulatedSigner implements CryptoSigner { } } +/** Interface representing the create custom token options. */ +interface FirebaseTokenOptions { + developerClaims?: { [key: string]: any }; + expiresIn?: number; +} + /** * Class for generating different types of Firebase Auth tokens (JWTs). */ @@ -115,37 +122,43 @@ export class FirebaseTokenGenerator { * Creates a new Firebase Auth Custom token. * * @param uid The user ID to use for the generated Firebase Auth Custom token. - * @param developerClaims Optional developer claims to include in the generated Firebase - * Auth Custom token. + * @param options Options to use when creating the JWT.. * @return A Promise fulfilled with a Firebase Auth Custom token signed with a * service account key and containing the provided payload. */ - public createCustomToken(uid: string, developerClaims?: {[key: string]: any}): Promise { + public createCustomToken(uid: string, options?: FirebaseTokenOptions): Promise { let errorMessage: string | undefined; if (!validator.isNonEmptyString(uid)) { errorMessage = '`uid` argument must be a non-empty string uid.'; } else if (uid.length > 128) { errorMessage = '`uid` argument must a uid with less than or equal to 128 characters.'; - } else if (!this.isDeveloperClaimsValid_(developerClaims)) { - errorMessage = '`developerClaims` argument must be a valid, non-null object containing the developer claims.'; + } else if (typeof options !== 'undefined' && !validator.isObject(options)) { + errorMessage = '`options` argument must be a valid object.'; + } else if (!this.isDeveloperClaimsValid_(options?.developerClaims)) { + errorMessage = '`options.developerClaims` argument must be a valid, non-null object containing ' + + 'the developer claims.'; + } else if (typeof options?.expiresIn !== 'undefined' && (!validator.isNumber(options.expiresIn) || + options.expiresIn < MIN_JWT_EXPIRES_IN_MS || options.expiresIn > ONE_HOUR_IN_MS)) { + errorMessage = `\`options.expiresIn\` argument must be a valid number between ${MIN_JWT_EXPIRES_IN_MS} ` + + `and ${ONE_HOUR_IN_MS}.`; } if (errorMessage) { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); } - const claims: {[key: string]: any} = {}; - if (typeof developerClaims !== 'undefined') { - for (const key in developerClaims) { + const claims: { [key: string]: any } = {}; + if (typeof options?.developerClaims !== 'undefined') { + for (const key in options.developerClaims) { /* istanbul ignore else */ - if (Object.prototype.hasOwnProperty.call(developerClaims, key)) { + if (Object.prototype.hasOwnProperty.call(options.developerClaims, key)) { if (BLACKLISTED_CLAIMS.indexOf(key) !== -1) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, `Developer claim "${key}" is reserved and cannot be specified.`, ); } - claims[key] = developerClaims[key]; + claims[key] = options.developerClaims[key]; } } } @@ -158,7 +171,7 @@ export class FirebaseTokenGenerator { const body: JWTBody = { aud: FIREBASE_AUDIENCE, iat, - exp: iat + ONE_HOUR_IN_SECONDS, + exp: iat + Math.floor((options?.expiresIn || ONE_HOUR_IN_MS) / 1000), iss: account, sub: account, uid, diff --git a/src/utils/validator.ts b/src/utils/validator.ts index bca0c660ab..6af45ba35d 100644 --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -65,7 +65,7 @@ export function isBoolean(value: any): boolean { * @param {any} value The value to validate. * @return {boolean} Whether the value is a number or not. */ -export function isNumber(value: any): boolean { +export function isNumber(value: any): value is number { return typeof value === 'number' && !isNaN(value); } @@ -112,7 +112,7 @@ export function isNonEmptyString(value: any): value is string { * @param {any} value The value to validate. * @return {boolean} Whether the value is an object or not. */ -export function isObject(value: any): boolean { +export function isObject(value: any): value is object { return typeof value === 'object' && !isArray(value); } diff --git a/test/unit/auth/token-generator.spec.ts b/test/unit/auth/token-generator.spec.ts index 6a6d148b09..fd938f1868 100644 --- a/test/unit/auth/token-generator.spec.ts +++ b/test/unit/auth/token-generator.spec.ts @@ -125,8 +125,8 @@ describe('FirebaseTokenGenerator', () => { it('should generate a valid unsigned token', async () => { const uid = 'uid123'; - const claims = { foo: 'bar' }; - const token = await tokenGenerator.createCustomToken(uid, claims); + const developerClaims = { foo: 'bar' }; + const token = await tokenGenerator.createCustomToken(uid, { developerClaims }); // Check that verify doesn't throw // Note: the types for jsonwebtoken are wrong so we have to disguise the 'null' @@ -136,7 +136,7 @@ describe('FirebaseTokenGenerator', () => { const { header, payload, signature } = jwt.decode(token, { complete: true }) as { [key: string]: any }; expect(header).to.deep.equal({ alg: 'none', typ: 'JWT' }); expect(payload['uid']).to.equal(uid); - expect(payload['claims']).to.deep.equal(claims); + expect(payload['claims']).to.deep.equal(developerClaims); expect(signature).to.equal(''); }); @@ -183,11 +183,20 @@ describe('FirebaseTokenGenerator', () => { }).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error'); }); + it('should throw given a non-object options', () => { + const invalidOptions: any[] = [NaN, [], true, false, '', 'a', 0, 1, Infinity, _.noop]; + invalidOptions.forEach((opts) => { + expect(() => { + tokenGenerator.createCustomToken(mocks.uid, opts); + }).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error'); + }); + }); + it('should throw given a non-object developer claims', () => { const invalidDeveloperClaims: any[] = [null, NaN, [], true, false, '', 'a', 0, 1, Infinity, _.noop]; invalidDeveloperClaims.forEach((invalidDevClaims) => { expect(() => { - tokenGenerator.createCustomToken(mocks.uid, invalidDevClaims); + tokenGenerator.createCustomToken(mocks.uid, { developerClaims: invalidDevClaims }); }).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error'); }); }); @@ -197,21 +206,39 @@ describe('FirebaseTokenGenerator', () => { const blacklistedDeveloperClaims: { [key: string]: any } = _.clone(mocks.developerClaims); blacklistedDeveloperClaims[blacklistedClaim] = true; expect(() => { - tokenGenerator.createCustomToken(mocks.uid, blacklistedDeveloperClaims); + tokenGenerator.createCustomToken(mocks.uid, { developerClaims: blacklistedDeveloperClaims }); }).to.throw(FirebaseAuthError, blacklistedClaim).with.property('code', 'auth/argument-error'); }); }); + it('should throw given an invalid expiresIn', () => { + const invalidExpiresIns: any[] = [null, NaN, Infinity, _.noop, 0, 299999, 3600001]; + invalidExpiresIns.forEach((invalidExpiresIn) => { + expect(() => { + tokenGenerator.createCustomToken(mocks.uid, { expiresIn: invalidExpiresIn }); + }).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error'); + }); + }); + it('should be fulfilled given a valid uid and no developer claims', () => { return tokenGenerator.createCustomToken(mocks.uid); }); it('should be fulfilled given a valid uid and empty object developer claims', () => { - return tokenGenerator.createCustomToken(mocks.uid, {}); + return tokenGenerator.createCustomToken(mocks.uid, { developerClaims: {} }); }); it('should be fulfilled given a valid uid and valid developer claims', () => { - return tokenGenerator.createCustomToken(mocks.uid, mocks.developerClaims); + return tokenGenerator.createCustomToken(mocks.uid, { developerClaims: mocks.developerClaims }); + }); + + it('should be fulfilled given a valid uid, empty object developer claims and valid expiresIn', () => { + return tokenGenerator.createCustomToken(mocks.uid, { developerClaims: {}, expiresIn: 300000 }); + }); + + it('should be fulfilled given a valid uid, valid developer claims and valid expiresIn', () => { + return tokenGenerator + .createCustomToken(mocks.uid, { developerClaims: mocks.developerClaims, expiresIn: 3600000 }); }); it('should be fulfilled with a Firebase Custom JWT', () => { @@ -246,7 +273,7 @@ describe('FirebaseTokenGenerator', () => { it('should be fulfilled with a JWT with the developer claims in its decoded payload', () => { clock = sinon.useFakeTimers(1000); - return tokenGenerator.createCustomToken(mocks.uid, mocks.developerClaims) + return tokenGenerator.createCustomToken(mocks.uid, { developerClaims: mocks.developerClaims }) .then((token) => { const decoded = jwt.decode(token); @@ -272,6 +299,32 @@ describe('FirebaseTokenGenerator', () => { }); }); + it('should be fulfilled with a JWT with the expiresIn in its exp payload', () => { + clock = sinon.useFakeTimers(2000); + const expiresIn = 300900 + + return tokenGenerator.createCustomToken(mocks.uid, { expiresIn }) + .then((token) => { + const decoded = jwt.decode(token); + + const expected: { [key: string]: any } = { + uid: mocks.uid, + iat: 2, + exp: 302, + aud: FIREBASE_AUDIENCE, + iss: mocks.certificateObject.client_email, + sub: mocks.certificateObject.client_email, + }; + + if (tokenGenerator.tenantId) { + // eslint-disable-next-line @typescript-eslint/camelcase + expected.tenant_id = tokenGenerator.tenantId; + } + + expect(decoded).to.deep.equal(expected); + }); + }); + it('should be fulfilled with a JWT with the correct header', () => { clock = sinon.useFakeTimers(1000); @@ -329,7 +382,7 @@ describe('FirebaseTokenGenerator', () => { foo: 'bar', }; const clonedClaims = _.clone(originalClaims); - return tokenGenerator.createCustomToken(mocks.uid, clonedClaims) + return tokenGenerator.createCustomToken(mocks.uid, { developerClaims: clonedClaims }) .then(() => { expect(originalClaims).to.deep.equal(clonedClaims); });