From 4e8c8aa7b7b521009d7a0cfb2eb6cdf9e018bb5d Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:52:35 -0400 Subject: [PATCH 01/17] Add exception handling covered by standard platform ErrorCode --- .../appcheck/FirebaseAppCheckException.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/main/java/com/google/firebase/appcheck/FirebaseAppCheckException.java diff --git a/src/main/java/com/google/firebase/appcheck/FirebaseAppCheckException.java b/src/main/java/com/google/firebase/appcheck/FirebaseAppCheckException.java new file mode 100644 index 000000000..d70c168b3 --- /dev/null +++ b/src/main/java/com/google/firebase/appcheck/FirebaseAppCheckException.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 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 com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; + +/** + * Generic exception related to Firebase App Check. Check the error code and message for more + * details. + */ +public class FirebaseAppCheckException extends FirebaseException { + + public FirebaseAppCheckException( + @NonNull ErrorCode errorCode, + @NonNull String message, + @Nullable Throwable cause, + @Nullable IncomingHttpResponse response) { + super(errorCode, message, cause, response); + } + + public FirebaseAppCheckException( + @NonNull ErrorCode errorCode, + @NonNull String message, + @Nullable Throwable cause) { + this(errorCode, message, cause, null); + } + + public FirebaseAppCheckException(@NonNull FirebaseException base) { + this(base.getErrorCode(), base.getMessage(), base.getCause(), base.getHttpResponse()); + } +} From 7f56cb3d188ba71d2801245ef13faf055fb991a8 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:50:41 -0400 Subject: [PATCH 02/17] Add DecodedAppCheckToken, VerifyAppCheckTokenResponse and VerifyAppCheckTokenOptions classes --- .../appcheck/DecodedAppCheckToken.java | 103 ++++++++++++++++++ .../appcheck/VerifyAppCheckTokenOptions.java | 78 +++++++++++++ .../appcheck/VerifyAppCheckTokenResponse.java | 63 +++++++++++ 3 files changed, 244 insertions(+) create mode 100644 src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java create mode 100644 src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptions.java create mode 100644 src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponse.java diff --git a/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java b/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java new file mode 100644 index 000000000..e10e93358 --- /dev/null +++ b/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026 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 static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * Represents a verified Firebase App Check token. + */ +public class DecodedAppCheckToken { + + private final Map claims; + + /** + * Creates an instance of {@link DecodedAppCheckToken} from a map of JWT claims. + * + * @param claims A map of JWT claims. + */ + DecodedAppCheckToken(Map claims) { + checkNotNull(claims, "Claims map must not be null"); + checkArgument(claims.containsKey("sub"), "Claims map must contain sub"); + this.claims = ImmutableMap.copyOf(claims); + } + + /** + * Returns the issuer identifier for the token. + */ + public String getIssuer() { + return (String) claims.get("iss"); + } + + /** + * Returns the subject claim ('sub') of the token. + */ + public String getSubject() { + return (String) claims.get("sub"); + } + + /** + * Returns the audience for which this token is intended. + */ + public List getAudience() { + Object audience = claims.get("aud"); + if (audience instanceof String) { + return ImmutableList.of((String) audience); + } else if (audience instanceof List) { + @SuppressWarnings("unchecked") + List audienceList = (List) audience; + return ImmutableList.copyOf(audienceList); + } + return ImmutableList.of(); + } + + /** + * Returns the expiration time in seconds since the Unix epoch. + */ + public long getExpirationTime() { + Object exp = claims.get("exp"); + if (exp instanceof Date) { + return ((Date) exp).getTime() / 1000L; + } + return exp instanceof Number ? ((Number) exp).longValue() : 0L; + } + + /** + * Returns the issued-at time in seconds since the Unix epoch. + */ + public long getIssuedAt() { + Object iat = claims.get("iat"); + if (iat instanceof Date) { + return ((Date) iat).getTime() / 1000L; + } + return iat instanceof Number ? ((Number) iat).longValue() : 0L; + } + + /** + * Returns the entire map of claims. + */ + public Map getClaims() { + return claims; + } +} diff --git a/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptions.java b/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptions.java new file mode 100644 index 000000000..781688a75 --- /dev/null +++ b/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptions.java @@ -0,0 +1,78 @@ +/* + * Copyright 2026 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 java.util.Optional; + +/** + * Options for verifying a Firebase App Check token. + */ +public final class VerifyAppCheckTokenOptions { + + private final Optional consume; + + private VerifyAppCheckTokenOptions(Builder builder) { + this.consume = builder.consume; + } + + /** + * Returns whether to consume the App Check token during verification for replay protection. + */ + public Optional getConsume() { + return consume; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private Optional consume = Optional.empty(); + + private Builder() {} + + /** + * Sets whether to consume the token during verification. + * + * @param consume Set to true to consume the token. + * @return This builder. + */ + public Builder setConsume(boolean consume) { + this.consume = Optional.of(consume); + return this; + } + + /** + * Sets whether to consume the token during verification. + * + * @param consume Optional boolean value. + * @return This builder. + */ + public Builder setConsume(Optional consume) { + this.consume = consume != null ? consume : Optional.empty(); + return this; + } + + /** + * Builds a new {@link VerifyAppCheckTokenOptions} instance. + */ + public VerifyAppCheckTokenOptions build() { + return new VerifyAppCheckTokenOptions(this); + } + } +} diff --git a/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponse.java b/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponse.java new file mode 100644 index 000000000..81eaa4bb4 --- /dev/null +++ b/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponse.java @@ -0,0 +1,63 @@ +/* + * Copyright 2026 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.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import java.util.Optional; + +/** + * Represents the response from verifying a Firebase App Check token. + */ +public final class VerifyAppCheckTokenResponse { + + private final String appId; + private final DecodedAppCheckToken token; + private final Optional alreadyConsumed; + + VerifyAppCheckTokenResponse( + @NonNull String appId, + @NonNull DecodedAppCheckToken token, + @Nullable Boolean alreadyConsumed) { + this.appId = checkNotNull(appId, "appId must not be null"); + this.token = checkNotNull(token, "token must not be null"); + this.alreadyConsumed = Optional.ofNullable(alreadyConsumed); + } + + /** + * Returns the App ID associated with the App Check token. + */ + public String getAppId() { + return appId; + } + + /** + * Returns the decoded App Check token. + */ + public DecodedAppCheckToken getToken() { + return token; + } + + /** + * Returns whether the token was already consumed prior to verification, if consume option was requested. + */ + public Optional isAlreadyConsumed() { + return alreadyConsumed; + } +} From 4341926cc65f1bf8f06323b665ece9b967a1a0ce Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:42:35 -0400 Subject: [PATCH 03/17] Add internal appchecktokenverifier class to support token verification and one time token verification --- .../appcheck/DecodedAppCheckToken.java | 2 +- .../appcheck/FirebaseAppCheckException.java | 6 + .../appcheck/VerifyAppCheckTokenResponse.java | 2 +- .../internal/AppCheckTokenVerifier.java | 294 ++++++++++++++++++ 4 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java diff --git a/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java b/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java index e10e93358..6e5ca1460 100644 --- a/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java +++ b/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java @@ -37,7 +37,7 @@ public class DecodedAppCheckToken { * * @param claims A map of JWT claims. */ - DecodedAppCheckToken(Map claims) { + public DecodedAppCheckToken(Map claims) { checkNotNull(claims, "Claims map must not be null"); checkArgument(claims.containsKey("sub"), "Claims map must contain sub"); this.claims = ImmutableMap.copyOf(claims); diff --git a/src/main/java/com/google/firebase/appcheck/FirebaseAppCheckException.java b/src/main/java/com/google/firebase/appcheck/FirebaseAppCheckException.java index d70c168b3..29c3ddb15 100644 --- a/src/main/java/com/google/firebase/appcheck/FirebaseAppCheckException.java +++ b/src/main/java/com/google/firebase/appcheck/FirebaseAppCheckException.java @@ -43,6 +43,12 @@ public FirebaseAppCheckException( this(errorCode, message, cause, null); } + public FirebaseAppCheckException( + @NonNull ErrorCode errorCode, + @NonNull String message) { + this(errorCode, message, null, null); + } + public FirebaseAppCheckException(@NonNull FirebaseException base) { this(base.getErrorCode(), base.getMessage(), base.getCause(), base.getHttpResponse()); } diff --git a/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponse.java b/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponse.java index 81eaa4bb4..768c2b5e5 100644 --- a/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponse.java +++ b/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponse.java @@ -31,7 +31,7 @@ public final class VerifyAppCheckTokenResponse { private final DecodedAppCheckToken token; private final Optional alreadyConsumed; - VerifyAppCheckTokenResponse( + public VerifyAppCheckTokenResponse( @NonNull String appId, @NonNull DecodedAppCheckToken token, @Nullable Boolean alreadyConsumed) { diff --git a/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java b/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java new file mode 100644 index 000000000..6667b027c --- /dev/null +++ b/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java @@ -0,0 +1,294 @@ +/* + * Copyright 2026 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.internal; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.json.JsonHttpContent; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.appcheck.DecodedAppCheckToken; +import com.google.firebase.appcheck.FirebaseAppCheckException; +import com.google.firebase.appcheck.VerifyAppCheckTokenOptions; +import com.google.firebase.appcheck.VerifyAppCheckTokenResponse; +import com.google.firebase.internal.ApiClientUtils; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +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.DefaultJWTProcessor; +import com.nimbusds.jwt.proc.ExpiredJWTException; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URI; +import java.text.ParseException; + +/** + * Internal verifier for Firebase App Check tokens. + */ +public class AppCheckTokenVerifier { + + private static final String JWKS_URL = "https://firebaseappcheck.googleapis.com/v1/jwks"; + private static final String APP_CHECK_ISSUER = "https://firebaseappcheck.googleapis.com/"; + private static final String APP_CHECK_AUDIENCE_PREFIX = "projects/"; + private static final String VERIFY_TOKEN_URL_FORMAT = + "https://firebaseappcheck.googleapis.com/v1/projects/%s:verifyAppCheckToken"; + + private final FirebaseApp app; + private final String projectId; + private final HttpRequestFactory requestFactory; + private final JsonFactory jsonFactory; + private volatile DefaultJWTProcessor jwtProcessor; + + public AppCheckTokenVerifier(FirebaseApp app) { + this(app, ApiClientUtils.newAuthorizedRequestFactory(app), ApiClientUtils.getDefaultJsonFactory(), null); + } + + /** + * Package-private constructor designed explicitly for dependency injection + * of mock HTTP request factories, JSON factories, or mock JWT processors + * during isolated unit testing flows. + */ + @VisibleForTesting + AppCheckTokenVerifier( + FirebaseApp app, + HttpRequestFactory requestFactory, + JsonFactory jsonFactory, + DefaultJWTProcessor jwtProcessor) { + this.app = checkNotNull(app, "FirebaseApp must not be null"); + this.projectId = getProjectId(app); + this.requestFactory = checkNotNull(requestFactory, "HttpRequestFactory must not be null"); + this.jsonFactory = checkNotNull(jsonFactory, "JsonFactory must not be null"); + this.jwtProcessor = jwtProcessor; + } + + private DefaultJWTProcessor getJwtProcessor() { + DefaultJWTProcessor processor = this.jwtProcessor; + if (processor == null) { + synchronized (this) { + processor = this.jwtProcessor; + if (processor == null) { + processor = createJwtProcessor(); + this.jwtProcessor = processor; + } + } + } + return processor; + } + + /** + * Verifies an App Check token string. + * + * @param token The App Check token string to verify. + * @return A {@link VerifyAppCheckTokenResponse} containing the decoded token. + * @throws FirebaseAppCheckException If verification fails. + */ + public VerifyAppCheckTokenResponse verifyToken(String token) throws FirebaseAppCheckException { + return verifyToken(token, null); + } + + /** + * Verifies an App Check token string with options. + * + * @param token The App Check token string to verify. + * @param options Verification options specified via {@link VerifyAppCheckTokenOptions}. + * @return A {@link VerifyAppCheckTokenResponse} containing the decoded token and consumption status. + * @throws FirebaseAppCheckException If verification fails. + */ + public VerifyAppCheckTokenResponse verifyToken( + String token, VerifyAppCheckTokenOptions options) throws FirebaseAppCheckException { + checkArgument( + !Strings.isNullOrEmpty(token), "App Check token string must not be null or empty"); + + DecodedAppCheckToken decodedToken = verifyTokenLocally(token); + String appId = decodedToken.getSubject(); + + boolean consume = options != null && options.getConsume().orElse(false); + Boolean alreadyConsumed = null; + + if (consume) { + alreadyConsumed = verifyOneTimeToken(token); + } + + return new VerifyAppCheckTokenResponse(appId, decodedToken, alreadyConsumed); + } + + /** + * Performs local JWT signature and claims verification. + */ + private DecodedAppCheckToken verifyTokenLocally(String token) throws FirebaseAppCheckException { + try { + SignedJWT signedJwt = SignedJWT.parse(token); + verifyHeader(signedJwt.getHeader()); + JWTClaimsSet claims = getJwtProcessor().process(signedJwt, null); + verifyClaims(claims); + return new DecodedAppCheckToken(claims.getClaims()); + } catch (ParseException e) { + throw new FirebaseAppCheckException( + ErrorCode.INVALID_ARGUMENT, "Failed to parse App Check JWT token: " + e.getMessage(), e); + } catch (ExpiredJWTException e) { + throw new FirebaseAppCheckException( + ErrorCode.INVALID_ARGUMENT, "Firebase App Check token has expired.", e); + } catch (BadJOSEException e) { + throw new FirebaseAppCheckException( + ErrorCode.INVALID_ARGUMENT, + "Check your project: " + projectId + ". Firebase App Check token is invalid: " + + e.getMessage(), + e); + } catch (JOSEException e) { + throw new FirebaseAppCheckException( + ErrorCode.INTERNAL, + "Check your project: " + projectId + ". Failed to verify App Check token signature: " + + e.getMessage(), + e); + } + } + + private void verifyHeader(JWSHeader header) throws FirebaseAppCheckException { + if (header.getAlgorithm() == null || !JWSAlgorithm.RS256.equals(header.getAlgorithm())) { + throw new FirebaseAppCheckException( + ErrorCode.INVALID_ARGUMENT, + "App Check token has incorrect algorithm. Expected " + + JWSAlgorithm.RS256.getName() + + " but got: " + + header.getAlgorithm()); + } + if (Strings.isNullOrEmpty(header.getKeyID())) { + throw new FirebaseAppCheckException( + ErrorCode.INVALID_ARGUMENT, "App Check token has no 'kid' (key ID) header."); + } + if (header.getType() == null || !JOSEObjectType.JWT.equals(header.getType())) { + throw new FirebaseAppCheckException( + ErrorCode.INVALID_ARGUMENT, + "App Check token has incorrect 'typ' header. Expected JWT but got: " + + header.getType()); + } + } + + private void verifyClaims(JWTClaimsSet claims) throws FirebaseAppCheckException { + checkNotNull(claims, "JWTClaimsSet claims must not be null"); + String issuer = claims.getIssuer(); + + if (Strings.isNullOrEmpty(issuer)) { + throw new FirebaseAppCheckException( + ErrorCode.INVALID_ARGUMENT, "App Check token has no 'iss' (issuer) claim."); + } + + if (!issuer.startsWith(APP_CHECK_ISSUER)) { + throw new FirebaseAppCheckException( + ErrorCode.INVALID_ARGUMENT, + "App Check token has incorrect issuer. Expected to start with: " + + APP_CHECK_ISSUER + + " but got: " + + issuer); + } + + String expectedAudience = APP_CHECK_AUDIENCE_PREFIX + this.projectId; + if (claims.getAudience().isEmpty() || !claims.getAudience().contains(expectedAudience)) { + throw new FirebaseAppCheckException( + ErrorCode.INVALID_ARGUMENT, + "App Check token has incorrect audience. Expected to contain: " + + expectedAudience + + " but got: " + + claims.getAudience()); + } + + if (Strings.isNullOrEmpty(claims.getSubject())) { + throw new FirebaseAppCheckException( + ErrorCode.INVALID_ARGUMENT, "App Check token has empty 'sub' (app ID) claim."); + } + } + + /** + * Sends an RPC request to the Firebase App Check backend service to verify + * and consume a limited-use (one-time) App Check token. + * + * @param token The raw App Check token string to consume. + * @return {@code true} if the token was already consumed prior to this verification call; + * {@code false} otherwise. + * @throws FirebaseAppCheckException If an HTTP error or backend service failure occurs. + */ + private boolean verifyOneTimeToken(String token) + throws FirebaseAppCheckException { + String url = String.format(VERIFY_TOKEN_URL_FORMAT, this.projectId); + GenericUrl genericUrl = new GenericUrl(url); + + GenericJson requestPayload = new GenericJson(); + requestPayload.put("app_check_token", token); + + try { + HttpRequest httpRequest = + requestFactory.buildPostRequest( + genericUrl, new JsonHttpContent(jsonFactory, requestPayload)); + httpRequest.setParser(jsonFactory.createJsonObjectParser()); + HttpResponse httpResponse = httpRequest.execute(); + + GenericJson response = httpResponse.parseAs(GenericJson.class); + Boolean alreadyConsumed = (Boolean) response.get("alreadyConsumed"); + if (alreadyConsumed == null) { + alreadyConsumed = (Boolean) response.get("already_consumed"); + } + return Boolean.TRUE.equals(alreadyConsumed); + } catch (IOException e) { + throw new FirebaseAppCheckException( + ErrorCode.INTERNAL, "Error verifying App Check token with backend: " + e.getMessage(), e); + } + } + + private DefaultJWTProcessor createJwtProcessor() { + DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); + try { + JWKSource keySource = createKeySource(); + JWSKeySelector keySelector = + new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keySource); + processor.setJWSKeySelector(keySelector); + } catch (MalformedURLException e) { + throw new IllegalStateException("Invalid JWKS URL", e); + } + return processor; + } + + protected JWKSource createKeySource() throws MalformedURLException { + return JWKSourceBuilder.create(URI.create(JWKS_URL).toURL()).retrying(true).build(); + } + + private String getProjectId(FirebaseApp app) { + String projectId = ImplFirebaseTrampolines.getProjectId(app); + if (Strings.isNullOrEmpty(projectId)) { + throw new IllegalArgumentException("Project ID is required in FirebaseOptions."); + } + return projectId; + } +} From 7e4dd4743ab733decdf563d30d42138e92f4980c Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:53:18 -0400 Subject: [PATCH 04/17] create the entry point for the Firebase App Check service and fix some formatting --- .../firebase/appcheck/FirebaseAppCheck.java | 139 ++++++++++++++++++ .../appcheck/VerifyAppCheckTokenResponse.java | 3 +- .../internal/AppCheckTokenVerifier.java | 9 +- 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/google/firebase/appcheck/FirebaseAppCheck.java diff --git a/src/main/java/com/google/firebase/appcheck/FirebaseAppCheck.java b/src/main/java/com/google/firebase/appcheck/FirebaseAppCheck.java new file mode 100644 index 000000000..17a7d9c8d --- /dev/null +++ b/src/main/java/com/google/firebase/appcheck/FirebaseAppCheck.java @@ -0,0 +1,139 @@ +/* + * Copyright 2026 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.appcheck.internal.AppCheckTokenVerifier; +import com.google.firebase.internal.CallableOperation; +import com.google.firebase.internal.FirebaseService; + +/** + * This class is the entry point for the Firebase App Check service. + * + *

You can get an instance of {@link FirebaseAppCheck} via {@link #getInstance()} + * or {@link #getInstance(FirebaseApp)}. + */ +public final class FirebaseAppCheck { + + private static final String SERVICE_ID = FirebaseAppCheck.class.getName(); + + private final FirebaseApp app; + private final AppCheckTokenVerifier tokenVerifier; + + private FirebaseAppCheck(FirebaseApp app) { + this(app, new AppCheckTokenVerifier(app)); + } + + @VisibleForTesting + FirebaseAppCheck(FirebaseApp app, AppCheckTokenVerifier tokenVerifier) { + this.app = checkNotNull(app, "FirebaseApp must not be null"); + this.tokenVerifier = checkNotNull(tokenVerifier, "AppCheckTokenVerifier must not be null"); + } + + /** + * 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}. + * + * @param app The {@link FirebaseApp} instance. + * @return The {@link FirebaseAppCheck} instance for the specified {@link FirebaseApp}. + */ + public static synchronized FirebaseAppCheck getInstance(FirebaseApp app) { + FirebaseAppCheckService service = + ImplFirebaseTrampolines.getService(app, SERVICE_ID, FirebaseAppCheckService.class); + if (service == null) { + service = ImplFirebaseTrampolines.addService(app, new FirebaseAppCheckService(app)); + } + return service.getInstance(); + } + + /** + * Verifies an App Check token string. + * + * @param appCheckToken The App Check token string to verify. + * @return A {@link VerifyAppCheckTokenResponse} containing the decoded token. + * @throws FirebaseAppCheckException If verification fails. + */ + public VerifyAppCheckTokenResponse verifyToken(String appCheckToken) + throws FirebaseAppCheckException { + return verifyToken(appCheckToken, null); + } + + /** + * Verifies an App Check token string with options. + * + * @param appCheckToken The App Check token string to verify. + * @param options Verification options specified via {@link VerifyAppCheckTokenOptions}. + * @return A {@link VerifyAppCheckTokenResponse} containing the decoded token + * and consumption status. + * @throws FirebaseAppCheckException If verification fails. + */ + public VerifyAppCheckTokenResponse verifyToken( + String appCheckToken, VerifyAppCheckTokenOptions options) throws FirebaseAppCheckException { + return this.tokenVerifier.verifyToken(appCheckToken, options); + } + + /** + * Asynchronously verifies an App Check token string. + * + * @param appCheckToken The App Check token string to verify. + * @return An {@link ApiFuture} containing the {@link VerifyAppCheckTokenResponse}. + */ + public ApiFuture verifyTokenAsync(String appCheckToken) { + return verifyTokenAsync(appCheckToken, null); + } + + /** + * Asynchronously verifies an App Check token string with options. + * + * @param appCheckToken The App Check token string to verify. + * @param options Verification options specified via {@link VerifyAppCheckTokenOptions}. + * @return An {@link ApiFuture} containing the {@link VerifyAppCheckTokenResponse}. + */ + public ApiFuture verifyTokenAsync( + String appCheckToken, VerifyAppCheckTokenOptions options) { + return verifyTokenOp(appCheckToken, options).callAsync(this.app); + } + + private CallableOperation verifyTokenOp( + final String appCheckToken, final VerifyAppCheckTokenOptions options) { + return new CallableOperation() { + @Override + protected VerifyAppCheckTokenResponse execute() throws FirebaseAppCheckException { + return verifyToken(appCheckToken, options); + } + }; + } + + private static class FirebaseAppCheckService extends FirebaseService { + FirebaseAppCheckService(FirebaseApp app) { + super(SERVICE_ID, new FirebaseAppCheck(app)); + } + } +} diff --git a/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponse.java b/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponse.java index 768c2b5e5..f9cd71e7c 100644 --- a/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponse.java +++ b/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponse.java @@ -55,7 +55,8 @@ public DecodedAppCheckToken getToken() { } /** - * Returns whether the token was already consumed prior to verification, if consume option was requested. + * Returns whether the token was already consumed prior to verification, + * if consume option was requested. */ public Optional isAlreadyConsumed() { return alreadyConsumed; diff --git a/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java b/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java index 6667b027c..593e7113c 100644 --- a/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java +++ b/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java @@ -73,7 +73,11 @@ public class AppCheckTokenVerifier { private volatile DefaultJWTProcessor jwtProcessor; public AppCheckTokenVerifier(FirebaseApp app) { - this(app, ApiClientUtils.newAuthorizedRequestFactory(app), ApiClientUtils.getDefaultJsonFactory(), null); + this( + app, + ApiClientUtils.newAuthorizedRequestFactory(app), + ApiClientUtils.getDefaultJsonFactory(), + null); } /** @@ -124,7 +128,8 @@ public VerifyAppCheckTokenResponse verifyToken(String token) throws FirebaseAppC * * @param token The App Check token string to verify. * @param options Verification options specified via {@link VerifyAppCheckTokenOptions}. - * @return A {@link VerifyAppCheckTokenResponse} containing the decoded token and consumption status. + * @return A {@link VerifyAppCheckTokenResponse} containing the decoded token + * and consumption status. * @throws FirebaseAppCheckException If verification fails. */ public VerifyAppCheckTokenResponse verifyToken( From 8b672ad0050d4bb6930d0ac14cdcaa0c69196fa8 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:18:08 -0400 Subject: [PATCH 05/17] Add unit tests for internal appcheck processing --- .../internal/AppCheckTokenVerifierTest.java | 441 ++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifierTest.java diff --git a/src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifierTest.java b/src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifierTest.java new file mode 100644 index 000000000..5df8864dd --- /dev/null +++ b/src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifierTest.java @@ -0,0 +1,441 @@ +/* + * Copyright 2026 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.internal; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpStatusCodes; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.LowLevelHttpRequest; +import com.google.api.client.http.LowLevelHttpResponse; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.testing.http.MockHttpTransport; +import com.google.api.client.testing.http.MockLowLevelHttpRequest; +import com.google.api.client.testing.http.MockLowLevelHttpResponse; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.TestOnlyImplFirebaseTrampolines; +import com.google.firebase.appcheck.DecodedAppCheckToken; +import com.google.firebase.appcheck.FirebaseAppCheckException; +import com.google.firebase.appcheck.VerifyAppCheckTokenOptions; +import com.google.firebase.appcheck.VerifyAppCheckTokenResponse; +import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.FirebaseProcessEnvironment; +import com.google.firebase.testing.ServiceAccount; +import com.google.firebase.testing.TestUtils; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import com.nimbusds.jwt.proc.ExpiredJWTException; +import java.lang.reflect.Method; +import java.net.MalformedURLException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.Collections; +import java.util.Date; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class AppCheckTokenVerifierTest { + + private static final String PROJECT_ID = "test-project-id"; + private static final FirebaseOptions firebaseOptions = + FirebaseOptions.builder() + .setProjectId(PROJECT_ID) + .setCredentials(TestUtils.getCertCredential(ServiceAccount.OWNER.asStream())) + .build(); + private static final String ISSUER = "https://firebaseappcheck.googleapis.com/"; + private static final String AUDIENCE = "projects/" + PROJECT_ID; + private static final String APP_ID = "test-app-id"; + private static final String KEY_ID = "key-id-1"; + + @Mock private DefaultJWTProcessor mockJwtProcessor; + + private AppCheckTokenVerifier verifier; + private KeyPair rsaKeyPair; + private JWSHeader header; + private JWTClaimsSet claims; + private Date issueTime; + private Date expirationTime; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + + issueTime = new Date(); + expirationTime = new Date(System.currentTimeMillis() + 10000); + + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(2048); + rsaKeyPair = gen.generateKeyPair(); + + FirebaseApp firebaseApp = FirebaseApp.initializeApp(firebaseOptions); + + HttpRequestFactory requestFactory = ApiClientUtils.newAuthorizedRequestFactory(firebaseApp); + JsonFactory jsonFactory = ApiClientUtils.getDefaultJsonFactory(); + + verifier = + new AppCheckTokenVerifier(firebaseApp, requestFactory, jsonFactory, mockJwtProcessor); + + header = + new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(KEY_ID) + .type(JOSEObjectType.JWT) + .build(); + + claims = + new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(AUDIENCE) + .subject(APP_ID) + .issueTime(issueTime) + .expirationTime(expirationTime) + .build(); + } + + @After + public void tearDown() { + FirebaseProcessEnvironment.clearCache(); + TestOnlyImplFirebaseTrampolines.clearInstancesForTest(); + } + + private String createToken(JWSHeader header, JWTClaimsSet claims) throws Exception { + SignedJWT jwt = new SignedJWT(header, claims); + + if (JWSAlgorithm.RS256.equals(header.getAlgorithm()) + || JWSAlgorithm.RS384.equals(header.getAlgorithm()) + || JWSAlgorithm.RS512.equals(header.getAlgorithm())) { + jwt.sign(new RSASSASigner(rsaKeyPair.getPrivate())); + } else if (JWSAlgorithm.HS256.equals(header.getAlgorithm())) { + jwt.sign(new com.nimbusds.jose.crypto.MACSigner("12345678901234567890123456789012")); + } + + return jwt.serialize(); + } + + @Test + public void testVerifyToken_Success() throws Exception { + String token = createToken(header, claims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(claims); + + VerifyAppCheckTokenResponse response = verifier.verifyToken(token); + + assertNotNull(response); + assertEquals(APP_ID, response.getAppId()); + assertNotNull(response.getToken()); + assertFalse(response.isAlreadyConsumed().isPresent()); + + DecodedAppCheckToken decodedToken = response.getToken(); + assertEquals(APP_ID, decodedToken.getSubject()); + assertEquals(ISSUER, decodedToken.getIssuer()); + assertEquals(Collections.singletonList(AUDIENCE), decodedToken.getAudience()); + assertEquals(issueTime.getTime() / 1000L, decodedToken.getIssuedAt()); + assertEquals(expirationTime.getTime() / 1000L, decodedToken.getExpirationTime()); + assertEquals(ISSUER, decodedToken.getClaims().get("iss")); + assertEquals(APP_ID, decodedToken.getClaims().get("sub")); + } + + @Test + public void testVerifyToken_WithConsumeOption_CallsBackend() throws Exception { + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(claims); + + MockLowLevelHttpResponse mockResponse = new MockLowLevelHttpResponse(); + mockResponse.setContentType("application/json"); + mockResponse.setContent("{\"alreadyConsumed\": true}"); + + MockHttpTransport transport = + new MockHttpTransport.Builder().setLowLevelHttpResponse(mockResponse).build(); + + HttpRequestFactory mockRequestFactory = transport.createRequestFactory(); + JsonFactory jsonFactory = ApiClientUtils.getDefaultJsonFactory(); + + FirebaseApp app = FirebaseApp.getInstance(); + AppCheckTokenVerifier customVerifier = + new AppCheckTokenVerifier(app, mockRequestFactory, jsonFactory, mockJwtProcessor); + + String token = createToken(header, claims); + VerifyAppCheckTokenOptions options = + VerifyAppCheckTokenOptions.builder().setConsume(true).build(); + VerifyAppCheckTokenResponse response = customVerifier.verifyToken(token, options); + + assertNotNull(response); + assertEquals(APP_ID, response.getAppId()); + assertTrue(response.isAlreadyConsumed().isPresent()); + assertTrue(response.isAlreadyConsumed().get()); + } + + @Test + public void testVerifyToken_NullOrEmptyToken_ThrowsException() { + IllegalArgumentException ex1 = + assertThrows(IllegalArgumentException.class, () -> verifier.verifyToken(null)); + assertTrue(ex1.getMessage().contains("must not be null or empty")); + + IllegalArgumentException ex2 = + assertThrows(IllegalArgumentException.class, () -> verifier.verifyToken("")); + assertTrue(ex2.getMessage().contains("must not be null or empty")); + } + + @Test + public void testVerifyHeader_IncorrectAlgorithm_ThrowsException() throws Exception { + JWSHeader badHeader = + new JWSHeader.Builder(JWSAlgorithm.RS384) + .keyID(KEY_ID) + .type(JOSEObjectType.JWT) + .build(); + final String token = createToken(badHeader, claims); + + FirebaseAppCheckException ex = + assertThrows(FirebaseAppCheckException.class, () -> verifier.verifyToken(token)); + assertEquals(ErrorCode.INVALID_ARGUMENT, ex.getErrorCode()); + assertTrue(ex.getMessage().contains("incorrect algorithm")); + } + + @Test + public void testVerifyHeader_MissingKid_ThrowsException() throws Exception { + JWSHeader badHeader = + new JWSHeader.Builder(JWSAlgorithm.RS256).type(JOSEObjectType.JWT).build(); + final String token = createToken(badHeader, claims); + + FirebaseAppCheckException ex = + assertThrows(FirebaseAppCheckException.class, () -> verifier.verifyToken(token)); + assertEquals(ErrorCode.INVALID_ARGUMENT, ex.getErrorCode()); + assertTrue(ex.getMessage().contains("no 'kid'")); + } + + @Test + public void testVerifyHeader_IncorrectType_ThrowsException() throws Exception { + JWSHeader badHeader = + new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(KEY_ID) + .type(JOSEObjectType.JOSE) + .build(); + String token = createToken(badHeader, claims); + + FirebaseAppCheckException ex = + assertThrows(FirebaseAppCheckException.class, () -> verifier.verifyToken(token)); + assertEquals(ErrorCode.INVALID_ARGUMENT, ex.getErrorCode()); + assertTrue(ex.getMessage().contains("incorrect 'typ'")); + } + + @Test + public void testVerifyClaims_MissingIssuer_ThrowsException() throws Exception { + JWTClaimsSet badClaims = + new JWTClaimsSet.Builder() + .audience(AUDIENCE) + .subject(APP_ID) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + String token = createToken(header, badClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badClaims); + + FirebaseAppCheckException ex = + assertThrows(FirebaseAppCheckException.class, () -> verifier.verifyToken(token)); + assertEquals(ErrorCode.INVALID_ARGUMENT, ex.getErrorCode()); + assertTrue(ex.getMessage().contains("no 'iss'")); + } + + @Test + public void testVerifyClaims_IncorrectIssuer_ThrowsException() throws Exception { + JWTClaimsSet badClaims = + new JWTClaimsSet.Builder() + .issuer("https://invalid-issuer.com") + .audience(AUDIENCE) + .subject(APP_ID) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + String token = createToken(header, badClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badClaims); + + FirebaseAppCheckException ex = + assertThrows(FirebaseAppCheckException.class, () -> verifier.verifyToken(token)); + assertEquals(ErrorCode.INVALID_ARGUMENT, ex.getErrorCode()); + assertTrue(ex.getMessage().contains("incorrect issuer")); + } + + @Test + public void testVerifyClaims_MissingAudience_ThrowsException() throws Exception { + JWTClaimsSet badClaims = + new JWTClaimsSet.Builder() + .issuer(ISSUER) + .subject(APP_ID) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + String token = createToken(header, badClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badClaims); + + FirebaseAppCheckException ex = + assertThrows(FirebaseAppCheckException.class, () -> verifier.verifyToken(token)); + assertEquals(ErrorCode.INVALID_ARGUMENT, ex.getErrorCode()); + assertTrue(ex.getMessage().contains("incorrect audience")); + } + + @Test + public void testVerifyClaims_IncorrectAudience_ThrowsException() throws Exception { + JWTClaimsSet badClaims = + new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience("projects/wrong-project-id") + .subject(APP_ID) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + String token = createToken(header, badClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badClaims); + + FirebaseAppCheckException ex = + assertThrows(FirebaseAppCheckException.class, () -> verifier.verifyToken(token)); + assertEquals(ErrorCode.INVALID_ARGUMENT, ex.getErrorCode()); + assertTrue(ex.getMessage().contains("incorrect audience")); + } + + @Test + public void testVerifyClaims_MissingSubject_ThrowsException() throws Exception { + JWTClaimsSet badClaims = + new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(AUDIENCE) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + String token = createToken(header, badClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badClaims); + + FirebaseAppCheckException ex = + assertThrows(FirebaseAppCheckException.class, () -> verifier.verifyToken(token)); + assertEquals(ErrorCode.INVALID_ARGUMENT, ex.getErrorCode()); + assertTrue(ex.getMessage().contains("empty 'sub'")); + } + + @Test + public void testVerifyToken_ExpiredToken_ThrowsException() throws Exception { + String token = createToken(header, claims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())) + .thenThrow(new ExpiredJWTException("Expired token")); + + FirebaseAppCheckException ex = + assertThrows(FirebaseAppCheckException.class, () -> verifier.verifyToken(token)); + assertEquals(ErrorCode.INVALID_ARGUMENT, ex.getErrorCode()); + assertTrue(ex.getMessage().contains("has expired")); + } + + @Test + public void testVerifyToken_BadJOSEException_ThrowsException() throws Exception { + String token = createToken(header, claims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())) + .thenThrow(new BadJOSEException("Bad signature")); + + FirebaseAppCheckException ex = + assertThrows(FirebaseAppCheckException.class, () -> verifier.verifyToken(token)); + assertEquals(ErrorCode.INVALID_ARGUMENT, ex.getErrorCode()); + assertTrue(ex.getMessage().contains("token is invalid")); + } + + @Test + public void testVerifyToken_JOSEException_ThrowsException() throws Exception { + String token = createToken(header, claims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())) + .thenThrow(new JOSEException("Key error")); + + FirebaseAppCheckException ex = + assertThrows(FirebaseAppCheckException.class, () -> verifier.verifyToken(token)); + assertEquals(ErrorCode.INTERNAL, ex.getErrorCode()); + assertTrue(ex.getMessage().contains("Failed to verify App Check token signature")); + } + + @Test + public void testVerifyToken_ParseException_ThrowsException() { + String invalidToken = "invalid-token-string"; + + FirebaseAppCheckException ex = + assertThrows(FirebaseAppCheckException.class, () -> verifier.verifyToken(invalidToken)); + assertEquals(ErrorCode.INVALID_ARGUMENT, ex.getErrorCode()); + assertTrue(ex.getMessage().contains("Failed to parse App Check JWT token")); + } + + @Test + public void testVerifyToken_Claims_Null() throws Exception { + JWTClaimsSet noSubClaims = new JWTClaimsSet.Builder().build(); + String tokenString = createToken(header, noSubClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(null); + + NullPointerException e = + assertThrows(NullPointerException.class, () -> verifier.verifyToken(tokenString)); + assertTrue(e.getMessage().contains("JWTClaimsSet claims must not be null")); + } + + @Test + public void testVerifierWithoutProjectId() { + FirebaseOptions localFirebaseOptions = + FirebaseOptions.builder() + .setCredentials(GoogleCredentials.create(null)) + .build(); + + FirebaseApp localApp = + FirebaseApp.initializeApp(localFirebaseOptions, "no-project-id-app"); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, () -> new AppCheckTokenVerifier(localApp)); + assertEquals("Project ID is required in FirebaseOptions.", e.getMessage()); + } + + @Test + public void testCreateJwtProcessor_HandlesException() throws Exception { + FirebaseApp app = FirebaseApp.initializeApp(firebaseOptions, "third"); + AppCheckTokenVerifier original = new AppCheckTokenVerifier(app); + AppCheckTokenVerifier spyClass = spy(original); + + doThrow(new MalformedURLException("Simulated bad URL")) + .when(spyClass) + .createKeySource(); + + Method method = AppCheckTokenVerifier.class.getDeclaredMethod("createJwtProcessor"); + method.setAccessible(true); + + try { + method.invoke(spyClass); + } catch (Exception e) { + Throwable cause = e.getCause(); + assertEquals(IllegalStateException.class, cause.getClass()); + assertEquals("Invalid JWKS URL", cause.getMessage()); + assertTrue(cause.getCause() instanceof MalformedURLException); + } + } +} From b015d1591e678c2b219abb5355e2f3c97506e59a Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:50:22 -0400 Subject: [PATCH 06/17] add unit tests for decoded app check token --- .../appcheck/DecodedAppCheckTokenTest.java | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 src/test/java/com/google/firebase/appcheck/DecodedAppCheckTokenTest.java diff --git a/src/test/java/com/google/firebase/appcheck/DecodedAppCheckTokenTest.java b/src/test/java/com/google/firebase/appcheck/DecodedAppCheckTokenTest.java new file mode 100644 index 000000000..a388585e0 --- /dev/null +++ b/src/test/java/com/google/firebase/appcheck/DecodedAppCheckTokenTest.java @@ -0,0 +1,155 @@ +/* + * Copyright 2026 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 org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.TestOnlyImplFirebaseTrampolines; +import com.google.firebase.internal.FirebaseProcessEnvironment; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import org.junit.After; +import org.junit.Test; + +public class DecodedAppCheckTokenTest { + + private static final String APP_ID = "test-app-id"; + private static final String ISSUER = "https://firebaseappcheck.googleapis.com/"; + private static final String AUDIENCE = "projects/test-project-id"; + + @After + public void tearDown() { + FirebaseProcessEnvironment.clearCache(); + TestOnlyImplFirebaseTrampolines.clearInstancesForTest(); + } + + @Test + public void testNullClaims_ThrowsException() { + NullPointerException e = + assertThrows(NullPointerException.class, () -> new DecodedAppCheckToken(null)); + assertTrue(e.getMessage().contains("Claims map must not be null")); + } + + @Test + public void testMissingSub_ThrowsException() { + Map claims = new HashMap<>(); + claims.put("iss", ISSUER); + + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> new DecodedAppCheckToken(claims)); + assertTrue(e.getMessage().contains("Claims map must contain sub")); + } + + @Test + public void testGetters_WithDateTimestamps() { + Date iat = new Date(1600000000000L); + Date exp = new Date(1600003600000L); + + Map claims = + ImmutableMap.builder() + .put("sub", APP_ID) + .put("iss", ISSUER) + .put("aud", AUDIENCE) + .put("iat", iat) + .put("exp", exp) + .build(); + + DecodedAppCheckToken token = new DecodedAppCheckToken(claims); + + assertNotNull(token); + assertEquals(APP_ID, token.getSubject()); + assertEquals(ISSUER, token.getIssuer()); + assertEquals(ImmutableList.of(AUDIENCE), token.getAudience()); + assertEquals(1600000000L, token.getIssuedAt()); + assertEquals(1600003600L, token.getExpirationTime()); + assertEquals(claims, token.getClaims()); + } + + @Test + public void testGetters_WithNumericTimestamps() { + Map claims = + ImmutableMap.builder() + .put("sub", APP_ID) + .put("iat", 1600000000L) + .put("exp", 1600003600L) + .build(); + + DecodedAppCheckToken token = new DecodedAppCheckToken(claims); + + assertNotNull(token); + assertEquals(1600000000L, token.getIssuedAt()); + assertEquals(1600003600L, token.getExpirationTime()); + } + + @Test + public void testGetAudience_ListFormat() { + Map claims = + ImmutableMap.of( + "sub", APP_ID, + "aud", ImmutableList.of("aud1", "aud2")); + + DecodedAppCheckToken token = new DecodedAppCheckToken(claims); + + assertEquals(ImmutableList.of("aud1", "aud2"), token.getAudience()); + } + + @Test + public void testGetAudience_StringFormat() { + Map claims = + ImmutableMap.of( + "sub", APP_ID, + "aud", AUDIENCE); + + DecodedAppCheckToken token = new DecodedAppCheckToken(claims); + + assertEquals(ImmutableList.of(AUDIENCE), token.getAudience()); + } + + @Test + public void testGetAudience_EmptyList() { + Map claims = + ImmutableMap.of( + "sub", APP_ID, + "aud", ImmutableList.of()); + + DecodedAppCheckToken token = new DecodedAppCheckToken(claims); + + assertEquals(ImmutableList.of(), token.getAudience()); + } + + @Test + public void testGetAudience_EmptyWhenMissingOrUnknownType() { + Map claims = ImmutableMap.of("sub", APP_ID); + DecodedAppCheckToken token = new DecodedAppCheckToken(claims); + assertEquals(ImmutableList.of(), token.getAudience()); + } + + @Test + public void testTimestamps_ZeroWhenMissingOrUnknownType() { + Map claims = ImmutableMap.of("sub", APP_ID); + DecodedAppCheckToken token = new DecodedAppCheckToken(claims); + assertEquals(0L, token.getIssuedAt()); + assertEquals(0L, token.getExpirationTime()); + } +} From c2818a9f3628112cfe36fe2105c4c90c28ec27ce Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:16:00 -0400 Subject: [PATCH 07/17] add unit tests for firebaseappcheck class --- .../appcheck/FirebaseAppCheckTest.java | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 src/test/java/com/google/firebase/appcheck/FirebaseAppCheckTest.java diff --git a/src/test/java/com/google/firebase/appcheck/FirebaseAppCheckTest.java b/src/test/java/com/google/firebase/appcheck/FirebaseAppCheckTest.java new file mode 100644 index 000000000..096e51f1d --- /dev/null +++ b/src/test/java/com/google/firebase/appcheck/FirebaseAppCheckTest.java @@ -0,0 +1,167 @@ +/* + * Copyright 2026 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 org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.api.core.ApiFuture; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.TestOnlyImplFirebaseTrampolines; +import com.google.firebase.appcheck.internal.AppCheckTokenVerifier; +import com.google.firebase.internal.FirebaseProcessEnvironment; +import com.google.firebase.testing.ServiceAccount; +import com.google.firebase.testing.TestUtils; +import java.lang.reflect.Field; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class FirebaseAppCheckTest { + + private static final FirebaseOptions firebaseOptions = + FirebaseOptions.builder() + .setCredentials(TestUtils.getCertCredential(ServiceAccount.OWNER.asStream())) + .setProjectId("test-project-id") + .build(); + + @Mock private AppCheckTokenVerifier mockVerifier; + + private FirebaseAppCheck firebaseAppCheck; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + + FirebaseApp.initializeApp(firebaseOptions); + firebaseAppCheck = FirebaseAppCheck.getInstance(); + + Field verifierField = FirebaseAppCheck.class.getDeclaredField("tokenVerifier"); + verifierField.setAccessible(true); + verifierField.set(firebaseAppCheck, mockVerifier); + } + + @After + public void tearDown() { + FirebaseProcessEnvironment.clearCache(); + TestOnlyImplFirebaseTrampolines.clearInstancesForTest(); + } + + @Test + public void testGetInstance() { + FirebaseAppCheck instance = FirebaseAppCheck.getInstance(); + assertNotNull(instance); + assertSame(instance, FirebaseAppCheck.getInstance()); + } + + @Test + public void testGetInstanceForApp() { + FirebaseApp app = FirebaseApp.initializeApp(firebaseOptions, "testGetInstanceForApp"); + FirebaseAppCheck instance = FirebaseAppCheck.getInstance(app); + assertNotNull(instance); + assertSame(instance, FirebaseAppCheck.getInstance(app)); + } + + @Test + public void testVerifyToken_DelegatesToVerifier() throws FirebaseAppCheckException { + String testToken = "test.token"; + DecodedAppCheckToken decodedToken = + new DecodedAppCheckToken(ImmutableMap.of("sub", "app-id")); + VerifyAppCheckTokenResponse expectedResponse = + new VerifyAppCheckTokenResponse("app-id", decodedToken, null); + + when(mockVerifier.verifyToken(testToken, null)).thenReturn(expectedResponse); + + VerifyAppCheckTokenResponse result = firebaseAppCheck.verifyToken(testToken); + + assertEquals(expectedResponse, result); + verify(mockVerifier, times(1)).verifyToken(testToken, null); + } + + @Test + public void testVerifyTokenWithOptions_DelegatesToVerifier() throws FirebaseAppCheckException { + String testToken = "test.token"; + VerifyAppCheckTokenOptions options = + VerifyAppCheckTokenOptions.builder().setConsume(true).build(); + DecodedAppCheckToken decodedToken = + new DecodedAppCheckToken(ImmutableMap.of("sub", "app-id")); + VerifyAppCheckTokenResponse expectedResponse = + new VerifyAppCheckTokenResponse("app-id", decodedToken, true); + + when(mockVerifier.verifyToken(testToken, options)).thenReturn(expectedResponse); + + VerifyAppCheckTokenResponse result = firebaseAppCheck.verifyToken(testToken, options); + + assertEquals(expectedResponse, result); + verify(mockVerifier, times(1)).verifyToken(testToken, options); + } + + @Test + public void testVerifyTokenAsync_DelegatesToVerifier() throws Exception { + String testToken = "test.token"; + DecodedAppCheckToken decodedToken = + new DecodedAppCheckToken(ImmutableMap.of("sub", "app-id")); + VerifyAppCheckTokenResponse expectedResponse = + new VerifyAppCheckTokenResponse("app-id", decodedToken, null); + + when(mockVerifier.verifyToken(testToken, null)).thenReturn(expectedResponse); + + ApiFuture future = firebaseAppCheck.verifyTokenAsync(testToken); + + assertEquals(expectedResponse, future.get()); + verify(mockVerifier, times(1)).verifyToken(testToken, null); + } + + @Test + public void testVerifyTokenAsyncWithOptions_DelegatesToVerifier() throws Exception { + String testToken = "test.token"; + VerifyAppCheckTokenOptions options = + VerifyAppCheckTokenOptions.builder().setConsume(true).build(); + DecodedAppCheckToken decodedToken = + new DecodedAppCheckToken(ImmutableMap.of("sub", "app-id")); + VerifyAppCheckTokenResponse expectedResponse = + new VerifyAppCheckTokenResponse("app-id", decodedToken, true); + + when(mockVerifier.verifyToken(testToken, options)).thenReturn(expectedResponse); + + ApiFuture future = + firebaseAppCheck.verifyTokenAsync(testToken, options); + + assertEquals(expectedResponse, future.get()); + verify(mockVerifier, times(1)).verifyToken(testToken, options); + } + + @Test(expected = NullPointerException.class) + public void testNullApp_ThrowsException() { + new FirebaseAppCheck(null, mockVerifier); + } + + @Test(expected = NullPointerException.class) + public void testNullVerifier_ThrowsException() { + FirebaseApp app = FirebaseApp.getInstance(); + new FirebaseAppCheck(app, null); + } +} From 2cdabc532ad786918a7789addae8d6689a51c77c Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:18:45 -0400 Subject: [PATCH 08/17] add unit tests for appchecktokenoptions --- .../VerifyAppCheckTokenOptionsTest.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/test/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptionsTest.java diff --git a/src/test/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptionsTest.java b/src/test/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptionsTest.java new file mode 100644 index 000000000..e55c440df --- /dev/null +++ b/src/test/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptionsTest.java @@ -0,0 +1,51 @@ +/* + * Copyright 2026 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 org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class VerifyAppCheckTokenOptionsTest { + + @Test + public void testDefaultBuilder_EmptyConsume() { + VerifyAppCheckTokenOptions options = VerifyAppCheckTokenOptions.builder().build(); + assertNotNull(options); + assertFalse(options.getConsume().isPresent()); + } + + @Test + public void testBuilder_SetConsumeTrue() { + VerifyAppCheckTokenOptions options = + VerifyAppCheckTokenOptions.builder().setConsume(true).build(); + assertNotNull(options); + assertTrue(options.getConsume().isPresent()); + assertTrue(options.getConsume().get()); + } + + @Test + public void testBuilder_SetConsumeFalse() { + VerifyAppCheckTokenOptions options = + VerifyAppCheckTokenOptions.builder().setConsume(false).build(); + assertNotNull(options); + assertTrue(options.getConsume().isPresent()); + assertFalse(options.getConsume().get()); + } +} From 03e1be154cd8f4af3857db802ea85c8dba8ddf8a Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:29:50 -0400 Subject: [PATCH 09/17] add unit tests for appchecktokenresponse --- .../VerifyAppCheckTokenResponseTest.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/test/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponseTest.java diff --git a/src/test/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponseTest.java b/src/test/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponseTest.java new file mode 100644 index 000000000..ba605621f --- /dev/null +++ b/src/test/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponseTest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2026 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 org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.common.collect.ImmutableMap; +import org.junit.Test; + +public class VerifyAppCheckTokenResponseTest { + + private static final String APP_ID = "test-app-id"; + private static final DecodedAppCheckToken DECODED_TOKEN = + new DecodedAppCheckToken(ImmutableMap.of("sub", APP_ID)); + + @Test + public void testNullAppId_ThrowsException() { + NullPointerException e = + assertThrows( + NullPointerException.class, + () -> new VerifyAppCheckTokenResponse(null, DECODED_TOKEN, null)); + assertTrue(e.getMessage().contains("appId must not be null")); + } + + @Test + public void testNullToken_ThrowsException() { + NullPointerException e = + assertThrows( + NullPointerException.class, + () -> new VerifyAppCheckTokenResponse(APP_ID, null, null)); + assertTrue(e.getMessage().contains("token must not be null")); + } + + @Test + public void testGetters_WithoutAlreadyConsumed() { + VerifyAppCheckTokenResponse response = + new VerifyAppCheckTokenResponse(APP_ID, DECODED_TOKEN, null); + + assertNotNull(response); + assertEquals(APP_ID, response.getAppId()); + assertEquals(DECODED_TOKEN, response.getToken()); + assertFalse(response.isAlreadyConsumed().isPresent()); + } + + @Test + public void testGetters_WithAlreadyConsumedTrue() { + VerifyAppCheckTokenResponse response = + new VerifyAppCheckTokenResponse(APP_ID, DECODED_TOKEN, true); + + assertNotNull(response); + assertEquals(APP_ID, response.getAppId()); + assertEquals(DECODED_TOKEN, response.getToken()); + assertTrue(response.isAlreadyConsumed().isPresent()); + assertTrue(response.isAlreadyConsumed().get()); + } + + @Test + public void testGetters_WithAlreadyConsumedFalse() { + VerifyAppCheckTokenResponse response = + new VerifyAppCheckTokenResponse(APP_ID, DECODED_TOKEN, false); + + assertNotNull(response); + assertEquals(APP_ID, response.getAppId()); + assertEquals(DECODED_TOKEN, response.getToken()); + assertTrue(response.isAlreadyConsumed().isPresent()); + assertFalse(response.isAlreadyConsumed().get()); + } +} From e5269d6743930910b525b2499e31b0cc4bf3124e Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:27:06 -0400 Subject: [PATCH 10/17] add defensively check for null for audience claim and wrap the httprequest execute in a try-finally block --- .../appcheck/internal/AppCheckTokenVerifier.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java b/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java index 593e7113c..d33813908 100644 --- a/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java +++ b/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java @@ -54,6 +54,7 @@ import java.net.MalformedURLException; import java.net.URI; import java.text.ParseException; +import java.util.List; /** * Internal verifier for Firebase App Check tokens. @@ -220,14 +221,15 @@ private void verifyClaims(JWTClaimsSet claims) throws FirebaseAppCheckException + issuer); } + List audience = claims.getAudience(); String expectedAudience = APP_CHECK_AUDIENCE_PREFIX + this.projectId; - if (claims.getAudience().isEmpty() || !claims.getAudience().contains(expectedAudience)) { + if (audience == null || audience.isEmpty() || !audience.contains(expectedAudience)) { throw new FirebaseAppCheckException( ErrorCode.INVALID_ARGUMENT, "App Check token has incorrect audience. Expected to contain: " + expectedAudience + " but got: " - + claims.getAudience()); + + audience); } if (Strings.isNullOrEmpty(claims.getSubject())) { @@ -253,12 +255,13 @@ private boolean verifyOneTimeToken(String token) GenericJson requestPayload = new GenericJson(); requestPayload.put("app_check_token", token); + HttpResponse httpResponse = null; try { HttpRequest httpRequest = requestFactory.buildPostRequest( genericUrl, new JsonHttpContent(jsonFactory, requestPayload)); httpRequest.setParser(jsonFactory.createJsonObjectParser()); - HttpResponse httpResponse = httpRequest.execute(); + httpResponse = httpRequest.execute(); GenericJson response = httpResponse.parseAs(GenericJson.class); Boolean alreadyConsumed = (Boolean) response.get("alreadyConsumed"); @@ -269,6 +272,8 @@ private boolean verifyOneTimeToken(String token) } catch (IOException e) { throw new FirebaseAppCheckException( ErrorCode.INTERNAL, "Error verifying App Check token with backend: " + e.getMessage(), e); + } finally { + ApiClientUtils.disconnectQuietly(httpResponse); } } From 3ebfa55fe6ae6ba60c93826f95e9dc882fd3b9f1 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:04:18 -0400 Subject: [PATCH 11/17] add 6-hour caching and default refresh timeout to createKeySource --- .../firebase/appcheck/internal/AppCheckTokenVerifier.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java b/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java index d33813908..6c61f1105 100644 --- a/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java +++ b/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java @@ -55,6 +55,7 @@ import java.net.URI; import java.text.ParseException; import java.util.List; +import java.util.concurrent.TimeUnit; /** * Internal verifier for Firebase App Check tokens. @@ -66,6 +67,7 @@ public class AppCheckTokenVerifier { private static final String APP_CHECK_AUDIENCE_PREFIX = "projects/"; private static final String VERIFY_TOKEN_URL_FORMAT = "https://firebaseappcheck.googleapis.com/v1/projects/%s:verifyAppCheckToken"; + private static final long JWKS_CACHE_TTL_MILLIS = TimeUnit.HOURS.toMillis(6); private final FirebaseApp app; private final String projectId; @@ -291,7 +293,10 @@ private DefaultJWTProcessor createJwtProcessor() { } protected JWKSource createKeySource() throws MalformedURLException { - return JWKSourceBuilder.create(URI.create(JWKS_URL).toURL()).retrying(true).build(); + return JWKSourceBuilder.create(URI.create(JWKS_URL).toURL()) + .cache(JWKS_CACHE_TTL_MILLIS, JWKSourceBuilder.DEFAULT_CACHE_REFRESH_TIMEOUT) + .retrying(true) + .build(); } private String getProjectId(FirebaseApp app) { From 208dbe87f2a03a2a25d17a5f5019558affd05f0a Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:14:57 -0400 Subject: [PATCH 12/17] initialize the jwtProcessor inside the constructor and remove getJwtProcessor --- .../internal/AppCheckTokenVerifier.java | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java b/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java index 6c61f1105..0b8b9111e 100644 --- a/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java +++ b/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java @@ -73,7 +73,7 @@ public class AppCheckTokenVerifier { private final String projectId; private final HttpRequestFactory requestFactory; private final JsonFactory jsonFactory; - private volatile DefaultJWTProcessor jwtProcessor; + private final DefaultJWTProcessor jwtProcessor; public AppCheckTokenVerifier(FirebaseApp app) { this( @@ -98,21 +98,7 @@ public AppCheckTokenVerifier(FirebaseApp app) { this.projectId = getProjectId(app); this.requestFactory = checkNotNull(requestFactory, "HttpRequestFactory must not be null"); this.jsonFactory = checkNotNull(jsonFactory, "JsonFactory must not be null"); - this.jwtProcessor = jwtProcessor; - } - - private DefaultJWTProcessor getJwtProcessor() { - DefaultJWTProcessor processor = this.jwtProcessor; - if (processor == null) { - synchronized (this) { - processor = this.jwtProcessor; - if (processor == null) { - processor = createJwtProcessor(); - this.jwtProcessor = processor; - } - } - } - return processor; + this.jwtProcessor = jwtProcessor != null ? jwtProcessor : createJwtProcessor(); } /** @@ -160,7 +146,7 @@ private DecodedAppCheckToken verifyTokenLocally(String token) throws FirebaseApp try { SignedJWT signedJwt = SignedJWT.parse(token); verifyHeader(signedJwt.getHeader()); - JWTClaimsSet claims = getJwtProcessor().process(signedJwt, null); + JWTClaimsSet claims = this.jwtProcessor.process(signedJwt, null); verifyClaims(claims); return new DecodedAppCheckToken(claims.getClaims()); } catch (ParseException e) { From eebd884efa282ef64c7c10c642967d4fc4631b38 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:36:21 -0400 Subject: [PATCH 13/17] add comment for future issuer check upgrade --- .../firebase/appcheck/internal/AppCheckTokenVerifier.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java b/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java index 0b8b9111e..fd54034a5 100644 --- a/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java +++ b/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java @@ -200,6 +200,12 @@ private void verifyClaims(JWTClaimsSet claims) throws FirebaseAppCheckException ErrorCode.INVALID_ARGUMENT, "App Check token has no 'iss' (issuer) claim."); } + // The issuer is of the form https://firebaseappcheck.googleapis.com/. + // Because the SDK currently only has access to the project ID (not the project number), + // the verifier checks the issuer prefix rather than strict equality. If the SDK is updated in + // the future to expose the project number (per https://google.aip.dev/cloud/2510), this check + // should be updated to verify strict equality against + // https://firebaseappcheck.googleapis.com/. if (!issuer.startsWith(APP_CHECK_ISSUER)) { throw new FirebaseAppCheckException( ErrorCode.INVALID_ARGUMENT, From a737d2bb4bed711f8c22b510262c70d279522d23 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:49:24 -0400 Subject: [PATCH 14/17] reverse error messages for badjoseexception and joseexception --- .../firebase/appcheck/internal/AppCheckTokenVerifier.java | 4 ++-- .../firebase/appcheck/internal/AppCheckTokenVerifierTest.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java b/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java index fd54034a5..acbe9fa1e 100644 --- a/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java +++ b/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java @@ -158,13 +158,13 @@ private DecodedAppCheckToken verifyTokenLocally(String token) throws FirebaseApp } catch (BadJOSEException e) { throw new FirebaseAppCheckException( ErrorCode.INVALID_ARGUMENT, - "Check your project: " + projectId + ". Firebase App Check token is invalid: " + "Check your project: " + projectId + ". Failed to verify App Check token signature: " + e.getMessage(), e); } catch (JOSEException e) { throw new FirebaseAppCheckException( ErrorCode.INTERNAL, - "Check your project: " + projectId + ". Failed to verify App Check token signature: " + "Check your project: " + projectId + ". Internal error processing App Check token: " + e.getMessage(), e); } diff --git a/src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifierTest.java b/src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifierTest.java index 5df8864dd..0758de6e7 100644 --- a/src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifierTest.java +++ b/src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifierTest.java @@ -364,7 +364,7 @@ public void testVerifyToken_BadJOSEException_ThrowsException() throws Exception FirebaseAppCheckException ex = assertThrows(FirebaseAppCheckException.class, () -> verifier.verifyToken(token)); assertEquals(ErrorCode.INVALID_ARGUMENT, ex.getErrorCode()); - assertTrue(ex.getMessage().contains("token is invalid")); + assertTrue(ex.getMessage().contains("Failed to verify App Check token signature")); } @Test @@ -376,7 +376,7 @@ public void testVerifyToken_JOSEException_ThrowsException() throws Exception { FirebaseAppCheckException ex = assertThrows(FirebaseAppCheckException.class, () -> verifier.verifyToken(token)); assertEquals(ErrorCode.INTERNAL, ex.getErrorCode()); - assertTrue(ex.getMessage().contains("Failed to verify App Check token signature")); + assertTrue(ex.getMessage().contains("Internal error processing App Check token")); } @Test From 2c05450e305396ea1a4ef1bbeb55a93283fe6c27 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:28:29 -0400 Subject: [PATCH 15/17] use java.time.Instant for get issued at time and expiration time --- .../appcheck/DecodedAppCheckToken.java | 31 ++++++++++--------- .../appcheck/DecodedAppCheckTokenTest.java | 15 ++++----- .../internal/AppCheckTokenVerifierTest.java | 4 +-- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java b/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java index 6e5ca1460..bb1bf0c60 100644 --- a/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java +++ b/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java @@ -21,6 +21,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import java.time.Instant; import java.util.Date; import java.util.List; import java.util.Map; @@ -73,25 +74,17 @@ public List getAudience() { } /** - * Returns the expiration time in seconds since the Unix epoch. + * Returns the expiration time as an {@link Instant}, or {@code null} if not present. */ - public long getExpirationTime() { - Object exp = claims.get("exp"); - if (exp instanceof Date) { - return ((Date) exp).getTime() / 1000L; - } - return exp instanceof Number ? ((Number) exp).longValue() : 0L; + public Instant getExpirationTime() { + return toInstant(claims.get("exp")); } /** - * Returns the issued-at time in seconds since the Unix epoch. + * Returns the issued-at time as an {@link Instant}, or {@code null} if not present. */ - public long getIssuedAt() { - Object iat = claims.get("iat"); - if (iat instanceof Date) { - return ((Date) iat).getTime() / 1000L; - } - return iat instanceof Number ? ((Number) iat).longValue() : 0L; + public Instant getIssuedAt() { + return toInstant(claims.get("iat")); } /** @@ -100,4 +93,14 @@ public long getIssuedAt() { public Map getClaims() { return claims; } + + private static Instant toInstant(Object timeObj) { + if (timeObj instanceof Date) { + return ((Date) timeObj).toInstant(); + } + if (timeObj instanceof Number) { + return Instant.ofEpochSecond(((Number) timeObj).longValue()); + } + return null; + } } diff --git a/src/test/java/com/google/firebase/appcheck/DecodedAppCheckTokenTest.java b/src/test/java/com/google/firebase/appcheck/DecodedAppCheckTokenTest.java index a388585e0..96f04d4e7 100644 --- a/src/test/java/com/google/firebase/appcheck/DecodedAppCheckTokenTest.java +++ b/src/test/java/com/google/firebase/appcheck/DecodedAppCheckTokenTest.java @@ -26,6 +26,7 @@ import com.google.common.collect.ImmutableMap; import com.google.firebase.TestOnlyImplFirebaseTrampolines; import com.google.firebase.internal.FirebaseProcessEnvironment; +import java.time.Instant; import java.util.Date; import java.util.HashMap; import java.util.Map; @@ -81,8 +82,8 @@ public void testGetters_WithDateTimestamps() { assertEquals(APP_ID, token.getSubject()); assertEquals(ISSUER, token.getIssuer()); assertEquals(ImmutableList.of(AUDIENCE), token.getAudience()); - assertEquals(1600000000L, token.getIssuedAt()); - assertEquals(1600003600L, token.getExpirationTime()); + assertEquals(Instant.ofEpochMilli(1600000000000L), token.getIssuedAt()); + assertEquals(Instant.ofEpochMilli(1600003600000L), token.getExpirationTime()); assertEquals(claims, token.getClaims()); } @@ -98,8 +99,8 @@ public void testGetters_WithNumericTimestamps() { DecodedAppCheckToken token = new DecodedAppCheckToken(claims); assertNotNull(token); - assertEquals(1600000000L, token.getIssuedAt()); - assertEquals(1600003600L, token.getExpirationTime()); + assertEquals(Instant.ofEpochSecond(1600000000L), token.getIssuedAt()); + assertEquals(Instant.ofEpochSecond(1600003600L), token.getExpirationTime()); } @Test @@ -146,10 +147,10 @@ public void testGetAudience_EmptyWhenMissingOrUnknownType() { } @Test - public void testTimestamps_ZeroWhenMissingOrUnknownType() { + public void testTimestamps_NullWhenMissingOrUnknownType() { Map claims = ImmutableMap.of("sub", APP_ID); DecodedAppCheckToken token = new DecodedAppCheckToken(claims); - assertEquals(0L, token.getIssuedAt()); - assertEquals(0L, token.getExpirationTime()); + assertNull(token.getIssuedAt()); + assertNull(token.getExpirationTime()); } } diff --git a/src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifierTest.java b/src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifierTest.java index 0758de6e7..005531c76 100644 --- a/src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifierTest.java +++ b/src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifierTest.java @@ -168,8 +168,8 @@ public void testVerifyToken_Success() throws Exception { assertEquals(APP_ID, decodedToken.getSubject()); assertEquals(ISSUER, decodedToken.getIssuer()); assertEquals(Collections.singletonList(AUDIENCE), decodedToken.getAudience()); - assertEquals(issueTime.getTime() / 1000L, decodedToken.getIssuedAt()); - assertEquals(expirationTime.getTime() / 1000L, decodedToken.getExpirationTime()); + assertEquals(issueTime.toInstant(), decodedToken.getIssuedAt()); + assertEquals(expirationTime.toInstant(), decodedToken.getExpirationTime()); assertEquals(ISSUER, decodedToken.getClaims().get("iss")); assertEquals(APP_ID, decodedToken.getClaims().get("sub")); } From c30d590051fc5d6ebcf9907385183a38bbba2747 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:52:17 -0400 Subject: [PATCH 16/17] add appid, provider and jti properties exposure --- .../appcheck/DecodedAppCheckToken.java | 22 +++++++++++++++++++ .../appcheck/DecodedAppCheckTokenTest.java | 8 +++++++ 2 files changed, 30 insertions(+) diff --git a/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java b/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java index bb1bf0c60..599bd99e1 100644 --- a/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java +++ b/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java @@ -58,6 +58,28 @@ public String getSubject() { return (String) claims.get("sub"); } + /** + * Returns the App ID for which this token was issued. + * This is an alias for {@link #getSubject()}. + */ + public String getAppId() { + return getSubject(); + } + + /** + * Returns the JWT ID ('jti') of the token, or {@code null} if not present. + */ + public String getJti() { + return (String) claims.get("jti"); + } + + /** + * Returns the attestation provider for this token, or {@code null} if not present. + */ + public String getProvider() { + return (String) claims.get("provider"); + } + /** * Returns the audience for which this token is intended. */ diff --git a/src/test/java/com/google/firebase/appcheck/DecodedAppCheckTokenTest.java b/src/test/java/com/google/firebase/appcheck/DecodedAppCheckTokenTest.java index 96f04d4e7..6a36d7e78 100644 --- a/src/test/java/com/google/firebase/appcheck/DecodedAppCheckTokenTest.java +++ b/src/test/java/com/google/firebase/appcheck/DecodedAppCheckTokenTest.java @@ -74,13 +74,18 @@ public void testGetters_WithDateTimestamps() { .put("aud", AUDIENCE) .put("iat", iat) .put("exp", exp) + .put("jti", "test-jwt-id") + .put("provider", "play_integrity") .build(); DecodedAppCheckToken token = new DecodedAppCheckToken(claims); assertNotNull(token); assertEquals(APP_ID, token.getSubject()); + assertEquals(APP_ID, token.getAppId()); assertEquals(ISSUER, token.getIssuer()); + assertEquals("test-jwt-id", token.getJti()); + assertEquals("play_integrity", token.getProvider()); assertEquals(ImmutableList.of(AUDIENCE), token.getAudience()); assertEquals(Instant.ofEpochMilli(1600000000000L), token.getIssuedAt()); assertEquals(Instant.ofEpochMilli(1600003600000L), token.getExpirationTime()); @@ -99,6 +104,9 @@ public void testGetters_WithNumericTimestamps() { DecodedAppCheckToken token = new DecodedAppCheckToken(claims); assertNotNull(token); + assertEquals(APP_ID, token.getAppId()); + assertNull(token.getJti()); + assertNull(token.getProvider()); assertEquals(Instant.ofEpochSecond(1600000000L), token.getIssuedAt()); assertEquals(Instant.ofEpochSecond(1600003600L), token.getExpirationTime()); } From 8ee2a993d1c872bc63581e9f528c3e779fa6e2da Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:04:22 -0400 Subject: [PATCH 17/17] add check for null for setConsume --- .../appcheck/VerifyAppCheckTokenOptions.java | 6 ++-- .../VerifyAppCheckTokenOptionsTest.java | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptions.java b/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptions.java index 781688a75..4b1c7bc2c 100644 --- a/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptions.java +++ b/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptions.java @@ -16,6 +16,8 @@ package com.google.firebase.appcheck; +import static com.google.common.base.Preconditions.checkNotNull; + import java.util.Optional; /** @@ -60,11 +62,11 @@ public Builder setConsume(boolean consume) { /** * Sets whether to consume the token during verification. * - * @param consume Optional boolean value. + * @param consume Optional boolean value. Must not be null. * @return This builder. */ public Builder setConsume(Optional consume) { - this.consume = consume != null ? consume : Optional.empty(); + this.consume = checkNotNull(consume, "consume must not be null"); return this; } diff --git a/src/test/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptionsTest.java b/src/test/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptionsTest.java index e55c440df..5948ffcd3 100644 --- a/src/test/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptionsTest.java +++ b/src/test/java/com/google/firebase/appcheck/VerifyAppCheckTokenOptionsTest.java @@ -16,10 +16,13 @@ package com.google.firebase.appcheck; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import java.util.Optional; import org.junit.Test; public class VerifyAppCheckTokenOptionsTest { @@ -48,4 +51,30 @@ public void testBuilder_SetConsumeFalse() { assertTrue(options.getConsume().isPresent()); assertFalse(options.getConsume().get()); } + + @Test + public void testBuilder_SetConsumeOptional_Present() { + VerifyAppCheckTokenOptions options = + VerifyAppCheckTokenOptions.builder().setConsume(Optional.of(true)).build(); + assertNotNull(options); + assertTrue(options.getConsume().isPresent()); + assertTrue(options.getConsume().get()); + } + + @Test + public void testBuilder_SetConsumeOptional_Empty() { + VerifyAppCheckTokenOptions options = + VerifyAppCheckTokenOptions.builder().setConsume(Optional.empty()).build(); + assertNotNull(options); + assertFalse(options.getConsume().isPresent()); + } + + @Test + public void testBuilder_SetConsumeOptionalNull_ThrowsException() { + NullPointerException e = + assertThrows( + NullPointerException.class, + () -> VerifyAppCheckTokenOptions.builder().setConsume((Optional) null)); + assertEquals("consume must not be null", e.getMessage()); + } }