diff --git a/appcheck/firebase-appcheck-recaptchaenterprise/api.txt b/appcheck/firebase-appcheck-recaptchaenterprise/api.txt new file mode 100644 index 00000000000..99d21ff038c --- /dev/null +++ b/appcheck/firebase-appcheck-recaptchaenterprise/api.txt @@ -0,0 +1,10 @@ +// Signature format: 3.0 +package com.google.firebase.appcheck.recaptchaenterprise { + + public class RecaptchaEnterpriseAppCheckProviderFactory implements com.google.firebase.appcheck.AppCheckProviderFactory { + method public com.google.firebase.appcheck.AppCheckProvider create(com.google.firebase.FirebaseApp); + method public static com.google.firebase.appcheck.recaptchaenterprise.RecaptchaEnterpriseAppCheckProviderFactory getInstance(String); + } + +} + diff --git a/appcheck/firebase-appcheck-recaptchaenterprise/existing_api.txt b/appcheck/firebase-appcheck-recaptchaenterprise/existing_api.txt new file mode 100644 index 00000000000..99d21ff038c --- /dev/null +++ b/appcheck/firebase-appcheck-recaptchaenterprise/existing_api.txt @@ -0,0 +1,10 @@ +// Signature format: 3.0 +package com.google.firebase.appcheck.recaptchaenterprise { + + public class RecaptchaEnterpriseAppCheckProviderFactory implements com.google.firebase.appcheck.AppCheckProviderFactory { + method public com.google.firebase.appcheck.AppCheckProvider create(com.google.firebase.FirebaseApp); + method public static com.google.firebase.appcheck.recaptchaenterprise.RecaptchaEnterpriseAppCheckProviderFactory getInstance(String); + } + +} + diff --git a/appcheck/firebase-appcheck-recaptchaenterprise/firebase-appcheck-recaptchaenterprise.gradle b/appcheck/firebase-appcheck-recaptchaenterprise/firebase-appcheck-recaptchaenterprise.gradle new file mode 100644 index 00000000000..0a9c95c31c7 --- /dev/null +++ b/appcheck/firebase-appcheck-recaptchaenterprise/firebase-appcheck-recaptchaenterprise.gradle @@ -0,0 +1,68 @@ +// Copyright 2025 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. + +plugins { + id 'firebase-library' +} + +firebaseLibrary { + libraryGroup = "appcheck" + releaseNotes { + name.set("{{app_check}} Recaptcha Enterprise") + versionName.set("appcheck-recaptchaenterprise") + } +} + +android { + adbOptions { + timeOutInMs 60 * 1000 + } + + namespace "com.google.firebase.appcheck.recaptchaenterprise" + compileSdkVersion project.compileSdkVersion + defaultConfig { + targetSdkVersion project.targetSdkVersion + minSdkVersion project.minSdkVersion + versionName version + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + testOptions.unitTests.includeAndroidResources = false +} + +dependencies { + implementation(libs.dagger.dagger) + + api project(':appcheck:firebase-appcheck') + api 'com.google.firebase:firebase-common' + api 'com.google.firebase:firebase-components' + api 'com.google.android.recaptcha:recaptcha:18.7.1' + + annotationProcessor(libs.dagger.compiler) + + testImplementation(project(":integ-testing")) { + exclude group: 'com.google.firebase', module: 'firebase-common' + exclude group: 'com.google.firebase', module: 'firebase-components' + } + testImplementation libs.androidx.test.core + testImplementation libs.truth + testImplementation libs.junit + testImplementation libs.mockito.core + testImplementation libs.robolectric + testImplementation libs.org.json +} \ No newline at end of file diff --git a/appcheck/firebase-appcheck-recaptchaenterprise/src/main/AndroidManifest.xml b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..7d331ed90c3 --- /dev/null +++ b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/FirebaseAppCheckRecaptchaEnterpriseRegistrar.java b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/FirebaseAppCheckRecaptchaEnterpriseRegistrar.java new file mode 100644 index 00000000000..2a87456ed69 --- /dev/null +++ b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/FirebaseAppCheckRecaptchaEnterpriseRegistrar.java @@ -0,0 +1,64 @@ +// Copyright 2025 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.recaptchaenterprise; + +import com.google.android.gms.common.annotation.KeepForSdk; +import com.google.firebase.FirebaseApp; +import com.google.firebase.annotations.concurrent.Blocking; +import com.google.firebase.annotations.concurrent.Lightweight; +import com.google.firebase.appcheck.recaptchaenterprise.internal.DaggerProviderComponent; +import com.google.firebase.appcheck.recaptchaenterprise.internal.ProviderMultiResourceComponent; +import com.google.firebase.components.Component; +import com.google.firebase.components.ComponentRegistrar; +import com.google.firebase.components.Dependency; +import com.google.firebase.components.Qualified; +import com.google.firebase.platforminfo.LibraryVersionComponent; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Executor; + +/** + * {@link ComponentRegistrar} for setting up FirebaseAppCheck reCAPTCHA Enterprise's dependency + * injections in Firebase Android Components. + * + * @hide + */ +@KeepForSdk +public class FirebaseAppCheckRecaptchaEnterpriseRegistrar implements ComponentRegistrar { + private static final String LIBRARY_NAME = "fire-app-check-recaptcha-enterprise"; + + @Override + public List> getComponents() { + Qualified liteExecutor = Qualified.qualified(Lightweight.class, Executor.class); + Qualified blockingExecutor = Qualified.qualified(Blocking.class, Executor.class); + + return Arrays.asList( + Component.builder(ProviderMultiResourceComponent.class) + .name(LIBRARY_NAME) + .add(Dependency.required(FirebaseApp.class)) + .add(Dependency.required(liteExecutor)) + .add(Dependency.required(blockingExecutor)) + .factory( + container -> + DaggerProviderComponent.builder() + .setFirebaseApp(container.get(FirebaseApp.class)) + .setLiteExecutor(container.get(liteExecutor)) + .setBlockingExecutor(container.get(blockingExecutor)) + .build() + .getMultiResourceComponent()) + .build(), + LibraryVersionComponent.create(LIBRARY_NAME, BuildConfig.VERSION_NAME)); + } +} diff --git a/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/RecaptchaEnterpriseAppCheckProviderFactory.java b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/RecaptchaEnterpriseAppCheckProviderFactory.java new file mode 100644 index 00000000000..e051f54d0e9 --- /dev/null +++ b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/RecaptchaEnterpriseAppCheckProviderFactory.java @@ -0,0 +1,62 @@ +// Copyright 2025 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.recaptchaenterprise; + +import androidx.annotation.NonNull; +import com.google.firebase.FirebaseApp; +import com.google.firebase.appcheck.AppCheckProvider; +import com.google.firebase.appcheck.AppCheckProviderFactory; +import com.google.firebase.appcheck.FirebaseAppCheck; +import com.google.firebase.appcheck.recaptchaenterprise.internal.ProviderMultiResourceComponent; +import com.google.firebase.appcheck.recaptchaenterprise.internal.RecaptchaEnterpriseAppCheckProvider; +import java.util.Objects; + +/** + * Implementation of an {@link AppCheckProviderFactory} that builds
+ * {@link RecaptchaEnterpriseAppCheckProvider}s. This is the default implementation. + */ +public class RecaptchaEnterpriseAppCheckProviderFactory implements AppCheckProviderFactory { + + private final String siteKey; + private volatile RecaptchaEnterpriseAppCheckProvider provider; + + private RecaptchaEnterpriseAppCheckProviderFactory(@NonNull String siteKey) { + this.siteKey = siteKey; + } + + /** Gets an instance of this class for installation into a {@link FirebaseAppCheck} instance. */ + @NonNull + public static RecaptchaEnterpriseAppCheckProviderFactory getInstance(@NonNull String siteKey) { + Objects.requireNonNull(siteKey, "siteKey cannot be null"); + return new RecaptchaEnterpriseAppCheckProviderFactory(siteKey); + } + + @NonNull + @Override + @SuppressWarnings("FirebaseUseExplicitDependencies") + public AppCheckProvider create(@NonNull FirebaseApp firebaseApp) { + if (provider == null) { + synchronized (this) { + if (provider == null) { + ProviderMultiResourceComponent component = + firebaseApp.get(ProviderMultiResourceComponent.class); + provider = component.get(siteKey); + provider.initializeRecaptchaClient(); + } + } + } + return provider; + } +} diff --git a/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/internal/ExchangeRecaptchaEnterpriseTokenRequest.java b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/internal/ExchangeRecaptchaEnterpriseTokenRequest.java new file mode 100644 index 00000000000..76107a7ad0e --- /dev/null +++ b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/internal/ExchangeRecaptchaEnterpriseTokenRequest.java @@ -0,0 +1,44 @@ +// Copyright 2025 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.recaptchaenterprise.internal; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Client-side model of the ExchangeRecaptchaEnterpriseTokenRequest payload from the Firebase App + * Check Token Exchange API. + */ +public class ExchangeRecaptchaEnterpriseTokenRequest { + + @VisibleForTesting + static final String RECAPTCHA_ENTERPRISE_TOKEN_KEY = "recaptchaEnterpriseToken"; + + private final String recaptchaEnterpriseToken; + + public ExchangeRecaptchaEnterpriseTokenRequest(@NonNull String recaptchaEnterpriseToken) { + this.recaptchaEnterpriseToken = recaptchaEnterpriseToken; + } + + @NonNull + public String toJsonString() throws JSONException { + JSONObject jsonObject = new JSONObject(); + jsonObject.put(RECAPTCHA_ENTERPRISE_TOKEN_KEY, recaptchaEnterpriseToken); + + return jsonObject.toString(); + } +} diff --git a/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/internal/ProviderComponent.java b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/internal/ProviderComponent.java new file mode 100644 index 00000000000..356a646bd5b --- /dev/null +++ b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/internal/ProviderComponent.java @@ -0,0 +1,47 @@ +// Copyright 2025 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.recaptchaenterprise.internal; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.annotations.concurrent.Blocking; +import com.google.firebase.annotations.concurrent.Lightweight; +import dagger.BindsInstance; +import dagger.Component; +import dagger.Module; +import java.util.concurrent.Executor; +import javax.inject.Singleton; + +@Singleton +@Component(modules = ProviderComponent.MainModule.class) +public interface ProviderComponent { + ProviderMultiResourceComponent getMultiResourceComponent(); + + @Component.Builder + interface Builder { + @BindsInstance + Builder setFirebaseApp(FirebaseApp firebaseApp); + + @BindsInstance + Builder setLiteExecutor(@Lightweight Executor liteExecutor); + + @BindsInstance + Builder setBlockingExecutor(@Blocking Executor blockingExecutor); + + ProviderComponent build(); + } + + @Module + abstract class MainModule {} +} diff --git a/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/internal/ProviderMultiResourceComponent.java b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/internal/ProviderMultiResourceComponent.java new file mode 100644 index 00000000000..93861c937a0 --- /dev/null +++ b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/internal/ProviderMultiResourceComponent.java @@ -0,0 +1,57 @@ +// Copyright 2025 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.recaptchaenterprise.internal; + +import androidx.annotation.NonNull; +import dagger.assisted.Assisted; +import dagger.assisted.AssistedFactory; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import javax.inject.Inject; +import javax.inject.Singleton; + +/** Multi-resource container for RecaptchaEnterpriseAppCheckProvider */ +@Singleton +public final class ProviderMultiResourceComponent { + private final RecaptchaEnterpriseAppCheckProviderFactory providerFactory; + + private final Map instances = + new ConcurrentHashMap<>(); + + @Inject + ProviderMultiResourceComponent(RecaptchaEnterpriseAppCheckProviderFactory providerFactory) { + this.providerFactory = providerFactory; + } + + @NonNull + public RecaptchaEnterpriseAppCheckProvider get(@NonNull String siteKey) { + RecaptchaEnterpriseAppCheckProvider provider = instances.get(siteKey); + if (provider == null) { + synchronized (instances) { + provider = instances.get(siteKey); + if (provider == null) { + provider = providerFactory.create(siteKey); + instances.put(siteKey, provider); + } + } + } + return provider; + } + + @AssistedFactory + interface RecaptchaEnterpriseAppCheckProviderFactory { + RecaptchaEnterpriseAppCheckProvider create(@Assisted String siteKey); + } +} diff --git a/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/internal/RecaptchaEnterpriseAppCheckProvider.java b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/internal/RecaptchaEnterpriseAppCheckProvider.java new file mode 100644 index 00000000000..961ab7defa8 --- /dev/null +++ b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/internal/RecaptchaEnterpriseAppCheckProvider.java @@ -0,0 +1,142 @@ +// Copyright 2025 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.recaptchaenterprise.internal; + +import android.app.Application; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.Tasks; +import com.google.android.recaptcha.Recaptcha; +import com.google.android.recaptcha.RecaptchaAction; +import com.google.android.recaptcha.RecaptchaTasksClient; +import com.google.firebase.FirebaseApp; +import com.google.firebase.annotations.concurrent.Blocking; +import com.google.firebase.annotations.concurrent.Lightweight; +import com.google.firebase.appcheck.AppCheckProvider; +import com.google.firebase.appcheck.AppCheckToken; +import com.google.firebase.appcheck.internal.DefaultAppCheckToken; +import com.google.firebase.appcheck.internal.NetworkClient; +import com.google.firebase.appcheck.internal.RetryManager; +import dagger.assisted.Assisted; +import dagger.assisted.AssistedInject; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.util.concurrent.Executor; + +/** + * An implementation of {@link AppCheckProvider} that uses reCAPTCHA Enterprise for device + * attestation. + * + *

This class orchestrates the flow: + * + *

    + *
  1. Obtain a reCAPTCHA token via {@code RecaptchaTasksClient}. + *
  2. Exchange the reCAPTCHA token with the Firebase App Check backend to receive a Firebase App + * Check token. + *
+ */ +public class RecaptchaEnterpriseAppCheckProvider implements AppCheckProvider { + + private final RecaptchaAction recaptchaAction = RecaptchaAction.custom("fire_app_check"); + private volatile Task recaptchaTasksClientTask; + private final Executor liteExecutor; + private final Executor blockingExecutor; + private final RetryManager retryManager; + private final NetworkClient networkClient; + private String siteKey; + private Application application; + private static final String TAG = "rCEAppCheckProvider"; + + @AssistedInject + public RecaptchaEnterpriseAppCheckProvider( + @NonNull FirebaseApp firebaseApp, + @Assisted @NonNull String siteKey, + @Lightweight Executor liteExecutor, + @Blocking Executor blockingExecutor) { + this.application = (Application) firebaseApp.getApplicationContext(); + this.siteKey = siteKey; + this.liteExecutor = liteExecutor; + this.blockingExecutor = blockingExecutor; + this.retryManager = new RetryManager(); + this.networkClient = new NetworkClient(firebaseApp); + } + + @VisibleForTesting + RecaptchaEnterpriseAppCheckProvider( + @Lightweight Executor liteExecutor, + @Blocking Executor blockingExecutor, + @NonNull RetryManager retryManager, + @NonNull NetworkClient networkClient, + @NonNull RecaptchaTasksClient recaptchaTasksClient) { + this.liteExecutor = liteExecutor; + this.blockingExecutor = blockingExecutor; + this.retryManager = retryManager; + this.networkClient = networkClient; + this.recaptchaTasksClientTask = Tasks.forResult(recaptchaTasksClient); + } + + public void initializeRecaptchaClient() { + if (recaptchaTasksClientTask == null) { + synchronized (this) { + if (recaptchaTasksClientTask == null) { + Log.d(TAG, "Initializing RecaptchaTasksClient for siteKey: " + siteKey); + recaptchaTasksClientTask = Recaptcha.fetchTaskClient(application, siteKey); + } + } + } + } + + @NonNull + @Override + public Task getToken() { + return getRecaptchaEnterpriseAttestation() + .onSuccessTask( + liteExecutor, + recaptchaEnterpriseToken -> { + ExchangeRecaptchaEnterpriseTokenRequest request = + new ExchangeRecaptchaEnterpriseTokenRequest(recaptchaEnterpriseToken); + return Tasks.call( + blockingExecutor, + () -> + networkClient.exchangeAttestationForAppCheckToken( + request.toJsonString().getBytes(StandardCharsets.UTF_8), + NetworkClient.RECAPTCHA_ENTERPRISE, + retryManager)); + }) + .onSuccessTask( + liteExecutor, + appCheckTokenResponse -> + Tasks.forResult( + DefaultAppCheckToken.constructFromAppCheckTokenResponse( + appCheckTokenResponse))); + } + + @NonNull + private Task getRecaptchaEnterpriseAttestation() { + return recaptchaTasksClientTask.continueWithTask( + blockingExecutor, + task -> { + if (task.isSuccessful()) { + RecaptchaTasksClient client = task.getResult(); + return client.executeTask(recaptchaAction); + } else { + Log.w(TAG, "Recaptcha task failed", task.getException()); + return Tasks.forException((Objects.requireNonNull(task.getException()))); + } + }); + } +} diff --git a/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/internal/package-info.java b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/internal/package-info.java new file mode 100644 index 00000000000..baae7a935b7 --- /dev/null +++ b/appcheck/firebase-appcheck-recaptchaenterprise/src/main/java/com/google/firebase/appcheck/recaptchaenterprise/internal/package-info.java @@ -0,0 +1,16 @@ +// Copyright 2025 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. + +/** @hide */ +package com.google.firebase.appcheck.recaptchaenterprise.internal; diff --git a/appcheck/firebase-appcheck-recaptchaenterprise/src/test/java/com/google/firebase/appcheck/recaptchaenterprise/FirebaseAppCheckRecaptchaEnterpriseRegistrarTest.java b/appcheck/firebase-appcheck-recaptchaenterprise/src/test/java/com/google/firebase/appcheck/recaptchaenterprise/FirebaseAppCheckRecaptchaEnterpriseRegistrarTest.java new file mode 100644 index 00000000000..1d3ddadca85 --- /dev/null +++ b/appcheck/firebase-appcheck-recaptchaenterprise/src/test/java/com/google/firebase/appcheck/recaptchaenterprise/FirebaseAppCheckRecaptchaEnterpriseRegistrarTest.java @@ -0,0 +1,38 @@ +// Copyright 2025 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.recaptchaenterprise; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.firebase.components.Component; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Tests for {@link FirebaseAppCheckRecaptchaEnterpriseRegistrar}. */ +@RunWith(RobolectricTestRunner.class) +public class FirebaseAppCheckRecaptchaEnterpriseRegistrarTest { + @Test + public void testGetComponents() { + FirebaseAppCheckRecaptchaEnterpriseRegistrar registrar = + new FirebaseAppCheckRecaptchaEnterpriseRegistrar(); + List> components = registrar.getComponents(); + assertThat(components).isNotEmpty(); + assertThat(components).hasSize(2); + Component firebaseExecutorsComponent = components.get(0); + assertThat(firebaseExecutorsComponent.isLazy()).isTrue(); + } +} diff --git a/appcheck/firebase-appcheck-recaptchaenterprise/src/test/java/com/google/firebase/appcheck/recaptchaenterprise/RecaptchaEnterpriseAppCheckProviderFactoryTest.java b/appcheck/firebase-appcheck-recaptchaenterprise/src/test/java/com/google/firebase/appcheck/recaptchaenterprise/RecaptchaEnterpriseAppCheckProviderFactoryTest.java new file mode 100644 index 00000000000..865b69e409c --- /dev/null +++ b/appcheck/firebase-appcheck-recaptchaenterprise/src/test/java/com/google/firebase/appcheck/recaptchaenterprise/RecaptchaEnterpriseAppCheckProviderFactoryTest.java @@ -0,0 +1,84 @@ +// Copyright 2025 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.recaptchaenterprise; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.appcheck.AppCheckProvider; +import com.google.firebase.appcheck.recaptchaenterprise.internal.ProviderMultiResourceComponent; +import com.google.firebase.appcheck.recaptchaenterprise.internal.RecaptchaEnterpriseAppCheckProvider; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +/** Tests for {@link RecaptchaEnterpriseAppCheckProviderFactory}. */ +@RunWith(MockitoJUnitRunner.class) +public class RecaptchaEnterpriseAppCheckProviderFactoryTest { + static final String SITE_KEY_1 = "siteKey1"; + + @Mock private FirebaseApp mockFirebaseApp; + @Mock private ProviderMultiResourceComponent mockComponent; + @Mock private RecaptchaEnterpriseAppCheckProvider mockProvider; + + @Before + public void setUp() { + when(mockFirebaseApp.get(eq(ProviderMultiResourceComponent.class))).thenReturn(mockComponent); + when(mockComponent.get(anyString())).thenReturn(mockProvider); + } + + @Test + public void getInstance_nonNullSiteKey_returnsNonNullInstance() { + RecaptchaEnterpriseAppCheckProviderFactory factory = + RecaptchaEnterpriseAppCheckProviderFactory.getInstance(SITE_KEY_1); + assertNotNull(factory); + } + + @Test + public void getInstance_nullSiteKey_expectThrows() { + assertThrows( + NullPointerException.class, + () -> RecaptchaEnterpriseAppCheckProviderFactory.getInstance(null)); + } + + @Test + public void create_nonNullFirebaseApp_returnsRecaptchaEnterpriseAppCheckProvider() { + RecaptchaEnterpriseAppCheckProviderFactory factory = + RecaptchaEnterpriseAppCheckProviderFactory.getInstance(SITE_KEY_1); + AppCheckProvider provider = factory.create(mockFirebaseApp); + assertNotNull(provider); + assertEquals(RecaptchaEnterpriseAppCheckProvider.class, provider.getClass()); + } + + @Test + public void create_callMultipleTimes_providerIsInitializedOnlyOnce() { + RecaptchaEnterpriseAppCheckProviderFactory factory = + RecaptchaEnterpriseAppCheckProviderFactory.getInstance(SITE_KEY_1); + + factory.create(mockFirebaseApp); + factory.create(mockFirebaseApp); + factory.create(mockFirebaseApp); + verify(mockProvider, times(1)).initializeRecaptchaClient(); + } +} diff --git a/appcheck/firebase-appcheck-recaptchaenterprise/src/test/java/com/google/firebase/appcheck/recaptchaenterprise/internal/ExchangeRecaptchaEnterpriseTokenRequestTest.java b/appcheck/firebase-appcheck-recaptchaenterprise/src/test/java/com/google/firebase/appcheck/recaptchaenterprise/internal/ExchangeRecaptchaEnterpriseTokenRequestTest.java new file mode 100644 index 00000000000..20e48b7eb46 --- /dev/null +++ b/appcheck/firebase-appcheck-recaptchaenterprise/src/test/java/com/google/firebase/appcheck/recaptchaenterprise/internal/ExchangeRecaptchaEnterpriseTokenRequestTest.java @@ -0,0 +1,39 @@ +// Copyright 2025 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.recaptchaenterprise.internal; + +import static com.google.common.truth.Truth.assertThat; + +import org.json.JSONObject; +import org.junit.Test; + +/** Tests for {@link ExchangeRecaptchaEnterpriseTokenRequest}. */ +public class ExchangeRecaptchaEnterpriseTokenRequestTest { + private static final String RECAPTCHA_ENTERPRISE_TOKEN = "recaptchaEnterpriseToken"; + + @Test + public void toJsonString_expectSerialized() throws Exception { + ExchangeRecaptchaEnterpriseTokenRequest exchangeRecaptchaEnterpriseTokenRequest = + new ExchangeRecaptchaEnterpriseTokenRequest(RECAPTCHA_ENTERPRISE_TOKEN); + + String jsonString = exchangeRecaptchaEnterpriseTokenRequest.toJsonString(); + JSONObject jsonObject = new JSONObject(jsonString); + + assertThat( + jsonObject.getString( + ExchangeRecaptchaEnterpriseTokenRequest.RECAPTCHA_ENTERPRISE_TOKEN_KEY)) + .isEqualTo(RECAPTCHA_ENTERPRISE_TOKEN); + } +} diff --git a/appcheck/firebase-appcheck-recaptchaenterprise/src/test/java/com/google/firebase/appcheck/recaptchaenterprise/internal/RecaptchaEnterpriseAppCheckProviderTest.java b/appcheck/firebase-appcheck-recaptchaenterprise/src/test/java/com/google/firebase/appcheck/recaptchaenterprise/internal/RecaptchaEnterpriseAppCheckProviderTest.java new file mode 100644 index 00000000000..19cbdc4957b --- /dev/null +++ b/appcheck/firebase-appcheck-recaptchaenterprise/src/test/java/com/google/firebase/appcheck/recaptchaenterprise/internal/RecaptchaEnterpriseAppCheckProviderTest.java @@ -0,0 +1,164 @@ +// Copyright 2025 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.recaptchaenterprise.internal; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.Tasks; +import com.google.android.recaptcha.RecaptchaAction; +import com.google.android.recaptcha.RecaptchaTasksClient; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseException; +import com.google.firebase.appcheck.AppCheckToken; +import com.google.firebase.appcheck.internal.AppCheckTokenResponse; +import com.google.firebase.appcheck.internal.DefaultAppCheckToken; +import com.google.firebase.appcheck.internal.NetworkClient; +import com.google.firebase.appcheck.internal.RetryManager; +import com.google.firebase.concurrent.TestOnlyExecutors; +import java.io.IOException; +import java.util.concurrent.Executor; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.LooperMode; + +/** Tests for {@link RecaptchaEnterpriseAppCheckProvider}. */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +@LooperMode(LooperMode.Mode.LEGACY) +public class RecaptchaEnterpriseAppCheckProviderTest { + private static final String APP_CHECK_TOKEN = "appCheckToken"; + private static final String RECAPTCHA_ENTERPRISE_TOKEN = "recaptchaEnterpriseToken"; + private final Executor liteExecutor = MoreExecutors.directExecutor(); + private final Executor blockingExecutor = MoreExecutors.directExecutor(); + private final String siteKey = "siteKey"; + + @Mock private NetworkClient mockNetworkClient; + @Mock FirebaseApp mockFirebaseApp; + @Mock RecaptchaTasksClient mockRecaptchaTasksClient; + @Mock RetryManager mockRetryManager; + + @Captor private ArgumentCaptor recaptchaActionCaptor; + @Captor private ArgumentCaptor requestCaptor; + + @Before + public void setup() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void testPublicConstructor_nullFirebaseApp_expectThrows() { + assertThrows( + NullPointerException.class, + () -> + new RecaptchaEnterpriseAppCheckProvider( + null, siteKey, TestOnlyExecutors.lite(), TestOnlyExecutors.blocking())); + } + + @Test + public void testPublicConstructor_nullSiteKey_expectThrows() { + assertThrows( + NullPointerException.class, + () -> + new RecaptchaEnterpriseAppCheckProvider( + mockFirebaseApp, null, TestOnlyExecutors.lite(), TestOnlyExecutors.blocking())); + } + + @Test + public void getToken_onSuccess_setsTaskResult() throws Exception { + when(mockRecaptchaTasksClient.executeTask(any(RecaptchaAction.class))) + .thenReturn(Tasks.forResult(RECAPTCHA_ENTERPRISE_TOKEN)); + String jsonResponse = + new JSONObject().put("token", APP_CHECK_TOKEN).put("ttl", 3600).toString(); + when(mockNetworkClient.exchangeAttestationForAppCheckToken( + any(byte[].class), eq(NetworkClient.RECAPTCHA_ENTERPRISE), eq(mockRetryManager))) + .thenReturn(AppCheckTokenResponse.fromJsonString(jsonResponse)); + + RecaptchaEnterpriseAppCheckProvider provider = + new RecaptchaEnterpriseAppCheckProvider( + liteExecutor, + blockingExecutor, + mockRetryManager, + mockNetworkClient, + mockRecaptchaTasksClient); + Task task = provider.getToken(); + + assertThat(task.isSuccessful()).isTrue(); + AppCheckToken token = task.getResult(); + assertThat(token).isInstanceOf(DefaultAppCheckToken.class); + assertThat(token.getToken()).isEqualTo(APP_CHECK_TOKEN); + + verify(mockRecaptchaTasksClient).executeTask(recaptchaActionCaptor.capture()); + assertThat(recaptchaActionCaptor.getValue().getAction()).isEqualTo("fire_app_check"); + verify(mockNetworkClient) + .exchangeAttestationForAppCheckToken( + requestCaptor.capture(), eq(NetworkClient.RECAPTCHA_ENTERPRISE), eq(mockRetryManager)); + } + + @Test + public void getToken_recaptchaFails_returnException() { + Exception exception = new Exception("Recaptcha error"); + when(mockRecaptchaTasksClient.executeTask(any(RecaptchaAction.class))) + .thenReturn(Tasks.forException(exception)); + + RecaptchaEnterpriseAppCheckProvider provider = + new RecaptchaEnterpriseAppCheckProvider( + liteExecutor, + blockingExecutor, + mockRetryManager, + mockNetworkClient, + mockRecaptchaTasksClient); + Task task = provider.getToken(); + assertThat(task.isSuccessful()).isFalse(); + assertThat(task.getException()).isEqualTo(exception); + } + + @Test + public void getToken_networkFails_returnException() + throws FirebaseException, JSONException, IOException { + when(mockRecaptchaTasksClient.executeTask(any(RecaptchaAction.class))) + .thenReturn(Tasks.forResult(RECAPTCHA_ENTERPRISE_TOKEN)); + Exception exception = new IOException("Network error"); + when(mockNetworkClient.exchangeAttestationForAppCheckToken( + any(byte[].class), eq(NetworkClient.RECAPTCHA_ENTERPRISE), eq(mockRetryManager))) + .thenThrow(exception); + + RecaptchaEnterpriseAppCheckProvider provider = + new RecaptchaEnterpriseAppCheckProvider( + liteExecutor, + blockingExecutor, + mockRetryManager, + mockNetworkClient, + mockRecaptchaTasksClient); + Task task = provider.getToken(); + assertThat(task.isSuccessful()).isFalse(); + assertThat(task.getException()).isEqualTo(exception); + } +} diff --git a/appcheck/firebase-appcheck/src/main/java/com/google/firebase/appcheck/internal/NetworkClient.java b/appcheck/firebase-appcheck/src/main/java/com/google/firebase/appcheck/internal/NetworkClient.java index 410f59290cb..fa91e6f8323 100644 --- a/appcheck/firebase-appcheck/src/main/java/com/google/firebase/appcheck/internal/NetworkClient.java +++ b/appcheck/firebase-appcheck/src/main/java/com/google/firebase/appcheck/internal/NetworkClient.java @@ -41,6 +41,7 @@ import java.lang.annotation.RetentionPolicy; import java.net.HttpURLConnection; import java.net.URL; +import java.nio.charset.StandardCharsets; import org.json.JSONException; /** @@ -57,9 +58,10 @@ public class NetworkClient { "https://firebaseappcheck.googleapis.com/v1/projects/%s/apps/%s:exchangePlayIntegrityToken?key=%s"; private static final String PLAY_INTEGRITY_CHALLENGE_URL_TEMPLATE = "https://firebaseappcheck.googleapis.com/v1/projects/%s/apps/%s:generatePlayIntegrityChallenge?key=%s"; + private static final String RECAPTCHA_ENTERPRISE_URL_TEMPLATE = + "https://firebaseappcheck.googleapis.com/v1/projects/%s/apps/%s:exchangeRecaptchaEnterpriseToken?key=%s"; private static final String CONTENT_TYPE = "Content-Type"; private static final String APPLICATION_JSON = "application/json"; - private static final String UTF_8 = "UTF-8"; @VisibleForTesting static final String X_FIREBASE_CLIENT = "X-Firebase-Client"; @VisibleForTesting static final String X_ANDROID_PACKAGE = "X-Android-Package"; @VisibleForTesting static final String X_ANDROID_CERT = "X-Android-Cert"; @@ -71,12 +73,13 @@ public class NetworkClient { private final Provider heartBeatControllerProvider; @Retention(RetentionPolicy.SOURCE) - @IntDef({UNKNOWN, DEBUG, PLAY_INTEGRITY}) + @IntDef({UNKNOWN, DEBUG, PLAY_INTEGRITY, RECAPTCHA_ENTERPRISE}) public @interface AttestationTokenType {} public static final int UNKNOWN = 0; public static final int DEBUG = 2; public static final int PLAY_INTEGRITY = 3; + public static final int RECAPTCHA_ENTERPRISE = 4; public NetworkClient(@NonNull FirebaseApp firebaseApp) { this( @@ -172,7 +175,8 @@ private String makeNetworkRequest( ? urlConnection.getInputStream() : urlConnection.getErrorStream(); StringBuilder response = new StringBuilder(); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, UTF_8))) { + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { response.append(line); @@ -236,6 +240,8 @@ private static String getUrlTemplate(@AttestationTokenType int tokenType) { return DEBUG_EXCHANGE_URL_TEMPLATE; case PLAY_INTEGRITY: return PLAY_INTEGRITY_EXCHANGE_URL_TEMPLATE; + case RECAPTCHA_ENTERPRISE: + return RECAPTCHA_ENTERPRISE_URL_TEMPLATE; default: throw new IllegalArgumentException("Unknown token type."); } @@ -246,7 +252,7 @@ HttpURLConnection createHttpUrlConnection(URL url) throws IOException { return (HttpURLConnection) url.openConnection(); } - private static final boolean isResponseSuccess(int responseCode) { + private static boolean isResponseSuccess(int responseCode) { return responseCode >= 200 && responseCode < 300; } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 03725a05989..7e3fd691d6a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,8 +4,8 @@ # needs to match the version of grpc that grpc-kotlin has a transitive dependency on. android-lint = "31.3.2" androidGradlePlugin = "8.6.1" -androidx-test-core="1.5.0" -androidx-test-junit="1.1.5" +androidx-test-core = "1.5.0" +androidx-test-junit = "1.1.5" androidx-test-truth = "1.6.0" appcompat = "1.7.1" autoValueParcel = "0.2.6" @@ -128,7 +128,7 @@ grpc-protobuf-lite = { module = "io.grpc:grpc-protobuf-lite", version.ref = "grp grpc-protoc-gen-java = { module = "io.grpc:protoc-gen-grpc-java", version.ref = "grpc" } grpc-protoc-gen-kotlin = { module = "io.grpc:protoc-gen-grpc-kotlin", version.ref = "grpcKotlin" } grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpc" } -grpc-testing = { module= "io.grpc:grpc-testing", version.ref="grpc" } +grpc-testing = { module = "io.grpc:grpc-testing", version.ref = "grpc" } hamcrest = { module = "org.hamcrest:hamcrest", version.ref = "hamcrest" } hamcrest-junit = { module = "org.hamcrest:hamcrest-junit", version.ref = "hamcrestJunit" } hamcrest-library = { module = "org.hamcrest:hamcrest-library", version.ref = "hamcrestLibrary" } @@ -238,4 +238,4 @@ spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } protobuf = { id = "com.google.protobuf", version.ref = "protobufGradlePlugin" } errorprone = { id = "net.ltgt.errorprone", version.ref = "gradleErrorpronePlugin" } google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } -crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsGradle" } +crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsGradle" } \ No newline at end of file diff --git a/subprojects.cfg b/subprojects.cfg index a43cf350e91..e42fd66e518 100644 --- a/subprojects.cfg +++ b/subprojects.cfg @@ -3,6 +3,7 @@ appcheck:firebase-appcheck-debug-testing appcheck:firebase-appcheck-debug appcheck:firebase-appcheck-interop appcheck:firebase-appcheck-playintegrity +appcheck:firebase-appcheck-recaptchaenterprise appcheck:firebase-appcheck firebase-abt