Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious. Why is ttl separate from exp?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to go/fac-configurable-ttls we are not exposing the option to use exp at all to keep the interface simple. So in this case a custom ttl will override exp.

};
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
Loading