Skip to content

Commit

Permalink
Added Auth#createCustomTokenWithOptions to allow token expiry to be s…
Browse files Browse the repository at this point in the history
…pecified
  • Loading branch information
rhodgkins committed Jul 22, 2021
1 parent 9ad3be7 commit be0e2ee
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 24 deletions.
16 changes: 15 additions & 1 deletion src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -100,7 +101,20 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> implements BaseAuthI
* @return {Promise<string>} A JWT for the provided payload.
*/
public createCustomToken(uid: string, developerClaims?: object): Promise<string> {
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<string>} A JWT for the provided payload.
*/
public createCustomTokenWithOptions(uid: string, options?: CustomTokenOptions): Promise<string> {
return this.tokenGenerator.createCustomToken(uid, options);
}

/**
Expand Down
35 changes: 35 additions & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 X 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.
Expand Down Expand Up @@ -1549,6 +1567,23 @@ export namespace auth {
*/
createCustomToken(uid: string, developerClaims?: object): Promise<string>;

/**
* 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<string>;

/**
* Creates a new user.
*
Expand Down
37 changes: 25 additions & 12 deletions src/auth/token-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 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 = [
Expand Down Expand Up @@ -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).
*/
Expand Down Expand Up @@ -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<string> {
public createCustomToken(uid: string, options?: FirebaseTokenOptions): Promise<string> {
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];
}
}
}
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/utils/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}

Expand Down
71 changes: 62 additions & 9 deletions test/unit/auth/token-generator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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('');
});

Expand Down Expand Up @@ -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');
});
});
Expand All @@ -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, 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', () => {
Expand Down Expand Up @@ -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);

Expand All @@ -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);

Expand Down Expand Up @@ -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);
});
Expand Down

0 comments on commit be0e2ee

Please sign in to comment.