feat(appcheck): Add App Check token verification support with replay protection - #1233
feat(appcheck): Add App Check token verification support with replay protection#1233yvonnep165 wants to merge 17 commits into
Conversation
…eckTokenOptions classes
…n and one time token verification
There was a problem hiding this comment.
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.
…quest execute in a try-finally block
weixifan
left a comment
There was a problem hiding this comment.
Thank you for this PR! Some minor comments.
| private final JsonFactory jsonFactory; | ||
| private volatile DefaultJWTProcessor<SecurityContext> jwtProcessor; | ||
|
|
||
| public AppCheckTokenVerifier(FirebaseApp app) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
Can we please cache this for 6 hours, using .cache(...)? The refresh timeout can be left at default (15 seconds).
|
|
||
| private DefaultJWTProcessor<SecurityContext> getJwtProcessor() { | ||
| DefaultJWTProcessor<SecurityContext> processor = this.jwtProcessor; | ||
| if (processor == null) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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.
| } catch (BadJOSEException e) { | ||
| throw new FirebaseAppCheckException( | ||
| ErrorCode.INVALID_ARGUMENT, | ||
| "Check your project: " + projectId + ". Firebase App Check token is invalid: " |
There was a problem hiding this comment.
I think this exception type is the one corresponding to an invalid signature, while the JOSEException is thrown when an internal error happens.
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
We should probably also check the other required claims iss, aud, exp, and iat.
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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() { |
| /** | ||
| * Represents a verified Firebase App Check token. | ||
| */ | ||
| public class DecodedAppCheckToken { |
There was a problem hiding this comment.
If this class is part of the public API, please also consider exposing provider and the optional claim jti.
There was a problem hiding this comment.
Added getJti() and getProvider().
| * @return This builder. | ||
| */ | ||
| public Builder setConsume(Optional<Boolean> consume) { | ||
| this.consume = consume != null ? consume : Optional.<Boolean>empty(); |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
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()}. |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
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.
FirebaseAppCheckservice entry point with synchronous and asynchronousverifyTokenmethods.DecodedAppCheckToken,VerifyAppCheckTokenResponse,VerifyAppCheckTokenOptions,FirebaseAppCheckException) for token verification results and options.