Skip to content

Commit

Permalink
feat(fac): Add custom TTL options for App Check (#1363)
Browse files Browse the repository at this point in the history
* Add custom ttl options for App Check

* PR fixes

* Add integration tests

* PR fixes
  • Loading branch information
lahirumaramba committed Jul 14, 2021
1 parent 760cd6a commit 4e816f4
Show file tree
Hide file tree
Showing 10 changed files with 232 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 @@ -53,13 +53,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
16 changes: 14 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,17 @@ export namespace appCheck {
ttlMillis: number;
}

/**
* Interface representing App Check token options.
*/
export interface AppCheckTokenOptions {
/**
* The length of time, in milliseconds, for which the App Check token will
* be valid. This value must be 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)) {
throw new FirebaseAppCheckError('invalid-argument',
'ttlMillis must be a 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.floor((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;
}
14 changes: 14 additions & 0 deletions test/integration/app-check.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ describe('admin.appCheck', () => {
expect(token).to.have.keys(['token', 'ttlMillis']);
expect(token.token).to.be.a('string').and.to.not.be.empty;
expect(token.ttlMillis).to.be.a('number');
expect(token.ttlMillis).to.equals(3600000);
});
});

it('should succeed with a valid token and a custom ttl', function() {
if (!appId) {
this.skip();
}
return admin.appCheck().createToken(appId as string, { ttlMillis: 1800000 })
.then((token) => {
expect(token).to.have.keys(['token', 'ttlMillis']);
expect(token.token).to.be.a('string').and.to.not.be.empty;
expect(token.ttlMillis).to.be.a('number');
expect(token.ttlMillis).to.equals(1800000);
});
});

Expand Down
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

0 comments on commit 4e816f4

Please sign in to comment.