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
7 changes: 6 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ plugins {
id("com.android.library") version "8.2.2"
id("org.jetbrains.kotlin.android") version "1.9.22"
id("maven-publish")
id("org.jlleitschuh.gradle.ktlint") version "12.1.1"
id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
}

group = "dev.thunderid"
Expand Down Expand Up @@ -36,6 +36,11 @@ android {
}
}

ktlint {
// Pin the ktlint engine version used by the plugin.
version.set("1.8.0")
}

dependencies {
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
Expand Down
4 changes: 3 additions & 1 deletion src/main/kotlin/dev/thunderid/android/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ data class EmbeddedFlowRequestConfig(
val flowType: FlowType = FlowType.AUTHENTICATION,
)

enum class FlowType(val value: String) {
enum class FlowType(
val value: String,
) {
AUTHENTICATION("AUTHENTICATION"),
REGISTRATION("REGISTRATION"),
PASSWORD_RECOVERY("PASSWORD_RECOVERY"),
Expand Down
3 changes: 2 additions & 1 deletion src/main/kotlin/dev/thunderid/android/StorageAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ class EncryptedStorageAdapter(
private val prefs =
run {
val masterKey =
MasterKey.Builder(context)
MasterKey
.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
Expand Down
16 changes: 11 additions & 5 deletions src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,14 @@ class ThunderIDClient {
val params =
StringBuilder("${cfg.baseUrl}/oauth2/authorize")
.append("?response_type=code")
.append("&client_id=").append(clientId)
.append("&redirect_uri=").append(cfg.afterSignInUrl ?: "")
.append("&scope=").append(cfg.scopes.joinToString(" "))
.append("&code_challenge=").append(challenge)
.append("&client_id=")
.append(clientId)
.append("&redirect_uri=")
.append(cfg.afterSignInUrl ?: "")
.append("&scope=")
.append(cfg.scopes.joinToString(" "))
.append("&code_challenge=")
.append(challenge)
.append("&code_challenge_method=S256")
options?.prompt?.let { params.append("&prompt=").append(it) }
options?.loginHint?.let { params.append("&login_hint=").append(it) }
Expand Down Expand Up @@ -273,7 +277,9 @@ class ThunderIDClient {
val path = "/flow/meta?id=$applicationId&type=APP&language=$language"
val json: com.google.gson.JsonObject = httpClient!!.get(path, requiresAuth = false)
val type = object : com.google.gson.reflect.TypeToken<Map<String, Any?>>() {}.type
return com.google.gson.Gson().fromJson(json, type)
return com.google.gson
.Gson()
.fromJson(json, type)
}

// MARK: - Private helpers
Expand Down
4 changes: 3 additions & 1 deletion src/main/kotlin/dev/thunderid/android/ThunderIDError.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ package dev.thunderid.android
/**
* Typed error codes for all ThunderID SDK error conditions (spec §10.2).
*/
enum class ThunderIDErrorCode(val value: String) {
enum class ThunderIDErrorCode(
val value: String,
) {
// Configuration
SDK_NOT_INITIALIZED("SDK_NOT_INITIALIZED"),
ALREADY_INITIALIZED("ALREADY_INITIALIZED"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import dev.thunderid.android.http.HttpClient
/**
* Drives the ThunderID Flow Execution API for app-native sign-in, sign-up, and recovery (spec §6.1–6.3).
*/
internal class FlowExecutionClient(private val httpClient: HttpClient) {
internal class FlowExecutionClient(
private val httpClient: HttpClient,
) {
suspend fun initiate(
applicationId: String,
flowType: FlowType,
Expand Down
29 changes: 23 additions & 6 deletions src/main/kotlin/dev/thunderid/android/http/HttpClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,22 +98,39 @@ internal class HttpClient(
}.getOrDefault("")

when (statusCode) {
in 200..299 -> parseResponse(responseBody)
in 200..299 -> {
parseResponse(responseBody)
}

400 -> {
val msg = runCatching { JSONObject(responseBody).optString("message", "Bad request") }.getOrDefault("Bad request")
throw IAMException(ThunderIDErrorCode.INVALID_INPUT, msg)
}
401 -> throw IAMException(ThunderIDErrorCode.AUTHENTICATION_FAILED, "Unauthorized")
409 -> throw IAMException(ThunderIDErrorCode.USER_ALREADY_EXISTS, "Conflict")
in 500..599 -> throw IAMException(ThunderIDErrorCode.SERVER_ERROR, "Server error: $statusCode")
else -> throw IAMException(ThunderIDErrorCode.UNKNOWN_ERROR, "Unexpected status: $statusCode")

401 -> {
throw IAMException(ThunderIDErrorCode.AUTHENTICATION_FAILED, "Unauthorized")
}

409 -> {
throw IAMException(ThunderIDErrorCode.USER_ALREADY_EXISTS, "Conflict")
}

in 500..599 -> {
throw IAMException(ThunderIDErrorCode.SERVER_ERROR, "Server error: $statusCode")
}

else -> {
throw IAMException(ThunderIDErrorCode.UNKNOWN_ERROR, "Unexpected status: $statusCode")
}
}
}

@Suppress("UNCHECKED_CAST")
private inline fun <reified T : Any> parseResponse(body: String): T {
if (T::class == Unit::class) return Unit as T
return com.google.gson.Gson().fromJson(body, T::class.java)
return com.google.gson
.Gson()
.fromJson(body, T::class.java)
}

private fun insecureSslSocketFactory(): javax.net.ssl.SSLSocketFactory {
Expand Down
8 changes: 6 additions & 2 deletions src/main/kotlin/dev/thunderid/android/token/JWKSCache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,16 @@ internal data class JWK(
val e: String? = null,
)

internal data class JWKSResponse(val keys: List<JWK>)
internal data class JWKSResponse(
val keys: List<JWK>,
)

/**
* Fetches and caches the server JWKS. Supports key rotation (spec §11.4).
*/
internal class JWKSCache(private val httpClient: HttpClient) {
internal class JWKSCache(
private val httpClient: HttpClient,
) {
private var cachedKeys: List<JWK> = emptyList()
private var cacheExpiryMs: Long = 0L
private val minCacheTtlMs: Long = 5 * 60 * 1000L // 5 minutes
Expand Down
4 changes: 3 additions & 1 deletion src/main/kotlin/dev/thunderid/android/token/TokenStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ private object StoreKey {
/**
* Persists and retrieves the token set using the configured StorageAdapter.
*/
internal class TokenStore(private val storage: StorageAdapter) {
internal class TokenStore(
private val storage: StorageAdapter,
) {
fun save(response: TokenResponse) {
storage.store(StoreKey.ACCESS_TOKEN, response.accessToken)
response.refreshToken?.let { storage.store(StoreKey.REFRESH_TOKEN, it) }
Expand Down
20 changes: 14 additions & 6 deletions src/main/kotlin/dev/thunderid/android/token/TokenValidator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,18 @@ internal class TokenValidator(
config.clientId?.let { clientId ->
val audValid =
when {
payload.optString("aud").isNotEmpty() -> payload.optString("aud") == clientId
payload.optString("aud").isNotEmpty() -> {
payload.optString("aud") == clientId
}

payload.optJSONArray("aud") != null -> {
val arr = payload.getJSONArray("aud")
(0 until arr.length()).any { arr.getString(it) == clientId }
}
else -> false

else -> {
false
}
}
if (!audValid) throw IAMException(ThunderIDErrorCode.AUTHENTICATION_FAILED, "ID token aud mismatch")
}
Expand Down Expand Up @@ -124,10 +130,12 @@ internal class TokenValidator(
val n = BigInteger(1, Base64.decode(jwk.n?.replace('-', '+')?.replace('_', '/') ?: return false, Base64.DEFAULT))
val e = BigInteger(1, Base64.decode(jwk.e?.replace('-', '+')?.replace('_', '/') ?: return false, Base64.DEFAULT))
val publicKey = KeyFactory.getInstance("RSA").generatePublic(RSAPublicKeySpec(n, e))
Signature.getInstance("SHA256withRSA").apply {
initVerify(publicKey)
update(signingInput)
}.verify(signature)
Signature
.getInstance("SHA256withRSA")
.apply {
initVerify(publicKey)
update(signingInput)
}.verify(signature)
} catch (_: Exception) {
false
}
Expand Down
21 changes: 14 additions & 7 deletions src/main/kotlin/dev/thunderid/compose/components/flow/Callback.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,22 @@ fun Callback(
) {
val state = LocalThunderID.current
BaseCallback(url = url, modifier = modifier, onResult = { result ->
result.onSuccess {
onComplete?.invoke()
}.onFailure { e ->
onError?.invoke(e.message ?: "Callback failed")
}
result
.onSuccess {
onComplete?.invoke()
}.onFailure { e ->
onError?.invoke(e.message ?: "Callback failed")
}
}) { isLoading, error ->
when {
isLoading -> BasicText(state.i18n.resolve("callback.loading"))
error != null -> BasicText(error)
isLoading -> {
BasicText(state.i18n.resolve("callback.loading"))
}

error != null -> {
BasicText(error)
}

else -> {}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
Expand All @@ -51,6 +52,7 @@ import dev.thunderid.android.FlowAction
import dev.thunderid.android.FlowInput
import dev.thunderid.android.FlowStatus
import dev.thunderid.android.FlowType
import dev.thunderid.android.IAMException
import dev.thunderid.compose.LocalThunderID
import dev.thunderid.compose.ThunderIDState
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -83,6 +85,14 @@ class SignInState {

fun fields(): Map<String, String> = fieldValues.toMap()

/**
* Drops all entered field values so the form does not keep input around after it is
* done with it. Called on successful completion and when the form leaves composition.
*/
internal fun clearFields() {
fieldValues.clear()
}

fun submit(actionId: String) = onSubmit(actionId)

internal fun update(response: EmbeddedFlowResponse) {
Expand Down Expand Up @@ -157,14 +167,16 @@ fun BaseSignIn(
try {
val payload =
EmbeddedSignInPayload(
flowId = signInState.flowId, actionId = actionId, inputs = signInState.fields(),
flowId = signInState.flowId,
actionId = actionId,
inputs = signInState.fields(),
challengeToken = signInState.challengeToken,
)
val request = EmbeddedFlowRequestConfig(applicationId, FlowType.AUTHENTICATION)
val response = thunderState.client.signIn(payload = payload, request = request)
handleSignInResponse(response, signInState, thunderState, onComplete, onError)
} catch (e: Exception) {
android.util.Log.e("SignInFlow", "Flow submit failed", e)
android.util.Log.e("SignInFlow", "Sign-in submit failed (${diagnosticLabel(e)})")
signInState.error = e.message
onError?.invoke(e.message ?: "Sign-in failed")
} finally {
Expand All @@ -181,19 +193,23 @@ fun BaseSignIn(
val response = thunderState.client.signIn(payload = payload, request = request)
android.util.Log.d(
"SignInFlow",
"status=${response.flowStatus} inputs=${response.data?.inputs?.size} " +
"actions=${response.data?.actions?.size} data=${response.data}",
"Flow initiated: status=${response.flowStatus} " +
"inputs=${response.data?.inputs?.size ?: 0} actions=${response.data?.actions?.size ?: 0}",
)
handleSignInResponse(response, signInState, thunderState, onComplete, onError)
} catch (e: Exception) {
android.util.Log.e("SignInFlow", "Flow initiation failed", e)
android.util.Log.e("SignInFlow", "Sign-in initiation failed (${diagnosticLabel(e)})")
signInState.error = e.message
onError?.invoke(e.message ?: "Sign-in failed")
} finally {
signInState.isLoading = false
}
}

DisposableEffect(Unit) {
onDispose { signInState.clearFields() }
}

Box(modifier = modifier) { content(signInState) }
}

Expand All @@ -206,14 +222,30 @@ private suspend fun handleSignInResponse(
) {
when (response.flowStatus) {
FlowStatus.COMPLETE -> {
signInState.clearFields()
thunderState.refresh()
onComplete?.invoke()
}
FlowStatus.PROMPT_ONLY, FlowStatus.INCOMPLETE -> signInState.update(response)

FlowStatus.PROMPT_ONLY, FlowStatus.INCOMPLETE -> {
signInState.update(response)
}

FlowStatus.ERROR -> {
val msg = response.failureReason ?: "Sign-in failed"
signInState.error = msg
onError?.invoke(msg)
}
}
}

/**
* Produces a concise diagnostic label for logging: the typed error code for an
* [IAMException], or the exception class name otherwise. Keeps log output compact and
* stable instead of dumping full exception detail.
*/
private fun diagnosticLabel(e: Throwable): String =
when (e) {
is IAMException -> "code=${e.code.value}"
else -> "type=${e.javaClass.simpleName}"
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ fun SignUp(
value = state.fieldValue(input.name),
onValueChange = { state.setField(input.name, it) },
modifier =
Modifier.fillMaxWidth().defaultMinSize(minHeight = 44.dp)
Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 44.dp)
.semantics { contentDescription = input.name },
)
}
Expand Down Expand Up @@ -182,8 +184,13 @@ private suspend fun handleSignUpResponse(
thunderState.refresh()
onComplete?.invoke()
}
FlowStatus.PROMPT_ONLY -> state.update(response)

FlowStatus.PROMPT_ONLY -> {
state.update(response)
}

FlowStatus.INCOMPLETE -> {}

FlowStatus.ERROR -> {
val msg = response.failureReason ?: "Sign-up failed"
state.error = msg
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ fun UserDropdown(
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier.size(44.dp)
Modifier
.size(44.dp)
.clickable { toggle() }
.semantics { contentDescription = user?.displayName ?: i18n.resolve("user.anonymous") },
) {
Expand Down
Loading
Loading