Skip to content

Commit 8b02f7a

Browse files
committed
Allow createCustomToken() to work with tenant-aware auth
1 parent 01a01a9 commit 8b02f7a

File tree

5 files changed

+331
-63
lines changed

5 files changed

+331
-63
lines changed

src/auth/auth.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -626,18 +626,16 @@ export class TenantAwareAuth extends BaseAuth<TenantAwareAuthRequestHandler> {
626626

627627
/**
628628
* Creates a new custom token that can be sent back to a client to use with
629-
* signInWithCustomToken().
629+
* signInWithCustomToken(). The tenant id will be embedded in the token and
630+
* will be verified during the call to signInWithCustomToken().
630631
*
631632
* @param {string} uid The uid to use as the JWT subject.
632633
* @param {object=} developerClaims Optional additional claims to include in the JWT payload.
633634
*
634635
* @return {Promise<string>} A JWT for the provided payload.
635636
*/
636637
public createCustomToken(uid: string, developerClaims?: object): Promise<string> {
637-
// This is not yet supported by the Auth server. It is also not yet determined how this will be
638-
// supported.
639-
return Promise.reject(
640-
new FirebaseAuthError(AuthClientErrorCode.UNSUPPORTED_TENANT_OPERATION));
638+
return this.tokenGenerator.createCustomTokenWithTenantId(uid, this.tenantId, developerClaims);
641639
}
642640

643641
/**

src/auth/token-generator.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ interface JWTBody {
7474
exp: number;
7575
iss: string;
7676
sub: string;
77+
tenant_id?: string;
7778
}
7879

7980
/**
@@ -254,13 +255,38 @@ export class FirebaseTokenGenerator {
254255
* service account key and containing the provided payload.
255256
*/
256257
public createCustomToken(uid: string, developerClaims?: {[key: string]: any}): Promise<string> {
258+
return this.createCustomTokenInternal(uid, null, developerClaims);
259+
}
260+
261+
/**
262+
* Creates a new Firebase Auth Custom token.
263+
*
264+
* @param {string} uid The user ID to use for the generated Firebase Auth Custom token.
265+
* @param {string} tenantId The tenant ID to use for the generated Firebase Auth Custom token.
266+
* @param {object} [developerClaims] Optional developer claims to include in the generated Firebase
267+
* Auth Custom token.
268+
* @return {Promise<string>} A Promise fulfilled with a Firebase Auth Custom token signed with a
269+
* service account key and containing the provided payload.
270+
*/
271+
public createCustomTokenWithTenantId(
272+
uid: string, tenantId: string, developerClaims?: {[key: string]: any}): Promise<string> {
273+
if (!validator.isNonEmptyString(tenantId)) {
274+
throw new FirebaseAuthError(
275+
AuthClientErrorCode.INVALID_ARGUMENT,
276+
'`tenantId` argument must be a non-empty string uid.');
277+
}
278+
return this.createCustomTokenInternal(uid, tenantId, developerClaims);
279+
}
280+
281+
private createCustomTokenInternal(
282+
uid: string, tenantId: string | null, developerClaims?: {[key: string]: any}): Promise<string> {
257283
let errorMessage: string;
258-
if (typeof uid !== 'string' || uid === '') {
259-
errorMessage = 'First argument to createCustomToken() must be a non-empty string uid.';
284+
if (!validator.isNonEmptyString(uid)) {
285+
errorMessage = '`uid` argument must be a non-empty string uid.';
260286
} else if (uid.length > 128) {
261-
errorMessage = 'First argument to createCustomToken() must a uid with less than or equal to 128 characters.';
287+
errorMessage = '`uid` argument must a uid with less than or equal to 128 characters.';
262288
} else if (!this.isDeveloperClaimsValid_(developerClaims)) {
263-
errorMessage = 'Second argument to createCustomToken() must be an object containing the developer claims.';
289+
errorMessage = '`developerClaims` argument must be a valid, non-null object containing the developer claims.';
264290
}
265291

266292
if (typeof errorMessage !== 'undefined') {
@@ -296,6 +322,9 @@ export class FirebaseTokenGenerator {
296322
sub: account,
297323
uid,
298324
};
325+
if (tenantId) {
326+
body.tenant_id = tenantId;
327+
}
299328
if (Object.keys(claims).length > 0) {
300329
body.claims = claims;
301330
}

test/integration/auth.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,49 @@ describe('admin.auth', () => {
710710
expect(userRecord.uid).to.equal(createdUserUid);
711711
});
712712
});
713+
714+
it('createCustomToken() mints a JWT that can be used to sign in tenant users', () => {
715+
return tenantAwareAuth.createCustomToken('uid1')
716+
.then((customToken) => {
717+
firebase.auth().tenantId = createdTenantId;
718+
return firebase.auth().signInWithCustomToken(customToken);
719+
})
720+
.then(({user}) => {
721+
return user.getIdToken();
722+
})
723+
.then((idToken) => {
724+
return tenantAwareAuth.verifyIdToken(idToken);
725+
})
726+
.then((token) => {
727+
expect(token.uid).to.equal('uid1');
728+
expect(token.firebase.tenant).to.equal(createdTenantId);
729+
});
730+
});
731+
732+
it('createCustomToken() should fail if tenantIds mismatch', async () => {
733+
const aDifferentTenant = await admin.auth().tenantManager().createTenant({displayName: 'A-Different-Tenant'});
734+
try {
735+
const customToken = await tenantAwareAuth.createCustomToken('uid1');
736+
737+
firebase.auth().tenantId = aDifferentTenant.tenantId;
738+
739+
await expect(firebase.auth().signInWithCustomToken(customToken))
740+
.to.be.rejectedWith('Specified tenant ID does not match the custom token.');
741+
742+
// TODO(rsgowman): Currently, the above error has a code of
743+
// 'auth/internal-error', i.e. you could add the following chain onto
744+
// it:
745+
//
746+
// .and.eventually.have.property('code', 'auth/internal-error');
747+
//
748+
// However, this doesn't strike me as an internal error, implying
749+
// that the client sdk is slightly "wrong". Fix the client sdk to
750+
// return a better error code, and then add the above statement to
751+
// the expect statement (with the new error code.)
752+
} finally {
753+
admin.auth().tenantManager().deleteTenant(aDifferentTenant.tenantId);
754+
}
755+
});
713756
});
714757

715758
// Sanity check OIDC/SAML config management API.

test/unit/auth/auth.spec.ts

Lines changed: 34 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
'use strict';
1818

19+
import * as jwt from 'jsonwebtoken';
1920
import * as _ from 'lodash';
2021
import * as chai from 'chai';
2122
import * as sinon from 'sinon';
@@ -28,7 +29,6 @@ import * as mocks from '../../resources/mocks';
2829
import {Auth, TenantAwareAuth, BaseAuth, DecodedIdToken} from '../../../src/auth/auth';
2930
import {UserRecord} from '../../../src/auth/user-record';
3031
import {FirebaseApp} from '../../../src/firebase-app';
31-
import {FirebaseTokenGenerator} from '../../../src/auth/token-generator';
3232
import {
3333
AuthRequestHandler, TenantAwareAuthRequestHandler, AbstractAuthRequestHandler,
3434
} from '../../../src/auth/auth-api-request';
@@ -328,63 +328,49 @@ AUTH_CONFIGS.forEach((testConfig) => {
328328
}
329329

330330
describe('createCustomToken()', () => {
331-
let spy: sinon.SinonSpy;
332-
beforeEach(() => {
333-
spy = sinon.spy(FirebaseTokenGenerator.prototype, 'createCustomToken');
334-
});
335-
336-
afterEach(() => {
337-
spy.restore();
331+
it('should return a jwt', async () => {
332+
const token = await auth.createCustomToken('uid1');
333+
const decodedToken = jwt.decode(token, {complete: true});
334+
expect(decodedToken).to.have.property('header').that.has.property('typ', 'JWT');
338335
});
339336

340337
if (testConfig.Auth === TenantAwareAuth) {
341-
it('should reject with an unsupported tenant operation error', () => {
342-
const expectedError = new FirebaseAuthError(AuthClientErrorCode.UNSUPPORTED_TENANT_OPERATION);
343-
return auth.createCustomToken(mocks.uid)
344-
.then(() => {
345-
throw new Error('Unexpected success');
346-
})
347-
.catch((error) => {
348-
expect(error).to.deep.equal(expectedError);
349-
});
338+
it('should contain tenant_id', async () => {
339+
const token = await auth.createCustomToken('uid1');
340+
expect(jwt.decode(token)).to.have.property('tenant_id', TENANT_ID);
350341
});
351342
} else {
352-
it('should throw if a cert credential is not specified', () => {
353-
const mockCredentialAuth = testConfig.init(mocks.mockCredentialApp());
354-
355-
expect(() => {
356-
mockCredentialAuth.createCustomToken(mocks.uid, mocks.developerClaims);
357-
}).not.to.throw;
343+
it('should not contain tenant_id', async () => {
344+
const token = await auth.createCustomToken('uid1');
345+
expect(jwt.decode(token)).to.not.have.property('tenant_id');
358346
});
347+
}
359348

360-
it('should forward on the call to the token generator\'s createCustomToken() method', () => {
361-
const developerClaimsCopy = deepCopy(mocks.developerClaims);
362-
return auth.createCustomToken(mocks.uid, mocks.developerClaims)
363-
.then(() => {
364-
expect(spy)
365-
.to.have.been.calledOnce
366-
.and.calledWith(mocks.uid, developerClaimsCopy);
367-
});
368-
});
349+
it('should throw if a cert credential is not specified', () => {
350+
const mockCredentialAuth = testConfig.init(mocks.mockCredentialApp());
369351

370-
it('should be fulfilled given an app which returns null access tokens', () => {
371-
// createCustomToken() does not rely on an access token and therefore works in this scenario.
372-
return nullAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims)
373-
.should.eventually.be.fulfilled;
374-
});
352+
expect(() => {
353+
mockCredentialAuth.createCustomToken(mocks.uid, mocks.developerClaims);
354+
}).not.to.throw;
355+
});
375356

376-
it('should be fulfilled given an app which returns invalid access tokens', () => {
377-
// createCustomToken() does not rely on an access token and therefore works in this scenario.
378-
return malformedAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims)
379-
.should.eventually.be.fulfilled;
380-
});
357+
it('should be fulfilled given an app which returns null access tokens', () => {
358+
// createCustomToken() does not rely on an access token and therefore works in this scenario.
359+
return nullAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims)
360+
.should.eventually.be.fulfilled;
361+
});
381362

382-
it('should be fulfilled given an app which fails to generate access tokens', () => {
383-
// createCustomToken() does not rely on an access token and therefore works in this scenario.
384-
return rejectedPromiseAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims)
385-
.should.eventually.be.fulfilled;
386-
});
387-
}
363+
it('should be fulfilled given an app which returns invalid access tokens', () => {
364+
// createCustomToken() does not rely on an access token and therefore works in this scenario.
365+
return malformedAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims)
366+
.should.eventually.be.fulfilled;
367+
});
368+
369+
it('should be fulfilled given an app which fails to generate access tokens', () => {
370+
// createCustomToken() does not rely on an access token and therefore works in this scenario.
371+
return rejectedPromiseAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims)
372+
.should.eventually.be.fulfilled;
373+
});
388374
});
389375

390376
it('verifyIdToken() should throw when project ID is not specified', () => {

0 commit comments

Comments
 (0)