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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user