diff --git a/appcheck/firebase-appcheck-playintegrity/firebase-appcheck-playintegrity.gradle b/appcheck/firebase-appcheck-playintegrity/firebase-appcheck-playintegrity.gradle index 4e02b8282fa..0733ab44ace 100644 --- a/appcheck/firebase-appcheck-playintegrity/firebase-appcheck-playintegrity.gradle +++ b/appcheck/firebase-appcheck-playintegrity/firebase-appcheck-playintegrity.gradle @@ -46,6 +46,11 @@ dependencies { 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-beta-2' + 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/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 index 4c08c783039..743092d2892 100644 --- 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 @@ -41,6 +41,6 @@ public static PlayIntegrityAppCheckProviderFactory getInstance() { @NonNull @Override public AppCheckProvider create(@NonNull FirebaseApp firebaseApp) { - return new PlayIntegrityAppCheckProvider(); + 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..7f3b38cf60c --- /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. + */ +public 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/PlayIntegrityAppCheckProvider.java b/appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/PlayIntegrityAppCheckProvider.java index 22d77a846cf..5f99d5e1228 100644 --- 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 @@ -15,20 +15,67 @@ package com.google.firebase.appcheck.playintegrity.internal; import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import com.google.android.gms.tasks.Continuation; import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.Tasks; -import com.google.firebase.FirebaseException; +import com.google.firebase.FirebaseApp; import com.google.firebase.appcheck.AppCheckProvider; 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.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; public class PlayIntegrityAppCheckProvider implements AppCheckProvider { - public PlayIntegrityAppCheckProvider() {} + private static final String UTF_8 = "UTF-8"; + + private final NetworkClient networkClient; + private final ExecutorService backgroundExecutor; + private final RetryManager retryManager; + + public PlayIntegrityAppCheckProvider(@NonNull FirebaseApp firebaseApp) { + this(new NetworkClient(firebaseApp), Executors.newCachedThreadPool(), new RetryManager()); + } + + @VisibleForTesting + PlayIntegrityAppCheckProvider( + @NonNull NetworkClient networkClient, + @NonNull ExecutorService backgroundExecutor, + @NonNull RetryManager retryManager) { + this.networkClient = networkClient; + this.backgroundExecutor = backgroundExecutor; + this.retryManager = retryManager; + } @NonNull @Override public Task getToken() { - // TODO(rosalyntan): Implement this. - return Tasks.forException(new FirebaseException("Unimplemented")); + // TODO(rosalyntan): Obtain the Play Integrity challenge nonce. + ExchangePlayIntegrityTokenRequest request = + new ExchangePlayIntegrityTokenRequest("placeholder"); + Task networkTask = + Tasks.call( + backgroundExecutor, + () -> + networkClient.exchangeAttestationForAppCheckToken( + request.toJsonString().getBytes(UTF_8), + NetworkClient.PLAY_INTEGRITY, + retryManager)); + return networkTask.continueWithTask( + new Continuation>() { + @Override + public Task then(@NonNull Task task) { + if (task.isSuccessful()) { + return Tasks.forResult( + DefaultAppCheckToken.constructFromAppCheckTokenResponse(task.getResult())); + } + // TODO: Surface more error details. + return Tasks.forException(task.getException()); + } + }); } } 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/PlayIntegrityAppCheckProviderTest.java b/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/internal/PlayIntegrityAppCheckProviderTest.java new file mode 100644 index 00000000000..faa5c6ee702 --- /dev/null +++ b/appcheck/firebase-appcheck-playintegrity/src/test/java/com/google/firebase/appcheck/playintegrity/internal/PlayIntegrityAppCheckProviderTest.java @@ -0,0 +1,107 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.firebase.appcheck.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.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.android.gms.tasks.Task; +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 org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +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 ATTESTATION_TOKEN = "token"; + private static final String TIME_TO_LIVE = "3600s"; + + private ExecutorService backgroundExecutor = MoreExecutors.newDirectExecutorService(); + @Mock private NetworkClient mockNetworkClient; + @Mock private RetryManager mockRetryManager; + @Mock private AppCheckTokenResponse mockAppCheckTokenResponse; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + } + + @Test + public void testPublicConstructor_nullFirebaseApp_expectThrows() { + assertThrows( + NullPointerException.class, + () -> { + new PlayIntegrityAppCheckProvider(null); + }); + } + + @Test + public void getToken_onSuccess_setsTaskResult() throws Exception { + when(mockNetworkClient.exchangeAttestationForAppCheckToken( + any(), eq(NetworkClient.PLAY_INTEGRITY), eq(mockRetryManager))) + .thenReturn(mockAppCheckTokenResponse); + when(mockAppCheckTokenResponse.getAttestationToken()).thenReturn(ATTESTATION_TOKEN); + when(mockAppCheckTokenResponse.getTimeToLive()).thenReturn(TIME_TO_LIVE); + + PlayIntegrityAppCheckProvider provider = + new PlayIntegrityAppCheckProvider(mockNetworkClient, backgroundExecutor, mockRetryManager); + Task task = provider.getToken(); + + verify(mockNetworkClient) + .exchangeAttestationForAppCheckToken( + any(), eq(NetworkClient.PLAY_INTEGRITY), eq(mockRetryManager)); + + AppCheckToken token = task.getResult(); + assertThat(token).isInstanceOf(DefaultAppCheckToken.class); + assertThat(token.getToken()).isEqualTo(ATTESTATION_TOKEN); + } + + @Test + public void getToken_onFailure_setsTaskException() throws Exception { + when(mockNetworkClient.exchangeAttestationForAppCheckToken( + any(), eq(NetworkClient.PLAY_INTEGRITY), eq(mockRetryManager))) + .thenThrow(new IOException()); + + PlayIntegrityAppCheckProvider provider = + new PlayIntegrityAppCheckProvider(mockNetworkClient, backgroundExecutor, mockRetryManager); + Task task = provider.getToken(); + + verify(mockNetworkClient) + .exchangeAttestationForAppCheckToken( + any(), eq(NetworkClient.PLAY_INTEGRITY), eq(mockRetryManager)); + + assertThat(task.isSuccessful()).isFalse(); + Exception exception = task.getException(); + assertThat(exception).isInstanceOf(IOException.class); + } +} 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 4cf4de5c602..81c8d7a0b94 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,8 @@ public class NetworkClient { "https://firebaseappcheck.googleapis.com/v1beta/projects/%s/apps/%s:exchangeSafetyNetToken?key=%s"; private static final String DEBUG_EXCHANGE_URL_TEMPLATE = "https://firebaseappcheck.googleapis.com/v1beta/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 CONTENT_TYPE = "Content-Type"; private static final String APPLICATION_JSON = "application/json"; private static final String UTF_8 = "UTF-8"; @@ -69,12 +71,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( @@ -203,6 +206,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/NetworkClientTest.java b/appcheck/firebase-appcheck/src/test/java/com/google/firebase/appcheck/internal/NetworkClientTest.java index 3d9c5965480..a105dbf7042 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,8 @@ public class NetworkClientTest { "https://firebaseappcheck.googleapis.com/v1beta/projects/projectId/apps/appId:exchangeSafetyNetToken?key=apiKey"; private static final String DEBUG_EXPECTED_URL = "https://firebaseappcheck.googleapis.com/v1beta/projects/projectId/apps/appId:exchangeDebugToken?key=apiKey"; + private static final String PLAY_INTEGRITY_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; @@ -211,17 +213,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.getAttestationToken()).isEqualTo(ATTESTATION_TOKEN); + assertThat(tokenResponse.getTimeToLive()).isEqualTo(TIME_TO_LIVE); + + URL expectedUrl = new URL(PLAY_INTEGRITY_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_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 +275,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);