diff --git a/.changeset/wicked-tomatoes-smoke.md b/.changeset/wicked-tomatoes-smoke.md new file mode 100644 index 00000000000..7da13ffc526 --- /dev/null +++ b/.changeset/wicked-tomatoes-smoke.md @@ -0,0 +1,6 @@ +--- +"@firebase/app-check": minor +"firebase": minor +--- + +Add new limited use token method to App Check diff --git a/common/api-review/app-check.api.md b/common/api-review/app-check.api.md index 9623a51c9ad..65cfb36aa90 100644 --- a/common/api-review/app-check.api.md +++ b/common/api-review/app-check.api.md @@ -60,6 +60,9 @@ export interface CustomProviderOptions { getToken: () => Promise; } +// @public +export function getLimitedUseToken(appCheckInstance: AppCheck): Promise; + // @public export function getToken(appCheckInstance: AppCheck, forceRefresh?: boolean): Promise; diff --git a/docs-devsite/app-check.md b/docs-devsite/app-check.md index b7895a05c96..da1b06b12fa 100644 --- a/docs-devsite/app-check.md +++ b/docs-devsite/app-check.md @@ -19,6 +19,7 @@ Firebase App Check | function(app...) | | [initializeAppCheck(app, options)](./app-check.md#initializeappcheck) | Activate App Check for the given app. Can be called only once per app. | | function(appCheckInstance...) | +| [getLimitedUseToken(appCheckInstance)](./app-check.md#getlimitedusetoken) | Requests a Firebase App Check token. This method should be used only if you need to authorize requests to a non-Firebase backend.Returns limited-use tokens that are intended for use with your non-Firebase backend endpoints that are protected with Replay Protection. This method does not affect the token generation behavior of the \#getAppCheckToken() method. | | [getToken(appCheckInstance, forceRefresh)](./app-check.md#gettoken) | Get the current App Check token. Attaches to the most recent in-flight request if one is present. Returns null if no token is present and no token requests are in-flight. | | [onTokenChanged(appCheckInstance, observer)](./app-check.md#ontokenchanged) | Registers a listener to changes in the token state. There can be more than one listener registered at the same time for one or more App Check instances. The listeners call back on the UI thread whenever the current token associated with this App Check instance changes. | | [onTokenChanged(appCheckInstance, onNext, onError, onCompletion)](./app-check.md#ontokenchanged) | Registers a listener to changes in the token state. There can be more than one listener registered at the same time for one or more App Check instances. The listeners call back on the UI thread whenever the current token associated with this App Check instance changes. | @@ -69,6 +70,30 @@ export declare function initializeAppCheck(app: FirebaseApp | undefined, options [AppCheck](./app-check.appcheck.md#appcheck_interface) +## getLimitedUseToken() + +Requests a Firebase App Check token. This method should be used only if you need to authorize requests to a non-Firebase backend. + +Returns limited-use tokens that are intended for use with your non-Firebase backend endpoints that are protected with Replay Protection. This method does not affect the token generation behavior of the \#getAppCheckToken() method. + +Signature: + +```typescript +export declare function getLimitedUseToken(appCheckInstance: AppCheck): Promise; +``` + +### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| appCheckInstance | [AppCheck](./app-check.appcheck.md#appcheck_interface) | The App Check service instance. | + +Returns: + +Promise<[AppCheckTokenResult](./app-check.appchecktokenresult.md#appchecktokenresult_interface)> + +The limited use token. + ## getToken() Get the current App Check token. Attaches to the most recent in-flight request if one is present. Returns null if no token is present and no token requests are in-flight. diff --git a/packages/app-check/src/api.test.ts b/packages/app-check/src/api.test.ts index 5f6cf1082a3..c01cc5f4029 100644 --- a/packages/app-check/src/api.test.ts +++ b/packages/app-check/src/api.test.ts @@ -21,7 +21,8 @@ import { setTokenAutoRefreshEnabled, initializeAppCheck, getToken, - onTokenChanged + onTokenChanged, + getLimitedUseToken } from './api'; import { FAKE_SITE_KEY, @@ -288,6 +289,22 @@ describe('api', () => { ); }); }); + describe('getLimitedUseToken()', () => { + it('getLimitedUseToken() calls the internal getLimitedUseToken() function', async () => { + const app = getFakeApp({ automaticDataCollectionEnabled: true }); + const appCheck = getFakeAppCheck(app); + const internalgetLimitedUseToken = stub( + internalApi, + 'getLimitedUseToken' + ).resolves({ + token: 'a-token-string' + }); + expect(await getLimitedUseToken(appCheck)).to.eql({ + token: 'a-token-string' + }); + expect(internalgetLimitedUseToken).to.be.calledWith(appCheck); + }); + }); describe('onTokenChanged()', () => { it('Listeners work when using top-level parameters pattern', async () => { const appCheck = initializeAppCheck(app, { diff --git a/packages/app-check/src/api.ts b/packages/app-check/src/api.ts index 1b7e29e008d..f738b21fce2 100644 --- a/packages/app-check/src/api.ts +++ b/packages/app-check/src/api.ts @@ -35,6 +35,7 @@ import { AppCheckService } from './factory'; import { AppCheckProvider, ListenerType } from './types'; import { getToken as getTokenInternal, + getLimitedUseToken as getLimitedUseTokenInternal, addTokenListener, removeTokenListener, isValid, @@ -209,6 +210,27 @@ export async function getToken( return { token: result.token }; } +/** + * Requests a Firebase App Check token. This method should be used + * only if you need to authorize requests to a non-Firebase backend. + * + * Returns limited-use tokens that are intended for use with your + * non-Firebase backend endpoints that are protected with + * + * Replay Protection. This method + * does not affect the token generation behavior of the + * #getAppCheckToken() method. + * + * @param appCheckInstance - The App Check service instance. + * @returns The limited use token. + * @public + */ +export function getLimitedUseToken( + appCheckInstance: AppCheck +): Promise { + return getLimitedUseTokenInternal(appCheckInstance as AppCheckService); +} + /** * Registers a listener to changes in the token state. There can be more * than one listener registered at the same time for one or more diff --git a/packages/app-check/src/internal-api.test.ts b/packages/app-check/src/internal-api.test.ts index 81a0bdb2f99..360ec3a026a 100644 --- a/packages/app-check/src/internal-api.test.ts +++ b/packages/app-check/src/internal-api.test.ts @@ -32,7 +32,8 @@ import { addTokenListener, removeTokenListener, formatDummyToken, - defaultTokenErrorData + defaultTokenErrorData, + getLimitedUseToken } from './internal-api'; import * as reCAPTCHA from './recaptcha'; import * as client from './client'; @@ -663,6 +664,98 @@ describe('internal api', () => { }); }); + describe('getLimitedUseToken()', () => { + it('uses customTokenProvider to get an AppCheck token', async () => { + const customTokenProvider = getFakeCustomTokenProvider(); + const customProviderSpy = spy(customTokenProvider, 'getToken'); + + const appCheck = initializeAppCheck(app, { + provider: customTokenProvider + }); + const token = await getLimitedUseToken(appCheck as AppCheckService); + + expect(customProviderSpy).to.be.called; + expect(token).to.deep.equal({ + token: 'fake-custom-app-check-token' + }); + }); + + it('does not interact with state', async () => { + const customTokenProvider = getFakeCustomTokenProvider(); + spy(customTokenProvider, 'getToken'); + + const appCheck = initializeAppCheck(app, { + provider: customTokenProvider + }); + await getLimitedUseToken(appCheck as AppCheckService); + + expect(getStateReference(app).token).to.be.undefined; + expect(getStateReference(app).isTokenAutoRefreshEnabled).to.be.false; + }); + + it('uses reCAPTCHA (V3) token to exchange for AppCheck token', async () => { + const appCheck = initializeAppCheck(app, { + provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) + }); + + const reCAPTCHASpy = stubGetRecaptchaToken(); + const exchangeTokenStub: SinonStub = stub( + client, + 'exchangeToken' + ).returns(Promise.resolve(fakeRecaptchaAppCheckToken)); + + const token = await getLimitedUseToken(appCheck as AppCheckService); + + expect(reCAPTCHASpy).to.be.called; + + expect(exchangeTokenStub.args[0][0].body['recaptcha_v3_token']).to.equal( + fakeRecaptchaToken + ); + expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token }); + }); + + it('uses reCAPTCHA (Enterprise) token to exchange for AppCheck token', async () => { + const appCheck = initializeAppCheck(app, { + provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY) + }); + + const reCAPTCHASpy = stubGetRecaptchaToken(); + const exchangeTokenStub: SinonStub = stub( + client, + 'exchangeToken' + ).returns(Promise.resolve(fakeRecaptchaAppCheckToken)); + + const token = await getLimitedUseToken(appCheck as AppCheckService); + + expect(reCAPTCHASpy).to.be.called; + + expect( + exchangeTokenStub.args[0][0].body['recaptcha_enterprise_token'] + ).to.equal(fakeRecaptchaToken); + expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token }); + }); + + it('exchanges debug token if in debug mode', async () => { + const exchangeTokenStub: SinonStub = stub( + client, + 'exchangeToken' + ).returns(Promise.resolve(fakeRecaptchaAppCheckToken)); + const debugState = getDebugState(); + debugState.enabled = true; + debugState.token = new Deferred(); + debugState.token.resolve('my-debug-token'); + const appCheck = initializeAppCheck(app, { + provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) + }); + + const token = await getLimitedUseToken(appCheck as AppCheckService); + expect(exchangeTokenStub.args[0][0].body['debug_token']).to.equal( + 'my-debug-token' + ); + expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token }); + }); + }); + describe('addTokenListener', () => { afterEach(async () => { clearState(); diff --git a/packages/app-check/src/internal-api.ts b/packages/app-check/src/internal-api.ts index f07b044be8a..728f2ca5e68 100644 --- a/packages/app-check/src/internal-api.ts +++ b/packages/app-check/src/internal-api.ts @@ -205,6 +205,32 @@ export async function getToken( return interopTokenResult; } +/** + * Internal API for limited use tokens. Skips all FAC state and simply calls + * the underlying provider. + */ +export async function getLimitedUseToken( + appCheck: AppCheckService +): Promise { + const app = appCheck.app; + ensureActivated(app); + + const { provider } = getStateReference(app); + + if (isDebugMode()) { + const debugToken = await getDebugToken(); + const { token } = await exchangeToken( + getExchangeDebugTokenRequest(app, debugToken), + appCheck.heartbeatServiceProvider + ); + return { token }; + } else { + // provider is definitely valid since we ensure AppCheck was activated + const { token } = await provider!.getToken(); + return { token }; + } +} + export function addTokenListener( appCheck: AppCheckService, type: ListenerType,