Skip to content

Commit 195e82e

Browse files
authored
Add new token method to App Check (#7169)
* FAC scoped token support * Rename * PR feedback * Changeset * PR feedback, docs * Cleanup * Formatting * sinon.restore * PR feedback * Devsite * PR feedback
1 parent 253b998 commit 195e82e

File tree

7 files changed

+194
-2
lines changed

7 files changed

+194
-2
lines changed

.changeset/wicked-tomatoes-smoke.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@firebase/app-check": minor
3+
"firebase": minor
4+
---
5+
6+
Add new limited use token method to App Check

common/api-review/app-check.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ export interface CustomProviderOptions {
6060
getToken: () => Promise<AppCheckToken>;
6161
}
6262

63+
// @public
64+
export function getLimitedUseToken(appCheckInstance: AppCheck): Promise<AppCheckTokenResult>;
65+
6366
// @public
6467
export function getToken(appCheckInstance: AppCheck, forceRefresh?: boolean): Promise<AppCheckTokenResult>;
6568

docs-devsite/app-check.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Firebase App Check
1919
| <b>function(app...)</b> |
2020
| [initializeAppCheck(app, options)](./app-check.md#initializeappcheck) | Activate App Check for the given app. Can be called only once per app. |
2121
| <b>function(appCheckInstance...)</b> |
22+
| [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 <a href="https://firebase.google.com/docs/app-check/custom-resource-backend#replay-protection"> Replay Protection</a>. This method does not affect the token generation behavior of the \#getAppCheckToken() method. |
2223
| [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. |
2324
| [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. |
2425
| [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
6970

7071
[AppCheck](./app-check.appcheck.md#appcheck_interface)
7172

73+
## getLimitedUseToken()
74+
75+
Requests a Firebase App Check token. This method should be used only if you need to authorize requests to a non-Firebase backend.
76+
77+
Returns limited-use tokens that are intended for use with your non-Firebase backend endpoints that are protected with <a href="https://firebase.google.com/docs/app-check/custom-resource-backend#replay-protection"> Replay Protection</a>. This method does not affect the token generation behavior of the \#getAppCheckToken() method.
78+
79+
<b>Signature:</b>
80+
81+
```typescript
82+
export declare function getLimitedUseToken(appCheckInstance: AppCheck): Promise<AppCheckTokenResult>;
83+
```
84+
85+
### Parameters
86+
87+
| Parameter | Type | Description |
88+
| --- | --- | --- |
89+
| appCheckInstance | [AppCheck](./app-check.appcheck.md#appcheck_interface) | The App Check service instance. |
90+
91+
<b>Returns:</b>
92+
93+
Promise&lt;[AppCheckTokenResult](./app-check.appchecktokenresult.md#appchecktokenresult_interface)<!-- -->&gt;
94+
95+
The limited use token.
96+
7297
## getToken()
7398

7499
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.

packages/app-check/src/api.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import {
2121
setTokenAutoRefreshEnabled,
2222
initializeAppCheck,
2323
getToken,
24-
onTokenChanged
24+
onTokenChanged,
25+
getLimitedUseToken
2526
} from './api';
2627
import {
2728
FAKE_SITE_KEY,
@@ -288,6 +289,22 @@ describe('api', () => {
288289
);
289290
});
290291
});
292+
describe('getLimitedUseToken()', () => {
293+
it('getLimitedUseToken() calls the internal getLimitedUseToken() function', async () => {
294+
const app = getFakeApp({ automaticDataCollectionEnabled: true });
295+
const appCheck = getFakeAppCheck(app);
296+
const internalgetLimitedUseToken = stub(
297+
internalApi,
298+
'getLimitedUseToken'
299+
).resolves({
300+
token: 'a-token-string'
301+
});
302+
expect(await getLimitedUseToken(appCheck)).to.eql({
303+
token: 'a-token-string'
304+
});
305+
expect(internalgetLimitedUseToken).to.be.calledWith(appCheck);
306+
});
307+
});
291308
describe('onTokenChanged()', () => {
292309
it('Listeners work when using top-level parameters pattern', async () => {
293310
const appCheck = initializeAppCheck(app, {

packages/app-check/src/api.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { AppCheckService } from './factory';
3535
import { AppCheckProvider, ListenerType } from './types';
3636
import {
3737
getToken as getTokenInternal,
38+
getLimitedUseToken as getLimitedUseTokenInternal,
3839
addTokenListener,
3940
removeTokenListener,
4041
isValid,
@@ -209,6 +210,27 @@ export async function getToken(
209210
return { token: result.token };
210211
}
211212

213+
/**
214+
* Requests a Firebase App Check token. This method should be used
215+
* only if you need to authorize requests to a non-Firebase backend.
216+
*
217+
* Returns limited-use tokens that are intended for use with your
218+
* non-Firebase backend endpoints that are protected with
219+
* <a href="https://firebase.google.com/docs/app-check/custom-resource-backend#replay-protection">
220+
* Replay Protection</a>. This method
221+
* does not affect the token generation behavior of the
222+
* #getAppCheckToken() method.
223+
*
224+
* @param appCheckInstance - The App Check service instance.
225+
* @returns The limited use token.
226+
* @public
227+
*/
228+
export function getLimitedUseToken(
229+
appCheckInstance: AppCheck
230+
): Promise<AppCheckTokenResult> {
231+
return getLimitedUseTokenInternal(appCheckInstance as AppCheckService);
232+
}
233+
212234
/**
213235
* Registers a listener to changes in the token state. There can be more
214236
* than one listener registered at the same time for one or more

packages/app-check/src/internal-api.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ import {
3232
addTokenListener,
3333
removeTokenListener,
3434
formatDummyToken,
35-
defaultTokenErrorData
35+
defaultTokenErrorData,
36+
getLimitedUseToken
3637
} from './internal-api';
3738
import * as reCAPTCHA from './recaptcha';
3839
import * as client from './client';
@@ -663,6 +664,98 @@ describe('internal api', () => {
663664
});
664665
});
665666

667+
describe('getLimitedUseToken()', () => {
668+
it('uses customTokenProvider to get an AppCheck token', async () => {
669+
const customTokenProvider = getFakeCustomTokenProvider();
670+
const customProviderSpy = spy(customTokenProvider, 'getToken');
671+
672+
const appCheck = initializeAppCheck(app, {
673+
provider: customTokenProvider
674+
});
675+
const token = await getLimitedUseToken(appCheck as AppCheckService);
676+
677+
expect(customProviderSpy).to.be.called;
678+
expect(token).to.deep.equal({
679+
token: 'fake-custom-app-check-token'
680+
});
681+
});
682+
683+
it('does not interact with state', async () => {
684+
const customTokenProvider = getFakeCustomTokenProvider();
685+
spy(customTokenProvider, 'getToken');
686+
687+
const appCheck = initializeAppCheck(app, {
688+
provider: customTokenProvider
689+
});
690+
await getLimitedUseToken(appCheck as AppCheckService);
691+
692+
expect(getStateReference(app).token).to.be.undefined;
693+
expect(getStateReference(app).isTokenAutoRefreshEnabled).to.be.false;
694+
});
695+
696+
it('uses reCAPTCHA (V3) token to exchange for AppCheck token', async () => {
697+
const appCheck = initializeAppCheck(app, {
698+
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
699+
});
700+
701+
const reCAPTCHASpy = stubGetRecaptchaToken();
702+
const exchangeTokenStub: SinonStub = stub(
703+
client,
704+
'exchangeToken'
705+
).returns(Promise.resolve(fakeRecaptchaAppCheckToken));
706+
707+
const token = await getLimitedUseToken(appCheck as AppCheckService);
708+
709+
expect(reCAPTCHASpy).to.be.called;
710+
711+
expect(exchangeTokenStub.args[0][0].body['recaptcha_v3_token']).to.equal(
712+
fakeRecaptchaToken
713+
);
714+
expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token });
715+
});
716+
717+
it('uses reCAPTCHA (Enterprise) token to exchange for AppCheck token', async () => {
718+
const appCheck = initializeAppCheck(app, {
719+
provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY)
720+
});
721+
722+
const reCAPTCHASpy = stubGetRecaptchaToken();
723+
const exchangeTokenStub: SinonStub = stub(
724+
client,
725+
'exchangeToken'
726+
).returns(Promise.resolve(fakeRecaptchaAppCheckToken));
727+
728+
const token = await getLimitedUseToken(appCheck as AppCheckService);
729+
730+
expect(reCAPTCHASpy).to.be.called;
731+
732+
expect(
733+
exchangeTokenStub.args[0][0].body['recaptcha_enterprise_token']
734+
).to.equal(fakeRecaptchaToken);
735+
expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token });
736+
});
737+
738+
it('exchanges debug token if in debug mode', async () => {
739+
const exchangeTokenStub: SinonStub = stub(
740+
client,
741+
'exchangeToken'
742+
).returns(Promise.resolve(fakeRecaptchaAppCheckToken));
743+
const debugState = getDebugState();
744+
debugState.enabled = true;
745+
debugState.token = new Deferred();
746+
debugState.token.resolve('my-debug-token');
747+
const appCheck = initializeAppCheck(app, {
748+
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
749+
});
750+
751+
const token = await getLimitedUseToken(appCheck as AppCheckService);
752+
expect(exchangeTokenStub.args[0][0].body['debug_token']).to.equal(
753+
'my-debug-token'
754+
);
755+
expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token });
756+
});
757+
});
758+
666759
describe('addTokenListener', () => {
667760
afterEach(async () => {
668761
clearState();

packages/app-check/src/internal-api.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,32 @@ export async function getToken(
205205
return interopTokenResult;
206206
}
207207

208+
/**
209+
* Internal API for limited use tokens. Skips all FAC state and simply calls
210+
* the underlying provider.
211+
*/
212+
export async function getLimitedUseToken(
213+
appCheck: AppCheckService
214+
): Promise<AppCheckTokenResult> {
215+
const app = appCheck.app;
216+
ensureActivated(app);
217+
218+
const { provider } = getStateReference(app);
219+
220+
if (isDebugMode()) {
221+
const debugToken = await getDebugToken();
222+
const { token } = await exchangeToken(
223+
getExchangeDebugTokenRequest(app, debugToken),
224+
appCheck.heartbeatServiceProvider
225+
);
226+
return { token };
227+
} else {
228+
// provider is definitely valid since we ensure AppCheck was activated
229+
const { token } = await provider!.getToken();
230+
return { token };
231+
}
232+
}
233+
208234
export function addTokenListener(
209235
appCheck: AppCheckService,
210236
type: ListenerType,

0 commit comments

Comments
 (0)