From 41c09c9cd1ed0835f137f372c17d06d0aa4e0049 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Fri, 28 Nov 2025 23:03:11 +0000 Subject: [PATCH 1/8] feat(appcheck): Add support for minting limited use tokens --- etc/firebase-admin.app-check.api.md | 1 + .../app-check-api-client-internal.ts | 11 ++++++-- src/app-check/app-check-api.ts | 8 ++++++ src/app-check/app-check.ts | 2 +- test/integration/app-check.spec.ts | 15 +++++++++- .../app-check-api-client-internal.spec.ts | 28 ++++++++++++++++++- test/unit/app-check/app-check.spec.ts | 16 +++++++++++ 7 files changed, 76 insertions(+), 5 deletions(-) diff --git a/etc/firebase-admin.app-check.api.md b/etc/firebase-admin.app-check.api.md index 8486d60ac7..12072c935b 100644 --- a/etc/firebase-admin.app-check.api.md +++ b/etc/firebase-admin.app-check.api.md @@ -24,6 +24,7 @@ export interface AppCheckToken { // @public export interface AppCheckTokenOptions { + limitedUse?: boolean; ttlMillis?: number; } diff --git a/src/app-check/app-check-api-client-internal.ts b/src/app-check/app-check-api-client-internal.ts index 8a1afa872e..34ba7b651c 100644 --- a/src/app-check/app-check-api-client-internal.ts +++ b/src/app-check/app-check-api-client-internal.ts @@ -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 { + public exchangeToken( + customToken: string, + appId: string, + limitedUse?: boolean + ): Promise { if (!validator.isNonEmptyString(appId)) { throw new FirebaseAppCheckError( 'invalid-argument', @@ -75,7 +79,10 @@ export class AppCheckApiClient { method: 'POST', url, headers: FIREBASE_APP_CHECK_CONFIG_HEADERS, - data: { customToken } + data: { + customToken, + limitedUse, + } }; return this.httpClient.send(request); }) diff --git a/src/app-check/app-check-api.ts b/src/app-check/app-check-api.ts index 7766299fcd..d3ef8201b1 100644 --- a/src/app-check/app-check-api.ts +++ b/src/app-check/app-check-api.ts @@ -39,6 +39,14 @@ export interface AppCheckTokenOptions { * be valid. This value must be between 30 minutes and 7 days, inclusive. */ ttlMillis?: number; + + /** + * Specifies whether this attestation is for use in a *limited use* (`true`) + * or *session based* (`false`) context. To enable this attestation to be used + * with the *replay protection* feature, set this to `true`. The default value + * is `false`. + */ + limitedUse?: boolean; } /** diff --git a/src/app-check/app-check.ts b/src/app-check/app-check.ts index c7e6cadbd1..f5ca8ec999 100644 --- a/src/app-check/app-check.ts +++ b/src/app-check/app-check.ts @@ -68,7 +68,7 @@ export class AppCheck { public createToken(appId: string, options?: AppCheckTokenOptions): Promise { return this.tokenGenerator.createCustomToken(appId, options) .then((customToken) => { - return this.client.exchangeToken(customToken, appId); + return this.client.exchangeToken(customToken, appId, options?.limitedUse); }); } diff --git a/test/integration/app-check.spec.ts b/test/integration/app-check.spec.ts index 89a920daea..0314d87236 100644 --- a/test/integration/app-check.spec.ts +++ b/test/integration/app-check.spec.ts @@ -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 vaild 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 }); }); diff --git a/test/unit/app-check/app-check-api-client-internal.spec.ts b/test/unit/app-check/app-check-api-client-internal.spec.ts index b6a5060bb5..2adc93a841 100644 --- a/test/unit/app-check/app-check-api-client-internal.spec.ts +++ b/test/unit/app-check/app-check-api-client-internal.spec.ts @@ -21,6 +21,7 @@ import * as _ from 'lodash'; import * as chai from 'chai'; import * as sinon from 'sinon'; import { HttpClient } from '../../../src/utils/api-request'; +import * as sinonChai from 'sinon-chai'; import * as utils from '../utils'; import * as mocks from '../../resources/mocks'; import { getMetricsHeader, getSdkVersion } from '../../../src/utils'; @@ -31,6 +32,7 @@ import { FirebaseAppError } from '../../../src/utils/error'; import { deepCopy } from '../../../src/utils/deep-copy'; const expect = chai.expect; +chai.use(sinonChai); describe('AppCheckApiClient', () => { @@ -210,7 +212,31 @@ 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, + } + }); + }); + }); + + 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, 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, + } }); }); }); diff --git a/test/unit/app-check/app-check.spec.ts b/test/unit/app-check/app-check.spec.ts index e849be9850..4881c0d7dd 100644 --- a/test/unit/app-check/app-check.spec.ts +++ b/test/unit/app-check/app-check.spec.ts @@ -20,6 +20,7 @@ import * as _ from 'lodash'; import * as chai from 'chai'; import * as sinon from 'sinon'; +import * as sinonChai from 'sinon-chai'; import * as mocks from '../../resources/mocks'; import { FirebaseApp } from '../../../src/app/firebase-app'; @@ -31,6 +32,7 @@ import { ServiceAccountSigner } from '../../../src/utils/crypto-signer'; import { AppCheckTokenVerifier } from '../../../src/app-check/token-verifier'; const expect = chai.expect; +chai.use(sinonChai); describe('AppCheck', () => { @@ -168,6 +170,20 @@ 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, true); + }); + }); }); describe('verifyToken', () => { From 033568a991bcd5c2a0144641ffe19d0ace6c88c9 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Fri, 28 Nov 2025 23:09:37 +0000 Subject: [PATCH 2/8] fix docs --- src/app-check/app-check-api.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app-check/app-check-api.ts b/src/app-check/app-check-api.ts index d3ef8201b1..697e4cb02c 100644 --- a/src/app-check/app-check-api.ts +++ b/src/app-check/app-check-api.ts @@ -41,10 +41,9 @@ export interface AppCheckTokenOptions { ttlMillis?: number; /** - * Specifies whether this attestation is for use in a *limited use* (`true`) - * or *session based* (`false`) context. To enable this attestation to be used - * with the *replay protection* feature, set this to `true`. The default value - * is `false`. + * 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; } From bae31aa21b4cf2932d4252ce731ca6f5d1c832bf Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Fri, 28 Nov 2025 23:16:35 +0000 Subject: [PATCH 3/8] clean up imports in unit tests --- test/unit/app-check/app-check-api-client-internal.spec.ts | 2 -- test/unit/app-check/app-check.spec.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/test/unit/app-check/app-check-api-client-internal.spec.ts b/test/unit/app-check/app-check-api-client-internal.spec.ts index 2adc93a841..c0c04e7d0b 100644 --- a/test/unit/app-check/app-check-api-client-internal.spec.ts +++ b/test/unit/app-check/app-check-api-client-internal.spec.ts @@ -21,7 +21,6 @@ import * as _ from 'lodash'; import * as chai from 'chai'; import * as sinon from 'sinon'; import { HttpClient } from '../../../src/utils/api-request'; -import * as sinonChai from 'sinon-chai'; import * as utils from '../utils'; import * as mocks from '../../resources/mocks'; import { getMetricsHeader, getSdkVersion } from '../../../src/utils'; @@ -32,7 +31,6 @@ import { FirebaseAppError } from '../../../src/utils/error'; import { deepCopy } from '../../../src/utils/deep-copy'; const expect = chai.expect; -chai.use(sinonChai); describe('AppCheckApiClient', () => { diff --git a/test/unit/app-check/app-check.spec.ts b/test/unit/app-check/app-check.spec.ts index 4881c0d7dd..8e253fbd93 100644 --- a/test/unit/app-check/app-check.spec.ts +++ b/test/unit/app-check/app-check.spec.ts @@ -20,7 +20,6 @@ import * as _ from 'lodash'; import * as chai from 'chai'; import * as sinon from 'sinon'; -import * as sinonChai from 'sinon-chai'; import * as mocks from '../../resources/mocks'; import { FirebaseApp } from '../../../src/app/firebase-app'; @@ -32,7 +31,6 @@ import { ServiceAccountSigner } from '../../../src/utils/crypto-signer'; import { AppCheckTokenVerifier } from '../../../src/app-check/token-verifier'; const expect = chai.expect; -chai.use(sinonChai); describe('AppCheck', () => { From 016b022dfdf4d332276d2f68ea1f61efefa5ea45 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 24 Feb 2026 11:08:21 -0500 Subject: [PATCH 4/8] feat(app-check): validate limitedUse parameter in exchangeToken() --- src/app-check/app-check-api-client-internal.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app-check/app-check-api-client-internal.ts b/src/app-check/app-check-api-client-internal.ts index 34ba7b651c..984dbf523c 100644 --- a/src/app-check/app-check-api-client-internal.ts +++ b/src/app-check/app-check-api-client-internal.ts @@ -73,6 +73,11 @@ export class AppCheckApiClient { 'invalid-argument', '`customToken` must be a non-empty string.'); } + if (typeof limitedUse !== 'undefined' && !validator.isBoolean(limitedUse)) { + throw new FirebaseAppCheckError( + 'invalid-argument', + '`limitedUse` must be a boolean value.'); + } return this.getUrl(appId) .then((url) => { const request: HttpRequestConfig = { From 4433ba9c434f2e52926a4593dfb291cfcbdab89b Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 24 Feb 2026 11:33:28 -0500 Subject: [PATCH 5/8] feat(app-check): add support for custom jti in limited-use tokens --- .../app-check-api-client-internal.ts | 21 +++++++-- src/app-check/app-check-api.ts | 13 +++++ src/app-check/app-check.ts | 2 +- .../app-check-api-client-internal.spec.ts | 47 ++++++++++++++++++- test/unit/app-check/app-check.spec.ts | 16 ++++++- 5 files changed, 92 insertions(+), 7 deletions(-) diff --git a/src/app-check/app-check-api-client-internal.ts b/src/app-check/app-check-api-client-internal.ts index 984dbf523c..4c4a33def8 100644 --- a/src/app-check/app-check-api-client-internal.ts +++ b/src/app-check/app-check-api-client-internal.ts @@ -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'; @@ -61,7 +61,7 @@ export class AppCheckApiClient { public exchangeToken( customToken: string, appId: string, - limitedUse?: boolean + options?: AppCheckTokenOptions ): Promise { if (!validator.isNonEmptyString(appId)) { throw new FirebaseAppCheckError( @@ -73,11 +73,23 @@ export class AppCheckApiClient { 'invalid-argument', '`customToken` must be a non-empty string.'); } - if (typeof limitedUse !== 'undefined' && !validator.isBoolean(limitedUse)) { + 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 = { @@ -86,7 +98,8 @@ export class AppCheckApiClient { headers: FIREBASE_APP_CHECK_CONFIG_HEADERS, data: { customToken, - limitedUse, + limitedUse: options?.limitedUse, + jti: options?.jti, } }; return this.httpClient.send(request); diff --git a/src/app-check/app-check-api.ts b/src/app-check/app-check-api.ts index 697e4cb02c..03e3b6c9fd 100644 --- a/src/app-check/app-check-api.ts +++ b/src/app-check/app-check-api.ts @@ -46,6 +46,19 @@ export interface AppCheckTokenOptions { * 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; } /** diff --git a/src/app-check/app-check.ts b/src/app-check/app-check.ts index f5ca8ec999..78e3ce478a 100644 --- a/src/app-check/app-check.ts +++ b/src/app-check/app-check.ts @@ -68,7 +68,7 @@ export class AppCheck { public createToken(appId: string, options?: AppCheckTokenOptions): Promise { return this.tokenGenerator.createCustomToken(appId, options) .then((customToken) => { - return this.client.exchangeToken(customToken, appId, options?.limitedUse); + return this.client.exchangeToken(customToken, appId, options); }); } diff --git a/test/unit/app-check/app-check-api-client-internal.spec.ts b/test/unit/app-check/app-check-api-client-internal.spec.ts index c0c04e7d0b..7d12fc2670 100644 --- a/test/unit/app-check/app-check-api-client-internal.spec.ts +++ b/test/unit/app-check/app-check-api-client-internal.spec.ts @@ -213,6 +213,7 @@ describe('AppCheckApiClient', () => { data: { customToken: TEST_TOKEN_TO_EXCHANGE, limitedUse: undefined, + jti: undefined, } }); }); @@ -223,7 +224,7 @@ describe('AppCheckApiClient', () => { .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); stubs.push(stub); - return apiClient.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID, true) + 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); @@ -234,11 +235,55 @@ describe('AppCheckApiClient', () => { data: { customToken: TEST_TOKEN_TO_EXCHANGE, limitedUse: true, + jti: undefined, } }); }); }); + 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" diff --git a/test/unit/app-check/app-check.spec.ts b/test/unit/app-check/app-check.spec.ts index 8e253fbd93..5276505a07 100644 --- a/test/unit/app-check/app-check.spec.ts +++ b/test/unit/app-check/app-check.spec.ts @@ -179,7 +179,21 @@ describe('AppCheck', () => { .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, true); + 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' }); }); }); }); From 05586a3cf804ec2a6d3c329f7b62b548bbaa8626 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 24 Feb 2026 12:06:17 -0500 Subject: [PATCH 6/8] chore: update api-extractor reports for app check jti parameter addition --- etc/firebase-admin.app-check.api.md | 1 + 1 file changed, 1 insertion(+) diff --git a/etc/firebase-admin.app-check.api.md b/etc/firebase-admin.app-check.api.md index 12072c935b..36af0f3a2f 100644 --- a/etc/firebase-admin.app-check.api.md +++ b/etc/firebase-admin.app-check.api.md @@ -24,6 +24,7 @@ export interface AppCheckToken { // @public export interface AppCheckTokenOptions { + jti?: string; limitedUse?: boolean; ttlMillis?: number; } From 0a1d94af777f97ce8db49a742070927b76707b47 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 24 Feb 2026 12:09:19 -0500 Subject: [PATCH 7/8] chore: fix lint in app-check.spec.ts --- test/unit/app-check/app-check.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/unit/app-check/app-check.spec.ts b/test/unit/app-check/app-check.spec.ts index 5276505a07..d1877cc014 100644 --- a/test/unit/app-check/app-check.spec.ts +++ b/test/unit/app-check/app-check.spec.ts @@ -193,7 +193,8 @@ describe('AppCheck', () => { .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' }); + expect(stub).to.have.been.calledOnce.and.calledWith( + sinon.match.string, APP_ID, { limitedUse: true, jti: 'test-jti' }); }); }); }); From 1f5d7895ef1474bd1e8b99790516ddea98421f25 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 24 Feb 2026 13:45:34 -0500 Subject: [PATCH 8/8] fix typo in tests --- test/integration/app-check.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/app-check.spec.ts b/test/integration/app-check.spec.ts index 0314d87236..d9bbf6cae2 100644 --- a/test/integration/app-check.spec.ts +++ b/test/integration/app-check.spec.ts @@ -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(); } @@ -57,7 +57,7 @@ describe('admin.appCheck', () => { }); }); - it('should succeed with a vaild limited use token', function () { + it('should succeed with a valid limited use token', function () { if (!appId) { this.skip(); }