Skip to content

Commit 4e816f4

Browse files
feat(fac): Add custom TTL options for App Check (#1363)
* Add custom ttl options for App Check * PR fixes * Add integration tests * PR fixes
1 parent 760cd6a commit 4e816f4

File tree

10 files changed

+232
-40
lines changed

10 files changed

+232
-40
lines changed

etc/firebase-admin.api.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,16 @@ export namespace appCheck {
5353
export interface AppCheck {
5454
// (undocumented)
5555
app: app.App;
56-
createToken(appId: string): Promise<AppCheckToken>;
56+
createToken(appId: string, options?: AppCheckTokenOptions): Promise<AppCheckToken>;
5757
verifyToken(appCheckToken: string): Promise<VerifyAppCheckTokenResponse>;
5858
}
5959
export interface AppCheckToken {
6060
token: string;
6161
ttlMillis: number;
6262
}
63+
export interface AppCheckTokenOptions {
64+
ttlMillis?: number;
65+
}
6366
export interface DecodedAppCheckToken {
6467
// (undocumented)
6568
[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: 14 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,17 @@ export namespace appCheck {
9596
ttlMillis: number;
9697
}
9798

99+
/**
100+
* Interface representing App Check token options.
101+
*/
102+
export interface AppCheckTokenOptions {
103+
/**
104+
* The length of time, in milliseconds, for which the App Check token will
105+
* be valid. This value must be between 30 minutes and 7 days, inclusive.
106+
*/
107+
ttlMillis?: number;
108+
}
109+
98110
/**
99111
* Interface representing a decoded Firebase App Check token, returned from the
100112
* {@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)) {
128+
throw new FirebaseAppCheckError('invalid-argument',
129+
'ttlMillis must be a 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.floor((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/integration/app-check.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,20 @@ describe('admin.appCheck', () => {
5353
expect(token).to.have.keys(['token', 'ttlMillis']);
5454
expect(token.token).to.be.a('string').and.to.not.be.empty;
5555
expect(token.ttlMillis).to.be.a('number');
56+
expect(token.ttlMillis).to.equals(3600000);
57+
});
58+
});
59+
60+
it('should succeed with a valid token and a custom ttl', function() {
61+
if (!appId) {
62+
this.skip();
63+
}
64+
return admin.appCheck().createToken(appId as string, { ttlMillis: 1800000 })
65+
.then((token) => {
66+
expect(token).to.have.keys(['token', 'ttlMillis']);
67+
expect(token.token).to.be.a('string').and.to.not.be.empty;
68+
expect(token.ttlMillis).to.be.a('number');
69+
expect(token.ttlMillis).to.equals(1800000);
5670
});
5771
});
5872

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

0 commit comments

Comments
 (0)