Skip to content

feat(appcheck): Add App Check token verification support with replay protection - #1233

Open
yvonnep165 wants to merge 17 commits into
mainfrom
yp-verify-appcheck-token
Open

feat(appcheck): Add App Check token verification support with replay protection#1233
yvonnep165 wants to merge 17 commits into
mainfrom
yp-verify-appcheck-token

Conversation

@yvonnep165

Copy link
Copy Markdown
Contributor

This PR adds support for App Check standard token verification and one-time token verification for replay protection. This enables backend servers to verify standard App Check JWTs locally and optionally verify/consume limited-use (replay-protected) tokens via backend RPC.

  • Adds the FirebaseAppCheck service entry point with synchronous and asynchronous verifyToken methods.
  • Creates the data models and options (DecodedAppCheckToken, VerifyAppCheckTokenResponse, VerifyAppCheckTokenOptions, FirebaseAppCheckException) for token verification results and options.
  • Verifies tokens locally using App Check public keys and calls the backend to consume one-time tokens.
  • Adds full unit test suites

@yvonnep165 yvonnep165 self-assigned this Aug 24, 2026
@yvonnep165 yvonnep165 added release-note release:stage Stage a release candidate labels Aug 24, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the Firebase App Check service, including token verification capabilities, option and response models, custom exceptions, and comprehensive unit tests. The review feedback highlights two critical issues in AppCheckTokenVerifier.java: a potential NullPointerException when checking the audience claim if it is missing from the JWT, and a resource leak due to the HttpResponse not being closed after executing the backend verification request.

Comment thread src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenVerifier.java Outdated

@weixifan weixifan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you for this PR! Some minor comments.

private final JsonFactory jsonFactory;
private volatile DefaultJWTProcessor<SecurityContext> jwtProcessor;

public AppCheckTokenVerifier(FirebaseApp app) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In the Admin SDK, do we use Guice or Dagger to manage the dependency injection of parameters? If so, a singleton-scoped JWK parameter can be injected and managed by Guice or Dagger instead.

@yvonnep165 yvonnep165 Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We don't use Guice or Dagger. Instead we use manual dependency injection via package-private @VisibleForTesting constructors and the services are singletons scoped to the FirebaseApp lifecycle.

}

protected JWKSource<SecurityContext> createKeySource() throws MalformedURLException {
return JWKSourceBuilder.create(URI.create(JWKS_URL).toURL()).retrying(true).build();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we please cache this for 6 hours, using .cache(...)? The refresh timeout can be left at default (15 seconds).

https://www.javadoc.io/doc/com.nimbusds/nimbus-jose-jwt/latest/com/nimbusds/jose/jwk/source/JWKSourceBuilder.html#cache(long,long)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sure! added.


private DefaultJWTProcessor<SecurityContext> getJwtProcessor() {
DefaultJWTProcessor<SecurityContext> processor = this.jwtProcessor;
if (processor == null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there a reason this initialization logic can't be put into the constructor? Doing so avoids the subtle and error-prone synchronization logic. For example, the necessity of the double null-check is not immediately obvious.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Just tried to mirror the pattern from FirebasePhoneNumberVerificationTokenVerifier, which used double-checked locking for its jwtProcessor. But that's a good point! I updated jwtProcessor to be initialized directly in the constructor as a final field and removed the double checking.

ErrorCode.INVALID_ARGUMENT, "App Check token has no 'iss' (issuer) claim.");
}

if (!issuer.startsWith(APP_CHECK_ISSUER)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think in Admin SDK, we don't have access to the developer's project number. This is actually a deficiency, as https://google.aip.dev/cloud/2510 states that the project number is the canonical identifier, and there are issues in working with project IDs in general.

In the future, if we ever address this issue and we finally get access to the project number, it's actually better to check for strict equality to https://firebaseappcheck.googleapis.com/<project_number> here -- for example, https://firebaseappcheck.googleapis.com/12345678.

Perhaps a comment explaining this would be appropriate here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment added.

} catch (BadJOSEException e) {
throw new FirebaseAppCheckException(
ErrorCode.INVALID_ARGUMENT,
"Check your project: " + projectId + ". Firebase App Check token is invalid: "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this exception type is the one corresponding to an invalid signature, while the JOSEException is thrown when an internal error happens.

https://www.javadoc.io/doc/com.nimbusds/nimbus-jose-jwt/latest/com/nimbusds/jwt/proc/DefaultJWTProcessor.html#process(com.nimbusds.jwt.SignedJWT,C)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for catching that!

*/
public DecodedAppCheckToken(Map<String, Object> claims) {
checkNotNull(claims, "Claims map must not be null");
checkArgument(claims.containsKey("sub"), "Claims map must contain sub");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should probably also check the other required claims iss, aud, exp, and iat.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This followed the convention from FirebaseToken (auth) and FirebasePhoneNumberVerificationToken, where only sub is strictly required in the constructor and the full claims validation (iss, aud, exp, iat) is handled by AppCheckTokenVerifier before the object is instantiated. Try to keep DecodedAppCheckToken as a flexible claims wrapper allows the getters to provide fallbacks (e.g. getAudience() returning []) and make creating mock tokens in tests much simpler. But let me know if you'd still prefer enforcing all of them here though!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't feel too strongly about this topic. Note that for these required claim names, the App Check backend is quite disciplined and will never return empty or null for them. For testing, it's reasonable to have an additional test-only constructor where these checks are not performed.

/**
* Returns the expiration time in seconds since the Unix epoch.
*/
public long getExpirationTime() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think java.time.Instant is a better candidate to hold instants in time, since we have access to Java 8.

/**
* Returns the issued-at time in seconds since the Unix epoch.
*/
public long getIssuedAt() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same here.

/**
* Represents a verified Firebase App Check token.
*/
public class DecodedAppCheckToken {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If this class is part of the public API, please also consider exposing provider and the optional claim jti.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added getJti() and getProvider().

* @return This builder.
*/
public Builder setConsume(Optional<Boolean> consume) {
this.consume = consume != null ? consume : Optional.<Boolean>empty();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In my opinion, allowing nulls into an optional parameter re-exposes the exact same problem that Optional<> was intended to solve. I think it's better for us to check for nullness and throw in this case.

@weixifan weixifan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved with only minor comments!

*/
public DecodedAppCheckToken(Map<String, Object> claims) {
checkNotNull(claims, "Claims map must not be null");
checkArgument(claims.containsKey("sub"), "Claims map must contain sub");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't feel too strongly about this topic. Note that for these required claim names, the App Check backend is quite disciplined and will never return empty or null for them. For testing, it's reasonable to have an additional test-only constructor where these checks are not performed.


/**
* Returns the App ID for which this token was issued.
* This is an alias for {@link #getSubject()}.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actually, I would prefer not promising that the sub claim is equal to the app ID, even though it is the case today.


/**
* Returns the expiration time in seconds since the Unix epoch.
* Returns the expiration time as an {@link Instant}, or {@code null} if not present.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You can actually promise that this is never null, if we perform the check for it. Similarly for getIssuedAt below.

private Date expirationTime;

@Before
public void setUp() throws Exception {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it possible to write a couple of real token tests? Creating JWTs with a simulated public/private RSA key pair shouldn't be too difficult with nimbusds. You can create JWT with a private key, and you can mock the JWKS URL to return a JWKS containing your public key.

This isn't too urgent, and can definitely be added in a follow-up PR, but the test will be a lot stronger for having real token tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:stage Stage a release candidate release-note

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants