Skip to content

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

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,11 @@
<artifactId>netty-transport</artifactId>
<version>${netty.version}</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.22</version>
</dependency>

<!-- Test Dependencies -->
<dependency>
Expand Down
33 changes: 33 additions & 0 deletions src/main/java/com/google/firebase/appcheck/AppCheckErrorCode.java
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
}
149 changes: 149 additions & 0 deletions src/main/java/com/google/firebase/appcheck/AppCheckTokenVerifier.java
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;
Copy link
Contributor

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.

Copy link
Member Author

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.


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)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer strict .equals() here instead of .startsWith().

Copy link
Member Author

Choose a reason for hiding this comment

The 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 startsWith here. Also see the equivalent of Node.js https://github.com/firebase/firebase-admin-node/blob/a32195daa9848b261fe892d9f606152a40ff2915/src/app-check/token-verifier.ts#L121

errorMessage = "invalid iss";
} else if (claimsSet.getSubject().isEmpty()) {
errorMessage = "invalid sub";
Copy link
Member Author

Choose a reason for hiding this comment

The 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 {
Copy link
Contributor

Choose a reason for hiding this comment

The 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 getAppId() for now like our Node.js (I think).

Copy link
Member Author

Choose a reason for hiding this comment

The 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 getAppId(). So it will be something similar to:

public final class VerifyAppCheckTokenResponse {

  public String getAppId()

  public DecodedAppCheckToken getToken()
}

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;
}
}
122 changes: 122 additions & 0 deletions src/main/java/com/google/firebase/appcheck/FirebaseAppCheck.java
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));
}
}
}
Loading