Skip to content
Open
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
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) {

Choose a reason for hiding this comment

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

I'm not familiar with JavaScript, but would it be possible to add a test case for leaving jti as undefined, while setting limitedUse to false? Ideally this should be an allowed combination, but I wonder if the !== '' would classify this as rejected. Similarly, it should also be allowed to leave both jti and limitedUse be undefined as an explicitly supplied empty options tuple {}.

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,
limitedUse: options?.limitedUse,
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
71 changes: 70 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,80 @@ 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,
limitedUse: undefined,
jti: undefined,
}
});
});
});

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,
jti: undefined,

Choose a reason for hiding this comment

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

Just to confirm, will the App Check backend see a JSON object with this jti: undefined field being omitted in this case? Or will the backend see a literal string undefined being the value of the jti field?

}
});
});
});

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',
}
});
});
});

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