Skip to content

Commit

Permalink
Add custom ttl options for App Check
Browse files Browse the repository at this point in the history
  • Loading branch information
lahirumaramba committed Jul 8, 2021
1 parent f09bd64 commit 8dab1b2
Show file tree
Hide file tree
Showing 9 changed files with 206 additions and 40 deletions.
5 changes: 4 additions & 1 deletion etc/firebase-admin.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,16 @@ export namespace appCheck {
export interface AppCheck {
// (undocumented)
app: app.App;
createToken(appId: string): Promise<AppCheckToken>;
createToken(appId: string, options?: AppCheckTokenOptions): Promise<AppCheckToken>;
verifyToken(appCheckToken: string): Promise<VerifyAppCheckTokenResponse>;
}
export interface AppCheckToken {
token: string;
ttlMillis: number;
}
export interface AppCheckTokenOptions {
ttlMillis?: number;
}
export interface DecodedAppCheckToken {
// (undocumented)
[key: string]: any;
Expand Down
10 changes: 6 additions & 4 deletions src/app-check/app-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { cryptoSignerFromApp } from '../utils/crypto-signer';

import AppCheckInterface = appCheck.AppCheck;
import AppCheckToken = appCheck.AppCheckToken;
import AppCheckTokenOptions = appCheck.AppCheckTokenOptions;
import VerifyAppCheckTokenResponse = appCheck.VerifyAppCheckTokenResponse;

/**
Expand Down Expand Up @@ -56,18 +57,19 @@ export class AppCheck implements AppCheckInterface {
* back to a client.
*
* @param appId The app ID to use as the JWT app_id.
* @param options Optional options object when creating a new App Check Token.
*
* @return A promise that fulfills with a `AppCheckToken`.
* @returns A promise that fulfills with a `AppCheckToken`.
*/
public createToken(appId: string): Promise<AppCheckToken> {
return this.tokenGenerator.createCustomToken(appId)
public createToken(appId: string, options?: AppCheckTokenOptions): Promise<AppCheckToken> {
return this.tokenGenerator.createCustomToken(appId, options)
.then((customToken) => {
return this.client.exchangeToken(customToken, appId);
});
}

/**
* Veifies an App Check token.
* Verifies an App Check token.
*
* @param appCheckToken The App Check token to verify.
*
Expand Down
17 changes: 15 additions & 2 deletions src/app-check/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,11 @@ export namespace appCheck {
* back to a client.
*
* @param appId The App ID of the Firebase App the token belongs to.
* @param options Optional options object when creating a new App Check Token.
*
* @return A promise that fulfills with a `AppCheckToken`.
* @returns A promise that fulfills with a `AppCheckToken`.
*/
createToken(appId: string): Promise<AppCheckToken>;
createToken(appId: string, options?: AppCheckTokenOptions): Promise<AppCheckToken>;

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

/**
* Interface representing an App Check token options.
*/
export interface AppCheckTokenOptions {
/**
* The length of time measured in milliseconds starting from when the server
* mints the token for which the returned FAC token will be valid.
* This value must be in milliseconds and between 30 minutes and 7 days, inclusive.
*/
ttlMillis?: number;
}

/**
* Interface representing a decoded Firebase App Check token, returned from the
* {@link appCheck.AppCheck.verifyToken `verifyToken()`} method.
Expand Down
46 changes: 43 additions & 3 deletions src/app-check/token-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
* limitations under the License.
*/

import { appCheck } from './index';

import * as validator from '../utils/validator';
import { toWebSafeBase64 } from '../utils';
import { toWebSafeBase64, transformMillisecondsToSecondsString } from '../utils';

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

import AppCheckTokenOptions = appCheck.AppCheckTokenOptions;

const ONE_HOUR_IN_SECONDS = 60 * 60;
const ONE_MINUTE_IN_MILLIS = 60 * 1000;
const ONE_DAY_IN_MILLIS = 24 * 60 * 60 * 1000;

// Audience to use for Firebase App Check Custom tokens
const FIREBASE_APP_CHECK_AUDIENCE = 'https://firebaseappcheck.googleapis.com/google.firebase.appcheck.v1beta.TokenExchangeService';
Expand Down Expand Up @@ -63,12 +69,16 @@ export class AppCheckTokenGenerator {
* @return A Promise fulfilled with a custom token signed with a service account key
* that can be exchanged to an App Check token.
*/
public createCustomToken(appId: string): Promise<string> {
public createCustomToken(appId: string, options?: AppCheckTokenOptions): Promise<string> {
if (!validator.isNonEmptyString(appId)) {
throw new FirebaseAppCheckError(
'invalid-argument',
'`appId` must be a non-empty string.');
}
let customOptions = {};
if (typeof options !== 'undefined') {
customOptions = this.validateTokenOptions(options);
}
return this.signer.getAccountId().then((account) => {
const header = {
alg: this.signer.algorithm,
Expand All @@ -83,6 +93,7 @@ export class AppCheckTokenGenerator {
aud: FIREBASE_APP_CHECK_AUDIENCE,
exp: iat + ONE_HOUR_IN_SECONDS,
iat,
...customOptions,
};
const token = `${this.encodeSegment(header)}.${this.encodeSegment(body)}`;
return this.signer.sign(Buffer.from(token))
Expand All @@ -98,6 +109,35 @@ export class AppCheckTokenGenerator {
const buffer: Buffer = (segment instanceof Buffer) ? segment : Buffer.from(JSON.stringify(segment));
return toWebSafeBase64(buffer).replace(/=+$/, '');
}

/**
* Checks if a given `AppCheckTokenOptions` object is valid. If successful, returns an object with
* custom properties.
*
* @param options An options object to be validated.
* @returns A custom object with ttl converted to protobuf Duration string format.
*/
private validateTokenOptions(options: AppCheckTokenOptions): {[key: string]: any} {
if (!validator.isNonNullObject(options)) {
throw new FirebaseAppCheckError(
'invalid-argument',
'AppCheckTokenOptions must be a non-null object.');
}
if (typeof options.ttlMillis !== 'undefined') {
if (!validator.isNumber(options.ttlMillis) || options.ttlMillis < 0) {
throw new FirebaseAppCheckError('invalid-argument',
'ttlMillis must be a non-negative duration in milliseconds.');
}
// ttlMillis must be between 30 minutes and 7 days (inclusive)
if (options.ttlMillis < (ONE_MINUTE_IN_MILLIS * 30) || options.ttlMillis > (ONE_DAY_IN_MILLIS * 7)) {
throw new FirebaseAppCheckError(
'invalid-argument',
'ttlMillis must be a duration in milliseconds between 30 minutes and 7 days (inclusive).');
}
return { ttl: transformMillisecondsToSecondsString(options.ttlMillis) };
}
return {};
}
}

/**
Expand All @@ -123,7 +163,7 @@ export function appCheckErrorFromCryptoSignerError(err: Error): Error {
code = APP_CHECK_ERROR_CODE_MAPPING[status];
}
return new FirebaseAppCheckError(code,
`Error returned from server while siging a custom token: ${description}`
`Error returned from server while signing a custom token: ${description}`
);
}
return new FirebaseAppCheckError('internal-error',
Expand Down
27 changes: 1 addition & 26 deletions src/messaging/messaging-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { renameProperties } from '../utils/index';
import { renameProperties, transformMillisecondsToSecondsString } from '../utils/index';
import { MessagingClientErrorCode, FirebaseMessagingError, } from '../utils/error';
import { messaging } from './index';
import * as validator from '../utils/validator';
Expand Down Expand Up @@ -589,28 +589,3 @@ function validateAndroidFcmOptions(fcmOptions: AndroidFcmOptions | undefined): v
MessagingClientErrorCode.INVALID_PAYLOAD, 'analyticsLabel must be a string value');
}
}

/**
* Transforms milliseconds to the format expected by FCM service.
* Returns the duration in seconds with up to nine fractional
* digits, terminated by 's'. Example: "3.5s".
*
* @param {number} milliseconds The duration in milliseconds.
* @return {string} The resulting formatted string in seconds with up to nine fractional
* digits, terminated by 's'.
*/
function transformMillisecondsToSecondsString(milliseconds: number): string {
let duration: string;
const seconds = Math.floor(milliseconds / 1000);
const nanos = (milliseconds - seconds * 1000) * 1000000;
if (nanos > 0) {
let nanoString = nanos.toString();
while (nanoString.length < 9) {
nanoString = '0' + nanoString;
}
duration = `${seconds}.${nanoString}s`;
} else {
duration = `${seconds}s`;
}
return duration;
}
26 changes: 26 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,29 @@ export function generateUpdateMask(
}
return updateMask;
}

/**
* Transforms milliseconds to a protobuf Duration type string.
* Returns the duration in seconds with up to nine fractional
* digits, terminated by 's'. Example: "3 seconds 0 nano seconds as 3s,
* 3 seconds 1 nano seconds as 3.000000001s".
*
* @param milliseconds The duration in milliseconds.
* @returns The resulting formatted string in seconds with up to nine fractional
* digits, terminated by 's'.
*/
export function transformMillisecondsToSecondsString(milliseconds: number): string {
let duration: string;
const seconds = Math.floor(milliseconds / 1000);
const nanos = Math.round((milliseconds - seconds * 1000) * 1000000);

This comment has been minimized.

Copy link
@weixifan

weixifan Jul 9, 2021

This doesn't matter in practice at all, but we can use floor here so that technically we never mint a token with a TTL that's longer than requested.

This comment has been minimized.

Copy link
@lahirumaramba

lahirumaramba Jul 9, 2021

Author Member

Good point! I actually considered that but when used floor, 3000.000001 ms will be transformed to 3s losing the nanosecond.
I think that would be okay in practice. I will change this to use floor here to ensure that we never mint a token with a longer ttl like you mentioned. :)
Thanks!

if (nanos > 0) {
let nanoString = nanos.toString();
while (nanoString.length < 9) {
nanoString = '0' + nanoString;
}
duration = `${seconds}.${nanoString}s`;
} else {
duration = `${seconds}s`;
}
return duration;
}
9 changes: 9 additions & 0 deletions test/unit/app-check/app-check.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ describe('AppCheck', () => {
.should.eventually.be.rejected.and.deep.equal(INTERNAL_ERROR);
});

it('should propagate API errors with custom options', () => {
const stub = sinon
.stub(AppCheckApiClient.prototype, 'exchangeToken')
.rejects(INTERNAL_ERROR);
stubs.push(stub);
return appCheck.createToken(APP_ID, { ttlMillis: 1800000 })
.should.eventually.be.rejected.and.deep.equal(INTERNAL_ERROR);
});

it('should resolve with AppCheckToken on success', () => {
const response = { token: 'token', ttlMillis: 3000 };
const stub = sinon
Expand Down
91 changes: 88 additions & 3 deletions test/unit/app-check/token-generator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,49 @@ describe('AppCheckTokenGenerator', () => {
}).to.throw(FirebaseAppCheckError).with.property('code', 'app-check/invalid-argument');
});

it('should be fulfilled with a Firebase Custom JWT', () => {
const invalidOptions = [null, NaN, 0, 1, true, false, [], _.noop];
invalidOptions.forEach((invalidOption) => {
it('should throw given an invalid options: ' + JSON.stringify(invalidOption), () => {
expect(() => {
tokenGenerator.createCustomToken(APP_ID, invalidOption as any);
}).to.throw(FirebaseAppCheckError).with.property('message', 'AppCheckTokenOptions must be a non-null object.');
});
});

const invalidTtls = [null, NaN, '0', 'abc', '', -100, -1, true, false, [], {}, { a: 1 }, _.noop];
invalidTtls.forEach((invalidTtl) => {
it('should throw given an options object with invalid ttl: ' + JSON.stringify(invalidTtl), () => {
expect(() => {
tokenGenerator.createCustomToken(APP_ID, { ttlMillis: invalidTtl as any });
}).to.throw(FirebaseAppCheckError).with.property('message',
'ttlMillis must be a non-negative duration in milliseconds.');
});
});

const THIRTY_MIN_IN_MS = 1800000;
const SEVEN_DAYS_IN_MS = 604800000;
[0, 10, THIRTY_MIN_IN_MS - 1, SEVEN_DAYS_IN_MS + 1, SEVEN_DAYS_IN_MS * 2].forEach((ttlMillis) => {
it('should throw given options with ttl < 30 minutes or ttl > 7 days:' + JSON.stringify(ttlMillis), () => {
expect(() => {
tokenGenerator.createCustomToken(APP_ID, { ttlMillis });
}).to.throw(FirebaseAppCheckError).with.property(
'message', 'ttlMillis must be a duration in milliseconds between 30 minutes and 7 days (inclusive).');
});
});

it('should be fulfilled with a Firebase Custom JWT with only an APP ID', () => {
return tokenGenerator.createCustomToken(APP_ID)
.should.eventually.be.a('string').and.not.be.empty;
});

[THIRTY_MIN_IN_MS, THIRTY_MIN_IN_MS + 1, SEVEN_DAYS_IN_MS / 2, SEVEN_DAYS_IN_MS - 1, SEVEN_DAYS_IN_MS]
.forEach((ttlMillis) => {
it('should be fulfilled with a Firebase Custom JWT with a valid custom ttl' + JSON.stringify(ttlMillis), () => {
return tokenGenerator.createCustomToken(APP_ID, { ttlMillis })
.should.eventually.be.a('string').and.not.be.empty;
});
});

it('should be fulfilled with a JWT with the correct decoded payload', () => {
clock = sinon.useFakeTimers(1000);

Expand All @@ -147,6 +185,53 @@ describe('AppCheckTokenGenerator', () => {
});
});

[{}, { ttlMillis: undefined }, { a: 123 }].forEach((options) => {
it('should be fulfilled with no ttl in the decoded payload when ttl is not provided in options', () => {
clock = sinon.useFakeTimers(1000);

return tokenGenerator.createCustomToken(APP_ID, options)
.then((token) => {
const decoded = jwt.decode(token);
const expected: { [key: string]: any } = {
// eslint-disable-next-line @typescript-eslint/camelcase
app_id: APP_ID,
iat: 1,
exp: ONE_HOUR_IN_SECONDS + 1,
aud: FIREBASE_APP_CHECK_AUDIENCE,
iss: mocks.certificateObject.client_email,
sub: mocks.certificateObject.client_email,
};

expect(decoded).to.deep.equal(expected);
});
});
});

[[1800000.000001, '1800.000000001s'], [1800000.001, '1800.000001000s'], [172800000, '172800s'],
[604799999, '604799.999000000s'], [604800000, '604800s']
].forEach((ttl) => {
it('should be fulfilled with a JWT with custom ttl in decoded payload', () => {
clock = sinon.useFakeTimers(1000);

return tokenGenerator.createCustomToken(APP_ID, { ttlMillis: ttl[0] as number })
.then((token) => {
const decoded = jwt.decode(token);
const expected: { [key: string]: any } = {
// eslint-disable-next-line @typescript-eslint/camelcase
app_id: APP_ID,
iat: 1,
exp: ONE_HOUR_IN_SECONDS + 1,
aud: FIREBASE_APP_CHECK_AUDIENCE,
iss: mocks.certificateObject.client_email,
sub: mocks.certificateObject.client_email,
ttl: ttl[1],
};

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 @@ -225,7 +310,7 @@ describe('AppCheckTokenGenerator', () => {
expect(appCheckError).to.be.an.instanceof(FirebaseAppCheckError);
expect(appCheckError).to.have.property('code', 'app-check/unknown-error');
expect(appCheckError).to.have.property('message',
'Error returned from server while siging a custom token: server error.');
'Error returned from server while signing a custom token: server error.');
});

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

Expand Down

0 comments on commit 8dab1b2

Please sign in to comment.