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 support for App Check replay protection in callable functions #7296

Merged
merged 7 commits into from
May 15, 2023
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 @@ -43,6 +43,7 @@ export function httpsCallableFromURL<RequestData = unknown, ResponseData = unkno

// @public
export interface HttpsCallableOptions {
limitedUseAppCheckTokens?: boolean;
timeout?: number;
}

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 @@ -22,8 +22,19 @@ export interface HttpsCallableOptions

| Property | Type | Description |
| --- | --- | --- |
| [limitedUseAppCheckTokens](./functions.httpscallableoptions.md#httpscallableoptionslimiteduseappchecktokens) | 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. |
| [timeout](./functions.httpscallableoptions.md#httpscallableoptionstimeout) | number | Time in milliseconds after which to cancel if there is no response. Default is 70000. |

## HttpsCallableOptions.limitedUseAppCheckTokens

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
limitedUseAppCheckTokens?: boolean;
```

## HttpsCallableOptions.timeout

Time in milliseconds after which to cancel if there is no response. Default is 70000.
Expand Down
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>;

// Always returns a fresh limited-use token suitable for Replay Protection.
// The returned token must be used and consumed as soon as possible.
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', {
limitedUseAppCheckTokens: 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
12 changes: 8 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(
limitedUseAppCheckTokens?: boolean
): Promise<string | null> {
if (this.appCheck) {
const result = await this.appCheck.getToken();
const result = limitedUseAppCheckTokens
? 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,10 @@ export class ContextProvider {
return null;
}

async getContext(): Promise<Context> {
async getContext(limitedUseAppCheckTokens?: boolean): Promise<Context> {
const authToken = await this.getAuthToken();
const messagingToken = await this.getMessagingToken();
const appCheckToken = await this.getAppCheckToken();
const appCheckToken = await this.getAppCheckToken(limitedUseAppCheckTokens);
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.
*/
limitedUseAppCheckTokens?: 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.limitedUseAppCheckTokens
);
if (context.authToken) {
headers['Authorization'] = 'Bearer ' + context.authToken;
}
Expand Down