Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

auth: Allow expiry to be specified in .createCustomToken #1017

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 5 minutes 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 = 5 * 60 * 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, 299999, 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: 300000 });
});

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