From f142d515e51eb6f7317aee8fa95610c4fe6fa703 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Fri, 28 Nov 2025 23:03:11 +0000 Subject: [PATCH 1/3] 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 a13c2f2e7d48c45602ae1f9e8e9576bc916b834e Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Fri, 28 Nov 2025 23:09:37 +0000 Subject: [PATCH 2/3] 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 0cebdc560910f9bfadd009505ec60317a00af5c7 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Fri, 28 Nov 2025 23:16:35 +0000 Subject: [PATCH 3/3] 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', () => {