Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions samples/quickstart/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## Run

Open in Android Studio, sync Gradle, and run on an API 24+ emulator or device.
Expand Down
5 changes: 5 additions & 0 deletions samples/quickstart/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions samples/quickstart/config.properties.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,19 @@ 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() },
scopes = listOf("openid", "profile", "email"),
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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
storage = EncryptedStorageAdapter(this),
allowInsecureConnections = BuildConfig.DEBUG,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <T> Task<T>.await(): T =
suspendCancellableCoroutine { cont ->
addOnSuccessListener { cont.resume(it) }
addOnFailureListener { cont.resumeWithException(it) }
}
16 changes: 14 additions & 2 deletions src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions src/main/kotlin/dev/thunderid/android/ThunderIDConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@ internal class FlowExecutionClient(
suspend fun initiate(
applicationId: String,
flowType: FlowType,
attestationToken: String? = null,
): EmbeddedFlowResponse {
val body =
mapOf(
"applicationId" to applicationId,
"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(
Expand All @@ -53,6 +54,8 @@ internal class FlowExecutionClient(
return httpClient.post("/flow/execute", body, requiresAuth = false)
}

private fun attestationTokenHeaders(token: String?): Map<String, String> = token?.let { mapOf("Attestation-Token" to it) } ?: emptyMap()

internal fun submitBody(
flowId: String,
actionId: String,
Expand Down
5 changes: 4 additions & 1 deletion src/main/kotlin/dev/thunderid/android/http/HttpClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,15 @@ internal class HttpClient(
path: String,
body: Map<String, Any>,
requiresAuth: Boolean = true,
): T = request("POST", path, body, requiresAuth)
headers: Map<String, String> = emptyMap(),
): T = request("POST", path, body, requiresAuth, headers)

suspend inline fun <reified T : Any> request(
method: String,
path: String,
body: Map<String, Any>?,
requiresAuth: Boolean,
headers: Map<String, String> = emptyMap(),
): T =
withContext(Dispatchers.IO) {
val urlString = baseUrl + path
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FlowAction>,
components: List<FlowComponent>,
): List<FlowAction> {
Expand Down
19 changes: 19 additions & 0 deletions src/test/kotlin/dev/thunderid/android/ThunderIDClientTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading