Skip to content

Commit 4f4ede7

Browse files
authored
Add support for FAC limited use tokens in HTTPS callables (#5000)
* Implementation of FAC limited-use tokens in callable functions SDK * changelog * copyright * API txt * PR feedback * PR feeback
1 parent da1495b commit 4f4ede7

File tree

12 files changed

+249
-41
lines changed

12 files changed

+249
-41
lines changed

firebase-functions/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Unreleased
2-
2+
* [changed] Added support for App Check limited-use tokens in HTTPS Callable Functions.
33

44
# 20.3.0
55
* [changed] Internal changes to ensure alignment with other SDK releases.

firebase-functions/api.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package com.google.firebase.functions {
33

44
public class FirebaseFunctions {
55
method @NonNull public com.google.firebase.functions.HttpsCallableReference getHttpsCallable(@NonNull String);
6+
method @NonNull public com.google.firebase.functions.HttpsCallableReference getHttpsCallable(@NonNull String, @NonNull com.google.firebase.functions.HttpsCallableOptions);
67
method @NonNull public com.google.firebase.functions.HttpsCallableReference getHttpsCallableFromUrl(@NonNull java.net.URL);
8+
method @NonNull public com.google.firebase.functions.HttpsCallableReference getHttpsCallableFromUrl(@NonNull java.net.URL, @NonNull com.google.firebase.functions.HttpsCallableOptions);
79
method @NonNull public static com.google.firebase.functions.FirebaseFunctions getInstance(@NonNull com.google.firebase.FirebaseApp, @NonNull String);
810
method @NonNull public static com.google.firebase.functions.FirebaseFunctions getInstance(@NonNull com.google.firebase.FirebaseApp);
911
method @NonNull public static com.google.firebase.functions.FirebaseFunctions getInstance(@NonNull String);
@@ -37,6 +39,17 @@ package com.google.firebase.functions {
3739
enum_constant public static final com.google.firebase.functions.FirebaseFunctionsException.Code UNKNOWN;
3840
}
3941

42+
public class HttpsCallableOptions {
43+
method public boolean getLimitedUseAppCheckTokens();
44+
}
45+
46+
public static class HttpsCallableOptions.Builder {
47+
ctor public HttpsCallableOptions.Builder();
48+
method @NonNull public com.google.firebase.functions.HttpsCallableOptions build();
49+
method public boolean getLimitedUseAppCheckTokens();
50+
method @NonNull public com.google.firebase.functions.HttpsCallableOptions.Builder setLimitedUseAppCheckTokens(boolean);
51+
}
52+
4053
public class HttpsCallableReference {
4154
method @NonNull public com.google.android.gms.tasks.Task<com.google.firebase.functions.HttpsCallableResult> call(@Nullable Object);
4255
method @NonNull public com.google.android.gms.tasks.Task<com.google.firebase.functions.HttpsCallableResult> call();

firebase-functions/src/androidTest/backend/functions/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ exports.appCheckTest = functions.https.onRequest((request, response) => {
6464
response.send({data: {}});
6565
});
6666

67+
exports.appCheckLimitedUseTest = functions.https.onRequest((request, response) => {
68+
assert.equal(request.get('X-Firebase-AppCheck'), 'appCheck-limited-use');
69+
assert.deepEqual(request.body, {data: {}});
70+
response.send({data: {}});
71+
});
72+
6773
exports.nullTest = functions.https.onRequest((request, response) => {
6874
assert.deepEqual(request.body, {data: null});
6975
response.send({data: null});

firebase-functions/src/androidTest/java/com/google/firebase/functions/CallTest.java

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ public void testToken() throws InterruptedException, ExecutionException {
8888
app.getApplicationContext(),
8989
app.getOptions().getProjectId(),
9090
"us-central1",
91-
() -> {
91+
(unused) -> {
9292
HttpsCallableContext context = new HttpsCallableContext("token", null, null);
9393
return Tasks.forResult(context);
9494
},
@@ -110,7 +110,7 @@ public void testInstanceId() throws InterruptedException, ExecutionException {
110110
app.getApplicationContext(),
111111
app.getOptions().getProjectId(),
112112
"us-central1",
113-
() -> {
113+
(unused) -> {
114114
HttpsCallableContext context = new HttpsCallableContext(null, "iid", null);
115115
return Tasks.forResult(context);
116116
},
@@ -132,7 +132,7 @@ public void testAppCheck() throws InterruptedException, ExecutionException {
132132
app.getApplicationContext(),
133133
app.getOptions().getProjectId(),
134134
"us-central1",
135-
() -> {
135+
(unused) -> {
136136
HttpsCallableContext context = new HttpsCallableContext(null, null, "appCheck");
137137
return Tasks.forResult(context);
138138
},
@@ -146,6 +146,32 @@ public void testAppCheck() throws InterruptedException, ExecutionException {
146146
assertEquals(new HashMap<>(), actual);
147147
}
148148

149+
@Test
150+
public void testAppCheckLimitedUse() throws InterruptedException, ExecutionException {
151+
// Override the normal token provider to simulate FirebaseAuth being logged in.
152+
FirebaseFunctions functions =
153+
new FirebaseFunctions(
154+
app.getApplicationContext(),
155+
app.getOptions().getProjectId(),
156+
"us-central1",
157+
(unused) -> {
158+
HttpsCallableContext context =
159+
new HttpsCallableContext(null, null, "appCheck-limited-use");
160+
return Tasks.forResult(context);
161+
},
162+
TestOnlyExecutors.lite(),
163+
TestOnlyExecutors.ui());
164+
165+
HttpsCallableReference function =
166+
functions.getHttpsCallable(
167+
"appCheckLimitedUseTest",
168+
new HttpsCallableOptions.Builder().setLimitedUseAppCheckTokens(true).build());
169+
Task<HttpsCallableResult> result = function.call(new HashMap<>());
170+
Object actual = Tasks.await(result).getData();
171+
172+
assertEquals(new HashMap<>(), actual);
173+
}
174+
149175
@Test
150176
public void testNull() throws InterruptedException, ExecutionException {
151177
FirebaseFunctions functions = FirebaseFunctions.getInstance(app);

firebase-functions/src/androidTest/java/com/google/firebase/functions/FirebaseContextProviderTest.java

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ public class FirebaseContextProviderTest {
3434
private static final String AUTH_TOKEN = "authToken";
3535
private static final String IID_TOKEN = "iidToken";
3636
private static final String APP_CHECK_TOKEN = "appCheckToken";
37+
38+
private static final String APP_CHECK_LIMITED_USE_TOKEN = "appCheckLimitedUseToken";
3739
private static final String ERROR = "errorString";
3840

3941
private static final InternalAuthProvider fixedAuthProvider =
@@ -46,9 +48,9 @@ public class FirebaseContextProviderTest {
4648
private static final FirebaseInstanceIdInternal fixedIidProvider =
4749
new TestFirebaseInstanceIdInternal(IID_TOKEN);
4850
private static final InteropAppCheckTokenProvider fixedAppCheckProvider =
49-
new TestInteropAppCheckTokenProvider(APP_CHECK_TOKEN);
51+
new TestInteropAppCheckTokenProvider(APP_CHECK_TOKEN, APP_CHECK_LIMITED_USE_TOKEN);
5052
private static final InteropAppCheckTokenProvider errorAppCheckProvider =
51-
new TestInteropAppCheckTokenProvider(APP_CHECK_TOKEN, ERROR);
53+
new TestInteropAppCheckTokenProvider(APP_CHECK_TOKEN, APP_CHECK_LIMITED_USE_TOKEN, ERROR);
5254

5355
@Test
5456
public void getContext_whenAuthAndAppCheckAreNotAvailable_shouldContainOnlyIid()
@@ -60,7 +62,7 @@ public void getContext_whenAuthAndAppCheckAreNotAvailable_shouldContainOnlyIid()
6062
absentDeferred(),
6163
TestOnlyExecutors.lite());
6264

63-
HttpsCallableContext context = Tasks.await(contextProvider.getContext());
65+
HttpsCallableContext context = Tasks.await(contextProvider.getContext(false));
6466
assertThat(context.getAuthToken()).isNull();
6567
assertThat(context.getAppCheckToken()).isNull();
6668
assertThat(context.getInstanceIdToken()).isEqualTo(IID_TOKEN);
@@ -76,7 +78,7 @@ public void getContext_whenOnlyAuthIsAvailable_shouldContainOnlyAuthTokenAndIid(
7678
absentDeferred(),
7779
TestOnlyExecutors.lite());
7880

79-
HttpsCallableContext context = Tasks.await(contextProvider.getContext());
81+
HttpsCallableContext context = Tasks.await(contextProvider.getContext(false));
8082
assertThat(context.getAuthToken()).isEqualTo(AUTH_TOKEN);
8183
assertThat(context.getAppCheckToken()).isNull();
8284
assertThat(context.getInstanceIdToken()).isEqualTo(IID_TOKEN);
@@ -92,7 +94,7 @@ public void getContext_whenOnlyAppCheckIsAvailable_shouldContainOnlyAppCheckToke
9294
deferredOf(fixedAppCheckProvider),
9395
TestOnlyExecutors.lite());
9496

95-
HttpsCallableContext context = Tasks.await(contextProvider.getContext());
97+
HttpsCallableContext context = Tasks.await(contextProvider.getContext(false));
9698
assertThat(context.getAuthToken()).isNull();
9799
assertThat(context.getAppCheckToken()).isEqualTo(APP_CHECK_TOKEN);
98100
assertThat(context.getInstanceIdToken()).isEqualTo(IID_TOKEN);
@@ -108,7 +110,7 @@ public void getContext_whenOnlyAuthIsAvailableAndNotSignedIn_shouldContainOnlyIi
108110
absentDeferred(),
109111
TestOnlyExecutors.lite());
110112

111-
HttpsCallableContext context = Tasks.await(contextProvider.getContext());
113+
HttpsCallableContext context = Tasks.await(contextProvider.getContext(false));
112114
assertThat(context.getAuthToken()).isNull();
113115
assertThat(context.getAppCheckToken()).isNull();
114116
assertThat(context.getInstanceIdToken()).isEqualTo(IID_TOKEN);
@@ -124,12 +126,44 @@ public void getContext_whenOnlyAppCheckIsAvailableAndHasError_shouldContainOnlyI
124126
deferredOf(errorAppCheckProvider),
125127
TestOnlyExecutors.lite());
126128

127-
HttpsCallableContext context = Tasks.await(contextProvider.getContext());
129+
HttpsCallableContext context = Tasks.await(contextProvider.getContext(false));
130+
assertThat(context.getAuthToken()).isNull();
131+
assertThat(context.getInstanceIdToken()).isEqualTo(IID_TOKEN);
132+
assertThat(context.getAppCheckToken()).isNull();
133+
}
134+
135+
@Test
136+
public void getContext_facLimitedUse_whenOnlyAppCheckIsAvailableAndHasError_shouldContainOnlyIid()
137+
throws ExecutionException, InterruptedException {
138+
FirebaseContextProvider contextProvider =
139+
new FirebaseContextProvider(
140+
absentProvider(),
141+
providerOf(fixedIidProvider),
142+
deferredOf(errorAppCheckProvider),
143+
TestOnlyExecutors.lite());
144+
145+
HttpsCallableContext context = Tasks.await(contextProvider.getContext(true));
128146
assertThat(context.getAuthToken()).isNull();
129147
assertThat(context.getInstanceIdToken()).isEqualTo(IID_TOKEN);
130148
assertThat(context.getAppCheckToken()).isNull();
131149
}
132150

151+
@Test
152+
public void getContext_facLimitedUse_whenOnlyAppCheckIsAvailable_shouldContainToken()
153+
throws ExecutionException, InterruptedException {
154+
FirebaseContextProvider contextProvider =
155+
new FirebaseContextProvider(
156+
absentProvider(),
157+
providerOf(fixedIidProvider),
158+
deferredOf(fixedAppCheckProvider),
159+
TestOnlyExecutors.lite());
160+
161+
HttpsCallableContext context = Tasks.await(contextProvider.getContext(true));
162+
assertThat(context.getAuthToken()).isNull();
163+
assertThat(context.getInstanceIdToken()).isEqualTo(IID_TOKEN);
164+
assertThat(context.getAppCheckToken()).isEqualTo(APP_CHECK_LIMITED_USE_TOKEN);
165+
}
166+
133167
@Test
134168
public void getContext_whenAuthAndAppCheckAreAvailable_shouldContainAuthAppCheckTokensAndIid()
135169
throws ExecutionException, InterruptedException {
@@ -140,7 +174,7 @@ public void getContext_whenAuthAndAppCheckAreAvailable_shouldContainAuthAppCheck
140174
deferredOf(fixedAppCheckProvider),
141175
TestOnlyExecutors.lite());
142176

143-
HttpsCallableContext context = Tasks.await(contextProvider.getContext());
177+
HttpsCallableContext context = Tasks.await(contextProvider.getContext(false));
144178
assertThat(context.getAuthToken()).isEqualTo(AUTH_TOKEN);
145179
assertThat(context.getAppCheckToken()).isEqualTo(APP_CHECK_TOKEN);
146180
assertThat(context.getInstanceIdToken()).isEqualTo(IID_TOKEN);

firebase-functions/src/androidTest/java/com/google/firebase/functions/TestInteropAppCheckTokenProvider.java

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@
2525

2626
public class TestInteropAppCheckTokenProvider implements InteropAppCheckTokenProvider {
2727
private final AppCheckTokenResult testToken;
28+
private final AppCheckTokenResult testLimitedUseToken;
2829

29-
public TestInteropAppCheckTokenProvider(String testToken) {
30+
public TestInteropAppCheckTokenProvider(String testToken, String testLimitedUseToken) {
3031
this.testToken =
3132
new AppCheckTokenResult() {
3233
@NonNull
@@ -35,6 +36,21 @@ public String getToken() {
3536
return testToken;
3637
}
3738

39+
@Nullable
40+
@Override
41+
public Exception getError() {
42+
return null;
43+
}
44+
};
45+
46+
this.testLimitedUseToken =
47+
new AppCheckTokenResult() {
48+
@NonNull
49+
@Override
50+
public String getToken() {
51+
return testLimitedUseToken;
52+
}
53+
3854
@Nullable
3955
@Override
4056
public Exception getError() {
@@ -43,7 +59,8 @@ public Exception getError() {
4359
};
4460
}
4561

46-
public TestInteropAppCheckTokenProvider(String testToken, String error) {
62+
public TestInteropAppCheckTokenProvider(
63+
String testToken, String testLimitedUseToken, String error) {
4764
this.testToken =
4865
new AppCheckTokenResult() {
4966
@NonNull
@@ -52,6 +69,20 @@ public String getToken() {
5269
return testToken;
5370
}
5471

72+
@Nullable
73+
@Override
74+
public Exception getError() {
75+
return new FirebaseException(error);
76+
}
77+
};
78+
this.testLimitedUseToken =
79+
new AppCheckTokenResult() {
80+
@NonNull
81+
@Override
82+
public String getToken() {
83+
return testLimitedUseToken;
84+
}
85+
5586
@Nullable
5687
@Override
5788
public Exception getError() {
@@ -69,7 +100,7 @@ public Task<AppCheckTokenResult> getToken(boolean forceRefresh) {
69100
@NonNull
70101
@Override
71102
public Task<AppCheckTokenResult> getLimitedUseToken() {
72-
return Tasks.forResult(testToken);
103+
return Tasks.forResult(testLimitedUseToken);
73104
}
74105

75106
@Override

firebase-functions/src/main/java/com/google/firebase/functions/ContextProvider.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,5 @@
1818

1919
/** The interface for getting metadata about the client. This is an interface for easier testing. */
2020
interface ContextProvider {
21-
Task<HttpsCallableContext> getContext();
21+
Task<HttpsCallableContext> getContext(boolean getLimitedUseAppCheckToken);
2222
}

firebase-functions/src/main/java/com/google/firebase/functions/FirebaseContextProvider.java

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import com.google.android.gms.tasks.Task;
1919
import com.google.android.gms.tasks.Tasks;
2020
import com.google.firebase.annotations.concurrent.Lightweight;
21+
import com.google.firebase.appcheck.AppCheckTokenResult;
2122
import com.google.firebase.appcheck.interop.InteropAppCheckTokenProvider;
2223
import com.google.firebase.auth.internal.InternalAuthProvider;
2324
import com.google.firebase.iid.internal.FirebaseInstanceIdInternal;
@@ -62,9 +63,9 @@ class FirebaseContextProvider implements ContextProvider {
6263
}
6364

6465
@Override
65-
public Task<HttpsCallableContext> getContext() {
66+
public Task<HttpsCallableContext> getContext(boolean limitedUseAppCheckToken) {
6667
Task<String> authToken = getAuthToken();
67-
Task<String> appCheckToken = getAppCheckToken();
68+
Task<String> appCheckToken = getAppCheckToken(limitedUseAppCheckToken);
6869
return Tasks.whenAll(authToken, appCheckToken)
6970
.onSuccessTask(
7071
executor,
@@ -100,23 +101,23 @@ private Task<String> getAuthToken() {
100101
});
101102
}
102103

103-
private Task<String> getAppCheckToken() {
104+
private Task<String> getAppCheckToken(boolean limitedUseAppCheckToken) {
104105
InteropAppCheckTokenProvider appCheck = appCheckRef.get();
105106
if (appCheck == null) {
106107
return Tasks.forResult(null);
107108
}
108-
return appCheck
109-
.getToken(false)
110-
.onSuccessTask(
111-
executor,
112-
result -> {
113-
if (result.getError() != null) {
114-
// If there was an error getting the App Check token, do NOT send the placeholder
115-
// token. Only valid App Check tokens should be sent to the functions backend.
116-
Log.w(TAG, "Error getting App Check token. Error: " + result.getError());
117-
return Tasks.forResult(null);
118-
}
119-
return Tasks.forResult(result.getToken());
120-
});
109+
Task<AppCheckTokenResult> tokenTask =
110+
limitedUseAppCheckToken ? appCheck.getLimitedUseToken() : appCheck.getToken(false);
111+
return tokenTask.onSuccessTask(
112+
executor,
113+
result -> {
114+
if (result.getError() != null) {
115+
// If there was an error getting the App Check token, do NOT send the placeholder
116+
// token. Only valid App Check tokens should be sent to the functions backend.
117+
Log.w(TAG, "Error getting App Check token. Error: " + result.getError());
118+
return Tasks.forResult(null);
119+
}
120+
return Tasks.forResult(result.getToken());
121+
});
121122
}
122123
}

0 commit comments

Comments
 (0)