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
This commit is contained in:
2026-04-28 16:54:39 +02:00
parent 06e5eaf94e
commit 0a24be9a95
4 changed files with 288 additions and 0 deletions

View File

@@ -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 <redacted>")
.replace(Regex("(?i)(Authorization:\\s*)[^\\n\\r]+")) { match ->
match.groupValues[1] + "<redacted>"
}
private val authJson =
Json {
ignoreUnknownKeys = true
}
}

View File

@@ -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>(AuthState.Loading)
val state: StateFlow<AuthState> = _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
}
}

View File

@@ -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()
}

View File

@@ -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<dev.ulfrx.recipe.shared.dto.MeResponse>()
.toUser()
private companion object {
val authJson =
Json {
ignoreUnknownKeys = true
}
}
}