Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new token method to App Check #7169

Merged
merged 11 commits into from
Apr 18, 2023
Merged
Show file tree
Hide file tree
Changes from 10 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
6 changes: 6 additions & 0 deletions .changeset/wicked-tomatoes-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@firebase/app-check": minor
"firebase": minor
---

Add new limited use token method to App Check
3 changes: 3 additions & 0 deletions common/api-review/app-check.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export interface CustomProviderOptions {
getToken: () => Promise<AppCheckToken>;
}

// @public
export function getLimitedUseToken(appCheckInstance: AppCheck): Promise<AppCheckTokenResult>;

// @public
export function getToken(appCheckInstance: AppCheck, forceRefresh?: boolean): Promise<AppCheckTokenResult>;

Expand Down
25 changes: 25 additions & 0 deletions docs-devsite/app-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Firebase App Check
| <b>function(app...)</b> |
| [initializeAppCheck(app, options)](./app-check.md#initializeappcheck) | Activate App Check for the given app. Can be called only once per app. |
| <b>function(appCheckInstance...)</b> |
| [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. |
| [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. |
Expand Down Expand Up @@ -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 <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.

<b>Signature:</b>

```typescript
export declare function getLimitedUseToken(appCheckInstance: AppCheck): Promise<AppCheckTokenResult>;
```

### Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| appCheckInstance | [AppCheck](./app-check.appcheck.md#appcheck_interface) | The App Check service instance. |

<b>Returns:</b>

Promise&lt;[AppCheckTokenResult](./app-check.appchecktokenresult.md#appchecktokenresult_interface)<!-- -->&gt;

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.
Expand Down
19 changes: 18 additions & 1 deletion packages/app-check/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import {
setTokenAutoRefreshEnabled,
initializeAppCheck,
getToken,
onTokenChanged
onTokenChanged,
getLimitedUseToken
} from './api';
import {
FAKE_SITE_KEY,
Expand Down Expand Up @@ -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, {
Expand Down
22 changes: 22 additions & 0 deletions packages/app-check/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { AppCheckService } from './factory';
import { AppCheckProvider, ListenerType } from './types';
import {
getToken as getTokenInternal,
getLimitedUseToken as getLimitedUseTokenInternal,
addTokenListener,
removeTokenListener,
isValid,
Expand Down Expand Up @@ -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
* <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.
*
* @param appCheckInstance - The App Check service instance.
* @returns The limited use token.
* @public
*/
export function getLimitedUseToken(
appCheckInstance: AppCheck
): Promise<AppCheckTokenResult> {
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
Expand Down
97 changes: 96 additions & 1 deletion packages/app-check/src/internal-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import '../test/setup';
import { expect } from 'chai';
import { SinonStub, spy, stub, useFakeTimers } from 'sinon';
import * as sinon from 'sinon';
import { deleteApp, FirebaseApp } from '@firebase/app';
import {
FAKE_SITE_KEY,
Expand All @@ -32,7 +33,8 @@ import {
addTokenListener,
removeTokenListener,
formatDummyToken,
defaultTokenErrorData
defaultTokenErrorData,
getLimitedUseToken
} from './internal-api';
import * as reCAPTCHA from './recaptcha';
import * as client from './client';
Expand Down Expand Up @@ -90,6 +92,7 @@ describe('internal api', () => {
clearState();
removegreCAPTCHAScriptsOnPage();
return deleteApp(app);
sinon.restore();
sam-gc marked this conversation as resolved.
Show resolved Hide resolved
});
// TODO: test error conditions
describe('getToken()', () => {
Expand Down Expand Up @@ -663,6 +666,98 @@ describe('internal api', () => {
});
});

describe('getToken() for limited use', () => {
sam-gc marked this conversation as resolved.
Show resolved Hide resolved
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();
Expand Down
26 changes: 26 additions & 0 deletions packages/app-check/src/internal-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppCheckTokenResult> {
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,
Expand Down