diff --git a/pom.xml b/pom.xml index 6209ecaf5..0c36203f3 100644 --- a/pom.xml +++ b/pom.xml @@ -455,6 +455,11 @@ netty-transport ${netty.version} + + com.nimbusds + nimbus-jose-jwt + 9.22 + diff --git a/src/main/java/com/google/firebase/appcheck/AppCheckErrorCode.java b/src/main/java/com/google/firebase/appcheck/AppCheckErrorCode.java new file mode 100644 index 000000000..a92d4743e --- /dev/null +++ b/src/main/java/com/google/firebase/appcheck/AppCheckErrorCode.java @@ -0,0 +1,33 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.appcheck; + +/** + * Error codes that can be raised by the App Check APIs. + */ +public enum AppCheckErrorCode { + + /** + * One or more arguments specified in the request were invalid. + */ + INVALID_ARGUMENT, + + /** + * Internal server error. + */ + INTERNAL +} diff --git a/src/main/java/com/google/firebase/appcheck/AppCheckTokenVerifier.java b/src/main/java/com/google/firebase/appcheck/AppCheckTokenVerifier.java new file mode 100644 index 000000000..6d273d6b8 --- /dev/null +++ b/src/main/java/com/google/firebase/appcheck/AppCheckTokenVerifier.java @@ -0,0 +1,149 @@ +/* + * Copyright 2022 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.appcheck; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Strings; +import com.google.firebase.ErrorCode; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.DefaultJWKSetCache; +import com.nimbusds.jose.jwk.source.JWKSetCache; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.RemoteJWKSet; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; + +import java.net.MalformedURLException; +import java.net.URL; +import java.text.ParseException; +import java.util.concurrent.TimeUnit; + +final class AppCheckTokenVerifier { + + private final URL jwksUrl; + private final String projectId; + + private static final String JWKS_URL = "https://firebaseappcheck.googleapis.com/v1/jwks"; + private static final String APP_CHECK_ISSUER = "https://firebaseappcheck.googleapis.com/"; + + private AppCheckTokenVerifier(Builder builder) { + checkArgument(!Strings.isNullOrEmpty(builder.projectId)); + this.projectId = builder.projectId; + try { + this.jwksUrl = new URL(JWKS_URL); + } catch (MalformedURLException e) { + throw new IllegalArgumentException("Malformed JWK url string", e); + } + } + + /** + * Verifies that the given App Check token string is a valid Firebase JWT. + * + * @param token The token string to be verified. + * @return A decoded representation of the input token string. + * @throws FirebaseAppCheckException If the input token string fails to verify due to any reason. + */ + DecodedAppCheckToken verifyToken(String token) throws FirebaseAppCheckException { + SignedJWT signedJWT; + JWTClaimsSet claimsSet; + String projectName = String.format("projects/%s", projectId); + String projectIdMatchMessage = " Make sure the App Check token comes from the same " + + "Firebase project as the service account used to authenticate this SDK."; + + try { + signedJWT = SignedJWT.parse(token); + claimsSet = signedJWT.getJWTClaimsSet(); + } catch (java.text.ParseException e) { + // Invalid signed JWT encoding + throw new FirebaseAppCheckException(ErrorCode.INVALID_ARGUMENT, "Invalid token"); + } + + String errorMessage = null; + + if (!signedJWT.getHeader().getAlgorithm().equals(JWSAlgorithm.RS256)) { + errorMessage = String.format("The provided App Check token has incorrect algorithm. " + + "Expected 'RS256' but got '%s'.", signedJWT.getHeader().getAlgorithm()); + } else if (!signedJWT.getHeader().getType().getType().equals("JWT")) { + errorMessage = String.format("The provided App Check token has invalid type header." + + "Expected %s but got %s", "JWT", signedJWT.getHeader().getType().getType()); + } else if (!claimsSet.getAudience().contains(projectName)) { + errorMessage = String.format("The provided App Check token has incorrect 'aud' (audience) " + + "claim. Expected %s but got %s. %s", + projectName, claimsSet.getAudience().toString(), projectIdMatchMessage); + } else if (!claimsSet.getIssuer().startsWith(APP_CHECK_ISSUER)) { + errorMessage = "invalid iss"; + } else if (claimsSet.getSubject().isEmpty()) { + errorMessage = "invalid sub"; + } + + if (errorMessage != null) { + throw new FirebaseAppCheckException(ErrorCode.INVALID_ARGUMENT, errorMessage); + } + + // Create a JWT processor for the access tokens + ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); + + // Cache the keys for 6 hours + JWKSetCache jwkSetCache = new DefaultJWKSetCache(6L, 6L, TimeUnit.HOURS); + JWKSource keySource = new RemoteJWKSet<>(this.jwksUrl, null, jwkSetCache); + + // Configure the JWT processor with a key selector to feed matching public + // RSA keys sourced from the JWK set URL. + JWSKeySelector keySelector = + new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keySource); + + jwtProcessor.setJWSKeySelector(keySelector); + + try { + claimsSet = jwtProcessor.process(token, null); + System.out.println(claimsSet.toJSONObject()); + } catch (ParseException | BadJOSEException | JOSEException e) { + throw new FirebaseAppCheckException(ErrorCode.INVALID_ARGUMENT, e.getMessage()); + } + + return new DecodedAppCheckToken(claimsSet.getClaims()); + } + + static AppCheckTokenVerifier.Builder builder() { + return new AppCheckTokenVerifier.Builder(); + } + + static final class Builder { + + private String projectId; + + private Builder() { + } + + AppCheckTokenVerifier.Builder setProjectId(String projectId) { + this.projectId = projectId; + return this; + } + + AppCheckTokenVerifier build() { + return new AppCheckTokenVerifier(this); + } + } +} 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..5895233e6 --- /dev/null +++ b/src/main/java/com/google/firebase/appcheck/DecodedAppCheckToken.java @@ -0,0 +1,68 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.appcheck; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.collect.ImmutableMap; + +import java.util.Map; + +/** + * A decoded and verified Firebase App Check token. See {@link FirebaseAppCheck#verifyToken(String)} + * for details on how to obtain an instance of this class. + */ +public final class DecodedAppCheckToken { + + private final Map claims; + + DecodedAppCheckToken(Map claims) { + checkArgument(claims != null && claims.containsKey("sub"), + "Claims map must at least contain sub"); + this.claims = ImmutableMap.copyOf(claims); + } + + /** Returns the Subject for this token. */ + public String getSubject() { + return (String) claims.get("sub"); + } + + /** Returns the Issuer for this token. */ + public String getIssuer() { + return (String) claims.get("iss"); + } + + /** Returns the Audience for this token. */ + public String getAudience() { + return (String) claims.get("aud"); + } + + /** Returns the Expiration Time for this token. */ + public Long getExpirationTime() { + return (Long) claims.get("exp"); + } + + /** Returns the Issued At for this token. */ + public Long getIssuedAt() { + return (Long) claims.get("iat"); + } + + /** Returns a map of all the claims on this token. */ + public Map getClaims() { + return this.claims; + } +} 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..7ce1b1d0a --- /dev/null +++ b/src/main/java/com/google/firebase/appcheck/FirebaseAppCheck.java @@ -0,0 +1,122 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.appcheck; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.core.ApiFuture; +import com.google.common.annotations.VisibleForTesting; +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.internal.CallableOperation; +import com.google.firebase.internal.FirebaseService; +import com.google.firebase.internal.NonNull; + +/** + * This class is the entry point for all server-side Firebase App Check actions. + * + *

You can get an instance of {@link FirebaseAppCheck} via {@link #getInstance(FirebaseApp)}, + * and then use it to access App Check services. + */ +public final class FirebaseAppCheck { + + private static final String SERVICE_ID = FirebaseAppCheck.class.getName(); + private final FirebaseApp app; + private final FirebaseAppCheckClient appCheckClient; + + @VisibleForTesting + FirebaseAppCheck(FirebaseApp app, FirebaseAppCheckClient client) { + this.app = checkNotNull(app); + this.appCheckClient = checkNotNull(client); + } + + private FirebaseAppCheck(FirebaseApp app) { + this(app, FirebaseAppCheckClientImpl.fromApp(app)); + } + + /** + * Gets the {@link FirebaseAppCheck} instance for the default {@link FirebaseApp}. + * + * @return The {@link FirebaseAppCheck} instance for the default {@link FirebaseApp}. + */ + public static FirebaseAppCheck getInstance() { + return getInstance(FirebaseApp.getInstance()); + } + + /** + * Gets the {@link FirebaseAppCheck} instance for the specified {@link FirebaseApp}. + * + * @return The {@link FirebaseAppCheck} instance for the specified {@link FirebaseApp}. + */ + public static synchronized FirebaseAppCheck getInstance(FirebaseApp app) { + FirebaseAppCheck.FirebaseAppCheckService service = ImplFirebaseTrampolines.getService(app, + SERVICE_ID, + FirebaseAppCheck.FirebaseAppCheckService.class); + if (service == null) { + service = ImplFirebaseTrampolines.addService(app, + new FirebaseAppCheck.FirebaseAppCheckService(app)); + } + return service.getInstance(); + } + + /** + * Verifies a given App Check Token. + * + * @param token The App Check token to be verified. + * @return A {@link VerifyAppCheckTokenResponse}. + * @throws FirebaseAppCheckException If an error occurs while getting the template. + */ + public VerifyAppCheckTokenResponse verifyToken( + @NonNull String token) throws FirebaseAppCheckException { + return verifyTokenOp(token).call(); + } + + /** + * Similar to {@link #verifyToken(String token)} but performs the operation + * asynchronously. + * + * @param token The App Check token to be verified. + * @return An {@code ApiFuture} that completes with a {@link VerifyAppCheckTokenResponse} when + * the provided token is valid. + */ + public ApiFuture verifyTokenAsync(@NonNull String token) + throws FirebaseAppCheckException { + return verifyTokenOp(token).callAsync(app); + } + + private CallableOperation verifyTokenOp( + final String token) { + final FirebaseAppCheckClient appCheckClient = getAppCheckClient(); + return new CallableOperation() { + @Override + protected VerifyAppCheckTokenResponse execute() throws FirebaseAppCheckException { + return appCheckClient.verifyToken(token); + } + }; + } + + @VisibleForTesting + FirebaseAppCheckClient getAppCheckClient() { + return appCheckClient; + } + + 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/FirebaseAppCheckClient.java b/src/main/java/com/google/firebase/appcheck/FirebaseAppCheckClient.java new file mode 100644 index 000000000..85423da42 --- /dev/null +++ b/src/main/java/com/google/firebase/appcheck/FirebaseAppCheckClient.java @@ -0,0 +1,31 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.appcheck; + +/** + * An interface for managing Firebase App Check services. + */ +interface FirebaseAppCheckClient { + + /** + * Gets the current active version of the App Check template. + * + * @return A {@link VerifyAppCheckTokenResponse}. + * @throws FirebaseAppCheckException If an error occurs while getting the template. + */ + VerifyAppCheckTokenResponse verifyToken(String token) throws FirebaseAppCheckException; +} diff --git a/src/main/java/com/google/firebase/appcheck/FirebaseAppCheckClientImpl.java b/src/main/java/com/google/firebase/appcheck/FirebaseAppCheckClientImpl.java new file mode 100644 index 000000000..c164a92c7 --- /dev/null +++ b/src/main/java/com/google/firebase/appcheck/FirebaseAppCheckClientImpl.java @@ -0,0 +1,85 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.appcheck; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Strings; +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; + +/** + * A helper class for interacting with Firebase App Check service. + */ +final class FirebaseAppCheckClientImpl implements FirebaseAppCheckClient { + private final AppCheckTokenVerifier tokenVerifier; + + private FirebaseAppCheckClientImpl(FirebaseAppCheckClientImpl.Builder builder) { + checkArgument(!Strings.isNullOrEmpty(builder.projectId)); + checkArgument(builder.appCheckTokenVerifier != null); + this.tokenVerifier = builder.appCheckTokenVerifier; + } + + static FirebaseAppCheckClientImpl fromApp(FirebaseApp app) { + String projectId = ImplFirebaseTrampolines.getProjectId(app); + checkArgument(!Strings.isNullOrEmpty(projectId), + "Project ID is required to access App Check service. Use a service " + + "account credential or set the project ID explicitly via FirebaseOptions. " + + "Alternatively you can also set the project ID via the GOOGLE_CLOUD_PROJECT " + + "environment variable."); + AppCheckTokenVerifier appCheckTokenVerifier = AppCheckTokenVerifier.builder() + .setProjectId(projectId) + .build(); + return FirebaseAppCheckClientImpl.builder() + .setProjectId(projectId) + .setAppCheckTokenVerifier(appCheckTokenVerifier) + .build(); + } + + static Builder builder() { + return new Builder(); + } + + @Override + public VerifyAppCheckTokenResponse verifyToken(String token) throws FirebaseAppCheckException { + DecodedAppCheckToken decodedAppCheckToken = tokenVerifier.verifyToken(token); + return new VerifyAppCheckTokenResponse(decodedAppCheckToken); + } + + static final class Builder { + + private String projectId; + private AppCheckTokenVerifier appCheckTokenVerifier; + + private Builder() { + } + + Builder setProjectId(String projectId) { + this.projectId = projectId; + return this; + } + + Builder setAppCheckTokenVerifier(AppCheckTokenVerifier appCheckTokenVerifier) { + this.appCheckTokenVerifier = appCheckTokenVerifier; + return this; + } + + FirebaseAppCheckClientImpl build() { + return new FirebaseAppCheckClientImpl(this); + } + } +} 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..63eca70e9 --- /dev/null +++ b/src/main/java/com/google/firebase/appcheck/FirebaseAppCheckException.java @@ -0,0 +1,63 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.appcheck; + +import com.google.common.annotations.VisibleForTesting; +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 final class FirebaseAppCheckException extends FirebaseException { + + private final AppCheckErrorCode errorCode; + + @VisibleForTesting + FirebaseAppCheckException(@NonNull ErrorCode code, @NonNull String message) { + this(code, message, null, null, null); + } + + public FirebaseAppCheckException( + @NonNull ErrorCode errorCode, + @NonNull String message, + @Nullable Throwable cause, + @Nullable IncomingHttpResponse response, + @Nullable AppCheckErrorCode appCheckErrorCode) { + super(errorCode, message, cause, response); + this.errorCode = appCheckErrorCode; + } + + static FirebaseAppCheckException withAppCheckErrorCode( + FirebaseException base, @Nullable AppCheckErrorCode errorCode) { + return new FirebaseAppCheckException( + base.getErrorCode(), + base.getMessage(), + base.getCause(), + base.getHttpResponse(), + errorCode); + } + + @Nullable + public AppCheckErrorCode getAppCheckErrorCode() { + return errorCode; + } +} 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..a23e945b7 --- /dev/null +++ b/src/main/java/com/google/firebase/appcheck/VerifyAppCheckTokenResponse.java @@ -0,0 +1,40 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.appcheck; + +/** + * Interface representing a verified App Check token response. + */ +public final class VerifyAppCheckTokenResponse { + private final String appId; + private final DecodedAppCheckToken token; + + VerifyAppCheckTokenResponse(DecodedAppCheckToken token) { + this.appId = token.getSubject(); + this.token = token; + } + + /** Returns the App ID for this token. */ + public String getAppId() { + return this.appId; + } + + /** Returns the decoded AppCheck token. */ + public DecodedAppCheckToken getToken() { + return this.token; + } +} 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..e86754e16 --- /dev/null +++ b/src/test/java/com/google/firebase/appcheck/FirebaseAppCheckTest.java @@ -0,0 +1,107 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.appcheck; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.TestOnlyImplFirebaseTrampolines; +import com.google.firebase.auth.MockGoogleCredentials; +import org.junit.After; +import org.junit.Test; + +public class FirebaseAppCheckTest { + + private static final FirebaseOptions TEST_OPTIONS = FirebaseOptions.builder() + .setCredentials(new MockGoogleCredentials("test-token")) + .setProjectId("test-project") + .build(); + + @After + public void tearDown() { + TestOnlyImplFirebaseTrampolines.clearInstancesForTest(); + } + + @Test + public void testGetInstance() { + FirebaseApp.initializeApp(TEST_OPTIONS); + + FirebaseAppCheck appCheck = FirebaseAppCheck.getInstance(); + + assertSame(appCheck, FirebaseAppCheck.getInstance()); + } + + @Test + public void testGetInstanceByApp() { + FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS, "custom-app"); + + FirebaseAppCheck appCheck = FirebaseAppCheck.getInstance(app); + + assertSame(appCheck, FirebaseAppCheck.getInstance(app)); + } + + @Test + public void testDefaultAppCheckClient() { + FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS, "custom-app"); + FirebaseAppCheck appCheck = FirebaseAppCheck.getInstance(app); + + FirebaseAppCheckClient client = appCheck.getAppCheckClient(); + + assertTrue(client instanceof FirebaseAppCheckClientImpl); + assertSame(client, appCheck.getAppCheckClient()); + } + + @Test + public void testAppDelete() { + FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS, "custom-app"); + FirebaseAppCheck appCheck = FirebaseAppCheck.getInstance(app); + assertNotNull(appCheck); + + app.delete(); + + try { + FirebaseAppCheck.getInstance(app); + fail("No error thrown when getting app check instance after deleting app"); + } catch (IllegalStateException expected) { + // expected + } + } + + @Test + public void testAppCheckClientWithoutProjectId() { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(new MockGoogleCredentials("test-token")) + .build(); + FirebaseApp.initializeApp(options); + + try { + FirebaseAppCheck.getInstance(); + fail("No error thrown for missing project ID"); + } catch (IllegalArgumentException expected) { + String message = "Project ID is required to access App Check service. Use a service " + + "account credential or set the project ID explicitly via FirebaseOptions. " + + "Alternatively you can also set the project ID via the GOOGLE_CLOUD_PROJECT " + + "environment variable."; + assertEquals(message, expected.getMessage()); + } + } +}