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 Sep 11, 2020
1 parent f11aed7 commit d75d4cd
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 23 deletions.
35 changes: 35 additions & 0 deletions src/auth.d.ts
Expand Up @@ -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.
Expand Down Expand Up @@ -1375,6 +1393,23 @@ export namespace admin.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
19 changes: 17 additions & 2 deletions src/auth/auth.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -159,7 +161,20 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> {
* @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
37 changes: 25 additions & 12 deletions src/auth/token-generator.ts
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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).
*/
Expand Down Expand Up @@ -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<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 @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/utils/validator.ts
Expand Up @@ -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);
}

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

Expand Down
67 changes: 60 additions & 7 deletions test/unit/auth/token-generator.spec.ts
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);

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

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

0 comments on commit d75d4cd

Please sign in to comment.