-
Notifications
You must be signed in to change notification settings - Fork 285
feat(appcheck): Add initial classes for App Check #672
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
/* | ||
* Copyright 2022 Google LLC | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package com.google.firebase.appcheck; | ||
|
||
/** | ||
* Error codes that can be raised by the App Check APIs. | ||
*/ | ||
public enum AppCheckErrorCode { | ||
|
||
/** | ||
* One or more arguments specified in the request were invalid. | ||
*/ | ||
INVALID_ARGUMENT, | ||
|
||
/** | ||
* Internal server error. | ||
*/ | ||
INTERNAL | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
/* | ||
* Copyright 2022 Google Inc. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package com.google.firebase.appcheck; | ||
|
||
import static com.google.common.base.Preconditions.checkArgument; | ||
|
||
import com.google.common.base.Strings; | ||
import com.google.firebase.ErrorCode; | ||
import com.nimbusds.jose.JOSEException; | ||
import com.nimbusds.jose.JWSAlgorithm; | ||
import com.nimbusds.jose.jwk.source.DefaultJWKSetCache; | ||
import com.nimbusds.jose.jwk.source.JWKSetCache; | ||
import com.nimbusds.jose.jwk.source.JWKSource; | ||
import com.nimbusds.jose.jwk.source.RemoteJWKSet; | ||
import com.nimbusds.jose.proc.BadJOSEException; | ||
import com.nimbusds.jose.proc.JWSKeySelector; | ||
import com.nimbusds.jose.proc.JWSVerificationKeySelector; | ||
import com.nimbusds.jose.proc.SecurityContext; | ||
import com.nimbusds.jwt.JWTClaimsSet; | ||
import com.nimbusds.jwt.SignedJWT; | ||
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; | ||
import com.nimbusds.jwt.proc.DefaultJWTProcessor; | ||
|
||
import java.net.MalformedURLException; | ||
import java.net.URL; | ||
import java.text.ParseException; | ||
import java.util.concurrent.TimeUnit; | ||
|
||
final class AppCheckTokenVerifier { | ||
|
||
private final URL jwksUrl; | ||
private final String projectId; | ||
|
||
private static final String JWKS_URL = "https://firebaseappcheck.googleapis.com/v1/jwks"; | ||
private static final String APP_CHECK_ISSUER = "https://firebaseappcheck.googleapis.com/"; | ||
|
||
private AppCheckTokenVerifier(Builder builder) { | ||
checkArgument(!Strings.isNullOrEmpty(builder.projectId)); | ||
this.projectId = builder.projectId; | ||
try { | ||
this.jwksUrl = new URL(JWKS_URL); | ||
} catch (MalformedURLException e) { | ||
throw new IllegalArgumentException("Malformed JWK url string", e); | ||
} | ||
} | ||
|
||
/** | ||
* Verifies that the given App Check token string is a valid Firebase JWT. | ||
* | ||
* @param token The token string to be verified. | ||
* @return A decoded representation of the input token string. | ||
* @throws FirebaseAppCheckException If the input token string fails to verify due to any reason. | ||
*/ | ||
DecodedAppCheckToken verifyToken(String token) throws FirebaseAppCheckException { | ||
SignedJWT signedJWT; | ||
JWTClaimsSet claimsSet; | ||
String projectName = String.format("projects/%s", projectId); | ||
String projectIdMatchMessage = " Make sure the App Check token comes from the same " | ||
+ "Firebase project as the service account used to authenticate this SDK."; | ||
|
||
try { | ||
signedJWT = SignedJWT.parse(token); | ||
claimsSet = signedJWT.getJWTClaimsSet(); | ||
} catch (java.text.ParseException e) { | ||
// Invalid signed JWT encoding | ||
throw new FirebaseAppCheckException(ErrorCode.INVALID_ARGUMENT, "Invalid token"); | ||
} | ||
|
||
String errorMessage = null; | ||
|
||
if (!signedJWT.getHeader().getAlgorithm().equals(JWSAlgorithm.RS256)) { | ||
errorMessage = String.format("The provided App Check token has incorrect algorithm. " | ||
+ "Expected 'RS256' but got '%s'.", signedJWT.getHeader().getAlgorithm()); | ||
} else if (!signedJWT.getHeader().getType().getType().equals("JWT")) { | ||
errorMessage = String.format("The provided App Check token has invalid type header." | ||
+ "Expected %s but got %s", "JWT", signedJWT.getHeader().getType().getType()); | ||
} else if (!claimsSet.getAudience().contains(projectName)) { | ||
errorMessage = String.format("The provided App Check token has incorrect 'aud' (audience) " | ||
+ "claim. Expected %s but got %s. %s", | ||
projectName, claimsSet.getAudience().toString(), projectIdMatchMessage); | ||
} else if (!claimsSet.getIssuer().startsWith(APP_CHECK_ISSUER)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prefer strict There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The issuer contains the App ID (which is not known to the Admin SDK). That's why we decided to do a |
||
errorMessage = "invalid iss"; | ||
} else if (claimsSet.getSubject().isEmpty()) { | ||
errorMessage = "invalid sub"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please ignore the error messages for now, these need to be improved to add more detail. |
||
} | ||
|
||
if (errorMessage != null) { | ||
throw new FirebaseAppCheckException(ErrorCode.INVALID_ARGUMENT, errorMessage); | ||
} | ||
|
||
// Create a JWT processor for the access tokens | ||
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>(); | ||
|
||
// Cache the keys for 6 hours | ||
JWKSetCache jwkSetCache = new DefaultJWKSetCache(6L, 6L, TimeUnit.HOURS); | ||
JWKSource<SecurityContext> keySource = new RemoteJWKSet<>(this.jwksUrl, null, jwkSetCache); | ||
|
||
// Configure the JWT processor with a key selector to feed matching public | ||
// RSA keys sourced from the JWK set URL. | ||
JWSKeySelector<SecurityContext> keySelector = | ||
new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keySource); | ||
|
||
jwtProcessor.setJWSKeySelector(keySelector); | ||
|
||
try { | ||
claimsSet = jwtProcessor.process(token, null); | ||
System.out.println(claimsSet.toJSONObject()); | ||
} catch (ParseException | BadJOSEException | JOSEException e) { | ||
throw new FirebaseAppCheckException(ErrorCode.INVALID_ARGUMENT, e.getMessage()); | ||
} | ||
|
||
return new DecodedAppCheckToken(claimsSet.getClaims()); | ||
} | ||
|
||
static AppCheckTokenVerifier.Builder builder() { | ||
return new AppCheckTokenVerifier.Builder(); | ||
} | ||
|
||
static final class Builder { | ||
|
||
private String projectId; | ||
|
||
private Builder() { | ||
} | ||
|
||
AppCheckTokenVerifier.Builder setProjectId(String projectId) { | ||
this.projectId = projectId; | ||
return this; | ||
} | ||
|
||
AppCheckTokenVerifier build() { | ||
return new AppCheckTokenVerifier(this); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
/* | ||
* Copyright 2022 Google LLC | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package com.google.firebase.appcheck; | ||
|
||
import static com.google.common.base.Preconditions.checkArgument; | ||
|
||
import com.google.common.collect.ImmutableMap; | ||
|
||
import java.util.Map; | ||
|
||
/** | ||
* A decoded and verified Firebase App Check token. See {@link FirebaseAppCheck#verifyToken(String)} | ||
* for details on how to obtain an instance of this class. | ||
*/ | ||
public final class DecodedAppCheckToken { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I almost wonder whether we should be exposing all the contents of the token or offer our own opinionated data layer. For example, we can offer just one method There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In go/fac-admin-verify-token we decided to wrap the full token in a response object with helper methods like
This is similar to the Node.js API: https://github.com/firebase/firebase-admin-node/blob/a32195daa9848b261fe892d9f606152a40ff2915/src/app-check/app-check-api.ts#L95 |
||
|
||
private final Map<String, Object> claims; | ||
|
||
DecodedAppCheckToken(Map<String, Object> claims) { | ||
checkArgument(claims != null && claims.containsKey("sub"), | ||
"Claims map must at least contain sub"); | ||
this.claims = ImmutableMap.copyOf(claims); | ||
} | ||
|
||
/** Returns the Subject for this token. */ | ||
public String getSubject() { | ||
return (String) claims.get("sub"); | ||
} | ||
|
||
/** Returns the Issuer for this token. */ | ||
public String getIssuer() { | ||
return (String) claims.get("iss"); | ||
} | ||
|
||
/** Returns the Audience for this token. */ | ||
public String getAudience() { | ||
return (String) claims.get("aud"); | ||
} | ||
|
||
/** Returns the Expiration Time for this token. */ | ||
public Long getExpirationTime() { | ||
return (Long) claims.get("exp"); | ||
} | ||
|
||
/** Returns the Issued At for this token. */ | ||
public Long getIssuedAt() { | ||
return (Long) claims.get("iat"); | ||
} | ||
|
||
/** Returns a map of all the claims on this token. */ | ||
public Map<String, Object> getClaims() { | ||
return this.claims; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
/* | ||
* Copyright 2022 Google LLC | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package com.google.firebase.appcheck; | ||
|
||
import static com.google.common.base.Preconditions.checkNotNull; | ||
|
||
import com.google.api.core.ApiFuture; | ||
import com.google.common.annotations.VisibleForTesting; | ||
import com.google.firebase.FirebaseApp; | ||
import com.google.firebase.ImplFirebaseTrampolines; | ||
import com.google.firebase.internal.CallableOperation; | ||
import com.google.firebase.internal.FirebaseService; | ||
import com.google.firebase.internal.NonNull; | ||
|
||
/** | ||
* This class is the entry point for all server-side Firebase App Check actions. | ||
* | ||
* <p>You can get an instance of {@link FirebaseAppCheck} via {@link #getInstance(FirebaseApp)}, | ||
* and then use it to access App Check services. | ||
*/ | ||
public final class FirebaseAppCheck { | ||
|
||
private static final String SERVICE_ID = FirebaseAppCheck.class.getName(); | ||
private final FirebaseApp app; | ||
private final FirebaseAppCheckClient appCheckClient; | ||
|
||
@VisibleForTesting | ||
FirebaseAppCheck(FirebaseApp app, FirebaseAppCheckClient client) { | ||
this.app = checkNotNull(app); | ||
this.appCheckClient = checkNotNull(client); | ||
} | ||
|
||
private FirebaseAppCheck(FirebaseApp app) { | ||
this(app, FirebaseAppCheckClientImpl.fromApp(app)); | ||
} | ||
|
||
/** | ||
* Gets the {@link FirebaseAppCheck} instance for the default {@link FirebaseApp}. | ||
* | ||
* @return The {@link FirebaseAppCheck} instance for the default {@link FirebaseApp}. | ||
*/ | ||
public static FirebaseAppCheck getInstance() { | ||
return getInstance(FirebaseApp.getInstance()); | ||
} | ||
|
||
/** | ||
* Gets the {@link FirebaseAppCheck} instance for the specified {@link FirebaseApp}. | ||
* | ||
* @return The {@link FirebaseAppCheck} instance for the specified {@link FirebaseApp}. | ||
*/ | ||
public static synchronized FirebaseAppCheck getInstance(FirebaseApp app) { | ||
FirebaseAppCheck.FirebaseAppCheckService service = ImplFirebaseTrampolines.getService(app, | ||
SERVICE_ID, | ||
FirebaseAppCheck.FirebaseAppCheckService.class); | ||
if (service == null) { | ||
service = ImplFirebaseTrampolines.addService(app, | ||
new FirebaseAppCheck.FirebaseAppCheckService(app)); | ||
} | ||
return service.getInstance(); | ||
} | ||
|
||
/** | ||
* Verifies a given App Check Token. | ||
* | ||
* @param token The App Check token to be verified. | ||
* @return A {@link VerifyAppCheckTokenResponse}. | ||
* @throws FirebaseAppCheckException If an error occurs while getting the template. | ||
*/ | ||
public VerifyAppCheckTokenResponse verifyToken( | ||
@NonNull String token) throws FirebaseAppCheckException { | ||
return verifyTokenOp(token).call(); | ||
} | ||
|
||
/** | ||
* Similar to {@link #verifyToken(String token)} but performs the operation | ||
* asynchronously. | ||
* | ||
* @param token The App Check token to be verified. | ||
* @return An {@code ApiFuture} that completes with a {@link VerifyAppCheckTokenResponse} when | ||
* the provided token is valid. | ||
*/ | ||
public ApiFuture<VerifyAppCheckTokenResponse> verifyTokenAsync(@NonNull String token) | ||
throws FirebaseAppCheckException { | ||
return verifyTokenOp(token).callAsync(app); | ||
} | ||
|
||
private CallableOperation<VerifyAppCheckTokenResponse, FirebaseAppCheckException> verifyTokenOp( | ||
final String token) { | ||
final FirebaseAppCheckClient appCheckClient = getAppCheckClient(); | ||
return new CallableOperation<VerifyAppCheckTokenResponse, FirebaseAppCheckException>() { | ||
@Override | ||
protected VerifyAppCheckTokenResponse execute() throws FirebaseAppCheckException { | ||
return appCheckClient.verifyToken(token); | ||
} | ||
}; | ||
} | ||
|
||
@VisibleForTesting | ||
FirebaseAppCheckClient getAppCheckClient() { | ||
return appCheckClient; | ||
} | ||
|
||
private static class FirebaseAppCheckService extends FirebaseService<FirebaseAppCheck> { | ||
FirebaseAppCheckService(FirebaseApp app) { | ||
super(SERVICE_ID, new FirebaseAppCheck(app)); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor point: Is it possible to base this on project number (a long)? This will comply with https://google.aip.dev/cloud/2510 for third-parties only using project numbers.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So this is interesting. The Admin SDK uses the project ID (string) and does not have any context of the project number. We addressed this by converting
aud
to an array and adding both id and number in the same claim.