diff --git a/build.gradle.kts b/build.gradle.kts index cede064..882112b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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" @@ -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") diff --git a/src/main/kotlin/dev/thunderid/android/Models.kt b/src/main/kotlin/dev/thunderid/android/Models.kt index 18a9976..02d1677 100644 --- a/src/main/kotlin/dev/thunderid/android/Models.kt +++ b/src/main/kotlin/dev/thunderid/android/Models.kt @@ -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"), diff --git a/src/main/kotlin/dev/thunderid/android/StorageAdapter.kt b/src/main/kotlin/dev/thunderid/android/StorageAdapter.kt index 7c25f8f..709fdb1 100644 --- a/src/main/kotlin/dev/thunderid/android/StorageAdapter.kt +++ b/src/main/kotlin/dev/thunderid/android/StorageAdapter.kt @@ -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( diff --git a/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt b/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt index 51f6000..8569c2c 100644 --- a/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt +++ b/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt @@ -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) } @@ -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>() {}.type - return com.google.gson.Gson().fromJson(json, type) + return com.google.gson + .Gson() + .fromJson(json, type) } // MARK: - Private helpers diff --git a/src/main/kotlin/dev/thunderid/android/ThunderIDError.kt b/src/main/kotlin/dev/thunderid/android/ThunderIDError.kt index 12f9c51..09d60c2 100644 --- a/src/main/kotlin/dev/thunderid/android/ThunderIDError.kt +++ b/src/main/kotlin/dev/thunderid/android/ThunderIDError.kt @@ -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"), diff --git a/src/main/kotlin/dev/thunderid/android/auth/FlowExecutionClient.kt b/src/main/kotlin/dev/thunderid/android/auth/FlowExecutionClient.kt index b6d3985..cde559d 100644 --- a/src/main/kotlin/dev/thunderid/android/auth/FlowExecutionClient.kt +++ b/src/main/kotlin/dev/thunderid/android/auth/FlowExecutionClient.kt @@ -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, diff --git a/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt b/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt index 2b9e36f..1dc5a90 100644 --- a/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt +++ b/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt @@ -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 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 { diff --git a/src/main/kotlin/dev/thunderid/android/token/JWKSCache.kt b/src/main/kotlin/dev/thunderid/android/token/JWKSCache.kt index 15c2be5..2e00058 100644 --- a/src/main/kotlin/dev/thunderid/android/token/JWKSCache.kt +++ b/src/main/kotlin/dev/thunderid/android/token/JWKSCache.kt @@ -29,12 +29,16 @@ internal data class JWK( val e: String? = null, ) -internal data class JWKSResponse(val keys: List) +internal data class JWKSResponse( + val keys: List, +) /** * 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 = emptyList() private var cacheExpiryMs: Long = 0L private val minCacheTtlMs: Long = 5 * 60 * 1000L // 5 minutes diff --git a/src/main/kotlin/dev/thunderid/android/token/TokenStore.kt b/src/main/kotlin/dev/thunderid/android/token/TokenStore.kt index 34471bd..942588a 100644 --- a/src/main/kotlin/dev/thunderid/android/token/TokenStore.kt +++ b/src/main/kotlin/dev/thunderid/android/token/TokenStore.kt @@ -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) } diff --git a/src/main/kotlin/dev/thunderid/android/token/TokenValidator.kt b/src/main/kotlin/dev/thunderid/android/token/TokenValidator.kt index 4d5cf0b..a1a858c 100644 --- a/src/main/kotlin/dev/thunderid/android/token/TokenValidator.kt +++ b/src/main/kotlin/dev/thunderid/android/token/TokenValidator.kt @@ -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") } @@ -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 } diff --git a/src/main/kotlin/dev/thunderid/compose/components/flow/Callback.kt b/src/main/kotlin/dev/thunderid/compose/components/flow/Callback.kt index 9a671a9..aa4f78e 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/flow/Callback.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/flow/Callback.kt @@ -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 -> {} } } 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 33dae2f..6d43e8d 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 @@ -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 @@ -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 @@ -83,6 +85,14 @@ class SignInState { fun fields(): Map = 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) { @@ -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 { @@ -181,12 +193,12 @@ 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 { @@ -194,6 +206,10 @@ fun BaseSignIn( } } + DisposableEffect(Unit) { + onDispose { signInState.clearFields() } + } + Box(modifier = modifier) { content(signInState) } } @@ -206,10 +222,15 @@ 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 @@ -217,3 +238,14 @@ private suspend fun handleSignInResponse( } } } + +/** + * 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}" + } diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignUp.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignUp.kt index c0542f3..8a8e5cf 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignUp.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignUp.kt @@ -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 }, ) } @@ -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 diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserDropdown.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserDropdown.kt index eed52bc..61d295d 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserDropdown.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserDropdown.kt @@ -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") }, ) { diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserProfile.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserProfile.kt index 5f30957..dc1b48f 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserProfile.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserProfile.kt @@ -58,15 +58,23 @@ fun UserProfile( Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { BasicText(i18n.resolve("userProfile.title")) when { - isLoading && profile == null -> BasicText(i18n.resolve("userProfile.loading")) - error != null -> BasicText(error) + isLoading && profile == null -> { + BasicText(i18n.resolve("userProfile.loading")) + } + + error != null -> { + BasicText(error) + } + else -> { editableKeys.forEach { key -> BasicTextField( value = fields[key]?.value ?: "", onValueChange = { fields[key]?.value = it }, modifier = - Modifier.fillMaxWidth().defaultMinSize(minHeight = 44.dp) + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 44.dp) .semantics { contentDescription = key }, ) } diff --git a/src/test/kotlin/dev/thunderid/android/ThunderIDClientTest.kt b/src/test/kotlin/dev/thunderid/android/ThunderIDClientTest.kt index 0232b54..5371074 100644 --- a/src/test/kotlin/dev/thunderid/android/ThunderIDClientTest.kt +++ b/src/test/kotlin/dev/thunderid/android/ThunderIDClientTest.kt @@ -96,7 +96,9 @@ class ThunderIDClientTest { @Test fun `PKCEManager generates S256 challenge`() { - val manager = dev.thunderid.android.auth.PKCEManager() + val manager = + dev.thunderid.android.auth + .PKCEManager() val (verifier, challenge) = manager.generate() assertTrue(verifier.isNotEmpty()) assertTrue(challenge.isNotEmpty()) @@ -109,7 +111,9 @@ class ThunderIDClientTest { @Test fun `PKCEManager clears verifier`() { - val manager = dev.thunderid.android.auth.PKCEManager() + val manager = + dev.thunderid.android.auth + .PKCEManager() manager.generate() assertNotNull(manager.codeVerifier) manager.clearVerifier() @@ -120,7 +124,9 @@ class ThunderIDClientTest { @Test fun `TokenStore saves and retrieves tokens`() { - val store = dev.thunderid.android.token.TokenStore(storage) + val store = + dev.thunderid.android.token + .TokenStore(storage) val response = TokenResponse( accessToken = "access123", @@ -137,21 +143,27 @@ class ThunderIDClientTest { @Test fun `TokenStore isNearExpiry when expires in 30s`() { - val store = dev.thunderid.android.token.TokenStore(storage) + val store = + dev.thunderid.android.token + .TokenStore(storage) store.save(TokenResponse(accessToken = "tok", tokenType = "Bearer", expiresIn = 30)) assertTrue(store.isNearExpiry()) } @Test fun `TokenStore not near expiry when expires in 3600s`() { - val store = dev.thunderid.android.token.TokenStore(storage) + val store = + dev.thunderid.android.token + .TokenStore(storage) store.save(TokenResponse(accessToken = "tok", tokenType = "Bearer", expiresIn = 3600)) assertFalse(store.isNearExpiry()) } @Test fun `TokenStore clear removes all tokens`() { - val store = dev.thunderid.android.token.TokenStore(storage) + val store = + dev.thunderid.android.token + .TokenStore(storage) store.save(TokenResponse(accessToken = "tok", tokenType = "Bearer")) store.clear() assertNull(store.accessToken()) diff --git a/src/test/kotlin/dev/thunderid/compose/ComponentTests.kt b/src/test/kotlin/dev/thunderid/compose/ComponentTests.kt index a006430..079ce30 100644 --- a/src/test/kotlin/dev/thunderid/compose/ComponentTests.kt +++ b/src/test/kotlin/dev/thunderid/compose/ComponentTests.kt @@ -80,10 +80,16 @@ class ComponentTests { fun `DefaultStrings contains all expected keys`() { val required = listOf( - "signIn.button", "signOut.button", "signUp.button", - "userProfile.title", "userProfile.save", - "organizationList.empty", "createOrganization.submit", - "languageSwitcher.title", "acceptInvite.submit", "inviteUser.submit", + "signIn.button", + "signOut.button", + "signUp.button", + "userProfile.title", + "userProfile.save", + "organizationList.empty", + "createOrganization.submit", + "languageSwitcher.title", + "acceptInvite.submit", + "inviteUser.submit", ) required.forEach { key -> assertNotNull("Missing default string for key: $key", DefaultStrings.all[key])