From edc2a1d4c89364ec753bed96b468733206f7c2f6 Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Tue, 28 Apr 2026 13:48:25 +0200 Subject: [PATCH] feat(02-03): define common auth contracts - Add OIDC result and expect client seam with pinned native AppAuth semantics - Add secure AuthState JSON store contract and JVM dev actuals for test compilation --- .../dev/ulfrx/recipe/auth/OidcClient.kt | 27 ++++++++++++++ .../dev/ulfrx/recipe/auth/OidcResult.kt | 25 +++++++++++++ .../ulfrx/recipe/auth/SecureAuthStateStore.kt | 19 ++++++++++ .../dev/ulfrx/recipe/auth/OidcClient.jvm.kt | 36 +++++++++++++++++++ .../recipe/auth/SecureAuthStateStore.jvm.kt | 17 +++++++++ 5 files changed, 124 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt create mode 100644 composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt create mode 100644 composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt new file mode 100644 index 0000000..03c8be8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt @@ -0,0 +1,27 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package dev.ulfrx.recipe.auth + +/** + * Common seam for Authentik OIDC. + * + * Native Android/iOS actuals must use AppAuth (D-01) and bridge AppAuth callback + * APIs with `suspendCancellableCoroutine`, cancelling the underlying AppAuth + * request when the coroutine is cancelled (D-04). Login requests must be public + * PKCE-compatible OIDC requests with exactly these scopes: + * `openid profile email offline_access` (D-06). AppAuth owns state and nonce + * verification. + * + * Refresh must go through AppAuth fresh-token APIs such as + * `performActionWithFreshTokens`, then return the updated AuthState JSON for + * persistence (D-16). Logout must use AppAuth RP-initiated end-session APIs + * before local state is cleared; callers still clear local state if remote + * logout fails so users are never trapped in a stale session (D-19, D-20). + */ +expect class OidcClient() { + suspend fun login(): OidcResult + + suspend fun refresh(authStateJson: String): OidcResult + + suspend fun logout(authStateJson: String) +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt new file mode 100644 index 0000000..9c12ebc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt @@ -0,0 +1,25 @@ +package dev.ulfrx.recipe.auth + +/** + * Result returned by platform OIDC clients. + * + * `authStateJson` is the opaque AppAuth AuthState JSON blob persisted by + * [SecureAuthStateStore]. Callers must not parse token values out of it directly. + */ +sealed interface OidcResult { + data class Success( + val authStateJson: String, + val accessToken: String, + val idToken: String?, + val expiresAtEpochMillis: Long, + ) : OidcResult + + data object Cancelled : OidcResult + + data object NetworkError : OidcResult + + data class AuthError( + val message: String, + val cause: Throwable? = null, + ) : OidcResult +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt new file mode 100644 index 0000000..3b85da5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt @@ -0,0 +1,19 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package dev.ulfrx.recipe.auth + +/** + * Persists the full AppAuth AuthState JSON blob for the current app install. + * + * Mobile actuals must use explicit secure platform storage for token material + * (D-13): iOS Keychain and Android encrypted/Keystore-backed storage. Do not use + * no-arg or default insecure settings implementations for tokens. The stored + * blob is global to the install and must be deleted on logout (D-15). + */ +expect class SecureAuthStateStore() { + fun read(): String? + + fun write(authStateJson: String) + + fun clear() +} diff --git a/composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt new file mode 100644 index 0000000..c9b4e2b --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt @@ -0,0 +1,36 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package dev.ulfrx.recipe.auth + +actual class OidcClient { + actual suspend fun login(): OidcResult { + val token = System.getenv(DEV_AUTH_TOKEN) + ?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set") + + return OidcResult.Success( + authStateJson = "dev:$token", + accessToken = token, + idToken = null, + expiresAtEpochMillis = Long.MAX_VALUE, + ) + } + + actual suspend fun refresh(authStateJson: String): OidcResult { + val token = authStateJson.removePrefix("dev:").takeIf { it.isNotBlank() } + ?: System.getenv(DEV_AUTH_TOKEN) + ?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set") + + return OidcResult.Success( + authStateJson = "dev:$token", + accessToken = token, + idToken = null, + expiresAtEpochMillis = Long.MAX_VALUE, + ) + } + + actual suspend fun logout(authStateJson: String) = Unit + + private companion object { + const val DEV_AUTH_TOKEN = "DEV_AUTH_TOKEN" + } +} diff --git a/composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt new file mode 100644 index 0000000..5735178 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt @@ -0,0 +1,17 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package dev.ulfrx.recipe.auth + +actual class SecureAuthStateStore { + private var authStateJson: String? = null + + actual fun read(): String? = authStateJson + + actual fun write(authStateJson: String) { + this.authStateJson = authStateJson + } + + actual fun clear() { + authStateJson = null + } +}