Skip to content

Commit be0e2ee

Browse files
committed
Added Auth#createCustomTokenWithOptions to allow token expiry to be specified
1 parent 9ad3be7 commit be0e2ee

File tree

5 files changed

+139
-24
lines changed

5 files changed

+139
-24
lines changed

src/auth/auth.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import GetUsersResult = auth.GetUsersResult;
5353
import ListUsersResult = auth.ListUsersResult;
5454
import DeleteUsersResult = auth.DeleteUsersResult;
5555
import DecodedIdToken = auth.DecodedIdToken;
56+
import CustomTokenOptions = auth.CustomTokenOptions;
5657
import SessionCookieOptions = auth.SessionCookieOptions;
5758
import OIDCAuthProviderConfig = auth.OIDCAuthProviderConfig;
5859
import SAMLAuthProviderConfig = auth.SAMLAuthProviderConfig;
@@ -100,7 +101,20 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> implements BaseAuthI
100101
* @return {Promise<string>} A JWT for the provided payload.
101102
*/
102103
public createCustomToken(uid: string, developerClaims?: object): Promise<string> {
103-
return this.tokenGenerator.createCustomToken(uid, developerClaims);
104+
return this.createCustomTokenWithOptions(uid, { developerClaims });
105+
}
106+
107+
/**
108+
* Creates a new custom token that can be sent back to a client to use with
109+
* signInWithCustomToken().
110+
*
111+
* @param {string} uid The uid to use as the JWT subject.
112+
* @param {CustomTokenOptions=} options Options to use when creating the JWT.
113+
*
114+
* @return {Promise<string>} A JWT for the provided payload.
115+
*/
116+
public createCustomTokenWithOptions(uid: string, options?: CustomTokenOptions): Promise<string> {
117+
return this.tokenGenerator.createCustomToken(uid, options);
104118
}
105119

106120
/**

src/auth/index.ts

+35
Original file line numberDiff line numberDiff line change
@@ -939,6 +939,24 @@ export namespace auth {
939939
multiFactor?: MultiFactorUpdateSettings;
940940
}
941941

942+
/**
943+
* Interface representing the custom token options needed for the
944+
* {@link https://firebase.google.com/docs/reference/admin/node/admin.auth.Auth#createcustomtoken `createCustomToken()`} method.
945+
*/
946+
export interface CustomTokenOptions {
947+
948+
/**
949+
* Optional additional claims to include in the JWT payload.
950+
*/
951+
developerClaims?: { [key: string]: any };
952+
953+
/**
954+
* The JWT expiration in milliseconds. The minimum allowed is X and the maximum allowed is 1 hour.
955+
* Defaults to 1 hour.
956+
*/
957+
expiresIn?: number;
958+
}
959+
942960
/**
943961
* Interface representing the session cookie options needed for the
944962
* {@link auth.Auth.createSessionCookie `createSessionCookie()`} method.
@@ -1549,6 +1567,23 @@ export namespace auth {
15491567
*/
15501568
createCustomToken(uid: string, developerClaims?: object): Promise<string>;
15511569

1570+
/**
1571+
* Creates a new Firebase custom token (JWT) that can be sent back to a client
1572+
* device to use to sign in with the client SDKs' `signInWithCustomToken()`
1573+
* methods. (Tenant-aware instances will also embed the tenant ID in the
1574+
* token.)
1575+
*
1576+
* See [Create Custom Tokens](/docs/auth/admin/create-custom-tokens) for code
1577+
* samples and detailed documentation.
1578+
*
1579+
* @param uid The `uid` to use as the custom token's subject.
1580+
* @param {CustomTokenOptions=} options Options to use when creating the JWT.
1581+
*
1582+
* @return A promise fulfilled with a custom token for the
1583+
* provided `uid` and payload.
1584+
*/
1585+
createCustomTokenWithOptions(uid: string, options?: CustomTokenOptions): Promise<string>;
1586+
15521587
/**
15531588
* Creates a new user.
15541589
*

src/auth/token-generator.ts

+25-12
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ import { HttpError } from '../utils/api-request';
2727

2828
const ALGORITHM_NONE: Algorithm = 'none' as const;
2929

30-
const ONE_HOUR_IN_SECONDS = 60 * 60;
30+
const MIN_JWT_EXPIRES_IN_MS = 1000;
31+
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
3132

3233
// List of blacklisted claims which cannot be provided when creating a custom token
3334
export const BLACKLISTED_CLAIMS = [
@@ -84,6 +85,12 @@ export class EmulatedSigner implements CryptoSigner {
8485
}
8586
}
8687

88+
/** Interface representing the create custom token options. */
89+
interface FirebaseTokenOptions {
90+
developerClaims?: { [key: string]: any };
91+
expiresIn?: number;
92+
}
93+
8794
/**
8895
* Class for generating different types of Firebase Auth tokens (JWTs).
8996
*/
@@ -115,37 +122,43 @@ export class FirebaseTokenGenerator {
115122
* Creates a new Firebase Auth Custom token.
116123
*
117124
* @param uid The user ID to use for the generated Firebase Auth Custom token.
118-
* @param developerClaims Optional developer claims to include in the generated Firebase
119-
* Auth Custom token.
125+
* @param options Options to use when creating the JWT..
120126
* @return A Promise fulfilled with a Firebase Auth Custom token signed with a
121127
* service account key and containing the provided payload.
122128
*/
123-
public createCustomToken(uid: string, developerClaims?: {[key: string]: any}): Promise<string> {
129+
public createCustomToken(uid: string, options?: FirebaseTokenOptions): Promise<string> {
124130
let errorMessage: string | undefined;
125131
if (!validator.isNonEmptyString(uid)) {
126132
errorMessage = '`uid` argument must be a non-empty string uid.';
127133
} else if (uid.length > 128) {
128134
errorMessage = '`uid` argument must a uid with less than or equal to 128 characters.';
129-
} else if (!this.isDeveloperClaimsValid_(developerClaims)) {
130-
errorMessage = '`developerClaims` argument must be a valid, non-null object containing the developer claims.';
135+
} else if (typeof options !== 'undefined' && !validator.isObject(options)) {
136+
errorMessage = '`options` argument must be a valid object.';
137+
} else if (!this.isDeveloperClaimsValid_(options?.developerClaims)) {
138+
errorMessage = '`options.developerClaims` argument must be a valid, non-null object containing ' +
139+
'the developer claims.';
140+
} else if (typeof options?.expiresIn !== 'undefined' && (!validator.isNumber(options.expiresIn) ||
141+
options.expiresIn < MIN_JWT_EXPIRES_IN_MS || options.expiresIn > ONE_HOUR_IN_MS)) {
142+
errorMessage = `\`options.expiresIn\` argument must be a valid number between ${MIN_JWT_EXPIRES_IN_MS} ` +
143+
`and ${ONE_HOUR_IN_MS}.`;
131144
}
132145

133146
if (errorMessage) {
134147
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage);
135148
}
136149

137-
const claims: {[key: string]: any} = {};
138-
if (typeof developerClaims !== 'undefined') {
139-
for (const key in developerClaims) {
150+
const claims: { [key: string]: any } = {};
151+
if (typeof options?.developerClaims !== 'undefined') {
152+
for (const key in options.developerClaims) {
140153
/* istanbul ignore else */
141-
if (Object.prototype.hasOwnProperty.call(developerClaims, key)) {
154+
if (Object.prototype.hasOwnProperty.call(options.developerClaims, key)) {
142155
if (BLACKLISTED_CLAIMS.indexOf(key) !== -1) {
143156
throw new FirebaseAuthError(
144157
AuthClientErrorCode.INVALID_ARGUMENT,
145158
`Developer claim "${key}" is reserved and cannot be specified.`,
146159
);
147160
}
148-
claims[key] = developerClaims[key];
161+
claims[key] = options.developerClaims[key];
149162
}
150163
}
151164
}
@@ -158,7 +171,7 @@ export class FirebaseTokenGenerator {
158171
const body: JWTBody = {
159172
aud: FIREBASE_AUDIENCE,
160173
iat,
161-
exp: iat + ONE_HOUR_IN_SECONDS,
174+
exp: iat + Math.floor((options?.expiresIn || ONE_HOUR_IN_MS) / 1000),
162175
iss: account,
163176
sub: account,
164177
uid,

src/utils/validator.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function isBoolean(value: any): boolean {
6565
* @param {any} value The value to validate.
6666
* @return {boolean} Whether the value is a number or not.
6767
*/
68-
export function isNumber(value: any): boolean {
68+
export function isNumber(value: any): value is number {
6969
return typeof value === 'number' && !isNaN(value);
7070
}
7171

@@ -112,7 +112,7 @@ export function isNonEmptyString(value: any): value is string {
112112
* @param {any} value The value to validate.
113113
* @return {boolean} Whether the value is an object or not.
114114
*/
115-
export function isObject(value: any): boolean {
115+
export function isObject(value: any): value is object {
116116
return typeof value === 'object' && !isArray(value);
117117
}
118118

test/unit/auth/token-generator.spec.ts

+62-9
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,8 @@ describe('FirebaseTokenGenerator', () => {
125125

126126
it('should generate a valid unsigned token', async () => {
127127
const uid = 'uid123';
128-
const claims = { foo: 'bar' };
129-
const token = await tokenGenerator.createCustomToken(uid, claims);
128+
const developerClaims = { foo: 'bar' };
129+
const token = await tokenGenerator.createCustomToken(uid, { developerClaims });
130130

131131
// Check that verify doesn't throw
132132
// Note: the types for jsonwebtoken are wrong so we have to disguise the 'null'
@@ -136,7 +136,7 @@ describe('FirebaseTokenGenerator', () => {
136136
const { header, payload, signature } = jwt.decode(token, { complete: true }) as { [key: string]: any };
137137
expect(header).to.deep.equal({ alg: 'none', typ: 'JWT' });
138138
expect(payload['uid']).to.equal(uid);
139-
expect(payload['claims']).to.deep.equal(claims);
139+
expect(payload['claims']).to.deep.equal(developerClaims);
140140
expect(signature).to.equal('');
141141
});
142142

@@ -183,11 +183,20 @@ describe('FirebaseTokenGenerator', () => {
183183
}).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error');
184184
});
185185

186+
it('should throw given a non-object options', () => {
187+
const invalidOptions: any[] = [NaN, [], true, false, '', 'a', 0, 1, Infinity, _.noop];
188+
invalidOptions.forEach((opts) => {
189+
expect(() => {
190+
tokenGenerator.createCustomToken(mocks.uid, opts);
191+
}).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error');
192+
});
193+
});
194+
186195
it('should throw given a non-object developer claims', () => {
187196
const invalidDeveloperClaims: any[] = [null, NaN, [], true, false, '', 'a', 0, 1, Infinity, _.noop];
188197
invalidDeveloperClaims.forEach((invalidDevClaims) => {
189198
expect(() => {
190-
tokenGenerator.createCustomToken(mocks.uid, invalidDevClaims);
199+
tokenGenerator.createCustomToken(mocks.uid, { developerClaims: invalidDevClaims });
191200
}).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error');
192201
});
193202
});
@@ -197,21 +206,39 @@ describe('FirebaseTokenGenerator', () => {
197206
const blacklistedDeveloperClaims: { [key: string]: any } = _.clone(mocks.developerClaims);
198207
blacklistedDeveloperClaims[blacklistedClaim] = true;
199208
expect(() => {
200-
tokenGenerator.createCustomToken(mocks.uid, blacklistedDeveloperClaims);
209+
tokenGenerator.createCustomToken(mocks.uid, { developerClaims: blacklistedDeveloperClaims });
201210
}).to.throw(FirebaseAuthError, blacklistedClaim).with.property('code', 'auth/argument-error');
202211
});
203212
});
204213

214+
it('should throw given an invalid expiresIn', () => {
215+
const invalidExpiresIns: any[] = [null, NaN, Infinity, _.noop, 0, 999, 3600001];
216+
invalidExpiresIns.forEach((invalidExpiresIn) => {
217+
expect(() => {
218+
tokenGenerator.createCustomToken(mocks.uid, { expiresIn: invalidExpiresIn });
219+
}).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error');
220+
});
221+
});
222+
205223
it('should be fulfilled given a valid uid and no developer claims', () => {
206224
return tokenGenerator.createCustomToken(mocks.uid);
207225
});
208226

209227
it('should be fulfilled given a valid uid and empty object developer claims', () => {
210-
return tokenGenerator.createCustomToken(mocks.uid, {});
228+
return tokenGenerator.createCustomToken(mocks.uid, { developerClaims: {} });
211229
});
212230

213231
it('should be fulfilled given a valid uid and valid developer claims', () => {
214-
return tokenGenerator.createCustomToken(mocks.uid, mocks.developerClaims);
232+
return tokenGenerator.createCustomToken(mocks.uid, { developerClaims: mocks.developerClaims });
233+
});
234+
235+
it('should be fulfilled given a valid uid, empty object developer claims and valid expiresIn', () => {
236+
return tokenGenerator.createCustomToken(mocks.uid, { developerClaims: {}, expiresIn: 1000 });
237+
});
238+
239+
it('should be fulfilled given a valid uid, valid developer claims and valid expiresIn', () => {
240+
return tokenGenerator
241+
.createCustomToken(mocks.uid, { developerClaims: mocks.developerClaims, expiresIn: 3600000 });
215242
});
216243

217244
it('should be fulfilled with a Firebase Custom JWT', () => {
@@ -246,7 +273,7 @@ describe('FirebaseTokenGenerator', () => {
246273
it('should be fulfilled with a JWT with the developer claims in its decoded payload', () => {
247274
clock = sinon.useFakeTimers(1000);
248275

249-
return tokenGenerator.createCustomToken(mocks.uid, mocks.developerClaims)
276+
return tokenGenerator.createCustomToken(mocks.uid, { developerClaims: mocks.developerClaims })
250277
.then((token) => {
251278
const decoded = jwt.decode(token);
252279

@@ -272,6 +299,32 @@ describe('FirebaseTokenGenerator', () => {
272299
});
273300
});
274301

302+
it('should be fulfilled with a JWT with the expiresIn in its exp payload', () => {
303+
clock = sinon.useFakeTimers(2000);
304+
const expiresIn = 300900
305+
306+
return tokenGenerator.createCustomToken(mocks.uid, { expiresIn })
307+
.then((token) => {
308+
const decoded = jwt.decode(token);
309+
310+
const expected: { [key: string]: any } = {
311+
uid: mocks.uid,
312+
iat: 2,
313+
exp: 302,
314+
aud: FIREBASE_AUDIENCE,
315+
iss: mocks.certificateObject.client_email,
316+
sub: mocks.certificateObject.client_email,
317+
};
318+
319+
if (tokenGenerator.tenantId) {
320+
// eslint-disable-next-line @typescript-eslint/camelcase
321+
expected.tenant_id = tokenGenerator.tenantId;
322+
}
323+
324+
expect(decoded).to.deep.equal(expected);
325+
});
326+
});
327+
275328
it('should be fulfilled with a JWT with the correct header', () => {
276329
clock = sinon.useFakeTimers(1000);
277330

@@ -329,7 +382,7 @@ describe('FirebaseTokenGenerator', () => {
329382
foo: 'bar',
330383
};
331384
const clonedClaims = _.clone(originalClaims);
332-
return tokenGenerator.createCustomToken(mocks.uid, clonedClaims)
385+
return tokenGenerator.createCustomToken(mocks.uid, { developerClaims: clonedClaims })
333386
.then(() => {
334387
expect(originalClaims).to.deep.equal(clonedClaims);
335388
});

0 commit comments

Comments
 (0)