Skip to content

Commit 8d6197a

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

File tree

8 files changed

+207
-37
lines changed

8 files changed

+207
-37
lines changed

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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ export namespace appCheck {
6666
*/
6767
createToken(appId: string): Promise<AppCheckToken>;
6868

69+
/**
70+
* Creates a new {@link appCheck.AppCheckToken `AppCheckToken`} that can be sent
71+
* back to a client.
72+
*
73+
* @param appId The App ID of the Firebase App the token belongs to.
74+
* @param options Optional options object when creating a new App Check Token.
75+
*
76+
* @returns A promise that fulfills with a `AppCheckToken`.
77+
*/
78+
createToken(appId: string, options?: AppCheckTokenOptions): Promise<AppCheckToken>;
79+
6980
/**
7081
* Verifies a Firebase App Check token (JWT). If the token is valid, the promise is
7182
* fulfilled with the token's decoded claims; otherwise, the promise is
@@ -95,6 +106,18 @@ export namespace appCheck {
95106
ttlMillis: number;
96107
}
97108

109+
/**
110+
* Interface representing an App Check token options.
111+
*/
112+
export interface AppCheckTokenOptions {
113+
/**
114+
* The length of time measured in milliseconds starting from when the server
115+
* mints the token for which the returned FAC token will be valid.
116+
* This value must be in milliseconds and between 30 minutes and 7 days, inclusive.
117+
*/
118+
ttlMillis?: number;
119+
}
120+
98121
/**
99122
* Interface representing a decoded Firebase App Check token, returned from the
100123
* {@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: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,46 @@ 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+
[0, 10, 1799999, 604800001, 605800000].forEach((ttlMillis) => {
145+
it('should throw given options with ttl < 30 minutes or ttl > 7 days:' + JSON.stringify(ttlMillis), () => {
146+
expect(() => {
147+
tokenGenerator.createCustomToken(APP_ID, { ttlMillis });
148+
}).to.throw(FirebaseAppCheckError).with.property(
149+
'message', 'ttlMillis must be a duration in milliseconds between 30 minutes and 7 days (inclusive).');
150+
});
151+
});
152+
153+
it('should be fulfilled with a Firebase Custom JWT with only an APP ID', () => {
126154
return tokenGenerator.createCustomToken(APP_ID)
127155
.should.eventually.be.a('string').and.not.be.empty;
128156
});
129157

158+
[1800000, 1800001, 172800000, 604799999, 604800000].forEach((ttlMillis) => {
159+
it('should be fulfilled with a Firebase Custom JWT with a valid custom ttl' + JSON.stringify(ttlMillis), () => {
160+
return tokenGenerator.createCustomToken(APP_ID, { ttlMillis })
161+
.should.eventually.be.a('string').and.not.be.empty;
162+
});
163+
});
164+
130165
it('should be fulfilled with a JWT with the correct decoded payload', () => {
131166
clock = sinon.useFakeTimers(1000);
132167

@@ -147,6 +182,53 @@ describe('AppCheckTokenGenerator', () => {
147182
});
148183
});
149184

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

@@ -225,7 +307,7 @@ describe('AppCheckTokenGenerator', () => {
225307
expect(appCheckError).to.be.an.instanceof(FirebaseAppCheckError);
226308
expect(appCheckError).to.have.property('code', 'app-check/unknown-error');
227309
expect(appCheckError).to.have.property('message',
228-
'Error returned from server while siging a custom token: server error.');
310+
'Error returned from server while signing a custom token: server error.');
229311
});
230312

231313
it('should convert CryptoSignerError HttpError with no error.message to FirebaseAppCheckError', () => {
@@ -240,7 +322,7 @@ describe('AppCheckTokenGenerator', () => {
240322
expect(appCheckError).to.be.an.instanceof(FirebaseAppCheckError);
241323
expect(appCheckError).to.have.property('code', 'app-check/unknown-error');
242324
expect(appCheckError).to.have.property('message',
243-
'Error returned from server while siging a custom token: '+
325+
'Error returned from server while signing a custom token: '+
244326
'{"status":500,"headers":{},"data":{"error":{}},"text":"{\\"error\\":{}}"}');
245327
});
246328

test/unit/utils/index.spec.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import * as sinon from 'sinon';
2222
import * as mocks from '../../resources/mocks';
2323
import {
2424
addReadonlyGetter, getExplicitProjectId, findProjectId,
25-
toWebSafeBase64, formatString, generateUpdateMask,
25+
toWebSafeBase64, formatString, generateUpdateMask, transformMillisecondsToSecondsString,
2626
} from '../../../src/utils/index';
2727
import { isNonEmptyString } from '../../../src/utils/validator';
2828
import { FirebaseApp } from '../../../src/firebase-app';
@@ -383,3 +383,16 @@ describe('generateUpdateMask()', () => {
383383
.to.deep.equal(['b', 'c', 'd', 'e', 'f', 'k', 'l', 'n']);
384384
});
385385
});
386+
387+
388+
describe('transformMillisecondsToSecondsString()', () => {
389+
[
390+
[3000.000001, '3.000000001s'], [3000.001, '3.000001000s'],
391+
[3000, '3s'], [3500, '3.500000000s']
392+
].forEach((duration) => {
393+
it('should transform to protobuf duration string when provided milliseconds:' + JSON.stringify(duration[0]),
394+
() => {
395+
expect(transformMillisecondsToSecondsString(duration[0] as number)).to.equal(duration[1]);
396+
});
397+
});
398+
});

0 commit comments

Comments
 (0)