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 <kevinthecheung@users.noreply.github.com> * s/useLimitedUseAppCheckToken/limitedUseAppCheckTokens/g * PR Feedback --------- Co-authored-by: Samuel Bushi <ssbushi@google.com> Co-authored-by: Kevin Cheung <kevinthecheung@users.noreply.github.com>
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
exportinterfaceHttpsCallableOptions {
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+
consttoken=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 | Ifsettotrue, useslimited-useAppChecktokenforcallablefunctionrequestsfromthisinstanceof [Functions](./functions.functions.md#functions_interface)<!-- -->. Youmustuselimited-usetokenstocallfunctionswithreplayprotectionenabled. Bydefault, thisisfalse. |
2526
| [timeout](./functions.httpscallableoptions.md#httpscallableoptionstimeout) | number | Timeinmillisecondsafterwhichtocancelifthereisnoresponse. Defaultis 70000. |
2627

28+
## HttpsCallableOptions.limitedUseAppCheckTokens
29+
30+
Ifsettotrue, useslimited-useAppChecktokenforcallablefunctionrequestsfromthisinstanceof [Functions](./functions.functions.md#functions_interface)<!-- -->. Youmustuselimited-usetokenstocallfunctionswithreplayprotectionenabled. Bydefault, thisisfalse.
31+
32+
<b>Signature:</b>
33+
34+
```typescript
35+
limitedUseAppCheckTokens?: boolean;
36+
```
37+
2738
## HttpsCallableOptions.timeout
2839

2940
Timeinmillisecondsafterwhichtocancelifthereisnoresponse. Defaultis 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
constauthMock: 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+
constappCheckMock: FirebaseAppCheckInternal={
142+
getToken: async()=>({token: 'app-check-token'})
143+
}asunknownasFirebaseAppCheckInternal;
144+
constappCheckProvider=newProvider<AppCheckInternalComponentName>(
145+
'app-check-internal',
146+
newComponentContainer('test')
147+
);
148+
appCheckProvider.setComponent(
149+
newComponent(
150+
'app-check-internal',
151+
()=>appCheckMock,
152+
ComponentType.PRIVATE
153+
)
154+
);
155+
constfunctions=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+
conststub=sinon.stub(appCheckMock,'getToken').callThrough();
165+
constfunc=httpsCallable(functions,'appCheckTest');
166+
constresult=awaitfunc({});
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+
constappCheckMock: FirebaseAppCheckInternal={
175+
getLimitedUseToken: async()=>({token: 'app-check-single-use-token'})
176+
}asunknownasFirebaseAppCheckInternal;
177+
constappCheckProvider=newProvider<AppCheckInternalComponentName>(
178+
'app-check-internal',
179+
newComponentContainer('test')
180+
);
181+
appCheckProvider.setComponent(
182+
newComponent(
183+
'app-check-internal',
184+
()=>appCheckMock,
185+
ComponentType.PRIVATE
186+
)
187+
);
188+
constfunctions=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+
conststub=sinon.stub(appCheckMock,'getLimitedUseToken').callThrough();
198+
constfunc=httpsCallable(functions,'appCheckTest',{
199+
limitedUseAppCheckTokens: true
200+
});
201+
constresult=awaitfunc({});
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-
asyncgetAppCheckToken(): Promise<string|null>{
122+
asyncgetAppCheckToken(
123+
limitedUseAppCheckTokens?: boolean
124+
): Promise<string|null>{
123125
if(this.appCheck){
124-
constresult=awaitthis.appCheck.getToken();
126+
constresult=limitedUseAppCheckTokens
127+
? awaitthis.appCheck.getLimitedUseToken()
128+
: awaitthis.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
returnnull;
134138
}
135139

136-
asyncgetContext(): Promise<Context>{
140+
asyncgetContext(limitedUseAppCheckTokens?: boolean): Promise<Context>{
137141
constauthToken=awaitthis.getAuthToken();
138142
constmessagingToken=awaitthis.getMessagingToken();
139-
constappCheckToken=awaitthis.getAppCheckToken();
143+
constappCheckToken=awaitthis.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
constheaders: {[key: string]: string}={};
280-
constcontext=awaitfunctionsInstance.contextProvider.getContext();
280+
constcontext=awaitfunctionsInstance.contextProvider.getContext(
281+
options.limitedUseAppCheckTokens
282+
);
281283
if(context.authToken){
282284
headers['Authorization']='Bearer '+context.authToken;
283285
}

0 commit comments

Comments
 (0)
close