Skip to content

Commit e12e7f5

Browse files
avolkovissbushikevinthecheung
authored
Add support for App Check replay protection in callable functions (#7296)
* Add limited token support for functions * add test, works as a unit test, broken karma * Finish adding support for app check callable functions SDK * PR Feedback * Doc feedback Co-authored-by: Kevin Cheung <[email protected]> * s/useLimitedUseAppCheckToken/limitedUseAppCheckTokens/g * PR Feedback --------- Co-authored-by: Samuel Bushi <[email protected]> Co-authored-by: Kevin Cheung <[email protected]>
1 parent a9da1b7 commit e12e7f5

File tree

11 files changed

+128
-6
lines changed

11 files changed

+128
-6
lines changed

.changeset/rude-adults-rest.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@firebase/app-check-interop-types': minor
3+
'@firebase/app-check': minor
4+
'@firebase/functions': minor
5+
---
6+
7+
Add support for App Check replay protection in callable functions

common/api-review/functions.api.md

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export function httpsCallableFromURL<RequestData = unknown, ResponseData = unkno
4343

4444
// @public
4545
export interface HttpsCallableOptions {
46+
limitedUseAppCheckTokens?: boolean;
4647
timeout?: number;
4748
}
4849

config/functions/index.js

+9
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ exports.instanceIdTest = functions.https.onRequest((request, response) => {
7676
});
7777
});
7878

79+
exports.appCheckTest = functions.https.onRequest((request, response) => {
80+
cors(request, response, () => {
81+
const token = request.get('X-Firebase-AppCheck');
82+
assert.equal(token !== undefined, true);
83+
assert.deepEqual(request.body, { data: {} });
84+
response.send({ data: { token } });
85+
});
86+
});
87+
7988
exports.nullTest = functions.https.onRequest((request, response) => {
8089
cors(request, response, () => {
8190
assert.deepEqual(request.body, { data: null });

docs-devsite/functions.httpscallableoptions.md

+11
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,19 @@ export interface HttpsCallableOptions
2222

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

28+
## HttpsCallableOptions.limitedUseAppCheckTokens
29+
30+
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.
31+
32+
<b>Signature:</b>
33+
34+
```typescript
35+
limitedUseAppCheckTokens?: boolean;
36+
```
37+
2738
## HttpsCallableOptions.timeout
2839

2940
Time in milliseconds after which to cancel if there is no response. Default is 70000.

packages/app-check-interop-types/index.d.ts

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export interface FirebaseAppCheckInternal {
2020
// is present. Returns null if no token is present and no token requests are in-flight.
2121
getToken(forceRefresh?: boolean): Promise<AppCheckTokenResult>;
2222

23+
// Always returns a fresh limited-use token suitable for Replay Protection.
24+
// The returned token must be used and consumed as soon as possible.
25+
getLimitedUseToken(): Promise<AppCheckTokenResult>;
26+
2327
// Registers a listener to changes in the token state. There can be more than one listener
2428
// registered at the same time for one or more FirebaseAppAttestation instances. The
2529
// listeners call back on the UI thread whenever the current token associated with this

packages/app-check/src/factory.ts

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { FirebaseApp, _FirebaseService } from '@firebase/app';
2020
import { FirebaseAppCheckInternal, ListenerType } from './types';
2121
import {
2222
getToken,
23+
getLimitedUseToken,
2324
addTokenListener,
2425
removeTokenListener
2526
} from './internal-api';
@@ -55,6 +56,7 @@ export function internalFactory(
5556
): FirebaseAppCheckInternal {
5657
return {
5758
getToken: forceRefresh => getToken(appCheck, forceRefresh),
59+
getLimitedUseToken: () => getLimitedUseToken(appCheck),
5860
addTokenListener: listener =>
5961
addTokenListener(appCheck, ListenerType.INTERNAL, listener),
6062
removeTokenListener: listener => removeTokenListener(appCheck.app, listener)

packages/app-check/src/types.ts

+4
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ export interface FirebaseAppCheckInternal {
2424
// is present. Returns null if no token is present and no token requests are in-flight.
2525
getToken(forceRefresh?: boolean): Promise<AppCheckTokenResult>;
2626

27+
// Get a Limited use Firebase App Check token. This method should be used
28+
// 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.
29+
getLimitedUseToken(): Promise<AppCheckTokenResult>;
30+
2731
// Registers a listener to changes in the token state. There can be more than one listener
2832
// registered at the same time for one or more FirebaseAppAttestation instances. The
2933
// listeners call back on the UI thread whenever the current token associated with this

packages/functions/src/callable.test.ts

+73-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ import {
3232
FirebaseAuthInternal,
3333
FirebaseAuthInternalName
3434
} from '@firebase/auth-interop-types';
35+
import {
36+
FirebaseAppCheckInternal,
37+
AppCheckInternalComponentName
38+
} from '@firebase/app-check-interop-types';
3539
import { makeFakeApp, createTestService } from '../test/utils';
3640
import { httpsCallable } from './service';
3741
import { FUNCTIONS_TYPE } from './constants';
@@ -108,7 +112,7 @@ describe('Firebase Functions > Call', () => {
108112
expect(result.data).to.equal(76);
109113
});
110114

111-
it('token', async () => {
115+
it('auth token', async () => {
112116
// mock auth-internal service
113117
const authMock: FirebaseAuthInternal = {
114118
getToken: async () => ({ accessToken: 'token' })
@@ -133,6 +137,74 @@ describe('Firebase Functions > Call', () => {
133137
stub.restore();
134138
});
135139

140+
it('app check token', async () => {
141+
const appCheckMock: FirebaseAppCheckInternal = {
142+
getToken: async () => ({ token: 'app-check-token' })
143+
} as unknown as FirebaseAppCheckInternal;
144+
const appCheckProvider = new Provider<AppCheckInternalComponentName>(
145+
'app-check-internal',
146+
new ComponentContainer('test')
147+
);
148+
appCheckProvider.setComponent(
149+
new Component(
150+
'app-check-internal',
151+
() => appCheckMock,
152+
ComponentType.PRIVATE
153+
)
154+
);
155+
const functions = createTestService(
156+
app,
157+
region,
158+
undefined,
159+
undefined,
160+
appCheckProvider
161+
);
162+
163+
// Stub out the internals to get an app check token.
164+
const stub = sinon.stub(appCheckMock, 'getToken').callThrough();
165+
const func = httpsCallable(functions, 'appCheckTest');
166+
const result = await func({});
167+
expect(result.data).to.deep.equal({ token: 'app-check-token' });
168+
169+
expect(stub.callCount).to.equal(1);
170+
stub.restore();
171+
});
172+
173+
it('app check limited use token', async () => {
174+
const appCheckMock: FirebaseAppCheckInternal = {
175+
getLimitedUseToken: async () => ({ token: 'app-check-single-use-token' })
176+
} as unknown as FirebaseAppCheckInternal;
177+
const appCheckProvider = new Provider<AppCheckInternalComponentName>(
178+
'app-check-internal',
179+
new ComponentContainer('test')
180+
);
181+
appCheckProvider.setComponent(
182+
new Component(
183+
'app-check-internal',
184+
() => appCheckMock,
185+
ComponentType.PRIVATE
186+
)
187+
);
188+
const functions = createTestService(
189+
app,
190+
region,
191+
undefined,
192+
undefined,
193+
appCheckProvider
194+
);
195+
196+
// Stub out the internals to get an app check token.
197+
const stub = sinon.stub(appCheckMock, 'getLimitedUseToken').callThrough();
198+
const func = httpsCallable(functions, 'appCheckTest', {
199+
limitedUseAppCheckTokens: true
200+
});
201+
const result = await func({});
202+
expect(result.data).to.deep.equal({ token: 'app-check-single-use-token' });
203+
204+
expect(stub.callCount).to.equal(1);
205+
stub.restore();
206+
});
207+
136208
it('instance id', async () => {
137209
// Should effectively skip this test in environments where messaging doesn't work.
138210
// (Node, IE)

packages/functions/src/context.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,13 @@ export class ContextProvider {
119119
}
120120
}
121121

122-
async getAppCheckToken(): Promise<string | null> {
122+
async getAppCheckToken(
123+
limitedUseAppCheckTokens?: boolean
124+
): Promise<string | null> {
123125
if (this.appCheck) {
124-
const result = await this.appCheck.getToken();
126+
const result = limitedUseAppCheckTokens
127+
? await this.appCheck.getLimitedUseToken()
128+
: await this.appCheck.getToken();
125129
if (result.error) {
126130
// Do not send the App Check header to the functions endpoint if
127131
// there was an error from the App Check exchange endpoint. The App
@@ -133,10 +137,10 @@ export class ContextProvider {
133137
return null;
134138
}
135139

136-
async getContext(): Promise<Context> {
140+
async getContext(limitedUseAppCheckTokens?: boolean): Promise<Context> {
137141
const authToken = await this.getAuthToken();
138142
const messagingToken = await this.getMessagingToken();
139-
const appCheckToken = await this.getAppCheckToken();
143+
const appCheckToken = await this.getAppCheckToken(limitedUseAppCheckTokens);
140144
return { authToken, messagingToken, appCheckToken };
141145
}
142146
}

packages/functions/src/public-types.ts

+6
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ export interface HttpsCallableOptions {
4747
* Default is 70000.
4848
*/
4949
timeout?: number;
50+
/**
51+
* If set to true, uses limited-use App Check token for callable function requests from this
52+
* instance of {@link Functions}. You must use limited-use tokens to call functions with
53+
* replay protection enabled. By default, this is false.
54+
*/
55+
limitedUseAppCheckTokens?: boolean;
5056
}
5157

5258
/**

packages/functions/src/service.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,9 @@ async function callAtURL(
277277

278278
// Add a header for the authToken.
279279
const headers: { [key: string]: string } = {};
280-
const context = await functionsInstance.contextProvider.getContext();
280+
const context = await functionsInstance.contextProvider.getContext(
281+
options.limitedUseAppCheckTokens
282+
);
281283
if (context.authToken) {
282284
headers['Authorization'] = 'Bearer ' + context.authToken;
283285
}

0 commit comments

Comments
 (0)