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