diff --git a/appcheck/firebase-appcheck-playintegrity/firebase-appcheck-playintegrity.gradle b/appcheck/firebase-appcheck-playintegrity/firebase-appcheck-playintegrity.gradle new file mode 100644 index 00000000000..0733ab44ace --- /dev/null +++ b/appcheck/firebase-appcheck-playintegrity/firebase-appcheck-playintegrity.gradle @@ -0,0 +1,56 @@ +// 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. + +plugins { + id 'firebase-library' +} + +firebaseLibrary { + publishSources = true +} + +android { + adbOptions { + timeOutInMs 60 * 1000 + } + + compileSdkVersion project.targetSdkVersion + 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 project(':firebase-common') + implementation project(':firebase-components') + implementation project(':appcheck:firebase-appcheck') + implementation 'com.google.android.gms:play-services-base:18.0.1' + implementation 'com.google.android.gms:play-services-tasks:18.0.1' + implementation 'com.google.android.play:integrity:1.0.1' + + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:3.4.6' + testImplementation "com.google.truth:truth:$googleTruthVersion" + testImplementation "org.robolectric:robolectric:$robolectricVersion" + testImplementation 'androidx.test:core:1.4.0' +} diff --git a/appcheck/firebase-appcheck-playintegrity/gradle.properties b/appcheck/firebase-appcheck-playintegrity/gradle.properties new file mode 100644 index 00000000000..33424bcee59 --- /dev/null +++ b/appcheck/firebase-appcheck-playintegrity/gradle.properties @@ -0,0 +1 @@ +version=16.0.0 diff --git a/appcheck/firebase-appcheck-playintegrity/src/main/AndroidManifest.xml b/appcheck/firebase-appcheck-playintegrity/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..3599f8e6755 --- /dev/null +++ b/appcheck/firebase-appcheck-playintegrity/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + diff --git a/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/PlayIntegrityAppCheckProviderFactory.java b/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/PlayIntegrityAppCheckProviderFactory.java new file mode 100644 index 00000000000..743092d2892 --- /dev/null +++ b/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/PlayIntegrityAppCheckProviderFactory.java @@ -0,0 +1,46 @@ +// 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.playintegrity; + +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.playintegrity.internal.PlayIntegrityAppCheckProvider; + +/** + * Implementation of an {@link AppCheckProviderFactory} that builds {@link + * PlayIntegrityAppCheckProvider}s. This is the default implementation. + */ +public class PlayIntegrityAppCheckProviderFactory implements AppCheckProviderFactory { + + private static final PlayIntegrityAppCheckProviderFactory instance = + new PlayIntegrityAppCheckProviderFactory(); + + /** + * Gets an instance of this class for installation into a {@link + * com.google.firebase.appcheck.FirebaseAppCheck} instance. + */ + @NonNull + public static PlayIntegrityAppCheckProviderFactory getInstance() { + return instance; + } + + @NonNull + @Override + public AppCheckProvider create(@NonNull FirebaseApp firebaseApp) { + return new PlayIntegrityAppCheckProvider(firebaseApp); + } +} diff --git a/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/ExchangePlayIntegrityTokenRequest.java b/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/ExchangePlayIntegrityTokenRequest.java new file mode 100644 index 00000000000..cc2e68d3054 --- /dev/null +++ b/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/ExchangePlayIntegrityTokenRequest.java @@ -0,0 +1,43 @@ +// 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.playintegrity.internal; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Client-side model of the ExchangePlayIntegrityTokenRequest payload from the Firebase App Check + * Token Exchange API. + */ +class ExchangePlayIntegrityTokenRequest { + + @VisibleForTesting static final String PLAY_INTEGRITY_TOKEN_KEY = "playIntegrityToken"; + + private final String playIntegrityToken; + + public ExchangePlayIntegrityTokenRequest(@NonNull String playIntegrityToken) { + this.playIntegrityToken = playIntegrityToken; + } + + @NonNull + public String toJsonString() throws JSONException { + JSONObject jsonObject = new JSONObject(); + jsonObject.put(PLAY_INTEGRITY_TOKEN_KEY, playIntegrityToken); + + return jsonObject.toString(); + } +} diff --git a/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/GeneratePlayIntegrityChallengeRequest.java b/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/GeneratePlayIntegrityChallengeRequest.java new file mode 100644 index 00000000000..5e213d13189 --- /dev/null +++ b/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/GeneratePlayIntegrityChallengeRequest.java @@ -0,0 +1,35 @@ +// 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.playintegrity.internal; + +import androidx.annotation.NonNull; +import org.json.JSONObject; + +/** + * Client-side model of the GeneratePlayIntegrityChallengeRequest payload from the Firebase App + * Check Token Exchange API. + */ +class GeneratePlayIntegrityChallengeRequest { + + public GeneratePlayIntegrityChallengeRequest() {} + + @NonNull + public String toJsonString() { + JSONObject jsonObject = new JSONObject(); + + // GeneratePlayIntegrityChallenge takes an empty POST body since the app ID is in the URL. + return jsonObject.toString(); + } +} diff --git a/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/GeneratePlayIntegrityChallengeResponse.java b/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/GeneratePlayIntegrityChallengeResponse.java new file mode 100644 index 00000000000..52d91827276 --- /dev/null +++ b/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/GeneratePlayIntegrityChallengeResponse.java @@ -0,0 +1,67 @@ +// 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.playintegrity.internal; + +import static com.google.android.gms.common.internal.Preconditions.checkNotNull; +import static com.google.android.gms.common.util.Strings.emptyToNull; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import com.google.firebase.FirebaseException; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Client-side model of the GeneratePlayIntegrityChallengeResponse payload from the Firebase App + * Check Token Exchange API. + */ +class GeneratePlayIntegrityChallengeResponse { + + @VisibleForTesting static final String CHALLENGE_KEY = "challenge"; + @VisibleForTesting static final String TIME_TO_LIVE_KEY = "ttl"; + + private String challenge; + private String timeToLive; + + @NonNull + public static GeneratePlayIntegrityChallengeResponse fromJsonString(@NonNull String jsonString) + throws FirebaseException, JSONException { + JSONObject jsonObject = new JSONObject(jsonString); + String challenge = emptyToNull(jsonObject.optString(CHALLENGE_KEY)); + String timeToLive = emptyToNull(jsonObject.optString(TIME_TO_LIVE_KEY)); + if (challenge == null || timeToLive == null) { + throw new FirebaseException("Unexpected server response."); + } + return new GeneratePlayIntegrityChallengeResponse(challenge, timeToLive); + } + + private GeneratePlayIntegrityChallengeResponse( + @NonNull String challenge, @NonNull String timeToLive) { + checkNotNull(challenge); + checkNotNull(timeToLive); + this.challenge = challenge; + this.timeToLive = timeToLive; + } + + @NonNull + public String getChallenge() { + return challenge; + } + + @NonNull + public String getTimeToLive() { + return timeToLive; + } +} diff --git a/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/PlayIntegrityAppCheckProvider.java b/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/PlayIntegrityAppCheckProvider.java new file mode 100644 index 00000000000..13381af83a6 --- /dev/null +++ b/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/PlayIntegrityAppCheckProvider.java @@ -0,0 +1,110 @@ +// 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.playintegrity.internal; + +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.play.core.integrity.IntegrityManager; +import com.google.android.play.core.integrity.IntegrityManagerFactory; +import com.google.android.play.core.integrity.IntegrityTokenRequest; +import com.google.android.play.core.integrity.IntegrityTokenResponse; +import com.google.firebase.FirebaseApp; +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 java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class PlayIntegrityAppCheckProvider implements AppCheckProvider { + + private static final String UTF_8 = "UTF-8"; + + private final String projectNumber; + private final IntegrityManager integrityManager; + private final NetworkClient networkClient; + private final ExecutorService backgroundExecutor; + private final RetryManager retryManager; + + public PlayIntegrityAppCheckProvider(@NonNull FirebaseApp firebaseApp) { + this( + firebaseApp.getOptions().getGcmSenderId(), + IntegrityManagerFactory.create(firebaseApp.getApplicationContext()), + new NetworkClient(firebaseApp), + Executors.newCachedThreadPool(), + new RetryManager()); + } + + @VisibleForTesting + PlayIntegrityAppCheckProvider( + @NonNull String projectNumber, + @NonNull IntegrityManager integrityManager, + @NonNull NetworkClient networkClient, + @NonNull ExecutorService backgroundExecutor, + @NonNull RetryManager retryManager) { + this.projectNumber = projectNumber; + this.integrityManager = integrityManager; + this.networkClient = networkClient; + this.backgroundExecutor = backgroundExecutor; + this.retryManager = retryManager; + } + + @NonNull + @Override + public Task getToken() { + return getPlayIntegrityAttestation() + .onSuccessTask( + integrityTokenResponse -> { + ExchangePlayIntegrityTokenRequest request = + new ExchangePlayIntegrityTokenRequest(integrityTokenResponse.token()); + return Tasks.call( + backgroundExecutor, + () -> + networkClient.exchangeAttestationForAppCheckToken( + request.toJsonString().getBytes(UTF_8), + NetworkClient.PLAY_INTEGRITY, + retryManager)); + }) + .onSuccessTask( + appCheckTokenResponse -> { + return Tasks.forResult( + DefaultAppCheckToken.constructFromAppCheckTokenResponse(appCheckTokenResponse)); + }); + } + + @NonNull + private Task getPlayIntegrityAttestation() { + GeneratePlayIntegrityChallengeRequest generateChallengeRequest = + new GeneratePlayIntegrityChallengeRequest(); + Task generateChallengeTask = + Tasks.call( + backgroundExecutor, + () -> + GeneratePlayIntegrityChallengeResponse.fromJsonString( + networkClient.generatePlayIntegrityChallenge( + generateChallengeRequest.toJsonString().getBytes(UTF_8), retryManager))); + return generateChallengeTask.onSuccessTask( + generatePlayIntegrityChallengeResponse -> { + return integrityManager.requestIntegrityToken( + IntegrityTokenRequest.builder() + .setCloudProjectNumber(Long.parseLong(projectNumber)) + .setNonce(generatePlayIntegrityChallengeResponse.getChallenge()) + .build()); + }); + } +} diff --git a/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/package-info.java b/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/package-info.java new file mode 100644 index 00000000000..3431597172a --- /dev/null +++ b/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/package-info.java @@ -0,0 +1,16 @@ +// 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. + +/** @hide */ +package com.google.firebase.appcheck.playintegrity.internal; diff --git a/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/PlayIntegrityAppCheckProviderFactoryTest.java b/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/PlayIntegrityAppCheckProviderFactoryTest.java new file mode 100644 index 00000000000..ed1ebddda96 --- /dev/null +++ b/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/PlayIntegrityAppCheckProviderFactoryTest.java @@ -0,0 +1,37 @@ +// 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.playintegrity; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** Tests for {@link PlayIntegrityAppCheckProviderFactory}. */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class PlayIntegrityAppCheckProviderFactoryTest { + + @Test + public void testGetInstance_callTwice_sameInstance() { + PlayIntegrityAppCheckProviderFactory firstInstance = + PlayIntegrityAppCheckProviderFactory.getInstance(); + PlayIntegrityAppCheckProviderFactory secondInstance = + PlayIntegrityAppCheckProviderFactory.getInstance(); + assertThat(firstInstance).isEqualTo(secondInstance); + } +} diff --git a/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/internal/ExchangePlayIntegrityTokenRequestTest.java b/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/internal/ExchangePlayIntegrityTokenRequestTest.java new file mode 100644 index 00000000000..8c01f97f887 --- /dev/null +++ b/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/internal/ExchangePlayIntegrityTokenRequestTest.java @@ -0,0 +1,42 @@ +// 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.playintegrity.internal; + +import static com.google.common.truth.Truth.assertThat; + +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** Tests for {@link ExchangePlayIntegrityTokenRequest}. */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class ExchangePlayIntegrityTokenRequestTest { + private static final String PLAY_INTEGRITY_TOKEN = "playIntegrityToken"; + + @Test + public void toJsonString_expectSerialized() throws Exception { + ExchangePlayIntegrityTokenRequest exchangePlayIntegrityTokenRequest = + new ExchangePlayIntegrityTokenRequest(PLAY_INTEGRITY_TOKEN); + + String jsonString = exchangePlayIntegrityTokenRequest.toJsonString(); + JSONObject jsonObject = new JSONObject(jsonString); + + assertThat(jsonObject.getString(ExchangePlayIntegrityTokenRequest.PLAY_INTEGRITY_TOKEN_KEY)) + .isEqualTo(PLAY_INTEGRITY_TOKEN); + } +} diff --git a/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/internal/GeneratePlayIntegrityChallengeRequestTest.java b/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/internal/GeneratePlayIntegrityChallengeRequestTest.java new file mode 100644 index 00000000000..abd86ae8258 --- /dev/null +++ b/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/internal/GeneratePlayIntegrityChallengeRequestTest.java @@ -0,0 +1,37 @@ +// 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.playintegrity.internal; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** Tests for {@link GeneratePlayIntegrityChallengeRequest}. */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class GeneratePlayIntegrityChallengeRequestTest { + private static final String EMPTY_JSON = "{}"; + + @Test + public void toJsonString_expectSerialized() throws Exception { + GeneratePlayIntegrityChallengeRequest generatePlayIntegrityChallengeRequest = + new GeneratePlayIntegrityChallengeRequest(); + + assertThat(generatePlayIntegrityChallengeRequest.toJsonString()).isEqualTo(EMPTY_JSON); + } +} diff --git a/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/internal/GeneratePlayIntegrityChallengeResponseTest.java b/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/internal/GeneratePlayIntegrityChallengeResponseTest.java new file mode 100644 index 00000000000..dcc48034b00 --- /dev/null +++ b/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/internal/GeneratePlayIntegrityChallengeResponseTest.java @@ -0,0 +1,65 @@ +// 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.playintegrity.internal; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.firebase.FirebaseException; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** Tests for {@link GeneratePlayIntegrityChallengeResponse}. */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class GeneratePlayIntegrityChallengeResponseTest { + private static final String CHALLENGE = "testChallenge"; + private static final String TIME_TO_LIVE = "3600s"; + + @Test + public void fromJsonString_expectDeserialized() throws Exception { + JSONObject jsonObject = new JSONObject(); + jsonObject.put(GeneratePlayIntegrityChallengeResponse.CHALLENGE_KEY, CHALLENGE); + jsonObject.put(GeneratePlayIntegrityChallengeResponse.TIME_TO_LIVE_KEY, TIME_TO_LIVE); + + GeneratePlayIntegrityChallengeResponse generatePlayIntegrityChallengeResponse = + GeneratePlayIntegrityChallengeResponse.fromJsonString(jsonObject.toString()); + assertThat(generatePlayIntegrityChallengeResponse.getChallenge()).isEqualTo(CHALLENGE); + assertThat(generatePlayIntegrityChallengeResponse.getTimeToLive()).isEqualTo(TIME_TO_LIVE); + } + + @Test + public void fromJsonString_nullChallenge_throwsException() throws Exception { + JSONObject jsonObject = new JSONObject(); + jsonObject.put(GeneratePlayIntegrityChallengeResponse.TIME_TO_LIVE_KEY, TIME_TO_LIVE); + + assertThrows( + FirebaseException.class, + () -> GeneratePlayIntegrityChallengeResponse.fromJsonString(jsonObject.toString())); + } + + @Test + public void fromJsonString_nullTimeToLive_throwsException() throws Exception { + JSONObject jsonObject = new JSONObject(); + jsonObject.put(GeneratePlayIntegrityChallengeResponse.CHALLENGE_KEY, CHALLENGE); + + assertThrows( + FirebaseException.class, + () -> GeneratePlayIntegrityChallengeResponse.fromJsonString(jsonObject.toString())); + } +} diff --git a/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/internal/PlayIntegrityAppCheckProviderTest.java b/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/internal/PlayIntegrityAppCheckProviderTest.java new file mode 100644 index 00000000000..1cbcd153bb3 --- /dev/null +++ b/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/internal/PlayIntegrityAppCheckProviderTest.java @@ -0,0 +1,226 @@ +// 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.playintegrity.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.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +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.play.core.integrity.IntegrityManager; +import com.google.android.play.core.integrity.IntegrityTokenRequest; +import com.google.android.play.core.integrity.IntegrityTokenResponse; +import com.google.common.util.concurrent.MoreExecutors; +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 java.io.IOException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeoutException; +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; + +/** Tests for {@link PlayIntegrityAppCheckProvider}. */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class PlayIntegrityAppCheckProviderTest { + + private static final String PROJECT_NUMBER = "123456"; + private static final String APP_CHECK_TOKEN = "appCheckToken"; + private static final String TIME_TO_LIVE = "3600s"; + private static final String CHALLENGE = "testChallenge"; + private static final String INTEGRITY_TOKEN = "integrityToken"; + + @Mock private IntegrityManager mockIntegrityManager; + @Mock private NetworkClient mockNetworkClient; + @Mock private RetryManager mockRetryManager; + @Mock private IntegrityTokenResponse mockIntegrityTokenResponse; + @Mock private AppCheckTokenResponse mockAppCheckTokenResponse; + + @Captor private ArgumentCaptor integrityTokenRequestCaptor; + @Captor private ArgumentCaptor exchangePlayIntegrityTokenRequestCaptor; + + private ExecutorService backgroundExecutor = MoreExecutors.newDirectExecutorService(); + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + when(mockIntegrityTokenResponse.token()).thenReturn(INTEGRITY_TOKEN); + when(mockAppCheckTokenResponse.getToken()).thenReturn(APP_CHECK_TOKEN); + when(mockAppCheckTokenResponse.getTimeToLive()).thenReturn(TIME_TO_LIVE); + } + + @Test + public void testPublicConstructor_nullFirebaseApp_expectThrows() { + assertThrows( + NullPointerException.class, + () -> { + new PlayIntegrityAppCheckProvider(null); + }); + } + + @Test + public void getToken_onSuccess_setsTaskResult() throws Exception { + when(mockNetworkClient.generatePlayIntegrityChallenge(any(), eq(mockRetryManager))) + .thenReturn(createGeneratePlayIntegrityChallengeResponse()); + when(mockIntegrityManager.requestIntegrityToken(any())) + .thenReturn(Tasks.forResult(mockIntegrityTokenResponse)); + when(mockNetworkClient.exchangeAttestationForAppCheckToken( + any(), eq(NetworkClient.PLAY_INTEGRITY), eq(mockRetryManager))) + .thenReturn(mockAppCheckTokenResponse); + + PlayIntegrityAppCheckProvider provider = + new PlayIntegrityAppCheckProvider( + PROJECT_NUMBER, + mockIntegrityManager, + mockNetworkClient, + backgroundExecutor, + mockRetryManager); + Task task = provider.getToken(); + + AppCheckToken token = task.getResult(); + assertThat(token).isInstanceOf(DefaultAppCheckToken.class); + assertThat(token.getToken()).isEqualTo(APP_CHECK_TOKEN); + + verify(mockNetworkClient).generatePlayIntegrityChallenge(any(), eq(mockRetryManager)); + + verify(mockIntegrityManager).requestIntegrityToken(integrityTokenRequestCaptor.capture()); + assertThat(integrityTokenRequestCaptor.getValue().cloudProjectNumber()) + .isEqualTo(Long.parseLong(PROJECT_NUMBER)); + assertThat(integrityTokenRequestCaptor.getValue().nonce()).isEqualTo(CHALLENGE); + + verify(mockNetworkClient) + .exchangeAttestationForAppCheckToken( + exchangePlayIntegrityTokenRequestCaptor.capture(), + eq(NetworkClient.PLAY_INTEGRITY), + eq(mockRetryManager)); + String exchangePlayIntegrityTokenRequestJsonString = + new String(exchangePlayIntegrityTokenRequestCaptor.getValue()); + assertThat(exchangePlayIntegrityTokenRequestJsonString).contains(INTEGRITY_TOKEN); + } + + @Test + public void getToken_generateChallengeFails_setsTaskException() throws Exception { + when(mockNetworkClient.generatePlayIntegrityChallenge(any(), eq(mockRetryManager))) + .thenThrow(new IOException()); + + PlayIntegrityAppCheckProvider provider = + new PlayIntegrityAppCheckProvider( + PROJECT_NUMBER, + mockIntegrityManager, + mockNetworkClient, + backgroundExecutor, + mockRetryManager); + Task task = provider.getToken(); + + assertThat(task.isSuccessful()).isFalse(); + assertThat(task.getException()).isInstanceOf(IOException.class); + + verify(mockNetworkClient).generatePlayIntegrityChallenge(any(), eq(mockRetryManager)); + verify(mockNetworkClient, never()).exchangeAttestationForAppCheckToken(any(), anyInt(), any()); + verify(mockIntegrityManager, never()).requestIntegrityToken(any()); + } + + @Test + public void getToken_requestIntegrityTokenFails_setsTaskException() throws Exception { + when(mockNetworkClient.generatePlayIntegrityChallenge(any(), eq(mockRetryManager))) + .thenReturn(createGeneratePlayIntegrityChallengeResponse()); + when(mockIntegrityManager.requestIntegrityToken(any())) + .thenReturn(Tasks.forException(new TimeoutException())); + + PlayIntegrityAppCheckProvider provider = + new PlayIntegrityAppCheckProvider( + PROJECT_NUMBER, + mockIntegrityManager, + mockNetworkClient, + backgroundExecutor, + mockRetryManager); + Task task = provider.getToken(); + + assertThat(task.isSuccessful()).isFalse(); + assertThat(task.getException()).isInstanceOf(TimeoutException.class); + + verify(mockNetworkClient).generatePlayIntegrityChallenge(any(), eq(mockRetryManager)); + verify(mockNetworkClient, never()).exchangeAttestationForAppCheckToken(any(), anyInt(), any()); + + verify(mockIntegrityManager).requestIntegrityToken(integrityTokenRequestCaptor.capture()); + assertThat(integrityTokenRequestCaptor.getValue().cloudProjectNumber()) + .isEqualTo(Long.parseLong(PROJECT_NUMBER)); + assertThat(integrityTokenRequestCaptor.getValue().nonce()).isEqualTo(CHALLENGE); + } + + @Test + public void getToken_tokenExchangeFails_setsTaskException() throws Exception { + when(mockNetworkClient.generatePlayIntegrityChallenge(any(), eq(mockRetryManager))) + .thenReturn(createGeneratePlayIntegrityChallengeResponse()); + when(mockIntegrityManager.requestIntegrityToken(any())) + .thenReturn(Tasks.forResult(mockIntegrityTokenResponse)); + when(mockNetworkClient.exchangeAttestationForAppCheckToken( + any(), eq(NetworkClient.PLAY_INTEGRITY), eq(mockRetryManager))) + .thenThrow(new IOException()); + + PlayIntegrityAppCheckProvider provider = + new PlayIntegrityAppCheckProvider( + PROJECT_NUMBER, + mockIntegrityManager, + mockNetworkClient, + backgroundExecutor, + mockRetryManager); + Task task = provider.getToken(); + + assertThat(task.isSuccessful()).isFalse(); + assertThat(task.getException()).isInstanceOf(IOException.class); + + verify(mockNetworkClient).generatePlayIntegrityChallenge(any(), eq(mockRetryManager)); + + verify(mockIntegrityManager).requestIntegrityToken(integrityTokenRequestCaptor.capture()); + assertThat(integrityTokenRequestCaptor.getValue().cloudProjectNumber()) + .isEqualTo(Long.parseLong(PROJECT_NUMBER)); + assertThat(integrityTokenRequestCaptor.getValue().nonce()).isEqualTo(CHALLENGE); + + verify(mockNetworkClient) + .exchangeAttestationForAppCheckToken( + exchangePlayIntegrityTokenRequestCaptor.capture(), + eq(NetworkClient.PLAY_INTEGRITY), + eq(mockRetryManager)); + String exchangePlayIntegrityTokenRequestJsonString = + new String(exchangePlayIntegrityTokenRequestCaptor.getValue()); + assertThat(exchangePlayIntegrityTokenRequestJsonString).contains(INTEGRITY_TOKEN); + } + + private static String createGeneratePlayIntegrityChallengeResponse() throws Exception { + JSONObject responseBodyJson = new JSONObject(); + responseBodyJson.put(GeneratePlayIntegrityChallengeResponse.CHALLENGE_KEY, CHALLENGE); + responseBodyJson.put(GeneratePlayIntegrityChallengeResponse.TIME_TO_LIVE_KEY, TIME_TO_LIVE); + + return responseBodyJson.toString(); + } +} diff --git a/appcheck/firebase-appcheck/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenResponse.java b/appcheck/firebase-appcheck/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenResponse.java index 77469b0dbfe..407ad603c3d 100644 --- a/appcheck/firebase-appcheck/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenResponse.java +++ b/appcheck/firebase-appcheck/src/main/java/com/google/firebase/appcheck/internal/AppCheckTokenResponse.java @@ -19,6 +19,7 @@ import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; +import com.google.firebase.FirebaseException; import org.json.JSONException; import org.json.JSONObject; @@ -35,10 +36,13 @@ public class AppCheckTokenResponse { @NonNull public static AppCheckTokenResponse fromJsonString(@NonNull String jsonString) - throws JSONException { + throws FirebaseException, JSONException { JSONObject jsonObject = new JSONObject(jsonString); String token = emptyToNull(jsonObject.optString(TOKEN_KEY)); String timeToLive = emptyToNull(jsonObject.optString(TIME_TO_LIVE_KEY)); + if (token == null || timeToLive == null) { + throw new FirebaseException("Unexpected server response."); + } return new AppCheckTokenResponse(token, timeToLive); } 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 ffa012288e6..7317524dfc2 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 @@ -55,6 +55,10 @@ public class NetworkClient { "https://firebaseappcheck.googleapis.com/v1/projects/%s/apps/%s:exchangeSafetyNetToken?key=%s"; private static final String DEBUG_EXCHANGE_URL_TEMPLATE = "https://firebaseappcheck.googleapis.com/v1/projects/%s/apps/%s:exchangeDebugToken?key=%s"; + private static final String PLAY_INTEGRITY_EXCHANGE_URL_TEMPLATE = + "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 CONTENT_TYPE = "Content-Type"; private static final String APPLICATION_JSON = "application/json"; private static final String UTF_8 = "UTF-8"; @@ -69,12 +73,13 @@ public class NetworkClient { private final Provider heartBeatControllerProvider; @Retention(RetentionPolicy.SOURCE) - @IntDef({UNKNOWN, SAFETY_NET, DEBUG}) + @IntDef({UNKNOWN, SAFETY_NET, DEBUG, PLAY_INTEGRITY}) public @interface AttestationTokenType {} public static final int UNKNOWN = 0; public static final int SAFETY_NET = 1; public static final int DEBUG = 2; + public static final int PLAY_INTEGRITY = 3; public NetworkClient(@NonNull FirebaseApp firebaseApp) { this( @@ -116,6 +121,29 @@ public AppCheckTokenResponse exchangeAttestationForAppCheckToken( throw new FirebaseException("Too many attempts."); } URL url = new URL(String.format(getUrlTemplate(tokenType), projectId, appId, apiKey)); + String response = makeNetworkRequest(url, requestBytes, retryManager); + return AppCheckTokenResponse.fromJsonString(response); + } + + /** + * Calls the App Check backend using {@link HttpURLConnection} in order to generate a challenge + * nonce for the Play Integrity attestation flow. + */ + @NonNull + public String generatePlayIntegrityChallenge( + @NonNull byte[] requestBytes, @NonNull RetryManager retryManager) + throws FirebaseException, IOException, JSONException { + if (!retryManager.canRetry()) { + throw new FirebaseException("Too many attempts."); + } + URL url = + new URL(String.format(PLAY_INTEGRITY_CHALLENGE_URL_TEMPLATE, projectId, appId, apiKey)); + return makeNetworkRequest(url, requestBytes, retryManager); + } + + private String makeNetworkRequest( + @NonNull URL url, @NonNull byte[] requestBytes, @NonNull RetryManager retryManager) + throws FirebaseException, IOException, JSONException { HttpURLConnection urlConnection = createHttpUrlConnection(url); try { @@ -160,7 +188,7 @@ public AppCheckTokenResponse exchangeAttestationForAppCheckToken( + httpErrorResponse.getErrorMessage()); } retryManager.resetBackoffOnSuccess(); - return AppCheckTokenResponse.fromJsonString(responseBody); + return responseBody; } finally { urlConnection.disconnect(); } @@ -203,6 +231,8 @@ private static String getUrlTemplate(@AttestationTokenType int tokenType) { return SAFETY_NET_EXCHANGE_URL_TEMPLATE; case DEBUG: return DEBUG_EXCHANGE_URL_TEMPLATE; + case PLAY_INTEGRITY: + return PLAY_INTEGRITY_EXCHANGE_URL_TEMPLATE; default: throw new IllegalArgumentException("Unknown token type."); } diff --git a/appcheck/firebase-appcheck/src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenResponseTest.java b/appcheck/firebase-appcheck/src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenResponseTest.java index 63e48cced87..db2255d151f 100644 --- a/appcheck/firebase-appcheck/src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenResponseTest.java +++ b/appcheck/firebase-appcheck/src/test/java/com/google/firebase/appcheck/internal/AppCheckTokenResponseTest.java @@ -17,6 +17,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; +import com.google.firebase.FirebaseException; import org.json.JSONObject; import org.junit.Test; import org.junit.runner.RunWith; @@ -49,8 +50,7 @@ public void fromJsonString_nullToken_throwsException() throws Exception { jsonObject.put(AppCheckTokenResponse.TIME_TO_LIVE_KEY, TIME_TO_LIVE); assertThrows( - NullPointerException.class, - () -> AppCheckTokenResponse.fromJsonString(jsonObject.toString())); + FirebaseException.class, () -> AppCheckTokenResponse.fromJsonString(jsonObject.toString())); } @Test @@ -59,7 +59,6 @@ public void fromJsonString_nullTimeToLive_throwsException() throws Exception { jsonObject.put(AppCheckTokenResponse.TOKEN_KEY, APP_CHECK_TOKEN); assertThrows( - NullPointerException.class, - () -> AppCheckTokenResponse.fromJsonString(jsonObject.toString())); + FirebaseException.class, () -> AppCheckTokenResponse.fromJsonString(jsonObject.toString())); } } diff --git a/appcheck/firebase-appcheck/src/test/java/com/google/firebase/appcheck/internal/NetworkClientTest.java b/appcheck/firebase-appcheck/src/test/java/com/google/firebase/appcheck/internal/NetworkClientTest.java index 44d920b4cd6..9a2441f97b6 100644 --- a/appcheck/firebase-appcheck/src/test/java/com/google/firebase/appcheck/internal/NetworkClientTest.java +++ b/appcheck/firebase-appcheck/src/test/java/com/google/firebase/appcheck/internal/NetworkClientTest.java @@ -69,6 +69,10 @@ public class NetworkClientTest { "https://firebaseappcheck.googleapis.com/v1/projects/projectId/apps/appId:exchangeSafetyNetToken?key=apiKey"; private static final String DEBUG_EXPECTED_URL = "https://firebaseappcheck.googleapis.com/v1/projects/projectId/apps/appId:exchangeDebugToken?key=apiKey"; + private static final String PLAY_INTEGRITY_CHALLENGE_EXPECTED_URL = + "https://firebaseappcheck.googleapis.com/v1/projects/projectId/apps/appId:generatePlayIntegrityChallenge?key=apiKey"; + private static final String PLAY_INTEGRITY_EXCHANGE_EXPECTED_URL = + "https://firebaseappcheck.googleapis.com/v1/projects/projectId/apps/appId:exchangePlayIntegrityToken?key=apiKey"; private static final String JSON_REQUEST = "jsonRequest"; private static final int SUCCESS_CODE = 200; private static final int ERROR_CODE = 404; @@ -76,6 +80,7 @@ public class NetworkClientTest { private static final String TIME_TO_LIVE = "3600s"; private static final String ERROR_MESSAGE = "error message"; private static final String HEART_BEAT_HEADER_TEST = "test-header"; + private static final String CHALLENGE_RESPONSE = "challengeResponse"; @Mock HeartBeatController mockHeartBeatController; @Mock HttpURLConnection mockHttpUrlConnection; @@ -211,17 +216,53 @@ public void exchangeDebugToken_errorResponse_throwsException() throws Exception } @Test - public void exchangeAttestation_heartbeatNone_doesNotAttachHeader() throws Exception { + public void exchangePlayIntegrityToken_successResponse_returnsAppCheckTokenResponse() + throws Exception { JSONObject responseBodyJson = createAttestationResponse(); when(mockHttpUrlConnection.getOutputStream()).thenReturn(mockOutputStream); when(mockHttpUrlConnection.getInputStream()) .thenReturn(new ByteArrayInputStream(responseBodyJson.toString().getBytes())); when(mockHttpUrlConnection.getResponseCode()).thenReturn(SUCCESS_CODE); - // The heartbeat request header should not be attached when the heartbeat is HeartBeat.NONE. - networkClient.exchangeAttestationForAppCheckToken( - JSON_REQUEST.getBytes(), NetworkClient.SAFETY_NET, mockRetryManager); + AppCheckTokenResponse tokenResponse = + networkClient.exchangeAttestationForAppCheckToken( + JSON_REQUEST.getBytes(), NetworkClient.PLAY_INTEGRITY, mockRetryManager); + assertThat(tokenResponse.getToken()).isEqualTo(APP_CHECK_TOKEN); + assertThat(tokenResponse.getTimeToLive()).isEqualTo(TIME_TO_LIVE); + + URL expectedUrl = new URL(PLAY_INTEGRITY_EXCHANGE_EXPECTED_URL); + verify(networkClient).createHttpUrlConnection(expectedUrl); + verify(mockOutputStream) + .write(JSON_REQUEST.getBytes(), /* off= */ 0, JSON_REQUEST.getBytes().length); + verify(mockRetryManager, never()).updateBackoffOnFailure(anyInt()); + verify(mockRetryManager).resetBackoffOnSuccess(); + verifyRequestHeaders(); + } + + @Test + public void exchangePlayIntegrityToken_errorResponse_throwsException() throws Exception { + JSONObject responseBodyJson = createHttpErrorResponse(); + + when(mockHttpUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + when(mockHttpUrlConnection.getErrorStream()) + .thenReturn(new ByteArrayInputStream(responseBodyJson.toString().getBytes())); + when(mockHttpUrlConnection.getResponseCode()).thenReturn(ERROR_CODE); + + FirebaseException exception = + assertThrows( + FirebaseException.class, + () -> + networkClient.exchangeAttestationForAppCheckToken( + JSON_REQUEST.getBytes(), NetworkClient.PLAY_INTEGRITY, mockRetryManager)); + + assertThat(exception.getMessage()).contains(ERROR_MESSAGE); + URL expectedUrl = new URL(PLAY_INTEGRITY_EXCHANGE_EXPECTED_URL); + verify(networkClient).createHttpUrlConnection(expectedUrl); + verify(mockOutputStream) + .write(JSON_REQUEST.getBytes(), /* off= */ 0, JSON_REQUEST.getBytes().length); + verify(mockRetryManager).updateBackoffOnFailure(ERROR_CODE); + verify(mockRetryManager, never()).resetBackoffOnSuccess(); verifyRequestHeaders(); } @@ -237,6 +278,21 @@ public void exchangeUnknownAttestation_throwsException() { verify(mockRetryManager, never()).resetBackoffOnSuccess(); } + @Test + public void exchangeAttestation_heartbeatNone_doesNotAttachHeader() throws Exception { + JSONObject responseBodyJson = createAttestationResponse(); + + when(mockHttpUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + when(mockHttpUrlConnection.getInputStream()) + .thenReturn(new ByteArrayInputStream(responseBodyJson.toString().getBytes())); + when(mockHttpUrlConnection.getResponseCode()).thenReturn(SUCCESS_CODE); + // The heartbeat request header should not be attached when the heartbeat is HeartBeat.NONE. + networkClient.exchangeAttestationForAppCheckToken( + JSON_REQUEST.getBytes(), NetworkClient.SAFETY_NET, mockRetryManager); + + verifyRequestHeaders(); + } + @Test public void exchangeAttestation_cannotRetry_throwsException() { when(mockRetryManager.canRetry()).thenReturn(false); @@ -253,6 +309,68 @@ public void exchangeAttestation_cannotRetry_throwsException() { verify(mockRetryManager, never()).resetBackoffOnSuccess(); } + @Test + public void generatePlayIntegrityChallenge_successResponse_returnsJsonString() throws Exception { + when(mockHttpUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + when(mockHttpUrlConnection.getInputStream()) + .thenReturn(new ByteArrayInputStream(CHALLENGE_RESPONSE.getBytes())); + when(mockHttpUrlConnection.getResponseCode()).thenReturn(SUCCESS_CODE); + + String challengeResponse = + networkClient.generatePlayIntegrityChallenge(JSON_REQUEST.getBytes(), mockRetryManager); + assertThat(challengeResponse).isEqualTo(CHALLENGE_RESPONSE); + + URL expectedUrl = new URL(PLAY_INTEGRITY_CHALLENGE_EXPECTED_URL); + verify(networkClient).createHttpUrlConnection(expectedUrl); + verify(mockOutputStream) + .write(JSON_REQUEST.getBytes(), /* off= */ 0, JSON_REQUEST.getBytes().length); + verify(mockRetryManager, never()).updateBackoffOnFailure(anyInt()); + verify(mockRetryManager).resetBackoffOnSuccess(); + verifyRequestHeaders(); + } + + @Test + public void generatePlayIntegrityChallenge_errorResponse_throwsException() throws Exception { + JSONObject responseBodyJson = createHttpErrorResponse(); + + when(mockHttpUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + when(mockHttpUrlConnection.getErrorStream()) + .thenReturn(new ByteArrayInputStream(responseBodyJson.toString().getBytes())); + when(mockHttpUrlConnection.getResponseCode()).thenReturn(ERROR_CODE); + + FirebaseException exception = + assertThrows( + FirebaseException.class, + () -> + networkClient.generatePlayIntegrityChallenge( + JSON_REQUEST.getBytes(), mockRetryManager)); + + assertThat(exception.getMessage()).contains(ERROR_MESSAGE); + URL expectedUrl = new URL(PLAY_INTEGRITY_CHALLENGE_EXPECTED_URL); + verify(networkClient).createHttpUrlConnection(expectedUrl); + verify(mockOutputStream) + .write(JSON_REQUEST.getBytes(), /* off= */ 0, JSON_REQUEST.getBytes().length); + verify(mockRetryManager).updateBackoffOnFailure(ERROR_CODE); + verify(mockRetryManager, never()).resetBackoffOnSuccess(); + verifyRequestHeaders(); + } + + @Test + public void generatePlayIntegrityChallenge_cannotRetry_throwsException() { + when(mockRetryManager.canRetry()).thenReturn(false); + + FirebaseException exception = + assertThrows( + FirebaseException.class, + () -> + networkClient.generatePlayIntegrityChallenge( + JSON_REQUEST.getBytes(), mockRetryManager)); + + assertThat(exception.getMessage()).contains("Too many attempts"); + verify(mockRetryManager, never()).updateBackoffOnFailure(anyInt()); + verify(mockRetryManager, never()).resetBackoffOnSuccess(); + } + private void verifyRequestHeaders() { verify(networkClient).getHeartBeat(); verify(mockHttpUrlConnection).setRequestProperty(X_FIREBASE_CLIENT, HEART_BEAT_HEADER_TEST); diff --git a/appcheck/firebase-appcheck/test-app/src/main/java/com/googletest/firebase/appcheck/MainActivity.java b/appcheck/firebase-appcheck/test-app/src/main/java/com/googletest/firebase/appcheck/MainActivity.java index 41299c2276d..38eca67b561 100644 --- a/appcheck/firebase-appcheck/test-app/src/main/java/com/googletest/firebase/appcheck/MainActivity.java +++ b/appcheck/firebase-appcheck/test-app/src/main/java/com/googletest/firebase/appcheck/MainActivity.java @@ -30,6 +30,7 @@ import com.google.firebase.appcheck.FirebaseAppCheck; import com.google.firebase.appcheck.FirebaseAppCheck.AppCheckListener; import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory; +import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory; import com.google.firebase.appcheck.safetynet.SafetyNetAppCheckProviderFactory; import com.google.firebase.storage.FirebaseStorage; import com.google.firebase.storage.ListResult; @@ -42,6 +43,7 @@ public class MainActivity extends AppCompatActivity { private FirebaseAppCheck firebaseAppCheck; private FirebaseStorage firebaseStorage; private AppCheckListener appCheckListener; + private Button installPlayIntegrityButton; private Button installSafetyNetButton; private Button installDebugButton; private Button getAppCheckTokenButton; @@ -80,6 +82,7 @@ public void onAppCheckTokenChanged(@NonNull AppCheckToken token) { } private void initViews() { + installPlayIntegrityButton = findViewById(R.id.install_play_integrity_app_check_button); installSafetyNetButton = findViewById(R.id.install_safety_net_app_check_button); installDebugButton = findViewById(R.id.install_debug_app_check_button); getAppCheckTokenButton = findViewById(R.id.exchange_app_check_button); @@ -89,6 +92,17 @@ private void initViews() { } private void setOnClickListeners() { + installPlayIntegrityButton.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + firebaseAppCheck.installAppCheckProviderFactory( + PlayIntegrityAppCheckProviderFactory.getInstance()); + Log.d(TAG, "Installed PlayIntegrityAppCheckProvider"); + showToast("Installed PlayIntegrityAppCheckProvider."); + } + }); + installSafetyNetButton.setOnClickListener( new OnClickListener() { @Override diff --git a/appcheck/firebase-appcheck/test-app/src/main/res/layout/activity_main.xml b/appcheck/firebase-appcheck/test-app/src/main/res/layout/activity_main.xml index eccdbf9ec6e..43a4d771cb7 100644 --- a/appcheck/firebase-appcheck/test-app/src/main/res/layout/activity_main.xml +++ b/appcheck/firebase-appcheck/test-app/src/main/res/layout/activity_main.xml @@ -6,6 +6,11 @@ android:gravity="center" android:orientation="vertical"> +