diff --git a/src/auth.d.ts b/src/auth.d.ts index 312f2c49f7..49814019f5 100644 --- a/src/auth.d.ts +++ b/src/auth.d.ts @@ -828,6 +828,24 @@ export namespace admin.auth { multiFactor?: admin.auth.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. + */ + interface CustomTokenOptions { + + /** + * Optional additional claims to include in the JWT payload. + */ + developerClaims?: { [key: string]: any }; + + /** + * The JWT expiration in milliseconds. The minimum allowed is X and the maximum allowed is 1 hour. + * Defaults to 1 hour. + */ + expiresIn?: number; + } + /** * Interface representing the session cookie options needed for the * {@link https://firebase.google.com/docs/reference/admin/node/admin.auth.Auth#createSessionCookie `createSessionCookie()`} method. @@ -1375,6 +1393,23 @@ export namespace admin.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/auth.ts b/src/auth/auth.ts index d22f3776b6..2f6def90c8 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -19,7 +19,7 @@ import { UserIdentifier, isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, isProviderIdentifier, } from './identifier'; import { FirebaseApp } from '../firebase-app'; -import { FirebaseTokenGenerator, cryptoSignerFromApp } from './token-generator'; +import { FirebaseTokenGenerator, cryptoSignerFromApp, FirebaseTokenOptions } from './token-generator'; import { AbstractAuthRequestHandler, AuthRequestHandler, TenantAwareAuthRequestHandler, } from './auth-api-request'; @@ -111,6 +111,8 @@ export interface DecodedIdToken { [key: string]: any; } +/** The public API interface representing the create custom token options. */ +export type CustomTokenOptions = FirebaseTokenOptions; /** Interface representing the session cookie options. */ export interface SessionCookieOptions { @@ -159,7 +161,20 @@ export class BaseAuth { * @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/token-generator.ts b/src/auth/token-generator.ts index 194ed48cbf..f61d589db8 100644 --- a/src/auth/token-generator.ts +++ b/src/auth/token-generator.ts @@ -24,7 +24,8 @@ import { toWebSafeBase64 } from '../utils'; const ALGORITHM_RS256 = 'RS256'; -const ONE_HOUR_IN_SECONDS = 60 * 60; +const MIN_JWT_EXPIRES_IN_MS = 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 = [ @@ -231,6 +232,12 @@ export function cryptoSignerFromApp(app: FirebaseApp): CryptoSigner { return new IAMSigner(new AuthorizedHttpClient(app), app.options.serviceAccountId); } +/** Interface representing the create custom token options. */ +export interface FirebaseTokenOptions { + developerClaims?: { [key: string]: any }; + expiresIn?: number; +} + /** * Class for generating different types of Firebase Auth tokens (JWTs). */ @@ -262,37 +269,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]; } } } @@ -305,7 +318,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 c2ce758267..550ca811c2 100644 --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -64,7 +64,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); } @@ -111,7 +111,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 e7e5dbaaca..7d190fce73 100644 --- a/test/unit/auth/token-generator.spec.ts +++ b/test/unit/auth/token-generator.spec.ts @@ -349,35 +349,62 @@ 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'); }); }); BLACKLISTED_CLAIMS.forEach((blacklistedClaim) => { it('should throw given a developer claims object with a blacklisted claim: ' + blacklistedClaim, () => { - const blacklistedDeveloperClaims: {[key: string]: any} = _.clone(mocks.developerClaims); + 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, 999, 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: 1000 }); + }); + + 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', () => { @@ -412,7 +439,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); @@ -438,6 +465,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); @@ -495,7 +548,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); });