Skip to content

Commit 8dab1b2

Browse files
committed
Add custom ttl options for App Check
1 parent f09bd64 commit 8dab1b2

File tree

9 files changed

+206
-40
lines changed

9 files changed

+206
-40
lines changed

etc/firebase-admin.api.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,16 @@ export namespace appCheck {
5151
export interface AppCheck {
5252
// (undocumented)
5353
app: app.App;
54-
createToken(appId: string): Promise<AppCheckToken>;
54+
createToken(appId: string, options?: AppCheckTokenOptions): Promise<AppCheckToken>;
5555
verifyToken(appCheckToken: string): Promise<VerifyAppCheckTokenResponse>;
5656
}
5757
export interface AppCheckToken {
5858
token: string;
5959
ttlMillis: number;
6060
}
61+
export interface AppCheckTokenOptions {
62+
ttlMillis?: number;
63+
}
6164
export interface DecodedAppCheckToken {
6265
// (undocumented)
6366
[key: string]: any;

src/app-check/app-check.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { cryptoSignerFromApp } from '../utils/crypto-signer';
2626

2727
import AppCheckInterface = appCheck.AppCheck;
2828
import AppCheckToken = appCheck.AppCheckToken;
29+
import AppCheckTokenOptions = appCheck.AppCheckTokenOptions;
2930
import VerifyAppCheckTokenResponse = appCheck.VerifyAppCheckTokenResponse;
3031

3132
/**
@@ -56,18 +57,19 @@ export class AppCheck implements AppCheckInterface {
5657
* back to a client.
5758
*
5859
* @param appId The app ID to use as the JWT app_id.
60+
* @param options Optional options object when creating a new App Check Token.
5961
*
60-
* @return A promise that fulfills with a `AppCheckToken`.
62+
* @returns A promise that fulfills with a `AppCheckToken`.
6163
*/
62-
public createToken(appId: string): Promise<AppCheckToken> {
63-
return this.tokenGenerator.createCustomToken(appId)
64+
public createToken(appId: string, options?: AppCheckTokenOptions): Promise<AppCheckToken> {
65+
return this.tokenGenerator.createCustomToken(appId, options)
6466
.then((customToken) => {
6567
return this.client.exchangeToken(customToken, appId);
6668
});
6769
}
6870

6971
/**
70-
* Veifies an App Check token.
72+
* Verifies an App Check token.
7173
*
7274
* @param appCheckToken The App Check token to verify.
7375
*

src/app-check/index.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,11 @@ export namespace appCheck {
6161
* back to a client.
6262
*
6363
* @param appId The App ID of the Firebase App the token belongs to.
64+
* @param options Optional options object when creating a new App Check Token.
6465
*
65-
* @return A promise that fulfills with a `AppCheckToken`.
66+
* @returns A promise that fulfills with a `AppCheckToken`.
6667
*/
67-
createToken(appId: string): Promise<AppCheckToken>;
68+
createToken(appId: string, options?: AppCheckTokenOptions): Promise<AppCheckToken>;
6869

6970
/**
7071
* Verifies a Firebase App Check token (JWT). If the token is valid, the promise is
@@ -95,6 +96,18 @@ export namespace appCheck {
9596
ttlMillis: number;
9697
}
9798

99+
/**
100+
* Interface representing an App Check token options.
101+
*/
102+
export interface AppCheckTokenOptions {
103+
/**
104+
* The length of time measured in milliseconds starting from when the server
105+
* mints the token for which the returned FAC token will be valid.
106+
* This value must be in milliseconds and between 30 minutes and 7 days, inclusive.
107+
*/
108+
ttlMillis?: number;
109+
}
110+
98111
/**
99112
* Interface representing a decoded Firebase App Check token, returned from the
100113
* {@link appCheck.AppCheck.verifyToken `verifyToken()`} method.

src/app-check/token-generator.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
* limitations under the License.
1616
*/
1717

18+
import { appCheck } from './index';
19+
1820
import * as validator from '../utils/validator';
19-
import { toWebSafeBase64 } from '../utils';
21+
import { toWebSafeBase64, transformMillisecondsToSecondsString } from '../utils';
2022

2123
import { CryptoSigner, CryptoSignerError, CryptoSignerErrorCode } from '../utils/crypto-signer';
2224
import {
@@ -26,7 +28,11 @@ import {
2628
} from './app-check-api-client-internal';
2729
import { HttpError } from '../utils/api-request';
2830

31+
import AppCheckTokenOptions = appCheck.AppCheckTokenOptions;
32+
2933
const ONE_HOUR_IN_SECONDS = 60 * 60;
34+
const ONE_MINUTE_IN_MILLIS = 60 * 1000;
35+
const ONE_DAY_IN_MILLIS = 24 * 60 * 60 * 1000;
3036

3137
// Audience to use for Firebase App Check Custom tokens
3238
const FIREBASE_APP_CHECK_AUDIENCE = 'https://firebaseappcheck.googleapis.com/google.firebase.appcheck.v1beta.TokenExchangeService';
@@ -63,12 +69,16 @@ export class AppCheckTokenGenerator {
6369
* @return A Promise fulfilled with a custom token signed with a service account key
6470
* that can be exchanged to an App Check token.
6571
*/
66-
public createCustomToken(appId: string): Promise<string> {
72+
public createCustomToken(appId: string, options?: AppCheckTokenOptions): Promise<string> {
6773
if (!validator.isNonEmptyString(appId)) {
6874
throw new FirebaseAppCheckError(
6975
'invalid-argument',
7076
'`appId` must be a non-empty string.');
7177
}
78+
let customOptions = {};
79+
if (typeof options !== 'undefined') {
80+
customOptions = this.validateTokenOptions(options);
81+
}
7282
return this.signer.getAccountId().then((account) => {
7383
const header = {
7484
alg: this.signer.algorithm,
@@ -83,6 +93,7 @@ export class AppCheckTokenGenerator {
8393
aud: FIREBASE_APP_CHECK_AUDIENCE,
8494
exp: iat + ONE_HOUR_IN_SECONDS,
8595
iat,
96+
...customOptions,
8697
};
8798
const token = `${this.encodeSegment(header)}.${this.encodeSegment(body)}`;
8899
return this.signer.sign(Buffer.from(token))
@@ -98,6 +109,35 @@ export class AppCheckTokenGenerator {
98109
const buffer: Buffer = (segment instanceof Buffer) ? segment : Buffer.from(JSON.stringify(segment));
99110
return toWebSafeBase64(buffer).replace(/=+$/, '');
100111
}
112+
113+
/**
114+
* Checks if a given `AppCheckTokenOptions` object is valid. If successful, returns an object with
115+
* custom properties.
116+
*
117+
* @param options An options object to be validated.
118+
* @returns A custom object with ttl converted to protobuf Duration string format.
119+
*/
120+
private validateTokenOptions(options: AppCheckTokenOptions): {[key: string]: any} {
121+
if (!validator.isNonNullObject(options)) {
122+
throw new FirebaseAppCheckError(
123+
'invalid-argument',
124+
'AppCheckTokenOptions must be a non-null object.');
125+
}
126+
if (typeof options.ttlMillis !== 'undefined') {
127+
if (!validator.isNumber(options.ttlMillis) || options.ttlMillis < 0) {
128+
throw new FirebaseAppCheckError('invalid-argument',
129+
'ttlMillis must be a non-negative duration in milliseconds.');
130+
}
131+
// ttlMillis must be between 30 minutes and 7 days (inclusive)
132+
if (options.ttlMillis < (ONE_MINUTE_IN_MILLIS * 30) || options.ttlMillis > (ONE_DAY_IN_MILLIS * 7)) {
133+
throw new FirebaseAppCheckError(
134+
'invalid-argument',
135+
'ttlMillis must be a duration in milliseconds between 30 minutes and 7 days (inclusive).');
136+
}
137+
return { ttl: transformMillisecondsToSecondsString(options.ttlMillis) };
138+
}
139+
return {};
140+
}
101141
}
102142

103143
/**
@@ -123,7 +163,7 @@ export function appCheckErrorFromCryptoSignerError(err: Error): Error {
123163
code = APP_CHECK_ERROR_CODE_MAPPING[status];
124164
}
125165
return new FirebaseAppCheckError(code,
126-
`Error returned from server while siging a custom token: ${description}`
166+
`Error returned from server while signing a custom token: ${description}`
127167
);
128168
}
129169
return new FirebaseAppCheckError('internal-error',

src/messaging/messaging-internal.ts

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { renameProperties } from '../utils/index';
17+
import { renameProperties, transformMillisecondsToSecondsString } from '../utils/index';
1818
import { MessagingClientErrorCode, FirebaseMessagingError, } from '../utils/error';
1919
import { messaging } from './index';
2020
import * as validator from '../utils/validator';
@@ -589,28 +589,3 @@ function validateAndroidFcmOptions(fcmOptions: AndroidFcmOptions | undefined): v
589589
MessagingClientErrorCode.INVALID_PAYLOAD, 'analyticsLabel must be a string value');
590590
}
591591
}
592-
593-
/**
594-
* Transforms milliseconds to the format expected by FCM service.
595-
* Returns the duration in seconds with up to nine fractional
596-
* digits, terminated by 's'. Example: "3.5s".
597-
*
598-
* @param {number} milliseconds The duration in milliseconds.
599-
* @return {string} The resulting formatted string in seconds with up to nine fractional
600-
* digits, terminated by 's'.
601-
*/
602-
function transformMillisecondsToSecondsString(milliseconds: number): string {
603-
let duration: string;
604-
const seconds = Math.floor(milliseconds / 1000);
605-
const nanos = (milliseconds - seconds * 1000) * 1000000;
606-
if (nanos > 0) {
607-
let nanoString = nanos.toString();
608-
while (nanoString.length < 9) {
609-
nanoString = '0' + nanoString;
610-
}
611-
duration = `${seconds}.${nanoString}s`;
612-
} else {
613-
duration = `${seconds}s`;
614-
}
615-
return duration;
616-
}

src/utils/index.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,29 @@ export function generateUpdateMask(
190190
}
191191
return updateMask;
192192
}
193+
194+
/**
195+
* Transforms milliseconds to a protobuf Duration type string.
196+
* Returns the duration in seconds with up to nine fractional
197+
* digits, terminated by 's'. Example: "3 seconds 0 nano seconds as 3s,
198+
* 3 seconds 1 nano seconds as 3.000000001s".
199+
*
200+
* @param milliseconds The duration in milliseconds.
201+
* @returns The resulting formatted string in seconds with up to nine fractional
202+
* digits, terminated by 's'.
203+
*/
204+
export function transformMillisecondsToSecondsString(milliseconds: number): string {
205+
let duration: string;
206+
const seconds = Math.floor(milliseconds / 1000);
207+
const nanos = Math.round((milliseconds - seconds * 1000) * 1000000);
208+
if (nanos > 0) {
209+
let nanoString = nanos.toString();
210+
while (nanoString.length < 9) {
211+
nanoString = '0' + nanoString;
212+
}
213+
duration = `${seconds}.${nanoString}s`;
214+
} else {
215+
duration = `${seconds}s`;
216+
}
217+
return duration;
218+
}

test/unit/app-check/app-check.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,15 @@ describe('AppCheck', () => {
147147
.should.eventually.be.rejected.and.deep.equal(INTERNAL_ERROR);
148148
});
149149

150+
it('should propagate API errors with custom options', () => {
151+
const stub = sinon
152+
.stub(AppCheckApiClient.prototype, 'exchangeToken')
153+
.rejects(INTERNAL_ERROR);
154+
stubs.push(stub);
155+
return appCheck.createToken(APP_ID, { ttlMillis: 1800000 })
156+
.should.eventually.be.rejected.and.deep.equal(INTERNAL_ERROR);
157+
});
158+
150159
it('should resolve with AppCheckToken on success', () => {
151160
const response = { token: 'token', ttlMillis: 3000 };
152161
const stub = sinon

test/unit/app-check/token-generator.spec.ts

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,49 @@ describe('AppCheckTokenGenerator', () => {
122122
}).to.throw(FirebaseAppCheckError).with.property('code', 'app-check/invalid-argument');
123123
});
124124

125-
it('should be fulfilled with a Firebase Custom JWT', () => {
125+
const invalidOptions = [null, NaN, 0, 1, true, false, [], _.noop];
126+
invalidOptions.forEach((invalidOption) => {
127+
it('should throw given an invalid options: ' + JSON.stringify(invalidOption), () => {
128+
expect(() => {
129+
tokenGenerator.createCustomToken(APP_ID, invalidOption as any);
130+
}).to.throw(FirebaseAppCheckError).with.property('message', 'AppCheckTokenOptions must be a non-null object.');
131+
});
132+
});
133+
134+
const invalidTtls = [null, NaN, '0', 'abc', '', -100, -1, true, false, [], {}, { a: 1 }, _.noop];
135+
invalidTtls.forEach((invalidTtl) => {
136+
it('should throw given an options object with invalid ttl: ' + JSON.stringify(invalidTtl), () => {
137+
expect(() => {
138+
tokenGenerator.createCustomToken(APP_ID, { ttlMillis: invalidTtl as any });
139+
}).to.throw(FirebaseAppCheckError).with.property('message',
140+
'ttlMillis must be a non-negative duration in milliseconds.');
141+
});
142+
});
143+
144+
const THIRTY_MIN_IN_MS = 1800000;
145+
const SEVEN_DAYS_IN_MS = 604800000;
146+
[0, 10, THIRTY_MIN_IN_MS - 1, SEVEN_DAYS_IN_MS + 1, SEVEN_DAYS_IN_MS * 2].forEach((ttlMillis) => {
147+
it('should throw given options with ttl < 30 minutes or ttl > 7 days:' + JSON.stringify(ttlMillis), () => {
148+
expect(() => {
149+
tokenGenerator.createCustomToken(APP_ID, { ttlMillis });
150+
}).to.throw(FirebaseAppCheckError).with.property(
151+
'message', 'ttlMillis must be a duration in milliseconds between 30 minutes and 7 days (inclusive).');
152+
});
153+
});
154+
155+
it('should be fulfilled with a Firebase Custom JWT with only an APP ID', () => {
126156
return tokenGenerator.createCustomToken(APP_ID)
127157
.should.eventually.be.a('string').and.not.be.empty;
128158
});
129159

160+
[THIRTY_MIN_IN_MS, THIRTY_MIN_IN_MS + 1, SEVEN_DAYS_IN_MS / 2, SEVEN_DAYS_IN_MS - 1, SEVEN_DAYS_IN_MS]
161+
.forEach((ttlMillis) => {
162+
it('should be fulfilled with a Firebase Custom JWT with a valid custom ttl' + JSON.stringify(ttlMillis), () => {
163+
return tokenGenerator.createCustomToken(APP_ID, { ttlMillis })
164+
.should.eventually.be.a('string').and.not.be.empty;
165+
});
166+
});
167+
130168
it('should be fulfilled with a JWT with the correct decoded payload', () => {
131169
clock = sinon.useFakeTimers(1000);
132170

@@ -147,6 +185,53 @@ describe('AppCheckTokenGenerator', () => {
147185
});
148186
});
149187

188+
[{}, { ttlMillis: undefined }, { a: 123 }].forEach((options) => {
189+
it('should be fulfilled with no ttl in the decoded payload when ttl is not provided in options', () => {
190+
clock = sinon.useFakeTimers(1000);
191+
192+
return tokenGenerator.createCustomToken(APP_ID, options)
193+
.then((token) => {
194+
const decoded = jwt.decode(token);
195+
const expected: { [key: string]: any } = {
196+
// eslint-disable-next-line @typescript-eslint/camelcase
197+
app_id: APP_ID,
198+
iat: 1,
199+
exp: ONE_HOUR_IN_SECONDS + 1,
200+
aud: FIREBASE_APP_CHECK_AUDIENCE,
201+
iss: mocks.certificateObject.client_email,
202+
sub: mocks.certificateObject.client_email,
203+
};
204+
205+
expect(decoded).to.deep.equal(expected);
206+
});
207+
});
208+
});
209+
210+
[[1800000.000001, '1800.000000001s'], [1800000.001, '1800.000001000s'], [172800000, '172800s'],
211+
[604799999, '604799.999000000s'], [604800000, '604800s']
212+
].forEach((ttl) => {
213+
it('should be fulfilled with a JWT with custom ttl in decoded payload', () => {
214+
clock = sinon.useFakeTimers(1000);
215+
216+
return tokenGenerator.createCustomToken(APP_ID, { ttlMillis: ttl[0] as number })
217+
.then((token) => {
218+
const decoded = jwt.decode(token);
219+
const expected: { [key: string]: any } = {
220+
// eslint-disable-next-line @typescript-eslint/camelcase
221+
app_id: APP_ID,
222+
iat: 1,
223+
exp: ONE_HOUR_IN_SECONDS + 1,
224+
aud: FIREBASE_APP_CHECK_AUDIENCE,
225+
iss: mocks.certificateObject.client_email,
226+
sub: mocks.certificateObject.client_email,
227+
ttl: ttl[1],
228+
};
229+
230+
expect(decoded).to.deep.equal(expected);
231+
});
232+
});
233+
});
234+
150235
it('should be fulfilled with a JWT with the correct header', () => {
151236
clock = sinon.useFakeTimers(1000);
152237

@@ -225,7 +310,7 @@ describe('AppCheckTokenGenerator', () => {
225310
expect(appCheckError).to.be.an.instanceof(FirebaseAppCheckError);
226311
expect(appCheckError).to.have.property('code', 'app-check/unknown-error');
227312
expect(appCheckError).to.have.property('message',
228-
'Error returned from server while siging a custom token: server error.');
313+
'Error returned from server while signing a custom token: server error.');
229314
});
230315

231316
it('should convert CryptoSignerError HttpError with no error.message to FirebaseAppCheckError', () => {
@@ -240,7 +325,7 @@ describe('AppCheckTokenGenerator', () => {
240325
expect(appCheckError).to.be.an.instanceof(FirebaseAppCheckError);
241326
expect(appCheckError).to.have.property('code', 'app-check/unknown-error');
242327
expect(appCheckError).to.have.property('message',
243-
'Error returned from server while siging a custom token: '+
328+
'Error returned from server while signing a custom token: '+
244329
'{"status":500,"headers":{},"data":{"error":{}},"text":"{\\"error\\":{}}"}');
245330
});
246331

0 commit comments

Comments
 (0)