Skip to content

Add support for App Check replay protection in callable functions #7296

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

Merged
merged 7 commits into from
May 15, 2023
Merged
Show file tree
Hide file tree
Changes from 5 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
7 changes: 7 additions & 0 deletions .changeset/rude-adults-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@firebase/app-check-interop-types': minor
'@firebase/app-check': minor
'@firebase/functions': minor
---

Add support for App Check replay protection in callable functions
1 change: 1 addition & 0 deletions common/api-review/functions.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function httpsCallableFromURL<RequestData = unknown, ResponseData = unkno
// @public
export interface HttpsCallableOptions {
timeout?: number;
useLimitedUseAppCheckToken?: boolean;
}

// @public
Expand Down
9 changes: 9 additions & 0 deletions config/functions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ exports.instanceIdTest = functions.https.onRequest((request, response) => {
});
});

exports.appCheckTest = functions.https.onRequest((request, response) => {
cors(request, response, () => {
const token = request.get('X-Firebase-AppCheck');
assert.equal(token !== undefined, true);
assert.deepEqual(request.body, { data: {} });
response.send({ data: { token } });
});
});

exports.nullTest = functions.https.onRequest((request, response) => {
cors(request, response, () => {
assert.deepEqual(request.body, { data: null });
Expand Down
11 changes: 11 additions & 0 deletions docs-devsite/functions.httpscallableoptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface HttpsCallableOptions
| Property | Type | Description |
| --- | --- | --- |
| [timeout](./functions.httpscallableoptions.md#httpscallableoptionstimeout) | number | Time in milliseconds after which to cancel if there is no response. Default is 70000. |
| [useLimitedUseAppCheckToken](./functions.httpscallableoptions.md#httpscallableoptionsuselimiteduseappchecktoken) | boolean | If set to true, uses limited-use App Check token for callable function requests from this instance of [Functions](./functions.functions.md#functions_interface)<!-- -->. You must use limited-use tokens to call functions with replay protection enabled. By default, this is false. |

## HttpsCallableOptions.timeout

Expand All @@ -33,3 +34,13 @@ Time in milliseconds after which to cancel if there is no response. Default is 7
```typescript
timeout?: number;
```

## HttpsCallableOptions.useLimitedUseAppCheckToken

If set to true, uses limited-use App Check token for callable function requests from this instance of [Functions](./functions.functions.md#functions_interface)<!-- -->. You must use limited-use tokens to call functions with replay protection enabled. By default, this is false.

<b>Signature:</b>

```typescript
useLimitedUseAppCheckToken?: boolean;
```
4 changes: 4 additions & 0 deletions packages/app-check-interop-types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export interface FirebaseAppCheckInternal {
// is present. Returns null if no token is present and no token requests are in-flight.
getToken(forceRefresh?: boolean): Promise<AppCheckTokenResult>;

// Gets a limited use Firebase App Check token. This method should be used
// only if you need to authorize requests to a non-Firebase backend.
getLimitedUseToken(): Promise<AppCheckTokenResult>;

// 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 FirebaseAppAttestation instances. The
// listeners call back on the UI thread whenever the current token associated with this
Expand Down
2 changes: 2 additions & 0 deletions packages/app-check/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { FirebaseApp, _FirebaseService } from '@firebase/app';
import { FirebaseAppCheckInternal, ListenerType } from './types';
import {
getToken,
getLimitedUseToken,
addTokenListener,
removeTokenListener
} from './internal-api';
Expand Down Expand Up @@ -55,6 +56,7 @@ export function internalFactory(
): FirebaseAppCheckInternal {
return {
getToken: forceRefresh => getToken(appCheck, forceRefresh),
getLimitedUseToken: () => getLimitedUseToken(appCheck),
addTokenListener: listener =>
addTokenListener(appCheck, ListenerType.INTERNAL, listener),
removeTokenListener: listener => removeTokenListener(appCheck.app, listener)
Expand Down
4 changes: 4 additions & 0 deletions packages/app-check/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export interface FirebaseAppCheckInternal {
// is present. Returns null if no token is present and no token requests are in-flight.
getToken(forceRefresh?: boolean): Promise<AppCheckTokenResult>;

// Get a Limited use Firebase App Check token. This method should be used
// only if you need to authorize requests to a non-Firebase backend. Returns null if no token is present and no token requests are in-flight.
getLimitedUseToken(): Promise<AppCheckTokenResult>;

// 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 FirebaseAppAttestation instances. The
// listeners call back on the UI thread whenever the current token associated with this
Expand Down
74 changes: 73 additions & 1 deletion packages/functions/src/callable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ import {
FirebaseAuthInternal,
FirebaseAuthInternalName
} from '@firebase/auth-interop-types';
import {
FirebaseAppCheckInternal,
AppCheckInternalComponentName
} from '@firebase/app-check-interop-types';
import { makeFakeApp, createTestService } from '../test/utils';
import { httpsCallable } from './service';
import { FUNCTIONS_TYPE } from './constants';
Expand Down Expand Up @@ -108,7 +112,7 @@ describe('Firebase Functions > Call', () => {
expect(result.data).to.equal(76);
});

it('token', async () => {
it('auth token', async () => {
// mock auth-internal service
const authMock: FirebaseAuthInternal = {
getToken: async () => ({ accessToken: 'token' })
Expand All @@ -133,6 +137,74 @@ describe('Firebase Functions > Call', () => {
stub.restore();
});

it('app check token', async () => {
const appCheckMock: FirebaseAppCheckInternal = {
getToken: async () => ({ token: 'app-check-token' })
} as unknown as FirebaseAppCheckInternal;
const appCheckProvider = new Provider<AppCheckInternalComponentName>(
'app-check-internal',
new ComponentContainer('test')
);
appCheckProvider.setComponent(
new Component(
'app-check-internal',
() => appCheckMock,
ComponentType.PRIVATE
)
);
const functions = createTestService(
app,
region,
undefined,
undefined,
appCheckProvider
);

// Stub out the internals to get an app check token.
const stub = sinon.stub(appCheckMock, 'getToken').callThrough();
const func = httpsCallable(functions, 'appCheckTest');
const result = await func({});
expect(result.data).to.deep.equal({ token: 'app-check-token' });

expect(stub.callCount).to.equal(1);
stub.restore();
});

it('app check limited use token', async () => {
const appCheckMock: FirebaseAppCheckInternal = {
getLimitedUseToken: async () => ({ token: 'app-check-single-use-token' })
} as unknown as FirebaseAppCheckInternal;
const appCheckProvider = new Provider<AppCheckInternalComponentName>(
'app-check-internal',
new ComponentContainer('test')
);
appCheckProvider.setComponent(
new Component(
'app-check-internal',
() => appCheckMock,
ComponentType.PRIVATE
)
);
const functions = createTestService(
app,
region,
undefined,
undefined,
appCheckProvider
);

// Stub out the internals to get an app check token.
const stub = sinon.stub(appCheckMock, 'getLimitedUseToken').callThrough();
const func = httpsCallable(functions, 'appCheckTest', {
useLimitedUseAppCheckToken: true
});
const result = await func({});
expect(result.data).to.deep.equal({ token: 'app-check-single-use-token' });

expect(stub.callCount).to.equal(1);
stub.restore();
});

it('instance id', async () => {
// Should effectively skip this test in environments where messaging doesn't work.
// (Node, IE)
Expand Down
14 changes: 10 additions & 4 deletions packages/functions/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,13 @@ export class ContextProvider {
}
}

async getAppCheckToken(): Promise<string | null> {
async getAppCheckToken(
useLimitedUseAppCheckToken?: boolean
): Promise<string | null> {
if (this.appCheck) {
const result = await this.appCheck.getToken();
const result = useLimitedUseAppCheckToken
? await this.appCheck.getLimitedUseToken()
: await this.appCheck.getToken();
if (result.error) {
// Do not send the App Check header to the functions endpoint if
// there was an error from the App Check exchange endpoint. The App
Expand All @@ -133,10 +137,12 @@ export class ContextProvider {
return null;
}

async getContext(): Promise<Context> {
async getContext(useLimitedUseAppCheckToken?: boolean): Promise<Context> {
const authToken = await this.getAuthToken();
const messagingToken = await this.getMessagingToken();
const appCheckToken = await this.getAppCheckToken();
const appCheckToken = await this.getAppCheckToken(
useLimitedUseAppCheckToken
);
return { authToken, messagingToken, appCheckToken };
}
}
6 changes: 6 additions & 0 deletions packages/functions/src/public-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ export interface HttpsCallableOptions {
* Default is 70000.
*/
timeout?: number;
/**
* If set to true, uses limited-use App Check token for callable function requests from this
* instance of {@link Functions}. You must use limited-use tokens to call functions with
* replay protection enabled. By default, this is false.
*/
useLimitedUseAppCheckToken?: boolean;
}

/**
Expand Down
4 changes: 3 additions & 1 deletion packages/functions/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,9 @@ async function callAtURL(

// Add a header for the authToken.
const headers: { [key: string]: string } = {};
const context = await functionsInstance.contextProvider.getContext();
const context = await functionsInstance.contextProvider.getContext(
options.useLimitedUseAppCheckToken
);
if (context.authToken) {
headers['Authorization'] = 'Bearer ' + context.authToken;
}
Expand Down