diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 032f955fdd..eddea69cb3 100755 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -98,13 +98,21 @@ export class BaseAuth { /** * The BaseAuth class constructor. * - * @param {T} authRequestHandler The RPC request handler - * for this instance. + * @param app The FirebaseApp to associate with this Auth instance. + * @param authRequestHandler The RPC request handler for this instance. + * @param tokenGenerator Optional token generator. If not specified, a + * (non-tenant-aware) instance will be created. Use this paramter to + * specify a tenant-aware tokenGenerator. * @constructor */ - constructor(app: FirebaseApp, protected readonly authRequestHandler: T) { - const cryptoSigner = cryptoSignerFromApp(app); - this.tokenGenerator = new FirebaseTokenGenerator(cryptoSigner); + constructor(app: FirebaseApp, protected readonly authRequestHandler: T, tokenGenerator?: FirebaseTokenGenerator) { + if (tokenGenerator) { + this.tokenGenerator = tokenGenerator; + } else { + const cryptoSigner = cryptoSignerFromApp(app); + this.tokenGenerator = new FirebaseTokenGenerator(cryptoSigner); + } + this.sessionCookieVerifier = createSessionCookieVerifier(app); this.idTokenVerifier = createIdTokenVerifier(app); } @@ -613,26 +621,12 @@ export class TenantAwareAuth extends BaseAuth { * @constructor */ constructor(app: FirebaseApp, tenantId: string) { - super(app, new TenantAwareAuthRequestHandler(app, tenantId)); + const cryptoSigner = cryptoSignerFromApp(app); + const tokenGenerator = new FirebaseTokenGenerator(cryptoSigner, tenantId); + super(app, new TenantAwareAuthRequestHandler(app, tenantId), tokenGenerator); utils.addReadonlyGetter(this, 'tenantId', tenantId); } - /** - * 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 {object=} developerClaims Optional additional claims to include in the JWT payload. - * - * @return {Promise} A JWT for the provided payload. - */ - public createCustomToken(uid: string, developerClaims?: object): Promise { - // This is not yet supported by the Auth server. It is also not yet determined how this will be - // supported. - return Promise.reject( - new FirebaseAuthError(AuthClientErrorCode.UNSUPPORTED_TENANT_OPERATION)); - } - /** * Verifies a JWT auth token. Returns a Promise with the tokens claims. Rejects * the promise if the token could not be verified. If checkRevoked is set to true, diff --git a/src/auth/token-generator.ts b/src/auth/token-generator.ts index b63e5c5186..28d76a43b4 100644 --- a/src/auth/token-generator.ts +++ b/src/auth/token-generator.ts @@ -74,6 +74,7 @@ interface JWTBody { exp: number; iss: string; sub: string; + tenant_id?: string; } /** @@ -247,33 +248,43 @@ export class FirebaseTokenGenerator { private readonly signer: CryptoSigner; - constructor(signer: CryptoSigner) { + /** + * @param tenantId The tenant ID to use for the generated Firebase Auth + * Custom token. If absent, then no tenant ID claim will be set in the + * resulting JWT. + */ + constructor(signer: CryptoSigner, public readonly tenantId?: string) { if (!validator.isNonNullObject(signer)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CREDENTIAL, 'INTERNAL ASSERT: Must provide a CryptoSigner to use FirebaseTokenGenerator.', ); } + if (typeof tenantId !== 'undefined' && !validator.isNonEmptyString(tenantId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '`tenantId` argument must be a non-empty string.'); + } this.signer = signer; } /** * Creates a new Firebase Auth Custom token. * - * @param {string} uid The user ID to use for the generated Firebase Auth Custom token. - * @param {object} [developerClaims] Optional developer claims to include in the generated Firebase - * Auth Custom token. - * @return {Promise} A Promise fulfilled with a Firebase Auth Custom token signed with a - * service account key and containing the provided payload. + * @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. + * @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 { let errorMessage: string | undefined; - if (typeof uid !== 'string' || uid === '') { - errorMessage = 'First argument to createCustomToken() must be a non-empty string uid.'; + if (!validator.isNonEmptyString(uid)) { + errorMessage = '`uid` argument must be a non-empty string uid.'; } else if (uid.length > 128) { - errorMessage = 'First argument to createCustomToken() must a uid with less than or equal to 128 characters.'; + errorMessage = '`uid` argument must a uid with less than or equal to 128 characters.'; } else if (!this.isDeveloperClaimsValid_(developerClaims)) { - errorMessage = 'Second argument to createCustomToken() must be an object containing the developer claims.'; + errorMessage = '`developerClaims` argument must be a valid, non-null object containing the developer claims.'; } if (errorMessage) { @@ -309,6 +320,9 @@ export class FirebaseTokenGenerator { sub: account, uid, }; + if (this.tenantId) { + body.tenant_id = this.tenantId; + } if (Object.keys(claims).length > 0) { body.claims = claims; } diff --git a/src/index.d.ts b/src/index.d.ts index 203e8d9bec..5bce24b49b 100755 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1445,7 +1445,8 @@ declare namespace admin.auth { /** * 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. + * 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. diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index e9679e0eca..b78e1e6c43 100755 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -30,7 +30,7 @@ import url = require('url'); import * as mocks from '../resources/mocks'; import { AuthProviderConfig } from '../../src/auth/auth-config'; import { deepExtend, deepCopy } from '../../src/utils/deep-copy'; -import { User } from '@firebase/auth-types'; +import { User, FirebaseAuth } from '@firebase/auth-types'; /* tslint:disable:no-var-requires */ const chalk = require('chalk'); @@ -91,6 +91,10 @@ function randomOidcProviderId(): string { return 'oidc.' + generateRandomString(10, false).toLowerCase(); } +function clientAuth(): FirebaseAuth { + expect(firebase.auth).to.be.ok; + return firebase.auth!(); +} describe('admin.auth', () => { @@ -213,7 +217,7 @@ describe('admin.auth', () => { let currentIdToken: string; let currentUser: User; // Sign in with an email and password account. - return firebase.auth!().signInWithEmailAndPassword(mockUserData.email, mockUserData.password) + return clientAuth().signInWithEmailAndPassword(mockUserData.email, mockUserData.password) .then(({user}) => { expect(user).to.exist; currentUser = user!; @@ -248,7 +252,7 @@ describe('admin.auth', () => { }) .then(() => { // New sign-in should succeed. - return firebase.auth!().signInWithEmailAndPassword( + return clientAuth().signInWithEmailAndPassword( mockUserData.email, mockUserData.password); }) .then(({user}) => { @@ -273,7 +277,7 @@ describe('admin.auth', () => { // Confirm custom claims set on the UserRecord. expect(userRecord.customClaims).to.deep.equal(customClaims); expect(userRecord.email).to.exist; - return firebase.auth!().signInWithEmailAndPassword( + return clientAuth().signInWithEmailAndPassword( userRecord.email!, mockUserData.password); }) .then(({user}) => { @@ -302,8 +306,8 @@ describe('admin.auth', () => { // Custom claims should be cleared. expect(userRecord.customClaims).to.deep.equal({}); // Force token refresh. All claims should be cleared. - expect(firebase.auth!().currentUser).to.exist; - return firebase.auth!().currentUser!.getIdToken(true); + expect(clientAuth().currentUser).to.exist; + return clientAuth().currentUser!.getIdToken(true); }) .then((idToken) => { // Verify ID token contents. @@ -368,7 +372,7 @@ describe('admin.auth', () => { isAdmin: true, }) .then((customToken) => { - return firebase.auth!().signInWithCustomToken(customToken); + return clientAuth().signInWithCustomToken(customToken); }) .then(({user}) => { expect(user).to.exist; @@ -388,7 +392,7 @@ describe('admin.auth', () => { isAdmin: true, }) .then((customToken) => { - return firebase.auth!().signInWithCustomToken(customToken); + return clientAuth().signInWithCustomToken(customToken); }) .then(({user}) => { expect(user).to.exist; @@ -426,7 +430,7 @@ describe('admin.auth', () => { // Sign out after each test. afterEach(() => { - return firebase.auth!().signOut(); + return clientAuth().signOut(); }); // Delete test user at the end of test suite. @@ -443,10 +447,10 @@ describe('admin.auth', () => { .then((link) => { const code = getActionCode(link); expect(getContinueUrl(link)).equal(actionCodeSettings.url); - return firebase.auth!().confirmPasswordReset(code, newPassword); + return clientAuth().confirmPasswordReset(code, newPassword); }) .then(() => { - return firebase.auth!().signInWithEmailAndPassword(email, newPassword); + return clientAuth().signInWithEmailAndPassword(email, newPassword); }) .then((result) => { expect(result.user).to.exist; @@ -466,10 +470,10 @@ describe('admin.auth', () => { .then((link) => { const code = getActionCode(link); expect(getContinueUrl(link)).equal(actionCodeSettings.url); - return firebase.auth!().applyActionCode(code); + return clientAuth().applyActionCode(code); }) .then(() => { - return firebase.auth!().signInWithEmailAndPassword(email, userData.password); + return clientAuth().signInWithEmailAndPassword(email, userData.password); }) .then((result) => { expect(result.user).to.exist; @@ -482,7 +486,7 @@ describe('admin.auth', () => { return admin.auth().generateSignInWithEmailLink(email, actionCodeSettings) .then((link) => { expect(getContinueUrl(link)).equal(actionCodeSettings.url); - return firebase.auth!().signInWithEmailLink(email, link); + return clientAuth().signInWithEmailLink(email, link); }) .then((result) => { expect(result.user).to.exist; @@ -722,6 +726,23 @@ describe('admin.auth', () => { expect(userRecord.uid).to.equal(createdUserUid); }); }); + + it('createCustomToken() mints a JWT that can be used to sign in tenant users', async () => { + try { + clientAuth().tenantId = createdTenantId; + + const customToken = await tenantAwareAuth.createCustomToken('uid1'); + const {user} = await clientAuth().signInWithCustomToken(customToken); + expect(user).to.not.be.null; + const idToken = await user!.getIdToken(); + const token = await tenantAwareAuth.verifyIdToken(idToken); + + expect(token.uid).to.equal('uid1'); + expect(token.firebase.tenant).to.equal(createdTenantId); + } finally { + clientAuth().tenantId = null; + } + }); }); // Sanity check OIDC/SAML config management API. @@ -1203,7 +1224,7 @@ describe('admin.auth', () => { it('creates a valid Firebase session cookie', () => { return admin.auth().createCustomToken(uid, {admin: true, groupId: '1234'}) - .then((customToken) => firebase.auth!().signInWithCustomToken(customToken)) + .then((customToken) => clientAuth().signInWithCustomToken(customToken)) .then(({user}) => { expect(user).to.exist; return user!.getIdToken(); @@ -1239,7 +1260,7 @@ describe('admin.auth', () => { it('creates a revocable session cookie', () => { let currentSessionCookie: string; return admin.auth().createCustomToken(uid2) - .then((customToken) => firebase.auth!().signInWithCustomToken(customToken)) + .then((customToken) => clientAuth().signInWithCustomToken(customToken)) .then(({user}) => { expect(user).to.exist; return user!.getIdToken(); @@ -1266,7 +1287,7 @@ describe('admin.auth', () => { it('fails when called with a revoked ID token', () => { return admin.auth().createCustomToken(uid3, {admin: true, groupId: '1234'}) - .then((customToken) => firebase.auth!().signInWithCustomToken(customToken)) + .then((customToken) => clientAuth().signInWithCustomToken(customToken)) .then(({user}) => { expect(user).to.exist; return user!.getIdToken(); @@ -1294,7 +1315,7 @@ describe('admin.auth', () => { it('fails when called with a Firebase ID token', () => { return admin.auth().createCustomToken(uid) - .then((customToken) => firebase.auth!().signInWithCustomToken(customToken)) + .then((customToken) => clientAuth().signInWithCustomToken(customToken)) .then(({user}) => { expect(user).to.exist; return user!.getIdToken(); @@ -1580,7 +1601,7 @@ function testImportAndSignInUser( expect(result.successCount).to.equal(1); expect(result.errors.length).to.equal(0); // Sign in with an email and password to the imported account. - return firebase.auth!().signInWithEmailAndPassword(users[0].email, rawPassword); + return clientAuth().signInWithEmailAndPassword(users[0].email, rawPassword); }) .then(({user}) => { // Confirm successful sign-in. diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index 03bdcd04bc..e0186265cd 100755 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -16,6 +16,7 @@ 'use strict'; +import * as jwt from 'jsonwebtoken'; import * as _ from 'lodash'; import * as chai from 'chai'; import * as sinon from 'sinon'; @@ -28,7 +29,6 @@ import * as mocks from '../../resources/mocks'; import {Auth, TenantAwareAuth, BaseAuth, DecodedIdToken} from '../../../src/auth/auth'; import {UserRecord, UpdateRequest} from '../../../src/auth/user-record'; import {FirebaseApp} from '../../../src/firebase-app'; -import {FirebaseTokenGenerator} from '../../../src/auth/token-generator'; import { AuthRequestHandler, TenantAwareAuthRequestHandler, AbstractAuthRequestHandler, } from '../../../src/auth/auth-api-request'; @@ -337,63 +337,48 @@ AUTH_CONFIGS.forEach((testConfig) => { } describe('createCustomToken()', () => { - let spy: sinon.SinonSpy; - beforeEach(() => { - spy = sinon.spy(FirebaseTokenGenerator.prototype, 'createCustomToken'); - }); - - afterEach(() => { - spy.restore(); + it('should return a jwt', async () => { + const token = await auth.createCustomToken('uid1'); + const decodedToken = jwt.decode(token, {complete: true}); + expect(decodedToken).to.have.property('header').that.has.property('typ', 'JWT'); }); if (testConfig.Auth === TenantAwareAuth) { - it('should reject with an unsupported tenant operation error', () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.UNSUPPORTED_TENANT_OPERATION); - return auth.createCustomToken(mocks.uid) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.deep.equal(expectedError); - }); + it('should contain tenant_id', async () => { + const token = await auth.createCustomToken('uid1'); + expect(jwt.decode(token)).to.have.property('tenant_id', TENANT_ID); }); } else { - it('should throw if a cert credential is not specified', () => { - const mockCredentialAuth = testConfig.init(mocks.mockCredentialApp()); - - expect(() => { - mockCredentialAuth.createCustomToken(mocks.uid, mocks.developerClaims); - }).not.to.throw; + it('should not contain tenant_id', async () => { + const token = await auth.createCustomToken('uid1'); + expect(jwt.decode(token)).to.not.have.property('tenant_id'); }); + } - it('should forward on the call to the token generator\'s createCustomToken() method', () => { - const developerClaimsCopy = deepCopy(mocks.developerClaims); - return auth.createCustomToken(mocks.uid, mocks.developerClaims) - .then(() => { - expect(spy) - .to.have.been.calledOnce - .and.calledWith(mocks.uid, developerClaimsCopy); - }); - }); + it('should be eventually rejected if a cert credential is not specified', () => { + const mockCredentialAuth = testConfig.init(mocks.mockCredentialApp()); - it('should be fulfilled given an app which returns null access tokens', () => { - // createCustomToken() does not rely on an access token and therefore works in this scenario. - return nullAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims) - .should.eventually.be.fulfilled; - }); + return mockCredentialAuth.createCustomToken(mocks.uid, mocks.developerClaims) + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-credential'); + }); - it('should be fulfilled given an app which returns invalid access tokens', () => { - // createCustomToken() does not rely on an access token and therefore works in this scenario. - return malformedAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims) - .should.eventually.be.fulfilled; - }); + it('should be fulfilled given an app which returns null access tokens', () => { + // createCustomToken() does not rely on an access token and therefore works in this scenario. + return nullAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims) + .should.eventually.be.fulfilled; + }); - it('should be fulfilled given an app which fails to generate access tokens', () => { - // createCustomToken() does not rely on an access token and therefore works in this scenario. - return rejectedPromiseAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims) - .should.eventually.be.fulfilled; - }); - } + it('should be fulfilled given an app which returns invalid access tokens', () => { + // createCustomToken() does not rely on an access token and therefore works in this scenario. + return malformedAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims) + .should.eventually.be.fulfilled; + }); + + it('should be fulfilled given an app which fails to generate access tokens', () => { + // createCustomToken() does not rely on an access token and therefore works in this scenario. + return rejectedPromiseAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims) + .should.eventually.be.fulfilled; + }); }); it('verifyIdToken() should reject when project ID is not specified', () => { diff --git a/test/unit/auth/token-generator.spec.ts b/test/unit/auth/token-generator.spec.ts index d36d73d3b6..eca50d8a2e 100644 --- a/test/unit/auth/token-generator.spec.ts +++ b/test/unit/auth/token-generator.spec.ts @@ -32,6 +32,7 @@ import {Certificate} from '../../../src/auth/credential'; import { AuthorizedHttpClient, HttpClient } from '../../../src/utils/api-request'; import { FirebaseApp } from '../../../src/firebase-app'; import * as utils from '../utils'; +import { FirebaseAuthError } from '../../../src/utils/error'; chai.should(); chai.use(sinonChai); @@ -253,14 +254,10 @@ describe('CryptoSigner', () => { }); describe('FirebaseTokenGenerator', () => { - let tokenGenerator: FirebaseTokenGenerator; + const tenantId = 'tenantId1'; + const cert = new Certificate(mocks.certificateObject); let clock: sinon.SinonFakeTimers | undefined; - beforeEach(() => { - const cert = new Certificate(mocks.certificateObject); - tokenGenerator = new FirebaseTokenGenerator(new ServiceAccountSigner(cert)); - }); - afterEach(() => { if (clock) { clock.restore(); @@ -285,183 +282,221 @@ describe('FirebaseTokenGenerator', () => { }).to.throw('Must provide a CryptoSigner to use FirebaseTokenGenerator'); }); }); - }); - - describe('createCustomToken()', () => { - it('should throw given no uid', () => { - expect(() => { - (tokenGenerator as any).createCustomToken(); - }).to.throw('First argument to createCustomToken() must be a non-empty string uid'); - }); - const invalidUids = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; - invalidUids.forEach((invalidUid) => { - it('should throw given a non-string uid: ' + JSON.stringify(invalidUid), () => { + const invalidTenantIds = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; + invalidTenantIds.forEach((invalidTenantId) => { + it('should throw given a non-string tenantId', () => { expect(() => { - tokenGenerator.createCustomToken(invalidUid as any); - }).to.throw('First argument to createCustomToken() must be a non-empty string uid'); + return new FirebaseTokenGenerator(new ServiceAccountSigner(cert), invalidTenantId as any); + }).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error'); }); }); - it('should throw given an empty string uid', () => { + it('should throw given an empty string tenantId', () => { expect(() => { - tokenGenerator.createCustomToken(''); - }).to.throw('First argument to createCustomToken() must be a non-empty string uid'); + return new FirebaseTokenGenerator(new ServiceAccountSigner(cert), ''); + }).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error'); }); + }); - it('should throw given a uid with a length greater than 128 characters', () => { - // uid of length 128 should be allowed - let uid = Array(129).join('a'); - expect(uid).to.have.length(128); - expect(() => { - tokenGenerator.createCustomToken(uid); - }).not.to.throw(); + const tokenGeneratorConfigs = [{ + name: 'createCustomToken()', + tokenGenerator: new FirebaseTokenGenerator(new ServiceAccountSigner(cert)), + }, { + name: 'createCustomToken() (tenant-aware)', + tokenGenerator: new FirebaseTokenGenerator(new ServiceAccountSigner(cert), tenantId), + }]; - // uid of length 129 should throw - uid = Array(130).join('a'); - expect(uid).to.have.length(129); - expect(() => { - tokenGenerator.createCustomToken(uid); - }).to.throw('First argument to createCustomToken() must a uid with less than or equal to 128 characters'); - }); + tokenGeneratorConfigs.forEach((tokenGeneratorConfig) => { + describe(tokenGeneratorConfig.name, () => { + const tokenGenerator = tokenGeneratorConfig.tokenGenerator; - it('should throw given a non-object developer claims', () => { - const invalidDeveloperClaims: any[] = [null, NaN, [], true, false, '', 'a', 0, 1, Infinity, _.noop]; - invalidDeveloperClaims.forEach((invalidDevClaims) => { + it('should throw given no uid', () => { expect(() => { - tokenGenerator.createCustomToken(mocks.uid, invalidDevClaims); - }).to.throw('Second argument to createCustomToken() must be an object containing the developer claims'); + (tokenGenerator as any).createCustomToken(); + }).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); - blacklistedDeveloperClaims[blacklistedClaim] = true; + const invalidUids = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; + invalidUids.forEach((invalidUid) => { + it('should throw given a non-string uid: ' + JSON.stringify(invalidUid), () => { + expect(() => { + tokenGenerator.createCustomToken(invalidUid as any); + }).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error'); + }); + }); + + it('should throw given an empty string uid', () => { expect(() => { - tokenGenerator.createCustomToken(mocks.uid, blacklistedDeveloperClaims); - }).to.throw('Developer claim "' + blacklistedClaim + '" is reserved and cannot be specified'); + tokenGenerator.createCustomToken(''); + }).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 throw given a uid with a length greater than 128 characters', () => { + // uid of length 128 should be allowed + let uid = Array(129).join('a'); + expect(uid).to.have.length(128); + expect(() => { + tokenGenerator.createCustomToken(uid); + }).not.to.throw(); - it('should be fulfilled given a valid uid and empty object developer claims', () => { - return tokenGenerator.createCustomToken(mocks.uid, {}); - }); + // uid of length 129 should throw + uid = Array(130).join('a'); + expect(uid).to.have.length(129); + expect(() => { + tokenGenerator.createCustomToken(uid); + }).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error'); + }); - it('should be fulfilled given a valid uid and valid developer claims', () => { - return tokenGenerator.createCustomToken(mocks.uid, mocks.developerClaims); - }); + 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); + }).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error'); + }); + }); - it('should be fulfilled with a Firebase Custom JWT', () => { - return tokenGenerator.createCustomToken(mocks.uid) - .should.eventually.be.a('string').and.not.be.empty; - }); + 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); + blacklistedDeveloperClaims[blacklistedClaim] = true; + expect(() => { + tokenGenerator.createCustomToken(mocks.uid, blacklistedDeveloperClaims); + }).to.throw(FirebaseAuthError, blacklistedClaim).with.property('code', 'auth/argument-error'); + }); + }); - it('should be fulfilled with a JWT with the correct decoded payload', () => { - clock = sinon.useFakeTimers(1000); + it('should be fulfilled given a valid uid and no developer claims', () => { + return tokenGenerator.createCustomToken(mocks.uid); + }); - return tokenGenerator.createCustomToken(mocks.uid) - .then((token) => { - const decoded = jwt.decode(token); + it('should be fulfilled given a valid uid and empty object developer claims', () => { + return tokenGenerator.createCustomToken(mocks.uid, {}); + }); - expect(decoded).to.deep.equal({ - uid: mocks.uid, - iat: 1, - exp: ONE_HOUR_IN_SECONDS + 1, - aud: FIREBASE_AUDIENCE, - iss: mocks.certificateObject.client_email, - sub: mocks.certificateObject.client_email, - }); - }); - }); + it('should be fulfilled given a valid uid and valid developer claims', () => { + return tokenGenerator.createCustomToken(mocks.uid, mocks.developerClaims); + }); - 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) - .then((token) => { - const decoded = jwt.decode(token); - - expect(decoded).to.deep.equal({ - uid: mocks.uid, - iat: 1, - exp: ONE_HOUR_IN_SECONDS + 1, - aud: FIREBASE_AUDIENCE, - iss: mocks.certificateObject.client_email, - sub: mocks.certificateObject.client_email, - claims: { - one: 'uno', - two: 'dos', - }, - }); - }); - }); + it('should be fulfilled with a Firebase Custom JWT', () => { + return tokenGenerator.createCustomToken(mocks.uid) + .should.eventually.be.a('string').and.not.be.empty; + }); - it('should be fulfilled with a JWT with the correct header', () => { - clock = sinon.useFakeTimers(1000); + it('should be fulfilled with a JWT with the correct decoded payload', () => { + clock = sinon.useFakeTimers(1000); + + return tokenGenerator.createCustomToken(mocks.uid) + .then((token) => { + const decoded = jwt.decode(token); + const expected: {[key: string]: any} = { + uid: mocks.uid, + iat: 1, + exp: ONE_HOUR_IN_SECONDS + 1, + aud: FIREBASE_AUDIENCE, + iss: mocks.certificateObject.client_email, + sub: mocks.certificateObject.client_email, + }; + + if (tokenGenerator.tenantId) { + expected.tenant_id = tokenGenerator.tenantId; + } + + expect(decoded).to.deep.equal(expected); + }); + }); - return tokenGenerator.createCustomToken(mocks.uid) - .then((token) => { - const decoded: any = jwt.decode(token, { - complete: true, + 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) + .then((token) => { + const decoded = jwt.decode(token); + + const expected: {[key: string]: any} = { + uid: mocks.uid, + iat: 1, + exp: ONE_HOUR_IN_SECONDS + 1, + aud: FIREBASE_AUDIENCE, + iss: mocks.certificateObject.client_email, + sub: mocks.certificateObject.client_email, + claims: { + one: 'uno', + two: 'dos', + }, + }; + + if (tokenGenerator.tenantId) { + expected.tenant_id = tokenGenerator.tenantId; + } + + expect(decoded).to.deep.equal(expected); }); - expect(decoded.header).to.deep.equal({ - alg: ALGORITHM, - typ: 'JWT', + }); + + it('should be fulfilled with a JWT with the correct header', () => { + clock = sinon.useFakeTimers(1000); + + return tokenGenerator.createCustomToken(mocks.uid) + .then((token) => { + const decoded: any = jwt.decode(token, { + complete: true, + }); + expect(decoded.header).to.deep.equal({ + alg: ALGORITHM, + typ: 'JWT', + }); }); - }); - }); + }); - it('should be fulfilled with a JWT which can be verified by the service account public key', () => { - return tokenGenerator.createCustomToken(mocks.uid) - .then((token) => { - return verifyToken(token, mocks.keyPairs[0].public); - }); - }); + it('should be fulfilled with a JWT which can be verified by the service account public key', () => { + return tokenGenerator.createCustomToken(mocks.uid) + .then((token) => { + return verifyToken(token, mocks.keyPairs[0].public); + }); + }); - it('should be fulfilled with a JWT which cannot be verified by a random public key', () => { - return tokenGenerator.createCustomToken(mocks.uid) - .then((token) => { - return verifyToken(token, mocks.keyPairs[1].public) - .should.eventually.be.rejectedWith('invalid signature'); - }); - }); + it('should be fulfilled with a JWT which cannot be verified by a random public key', () => { + return tokenGenerator.createCustomToken(mocks.uid) + .then((token) => { + return verifyToken(token, mocks.keyPairs[1].public) + .should.eventually.be.rejectedWith('invalid signature'); + }); + }); - it('should be fulfilled with a JWT which expires after one hour', () => { - clock = sinon.useFakeTimers(1000); + it('should be fulfilled with a JWT which expires after one hour', () => { + clock = sinon.useFakeTimers(1000); - let token: string; - return tokenGenerator.createCustomToken(mocks.uid) - .then((result) => { - token = result; + let token: string; + return tokenGenerator.createCustomToken(mocks.uid) + .then((result) => { + token = result; - clock!.tick((ONE_HOUR_IN_SECONDS * 1000) - 1); + clock!.tick((ONE_HOUR_IN_SECONDS * 1000) - 1); - // Token should still be valid - return verifyToken(token, mocks.keyPairs[0].public); - }) - .then(() => { - clock!.tick(1); + // Token should still be valid + return verifyToken(token, mocks.keyPairs[0].public); + }) + .then(() => { + clock!.tick(1); - // Token should now be invalid - return verifyToken(token, mocks.keyPairs[0].public) - .should.eventually.be.rejectedWith('jwt expired'); - }); - }); + // Token should now be invalid + return verifyToken(token, mocks.keyPairs[0].public) + .should.eventually.be.rejectedWith('jwt expired'); + }); + }); - it('should not mutate the passed in developer claims', () => { - const originalClaims = { - foo: 'bar', - }; - const clonedClaims = _.clone(originalClaims); - return tokenGenerator.createCustomToken(mocks.uid, clonedClaims) - .then(() => { - expect(originalClaims).to.deep.equal(clonedClaims); - }); + it('should not mutate the passed in developer claims', () => { + const originalClaims = { + foo: 'bar', + }; + const clonedClaims = _.clone(originalClaims); + return tokenGenerator.createCustomToken(mocks.uid, clonedClaims) + .then(() => { + expect(originalClaims).to.deep.equal(clonedClaims); + }); + }); }); }); });