Skip to content
2 changes: 2 additions & 0 deletions etc/firebase-admin.app-check.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface AppCheckToken {

// @public
export interface AppCheckTokenOptions {
jti?: string;
limitedUse?: boolean;
ttlMillis?: number;
}

Expand Down
31 changes: 28 additions & 3 deletions src/app-check/app-check-api-client-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
import { PrefixedFirebaseError } from '../utils/error';
import * as utils from '../utils/index';
import * as validator from '../utils/validator';
import { AppCheckToken } from './app-check-api'
import { AppCheckToken, AppCheckTokenOptions } from './app-check-api'

// App Check backend constants
const FIREBASE_APP_CHECK_V1_API_URL_FORMAT = 'https://firebaseappcheck.googleapis.com/v1/projects/{projectId}/apps/{appId}:exchangeCustomToken';
Expand Down Expand Up @@ -58,7 +58,11 @@ export class AppCheckApiClient {
* @param appId - The mobile App ID.
* @returns A promise that fulfills with a `AppCheckToken`.
*/
public exchangeToken(customToken: string, appId: string): Promise<AppCheckToken> {
public exchangeToken(
customToken: string,
appId: string,
options?: AppCheckTokenOptions
): Promise<AppCheckToken> {
if (!validator.isNonEmptyString(appId)) {
throw new FirebaseAppCheckError(
'invalid-argument',
Expand All @@ -69,13 +73,34 @@ export class AppCheckApiClient {
'invalid-argument',
'`customToken` must be a non-empty string.');
}
if (typeof options?.limitedUse !== 'undefined' && !validator.isBoolean(options.limitedUse)) {
throw new FirebaseAppCheckError(
'invalid-argument',
'`limitedUse` must be a boolean value.');
}
if (typeof options?.jti !== 'undefined') {
if (!validator.isString(options.jti)) {
throw new FirebaseAppCheckError(
'invalid-argument',
'`jti` must be a string value.');
}
if (options.jti !== '' && !options.limitedUse) {
throw new FirebaseAppCheckError(
'invalid-argument',
'`jti` cannot be specified without setting `limitedUse` to `true`.');
}
}
return this.getUrl(appId)
.then((url) => {
const request: HttpRequestConfig = {
method: 'POST',
url,
headers: FIREBASE_APP_CHECK_CONFIG_HEADERS,
data: { customToken }
data: {
customToken,
...(options?.limitedUse !== undefined && { limitedUse: options.limitedUse }),
...(options?.jti !== undefined && { jti: options.jti }),
}
};
return this.httpClient.send(request);
})
Expand Down
20 changes: 20 additions & 0 deletions src/app-check/app-check-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ export interface AppCheckTokenOptions {
* be valid. This value must be between 30 minutes and 7 days, inclusive.
*/
ttlMillis?: number;

/**
* Specifies whether this token is for a limited use context.
* To enable this token to be used with the replay protection feature, set this to `true`.
* The default value is `false`.
*/
limitedUse?: boolean;

/**
* Specifies the desired `jti` claim (Section 4.1.7 of RFC 7519) in the returned App
* Check token. Limited-use App Check tokens with the same `jti` will be counted as the
* same token for the purposes of replay protection.
*
* If this field is omitted or is empty, a randomly generated `jti` will be used in the
* returned App Check token.
*
* An error is returned if this field is specified without setting `limitedUse` to
* `true`.
*/
jti?: string;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/app-check/app-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export class AppCheck {
public createToken(appId: string, options?: AppCheckTokenOptions): Promise<AppCheckToken> {
return this.tokenGenerator.createCustomToken(appId, options)
.then((customToken) => {
return this.client.exchangeToken(customToken, appId);
return this.client.exchangeToken(customToken, appId, options);
});
}

Expand Down
17 changes: 15 additions & 2 deletions test/integration/app-check.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('admin.appCheck', () => {
});

describe('createToken', () => {
it('should succeed with a vaild token', function() {
it('should succeed with a valid token', function () {
if (!appId) {
this.skip();
}
Expand All @@ -53,7 +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);
expect(token.ttlMillis).to.equals(3600000); // 1 hour
});
});

it('should succeed with a valid limited use token', function () {
if (!appId) {
this.skip();
}
return admin.appCheck().createToken(appId as string, { limitedUse: true })
.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(300000); // 5 minutes
});
});

Expand Down
109 changes: 108 additions & 1 deletion test/unit/app-check/app-check-api-client-internal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,118 @@ describe('AppCheckApiClient', () => {
method: 'POST',
url: `https://firebaseappcheck.googleapis.com/v1/projects/test-project/apps/${APP_ID}:exchangeCustomToken`,
headers: EXPECTED_HEADERS,
data: { customToken: TEST_TOKEN_TO_EXCHANGE }
data: {
customToken: TEST_TOKEN_TO_EXCHANGE,
}
});
});
});

it('should resolve with the App Check token on success with limitedUse', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(TEST_RESPONSE, 200));
stubs.push(stub);
return apiClient.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID, { limitedUse: true })
.then((resp) => {
expect(resp.token).to.deep.equal(TEST_RESPONSE.token);
expect(resp.ttlMillis).to.deep.equal(3000);
expect(stub).to.have.been.calledOnce.and.calledWith({
method: 'POST',
url: `https://firebaseappcheck.googleapis.com/v1/projects/test-project/apps/${APP_ID}:exchangeCustomToken`,
headers: EXPECTED_HEADERS,
data: {
customToken: TEST_TOKEN_TO_EXCHANGE,
limitedUse: true,
}
});
});
});

it('should resolve with the App Check token on success with jti', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(TEST_RESPONSE, 200));
stubs.push(stub);
return apiClient.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID, { limitedUse: true, jti: 'test-jti' })
.then((resp) => {
expect(resp.token).to.deep.equal(TEST_RESPONSE.token);
expect(resp.ttlMillis).to.deep.equal(3000);
expect(stub).to.have.been.calledOnce.and.calledWith({
method: 'POST',
url: `https://firebaseappcheck.googleapis.com/v1/projects/test-project/apps/${APP_ID}:exchangeCustomToken`,
headers: EXPECTED_HEADERS,
data: {
customToken: TEST_TOKEN_TO_EXCHANGE,
limitedUse: true,
jti: 'test-jti',
}
});
});
});

it('should resolve with the App Check token on success with empty options', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(TEST_RESPONSE, 200));
stubs.push(stub);
return apiClient.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID, {})
.then((resp) => {
expect(resp.token).to.deep.equal(TEST_RESPONSE.token);
expect(resp.ttlMillis).to.deep.equal(3000);
expect(stub).to.have.been.calledOnce.and.calledWith({
method: 'POST',
url: `https://firebaseappcheck.googleapis.com/v1/projects/test-project/apps/${APP_ID}:exchangeCustomToken`,
headers: EXPECTED_HEADERS,
data: {
customToken: TEST_TOKEN_TO_EXCHANGE,
}
});
});
});

it('should resolve with the App Check token on success with limitedUse set to false and jti undefined', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(TEST_RESPONSE, 200));
stubs.push(stub);
return apiClient.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID, { limitedUse: false, jti: undefined })
.then((resp) => {
expect(resp.token).to.deep.equal(TEST_RESPONSE.token);
expect(resp.ttlMillis).to.deep.equal(3000);
expect(stub).to.have.been.calledOnce.and.calledWith({
method: 'POST',
url: `https://firebaseappcheck.googleapis.com/v1/projects/test-project/apps/${APP_ID}:exchangeCustomToken`,
headers: EXPECTED_HEADERS,
data: {
customToken: TEST_TOKEN_TO_EXCHANGE,
limitedUse: false,
}
});
});
});

const invalidJtis = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop];
invalidJtis.forEach((invalidJti) => {
it('should throw given a non-string jti: ' + JSON.stringify(invalidJti), () => {
expect(() => {
apiClient.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID, { jti: invalidJti as any });
}).to.throw('jti` must be a string value.');
});
});

it('should throw given jti without limitedUse', () => {
expect(() => {
apiClient.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID, { jti: 'test-jti' });
}).to.throw('jti` cannot be specified without setting `limitedUse` to `true`.');
});

it('should throw given jti with limitedUse set to false', () => {
expect(() => {
apiClient.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID, { jti: 'test-jti', limitedUse: false });
}).to.throw('jti` cannot be specified without setting `limitedUse` to `true`.');
});

new Map([['3s', 3000], ['4.1s', 4100], ['3.000000001s', 3000], ['3.000001s', 3000]])
.forEach((ttlMillis, ttlString) => { // value, key, map
// 3 seconds with 0 nanoseconds expressed as "3s"
Expand Down
29 changes: 29 additions & 0 deletions test/unit/app-check/app-check.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,35 @@ describe('AppCheck', () => {
expect(token.ttlMillis).equals(3000);
});
});

it('should resolve with AppCheckToken on success with limitedUse', () => {
const response = { token: 'token', ttlMillis: 3000 };
const stub = sinon
.stub(AppCheckApiClient.prototype, 'exchangeToken')
.resolves(response);
stubs.push(stub);
return appCheck.createToken(APP_ID, { limitedUse: true })
.then((token) => {
expect(token.token).equals('token');
expect(token.ttlMillis).equals(3000);
expect(stub).to.have.been.calledOnce.and.calledWith(sinon.match.string, APP_ID, { limitedUse: true });
});
});

it('should resolve with AppCheckToken on success with limitedUse and jti', () => {
const response = { token: 'token', ttlMillis: 3000 };
const stub = sinon
.stub(AppCheckApiClient.prototype, 'exchangeToken')
.resolves(response);
stubs.push(stub);
return appCheck.createToken(APP_ID, { limitedUse: true, jti: 'test-jti' })
.then((token) => {
expect(token.token).equals('token');
expect(token.ttlMillis).equals(3000);
expect(stub).to.have.been.calledOnce.and.calledWith(
sinon.match.string, APP_ID, { limitedUse: true, jti: 'test-jti' });
});
});
});

describe('verifyToken', () => {
Expand Down
Loading