From 2a6d9eb1c5e58725a9269145ad4948933a6d3495 Mon Sep 17 00:00:00 2001 From: Malith-19 Date: Mon, 20 Jul 2026 09:13:15 +0530 Subject: [PATCH] feat: support Google Play Integrity attestation Native sign-in/sign-up flows had no way to prove they originate from a genuine, unmodified app install. Add an attestationTokenProvider hook on ThunderIDConfig; when set, its token is attached to flow-initiate requests via an Attestation-Token header. The quickstart sample wires this to the Play Integrity Standard API. --- samples/quickstart/README.md | 32 +++++++++- samples/quickstart/build.gradle.kts | 5 ++ samples/quickstart/config.properties.example | 2 + .../dev/thunderid/quickstart/MainActivity.kt | 6 ++ .../quickstart/PlayIntegrityTokenProvider.kt | 59 +++++++++++++++++++ .../dev/thunderid/android/ThunderIDClient.kt | 16 ++++- .../dev/thunderid/android/ThunderIDConfig.kt | 5 ++ .../android/auth/FlowExecutionClient.kt | 5 +- .../dev/thunderid/android/http/HttpClient.kt | 5 +- .../components/presentation/auth/SignIn.kt | 2 +- .../thunderid/android/ThunderIDClientTest.kt | 19 ++++++ .../presentation/auth/SignInTest.kt | 56 ++++++++++++++++++ 12 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/PlayIntegrityTokenProvider.kt create mode 100644 src/test/kotlin/dev/thunderid/compose/components/presentation/auth/SignInTest.kt diff --git a/samples/quickstart/README.md b/samples/quickstart/README.md index ce36ac2..7ee310f 100644 --- a/samples/quickstart/README.md +++ b/samples/quickstart/README.md @@ -8,14 +8,42 @@ Demonstrates a native Android flow using the ThunderID Compose SDK: ## Setup -Copy `.env.example` to `.env` and add your ThunderID credentials: +Copy `config.properties.example` to `config.properties` (gitignored) and add your ThunderID credentials: -``` +```properties THUNDERID_BASE_URL=https://localhost:8090 THUNDERID_CLIENT_ID=your-client-id THUNDERID_APPLICATION_ID=your-application-id +THUNDERID_AFTER_SIGN_IN_URL= +THUNDERID_AFTER_SIGN_OUT_URL= +THUNDERID_ATTESTATION_ENABLED=false +THUNDERID_CLOUD_PROJECT_NUMBER= ``` +- `THUNDERID_APPLICATION_ID` comes from your application's page in the ThunderID console + (**Applications → your app → General**). +- `THUNDERID_CLIENT_ID` is only needed if you switch this sample to the redirect-based OAuth flow; the + embedded Flow Execution API used here (sign-in and sign-up) doesn't require it. + +To let users self-register, enable self-registration in **both** places in the console — it's disabled by +default in either and the flow fails until both are turned on: +1. The application's settings (registration flow enabled for this app). +2. The user type assigned to the application (self-registration enabled for that user type). + +### Google Play Integrity attestation (optional) + +If the application enforces Google Play Integrity attestation, set `THUNDERID_ATTESTATION_ENABLED=true` and +`THUNDERID_CLOUD_PROJECT_NUMBER` to the number (not the ID) of the Google Cloud project linked to your Play +Console app, then rebuild. When enabled, the sample mints a token via `PlayIntegrityTokenProvider` (Play +Integrity Standard API) and sends it with every native flow-initiate request. + +Testing this end-to-end requires: +- The app uploaded to a Play Console listing (an internal testing track is enough) with your test device's + Google account added as a tester, so Play recognizes the package name and signing certificate. +- The Play Integrity API enabled on the linked Google Cloud project. +- A release build signed with the certificate registered on the ThunderID application's attestation config + (`certificateSha256Digests`) — a debug-signed APK will fail the signing-identity check. + ## Run Open in Android Studio, sync Gradle, and run on an API 24+ emulator or device. diff --git a/samples/quickstart/build.gradle.kts b/samples/quickstart/build.gradle.kts index 14562bb..0054edf 100644 --- a/samples/quickstart/build.gradle.kts +++ b/samples/quickstart/build.gradle.kts @@ -27,11 +27,15 @@ android { val appId = config("THUNDERID_APPLICATION_ID") val afterSignInUrl = config("THUNDERID_AFTER_SIGN_IN_URL") val afterSignOutUrl = config("THUNDERID_AFTER_SIGN_OUT_URL") + val attestationEnabled = config("THUNDERID_ATTESTATION_ENABLED") + val cloudProjectNumber = config("THUNDERID_CLOUD_PROJECT_NUMBER") buildConfigField("String", "THUNDERID_BASE_URL", "\"$baseUrl\"") buildConfigField("String", "THUNDERID_CLIENT_ID", "\"$clientId\"") buildConfigField("String", "THUNDERID_APPLICATION_ID", "\"$appId\"") buildConfigField("String", "THUNDERID_AFTER_SIGN_IN_URL", "\"$afterSignInUrl\"") buildConfigField("String", "THUNDERID_AFTER_SIGN_OUT_URL", "\"$afterSignOutUrl\"") + buildConfigField("boolean", "THUNDERID_ATTESTATION_ENABLED", attestationEnabled.toBoolean().toString()) + buildConfigField("long", "THUNDERID_CLOUD_PROJECT_NUMBER", "${cloudProjectNumber.toLongOrNull() ?: 0L}L") } buildFeatures { @@ -55,6 +59,7 @@ android { dependencies { implementation("dev.thunderid:android") + implementation("com.google.android.play:integrity:1.6.0") val composeBom = platform("androidx.compose:compose-bom:2024.02.00") implementation(composeBom) diff --git a/samples/quickstart/config.properties.example b/samples/quickstart/config.properties.example index eb02f01..7bdb5a8 100644 --- a/samples/quickstart/config.properties.example +++ b/samples/quickstart/config.properties.example @@ -3,3 +3,5 @@ THUNDERID_CLIENT_ID=your-client-id THUNDERID_APPLICATION_ID=your-application-id THUNDERID_AFTER_SIGN_IN_URL= THUNDERID_AFTER_SIGN_OUT_URL= +THUNDERID_ATTESTATION_ENABLED=false +THUNDERID_CLOUD_PROJECT_NUMBER= diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/MainActivity.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/MainActivity.kt index 9e3ef50..5eeb9c7 100644 --- a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/MainActivity.kt +++ b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/MainActivity.kt @@ -34,6 +34,10 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val attestationEnabled = BuildConfig.THUNDERID_ATTESTATION_ENABLED + val integrityTokenProvider = + PlayIntegrityTokenProvider(applicationContext, BuildConfig.THUNDERID_CLOUD_PROJECT_NUMBER) + val config = ThunderIDConfig( baseUrl = BuildConfig.THUNDERID_BASE_URL, clientId = BuildConfig.THUNDERID_CLIENT_ID.takeIf { it.isNotBlank() }, @@ -41,6 +45,8 @@ class MainActivity : ComponentActivity() { afterSignInUrl = BuildConfig.THUNDERID_AFTER_SIGN_IN_URL.takeIf { it.isNotBlank() }, afterSignOutUrl = BuildConfig.THUNDERID_AFTER_SIGN_OUT_URL.takeIf { it.isNotBlank() }, applicationId = BuildConfig.THUNDERID_APPLICATION_ID.takeIf { it.isNotBlank() }, + attestationEnabled = attestationEnabled, + attestationTokenProvider = if (attestationEnabled) integrityTokenProvider::requestToken else null, storage = EncryptedStorageAdapter(this), allowInsecureConnections = BuildConfig.DEBUG, ) diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/PlayIntegrityTokenProvider.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/PlayIntegrityTokenProvider.kt new file mode 100644 index 0000000..6760727 --- /dev/null +++ b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/PlayIntegrityTokenProvider.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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 dev.thunderid.quickstart + +import android.content.Context +import com.google.android.gms.tasks.Task +import com.google.android.play.core.integrity.IntegrityManagerFactory +import com.google.android.play.core.integrity.StandardIntegrityManager.PrepareIntegrityTokenRequest +import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider +import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Mints Google Play Integrity Standard API tokens for [dev.thunderid.android.ThunderIDConfig.attestationTokenProvider]. + * The warm-up ([StandardIntegrityTokenProvider]) is cached and reused across sign-in attempts. + */ +class PlayIntegrityTokenProvider( + private val context: Context, + private val cloudProjectNumber: Long, +) { + private var tokenProvider: StandardIntegrityTokenProvider? = null + + suspend fun requestToken(): String { + val provider = tokenProvider ?: prepareTokenProvider().also { tokenProvider = it } + return provider.request(StandardIntegrityTokenRequest.builder().build()).await().token() + } + + private suspend fun prepareTokenProvider(): StandardIntegrityTokenProvider = + IntegrityManagerFactory.createStandard(context) + .prepareIntegrityToken( + PrepareIntegrityTokenRequest.builder() + .setCloudProjectNumber(cloudProjectNumber) + .build(), + ).await() +} + +private suspend fun Task.await(): T = + suspendCancellableCoroutine { cont -> + addOnSuccessListener { cont.resume(it) } + addOnFailureListener { cont.resumeWithException(it) } + } diff --git a/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt b/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt index 8569c2c..758bcff 100644 --- a/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt +++ b/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt @@ -101,7 +101,7 @@ class ThunderIDClient { if (payload.flowId != null) { flowClient!!.submit(payload.flowId, payload.actionId, payload.inputs, payload.challengeToken) } else { - flowClient!!.initiate(request.applicationId, request.flowType) + flowClient!!.initiate(request.applicationId, request.flowType, attestationToken()) } establishSessionIfNeeded(response) response @@ -193,7 +193,7 @@ class ThunderIDClient { if (payload?.flowId != null) { flowClient!!.submit(payload.flowId, payload.actionId, payload.inputs, payload.challengeToken) } else { - flowClient!!.initiate(appId, request?.flowType ?: FlowType.REGISTRATION) + flowClient!!.initiate(appId, request?.flowType ?: FlowType.REGISTRATION, attestationToken()) } establishSessionIfNeeded(response) return response @@ -294,6 +294,18 @@ class ThunderIDClient { private fun validateConfig(config: ThunderIDConfig) { if (config.baseUrl.isEmpty()) throw IAMException(ThunderIDErrorCode.INVALID_CONFIGURATION, "baseUrl is required") if (!config.baseUrl.startsWith("https://")) throw IAMException(ThunderIDErrorCode.INVALID_CONFIGURATION, "baseUrl must use HTTPS") + if (config.attestationEnabled && config.attestationTokenProvider == null) { + throw IAMException( + ThunderIDErrorCode.INVALID_CONFIGURATION, + "attestationTokenProvider is required when attestationEnabled is true", + ) + } + } + + private suspend fun attestationToken(): String? { + val cfg = config ?: return null + if (!cfg.attestationEnabled) return null + return cfg.attestationTokenProvider?.invoke() } private fun establishSessionIfNeeded(response: EmbeddedFlowResponse) { diff --git a/src/main/kotlin/dev/thunderid/android/ThunderIDConfig.kt b/src/main/kotlin/dev/thunderid/android/ThunderIDConfig.kt index 5e47756..558a469 100644 --- a/src/main/kotlin/dev/thunderid/android/ThunderIDConfig.kt +++ b/src/main/kotlin/dev/thunderid/android/ThunderIDConfig.kt @@ -39,6 +39,11 @@ data class ThunderIDConfig( // Application Identity val applicationId: String? = null, val organizationHandle: String? = null, + // Platform Attestation — when enabled, a token from [attestationTokenProvider] is sent with every + // native flow-initiate request (e.g. Google Play Integrity on Android). The provider is invoked by + // the SDK; the app supplies it since obtaining a platform attestation token is app/platform-specific. + val attestationEnabled: Boolean = false, + val attestationTokenProvider: (suspend () -> String)? = null, // Token Validation val tokenValidation: TokenValidationConfig = TokenValidationConfig(), // Storage & Platform diff --git a/src/main/kotlin/dev/thunderid/android/auth/FlowExecutionClient.kt b/src/main/kotlin/dev/thunderid/android/auth/FlowExecutionClient.kt index cde559d..ea1c119 100644 --- a/src/main/kotlin/dev/thunderid/android/auth/FlowExecutionClient.kt +++ b/src/main/kotlin/dev/thunderid/android/auth/FlowExecutionClient.kt @@ -31,6 +31,7 @@ internal class FlowExecutionClient( suspend fun initiate( applicationId: String, flowType: FlowType, + attestationToken: String? = null, ): EmbeddedFlowResponse { val body = mapOf( @@ -38,7 +39,7 @@ internal class FlowExecutionClient( "flowType" to flowType.value, "verbose" to true, ) - return httpClient.post("/flow/execute", body, requiresAuth = false) + return httpClient.post("/flow/execute", body, requiresAuth = false, headers = attestationTokenHeaders(attestationToken)) } suspend fun submit( @@ -53,6 +54,8 @@ internal class FlowExecutionClient( return httpClient.post("/flow/execute", body, requiresAuth = false) } + private fun attestationTokenHeaders(token: String?): Map = token?.let { mapOf("Attestation-Token" to it) } ?: emptyMap() + internal fun submitBody( flowId: String, actionId: String, diff --git a/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt b/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt index 1dc5a90..df38f7d 100644 --- a/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt +++ b/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt @@ -54,13 +54,15 @@ internal class HttpClient( path: String, body: Map, requiresAuth: Boolean = true, - ): T = request("POST", path, body, requiresAuth) + headers: Map = emptyMap(), + ): T = request("POST", path, body, requiresAuth, headers) suspend inline fun request( method: String, path: String, body: Map?, requiresAuth: Boolean, + headers: Map = emptyMap(), ): T = withContext(Dispatchers.IO) { val urlString = baseUrl + path @@ -76,6 +78,7 @@ internal class HttpClient( requestMethod = method setRequestProperty("Content-Type", "application/json") setRequestProperty("Accept", "application/json") + headers.forEach { (name, value) -> setRequestProperty(name, value) } if (requiresAuth) { val token = accessTokenProvider?.invoke() diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt index 5f3ac2a..c340abb 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt @@ -141,7 +141,7 @@ private fun FlowComponent.identifierKey(): String? = ref ?: id * variant, icon) from the matching `ACTION`-typed node in the component tree, matched by * whichever of `ref`/`id` each side populated. Explicit flat values always win. */ -private fun enrichActions( +internal fun enrichActions( actions: List, components: List, ): List { diff --git a/src/test/kotlin/dev/thunderid/android/ThunderIDClientTest.kt b/src/test/kotlin/dev/thunderid/android/ThunderIDClientTest.kt index 5371074..01a3a65 100644 --- a/src/test/kotlin/dev/thunderid/android/ThunderIDClientTest.kt +++ b/src/test/kotlin/dev/thunderid/android/ThunderIDClientTest.kt @@ -92,6 +92,25 @@ class ThunderIDClientTest { assertEquals(listOf("openid", "profile"), retrieved.scopes) } + @Test(expected = IAMException::class) + fun `initialize rejects attestationEnabled without a token provider`() = + runTest { + val config = ThunderIDConfig(baseUrl = "https://localhost:8090", attestationEnabled = true) + client.initialize(config, storage) + } + + @Test + fun `initialize accepts attestationEnabled with a token provider`() = + runTest { + val config = + ThunderIDConfig( + baseUrl = "https://localhost:8090", + attestationEnabled = true, + attestationTokenProvider = { "test-token" }, + ) + assertTrue(client.initialize(config, storage)) + } + // PKCE @Test diff --git a/src/test/kotlin/dev/thunderid/compose/components/presentation/auth/SignInTest.kt b/src/test/kotlin/dev/thunderid/compose/components/presentation/auth/SignInTest.kt new file mode 100644 index 0000000..e7421e3 --- /dev/null +++ b/src/test/kotlin/dev/thunderid/compose/components/presentation/auth/SignInTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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 dev.thunderid.compose.components.presentation.auth + +import dev.thunderid.android.FlowAction +import dev.thunderid.android.FlowComponent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SignInTest { + @Test + fun `enrichActions matches a flat action to its component by component id equals action ref`() { + // The server never shares a field name between a flat action and its ACTION component: + // the flat action only carries `ref`, the component only carries `id`. The pairing is + // component.id == action.ref. + val actions = listOf(FlowAction(ref = "action_001")) + val components = + listOf( + FlowComponent(id = "action_001", type = "ACTION", label = "Sign In", variant = "PRIMARY"), + ) + + val enriched = enrichActions(actions, components) + + assertEquals(1, enriched.size) + assertEquals("Sign In", enriched[0].label) + assertEquals("PRIMARY", enriched[0].variant) + } + + @Test + fun `enrichActions leaves an action unchanged when no component matches`() { + val actions = listOf(FlowAction(ref = "action_001")) + val components = listOf(FlowComponent(id = "some_other_action", type = "ACTION")) + + val enriched = enrichActions(actions, components) + + assertEquals(1, enriched.size) + assertNull(enriched[0].label) + } +}