From 0a24be9a957de2b28dce49543f7986d55cadf5aa Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Tue, 28 Apr 2026 16:54:39 +0200 Subject: [PATCH] feat(02-06): implement auth session runtime - add AuthState and AuthSession restore/login/logout state machine - add MeClient and token-redacting Ktor bearer HTTP client --- .../dev/ulfrx/recipe/auth/AuthHttpClient.kt | 60 +++++++ .../dev/ulfrx/recipe/auth/AuthSession.kt | 170 ++++++++++++++++++ .../kotlin/dev/ulfrx/recipe/auth/AuthState.kt | 16 ++ .../kotlin/dev/ulfrx/recipe/auth/MeClient.kt | 42 +++++ 4 files changed, 288 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt new file mode 100644 index 0000000..7f121a4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt @@ -0,0 +1,60 @@ +package dev.ulfrx.recipe.auth + +import dev.ulfrx.recipe.shared.Constants +import io.ktor.client.HttpClient +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.bearer +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.http.HttpHeaders +import io.ktor.http.Url +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +object AuthHttpClient { + fun create(authSession: AuthSession): HttpClient = + HttpClient { + install(ContentNegotiation) { + json(authJson) + } + install(Auth) { + bearer { + loadTokens { + authSession.currentBearerTokens() + } + refreshTokens { + authSession.refreshBearerTokens() + } + sendWithoutRequest { request -> + request.url.host == Url(Constants.API_BASE_URL).host + } + } + } + install(Logging) { + level = LogLevel.HEADERS + sanitizeHeader { header -> header.equals(HttpHeaders.Authorization, ignoreCase = true) } + logger = + object : Logger { + override fun log(message: String) { + co.touchlab.kermit.Logger + .withTag("auth-http") + .i(redact(message)) + } + } + } + } + + private fun redact(message: String): String = + message + .replace(Regex("Bearer\\s+[^\\s,;]+"), "Bearer ") + .replace(Regex("(?i)(Authorization:\\s*)[^\\n\\r]+")) { match -> + match.groupValues[1] + "" + } + + private val authJson = + Json { + ignoreUnknownKeys = true + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt new file mode 100644 index 0000000..ae19bab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt @@ -0,0 +1,170 @@ +package dev.ulfrx.recipe.auth + +import io.ktor.client.plugins.auth.providers.BearerTokens +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +interface OidcClientGateway { + suspend fun login(): OidcResult + + suspend fun refresh(authStateJson: String): OidcResult + + suspend fun logout(authStateJson: String) +} + +interface AuthStateStore { + fun read(): String? + + fun write(authStateJson: String) + + fun clear() +} + +sealed interface AuthLoginResult { + data object Success : AuthLoginResult + + data object Cancelled : AuthLoginResult + + data object NetworkError : AuthLoginResult + + data class Failed( + val message: String, + ) : AuthLoginResult +} + +class AuthSession( + private val oidcClient: OidcClientGateway, + private val store: AuthStateStore, + private val meClient: MeGateway, +) { + constructor( + oidcClient: OidcClient, + store: SecureAuthStateStore, + meClient: MeClient, + ) : this( + oidcClient = + object : OidcClientGateway { + override suspend fun login(): OidcResult = oidcClient.login() + + override suspend fun refresh(authStateJson: String): OidcResult = + oidcClient.refresh(authStateJson) + + override suspend fun logout(authStateJson: String) { + oidcClient.logout(authStateJson) + } + }, + store = + object : AuthStateStore { + override fun read(): String? = store.read() + + override fun write(authStateJson: String) { + store.write(authStateJson) + } + + override fun clear() { + store.clear() + } + }, + meClient = meClient, + ) + + private val _state = MutableStateFlow(AuthState.Loading) + val state: StateFlow = _state.asStateFlow() + + private var currentTokens: BearerTokens? = null + + suspend fun initialize() { + _state.value = AuthState.Loading + + val storedJson = store.read() + if (storedJson.isNullOrBlank()) { + clearSession() + return + } + + when (val refreshResult = oidcClient.refresh(storedJson)) { + is OidcResult.Success -> authenticate(refreshResult) + OidcResult.Cancelled, + OidcResult.NetworkError, + is OidcResult.AuthError, + -> clearSession() + } + } + + suspend fun login(): AuthLoginResult = + when (val loginResult = oidcClient.login()) { + is OidcResult.Success -> { + authenticate(loginResult) + AuthLoginResult.Success + } + OidcResult.Cancelled -> { + _state.value = AuthState.Unauthenticated + AuthLoginResult.Cancelled + } + OidcResult.NetworkError -> { + _state.value = AuthState.Unauthenticated + AuthLoginResult.NetworkError + } + is OidcResult.AuthError -> { + _state.value = AuthState.Unauthenticated + AuthLoginResult.Failed(loginResult.message) + } + } + + suspend fun logout() { + val storedJson = store.read() + if (!storedJson.isNullOrBlank()) { + runCatching { + oidcClient.logout(storedJson) + } + } + + clearSession() + } + + suspend fun getAccessToken(): String? = + refreshBearerTokens()?.accessToken + + fun currentBearerTokens(): BearerTokens? = currentTokens + + suspend fun refreshBearerTokens(): BearerTokens? { + val storedJson = store.read() ?: return null.also { + clearSession() + } + + return when (val refreshResult = oidcClient.refresh(storedJson)) { + is OidcResult.Success -> { + persistTokens(refreshResult) + currentTokens + } + OidcResult.Cancelled, + OidcResult.NetworkError, + is OidcResult.AuthError, + -> null.also { + clearSession() + } + } + } + + private suspend fun authenticate(result: OidcResult.Success) { + persistTokens(result) + val user = meClient.getMe(result.accessToken) + _state.value = AuthState.Authenticated(user = user, householdId = null) + } + + private fun persistTokens(result: OidcResult.Success) { + store.write(result.authStateJson) + currentTokens = + BearerTokens( + accessToken = result.accessToken, + refreshToken = result.authStateJson, + ) + } + + private fun clearSession() { + currentTokens = null + store.clear() + _state.value = AuthState.Unauthenticated + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt new file mode 100644 index 0000000..bab70d3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt @@ -0,0 +1,16 @@ +package dev.ulfrx.recipe.auth + +import dev.ulfrx.recipe.shared.dto.User + +typealias HouseholdId = String + +sealed class AuthState { + data object Loading : AuthState() + + data object Unauthenticated : AuthState() + + data class Authenticated( + val user: User, + val householdId: HouseholdId? = null, + ) : AuthState() +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt new file mode 100644 index 0000000..e400175 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt @@ -0,0 +1,42 @@ +package dev.ulfrx.recipe.auth + +import dev.ulfrx.recipe.shared.Constants +import dev.ulfrx.recipe.shared.dto.User +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +interface MeGateway { + suspend fun getMe(accessToken: String? = null): User +} + +class MeClient( + private val httpClient: HttpClient = + HttpClient { + install(ContentNegotiation) { + json(authJson) + } + }, +) : MeGateway { + override suspend fun getMe(accessToken: String?): User = + httpClient + .get("${Constants.API_BASE_URL}api/v1/me") { + if (!accessToken.isNullOrBlank()) { + header(HttpHeaders.Authorization, "Bearer ".plus(accessToken)) + } + } + .body() + .toUser() + + private companion object { + val authJson = + Json { + ignoreUnknownKeys = true + } + } +}