Add authentication

This commit is contained in:
2026-04-27 19:28:57 +02:00
parent 6684b7179d
commit e0af5f4053
92 changed files with 8140 additions and 208 deletions

View File

@@ -20,4 +20,4 @@
</activity>
</application>
</manifest>
</manifest>

View File

@@ -1,6 +1,7 @@
package dev.ulfrx.recipe
import android.app.Application
import dev.ulfrx.recipe.auth.androidAuthModule
import dev.ulfrx.recipe.di.initKoin
import dev.ulfrx.recipe.logging.configureLogging
import org.koin.android.ext.koin.androidContext
@@ -11,6 +12,7 @@ class MainApplication : Application() {
configureLogging()
initKoin {
androidContext(this@MainApplication)
modules(androidAuthModule)
}
}
}

View File

@@ -0,0 +1,9 @@
package dev.ulfrx.recipe.auth
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val androidAuthModule =
module {
single { createAndroidLokksmith(androidContext().applicationContext) }
}

View File

@@ -0,0 +1,98 @@
package dev.ulfrx.recipe.auth
import dev.lokksmith.Lokksmith
import dev.lokksmith.client.Client
import dev.lokksmith.client.InternalClient
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.lokksmith.client.request.flow.AuthFlowStateResponseHandler
import dev.lokksmith.client.request.flow.authorizationCode.AuthorizationCodeFlow
import dev.lokksmith.client.request.flow.endSession.EndSessionFlow
import dev.lokksmith.client.request.parameter.Scope
import dev.lokksmith.discoveryUrl
import dev.lokksmith.id
import dev.ulfrx.recipe.shared.Constants
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.selects.select
internal const val LOKKSMITH_CLIENT_KEY = "recipe-app"
internal const val LOKKSMITH_AUTH_STATE_JSON = "lokksmith:$LOKKSMITH_CLIENT_KEY"
internal suspend fun Lokksmith.recipeClient(): Client =
getOrCreate(LOKKSMITH_CLIENT_KEY) {
id = Constants.OIDC_CLIENT_ID
discoveryUrl = Constants.OIDC_ISSUER + ".well-known/openid-configuration"
}
internal fun Client.recipeAuthorizationCodeFlow(): dev.lokksmith.client.request.flow.AuthFlow =
authorizationCodeFlow(
AuthorizationCodeFlow.Request(
redirectUri = Constants.OIDC_REDIRECT_URI,
scope = setOf(Scope.Profile, Scope.Email, Scope.Custom("offline_access")),
),
)
internal fun Client.recipeEndSessionFlow(): dev.lokksmith.client.request.flow.AuthFlow? =
endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI))
internal suspend fun Client.awaitTerminalAuthFlowResult(): AuthFlowResultProvider.Result =
AuthFlowResultProvider
.forClient(this)
.first { result ->
result is AuthFlowResultProvider.Result.Success ||
result is AuthFlowResultProvider.Result.Cancelled ||
result is AuthFlowResultProvider.Result.Error
}
internal suspend fun Lokksmith.completeAuthFlow(client: Client): AuthFlowResultProvider.Result =
coroutineScope {
val terminal = async { client.awaitTerminalAuthFlowResult() }
val responseUri =
async {
(client as InternalClient)
.snapshots
.map { snapshot -> snapshot.ephemeralFlowState?.responseUri }
.distinctUntilChanged()
.first { responseUri -> responseUri != null }
}
select<AuthFlowResultProvider.Result> {
terminal.onAwait { result ->
responseUri.cancel()
result
}
responseUri.onAwait { uri ->
terminal.cancel()
AuthFlowStateResponseHandler(this@completeAuthFlow).onResponse(checkNotNull(uri))
client.awaitTerminalAuthFlowResult()
}
}
}
internal suspend fun Client.toOidcSuccess(): OidcResult.Success {
var freshTokens: Client.Tokens? = null
runWithTokens { tokens -> freshTokens = tokens }
val tokens = checkNotNull(freshTokens) { "Lokksmith returned no tokens" }
return OidcResult.Success(
authStateJson = LOKKSMITH_AUTH_STATE_JSON,
accessToken = tokens.accessToken.token,
idToken = tokens.idToken.raw,
expiresAtEpochMillis = tokens.accessToken.expiresAt?.let { it * 1_000L } ?: 0L,
)
}
internal fun AuthFlowResultProvider.Result.toOidcFailureOrNull(): OidcResult? =
when (this) {
is AuthFlowResultProvider.Result.Success -> null
is AuthFlowResultProvider.Result.Cancelled -> OidcResult.Cancelled
is AuthFlowResultProvider.Result.Error -> OidcResult.AuthError(message ?: "OIDC flow failed")
AuthFlowResultProvider.Result.Undefined,
is AuthFlowResultProvider.Result.Processing,
-> OidcResult.AuthError("OIDC flow did not complete")
}

View File

@@ -0,0 +1,76 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import android.content.Context
import android.content.Intent
import dev.lokksmith.Lokksmith
import dev.lokksmith.SingletonLokksmithProvider
import dev.lokksmith.android.LokksmithAuthFlowActivity
import dev.lokksmith.createLokksmith
import org.koin.core.context.GlobalContext
actual class OidcClient {
private val context: Context
get() = GlobalContext.get().get<Context>().applicationContext
private val lokksmith: Lokksmith
get() = GlobalContext.get().get()
actual suspend fun login(): OidcResult {
val client = lokksmith.recipeClient()
val flow = client.recipeAuthorizationCodeFlow()
val initiation = flow.prepare()
context.startActivity(
LokksmithAuthFlowActivity
.createCustomTabsIntent(context = context, initiation = initiation)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
return when (val failure = lokksmith.completeAuthFlow(client).toOidcFailureOrNull()) {
null -> {
runCatching { client.toOidcSuccess() }.getOrElse { error ->
OidcResult.AuthError(error.message ?: "OIDC login failed", error)
}
}
else -> {
failure
}
}
}
actual suspend fun refresh(authStateJson: String): OidcResult {
if (authStateJson != LOKKSMITH_AUTH_STATE_JSON) {
return OidcResult.AuthError("Stored Android auth state is not a Lokksmith session")
}
val client = lokksmith.recipeClient()
return runCatching { client.toOidcSuccess() }
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC refresh failed", it) }
}
actual suspend fun logout(authStateJson: String) {
val client = lokksmith.recipeClient()
val flow = client.recipeEndSessionFlow()
if (flow != null) {
runCatching {
val initiation = flow.prepare()
context.startActivity(
LokksmithAuthFlowActivity
.createCustomTabsIntent(context = context, initiation = initiation)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
lokksmith.completeAuthFlow(client)
}
}
client.resetTokens()
}
}
fun createAndroidLokksmith(context: Context): Lokksmith =
createLokksmith(context).also { lokksmith ->
SingletonLokksmithProvider.set(lokksmith)
}

View File

@@ -0,0 +1,50 @@
@file:Suppress("DEPRECATION", "EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.koin.core.context.GlobalContext
actual class SecureAuthStateStore {
private val preferences by lazy {
val appContext = GlobalContext.get().get<Context>().applicationContext
val masterKey =
MasterKey
.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
// AndroidX Security Crypto is deprecated, but AUTH-02 explicitly requires
// EncryptedSharedPreferences in v1; this abstraction contains that debt.
EncryptedSharedPreferences.create(
appContext,
FILE_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
actual fun read(): String? = preferences.getString(KEY_AUTH_STATE_JSON, null)
actual fun write(authStateJson: String) {
preferences
.edit()
.putString(KEY_AUTH_STATE_JSON, authStateJson)
.apply()
}
actual fun clear() {
preferences
.edit()
.remove(KEY_AUTH_STATE_JSON)
.apply()
}
private companion object {
const val FILE_NAME = "recipe_auth_state"
const val KEY_AUTH_STATE_JSON = "auth_state_json"
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Phase 2 auth scaffold copy. Polish-only v1; resources are multi-locale-ready per
CLAUDE.md non-negotiable #9. Phase 11 polishes copy + plurals; do not edit
these keys without coordinating with the auth UI in `ui/screens/auth/*`.
-->
<resources>
<string name="auth_app_name">Recipe</string>
<string name="auth_sign_in_button">Zaloguj się przez Authentik</string>
<string name="auth_sign_out_button">Wyloguj się</string>
<string name="auth_welcome_format">Witaj, %1$s!</string>
<string name="auth_error_cancelled">Logowanie anulowane. Spróbuj ponownie.</string>
<string name="auth_error_network">Nie można połączyć z Authentik. Sprawdź połączenie.</string>
<string name="auth_error_unknown">Coś poszło nie tak. Spróbuj ponownie.</string>
</resources>

View File

@@ -1,52 +1,54 @@
package dev.ulfrx.recipe
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import org.jetbrains.compose.resources.painterResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.compose_multiplatform
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthState
import dev.ulfrx.recipe.ui.screens.auth.LoginScreen
import dev.ulfrx.recipe.ui.screens.auth.LoginViewModel
import dev.ulfrx.recipe.ui.screens.auth.PostLoginPlaceholderScreen
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
import dev.ulfrx.recipe.ui.screens.auth.SplashScreen
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
/**
* Auth gate. Observes [AuthSession.state] and renders Splash / Login / PostLogin per
* `02-UI-SPEC.md` § Auth Gate Routing Contract. State changes drive recomposition;
* no manual navigation. Phase 3 replaces the `Authenticated` branch with `HouseholdGate`.
*/
@Composable
@Preview
fun App() {
MaterialTheme {
var showContent by remember { mutableStateOf(false) }
Column(
modifier =
Modifier
.background(MaterialTheme.colorScheme.primaryContainer)
.safeContentPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Button(onClick = { showContent = !showContent }) {
Text("Click me!")
RecipeTheme {
val authSession = koinInject<AuthSession>()
val authState by authSession.state.collectAsStateWithLifecycle()
// Kick off the persisted-session restore once. AuthSession.initialize()
// refreshes the stored AuthState (or transitions to Unauthenticated on
// empty store / refresh failure) and the gate below recomposes accordingly.
LaunchedEffect(authSession) {
authSession.initialize()
}
when (val current = authState) {
AuthState.Loading -> {
SplashScreen()
}
AnimatedVisibility(showContent) {
val greeting = remember { Greeting().greet() }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(painterResource(Res.drawable.compose_multiplatform), null)
Text("Compose: $greeting")
}
AuthState.Unauthenticated -> {
LoginScreen(viewModel = koinViewModel<LoginViewModel>())
}
is AuthState.Authenticated -> {
PostLoginPlaceholderScreen(
user = current.user,
viewModel = koinViewModel<PostLoginViewModel>(),
)
}
}
}

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,27 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.ui.screens.auth.LoginViewModel
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
import io.ktor.client.HttpClient
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
val authModule =
module {
single<SecureAuthStateStore> { SecureAuthStateStore() }
single<OidcClient> { OidcClient() }
single<MeClient> { MeClient() }
single<AuthSession> {
AuthSession(
oidcClient = get<OidcClient>(),
store = get<SecureAuthStateStore>(),
meClient = get<MeClient>(),
)
}
single<HttpClient> { AuthHttpClient.create(get()) }
// Phase 2 auth-gate ViewModels (02-07). Registered here so the same module that
// owns AuthSession also owns its UI consumers; Koin scopes them per Composable.
viewModel { LoginViewModel(authSession = get()) }
viewModel { PostLoginViewModel(authSession = get()) }
}

View File

@@ -0,0 +1,176 @@
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,41 @@
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
}
}
}

View File

@@ -0,0 +1,24 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
/**
* Common seam for Authentik OIDC.
*
* Native Android/iOS actuals use Lokksmith for browser-based Authorization Code
* + PKCE. Login requests must be public PKCE-compatible OIDC requests with
* exactly these scopes: `openid profile email offline_access` (D-06).
* Lokksmith owns state and nonce verification.
*
* Refresh must go through Lokksmith fresh-token handling, then return an opaque
* auth-state marker for persistence (D-16). Logout must use RP-initiated
* end-session 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)
}

View File

@@ -0,0 +1,25 @@
package dev.ulfrx.recipe.auth
/**
* Result returned by platform OIDC clients.
*
* `authStateJson` is an opaque platform-auth marker 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
}

View File

@@ -0,0 +1,19 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
/**
* Persists the opaque platform auth state marker 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 auth state. The stored
* value 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()
}

View File

@@ -1,9 +1,10 @@
package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.auth.authModule
import org.koin.dsl.module
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
val appModule =
module {
// intentionally empty in Phase 1
includes(authModule)
}

View File

@@ -0,0 +1,88 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_app_name
import recipe.composeapp.generated.resources.auth_sign_in_button
/**
* Visible during [dev.ulfrx.recipe.auth.AuthState.Unauthenticated]. Wordmark + sign-in
* button + inline error text (when present). Inline-error UX rules and loading rules
* locked in `02-UI-SPEC.md` § Copywriting Contract.
*/
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.safeContentPadding()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(Res.string.auth_app_name),
style = MaterialTheme.typography.displaySmall,
)
Spacer(Modifier.height(24.dp))
Button(
onClick = { viewModel.onSignInClick() },
enabled = !state.isLoading,
) {
if (state.isLoading) {
Box(
modifier = Modifier.size(16.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = LocalContentColor.current,
)
}
} else {
Text(text = stringResource(Res.string.auth_sign_in_button))
}
}
val errorKey = state.errorKey
if (errorKey != null) {
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(errorKey),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
}
}
}

View File

@@ -0,0 +1,63 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.ulfrx.recipe.auth.AuthLoginResult
import dev.ulfrx.recipe.auth.AuthSession
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_error_cancelled
import recipe.composeapp.generated.resources.auth_error_network
import recipe.composeapp.generated.resources.auth_error_unknown
/**
* Immutable UI state for [LoginScreen]. The [errorKey] is a Compose Resources
* [StringResource] handle, not a translated string — the screen resolves it via
* `stringResource(...)` so the ViewModel stays platform/locale agnostic.
*/
data class LoginScreenState(
val isLoading: Boolean = false,
val errorKey: StringResource? = null,
)
/**
* Wraps [AuthSession] to drive the LoginScreen. Method-per-action: [onSignInClick] is the
* single entry point. Cancellation/network/unknown failures map to user-facing string
* resources per `02-UI-SPEC.md` § Copywriting Contract.
*
* Returns the launched [Job] from [onSignInClick] so tests can deterministically await
* completion without dragging a TestDispatcher into commonTest.
*/
class LoginViewModel(
private val authSession: AuthSession,
) : ViewModel() {
private val _state = MutableStateFlow(LoginScreenState())
val state: StateFlow<LoginScreenState> = _state.asStateFlow()
fun onSignInClick(): Job {
// Clear any previous inline error and enter the loading state before suspending —
// contract from UI-SPEC: tapping the button again clears stale error text.
_state.value = LoginScreenState(isLoading = true, errorKey = null)
return viewModelScope.launch {
val result = authSession.login()
_state.value =
LoginScreenState(
isLoading = false,
errorKey = result.toErrorKeyOrNull(),
)
}
}
private fun AuthLoginResult.toErrorKeyOrNull(): StringResource? =
when (this) {
AuthLoginResult.Success -> null
AuthLoginResult.Cancelled -> Res.string.auth_error_cancelled
AuthLoginResult.NetworkError -> Res.string.auth_error_network
is AuthLoginResult.Failed -> Res.string.auth_error_unknown
}
}

View File

@@ -0,0 +1,57 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.shared.dto.User
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_sign_out_button
import recipe.composeapp.generated.resources.auth_welcome_format
/**
* Phase 2 placeholder: welcome message + logout. Phase 3 replaces this with `HouseholdGate`.
*/
@Composable
fun PostLoginPlaceholderScreen(
user: User,
viewModel: PostLoginViewModel,
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.safeContentPadding()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(Res.string.auth_welcome_format, user.displayName),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = { viewModel.onSignOutClick() }) {
Text(text = stringResource(Res.string.auth_sign_out_button))
}
}
}
}

View File

@@ -0,0 +1,21 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.ulfrx.recipe.auth.AuthSession
import kotlinx.coroutines.launch
/**
* Trivial in Phase 2 — exists so Phase 3's `HouseholdGate` follows the same VM pattern.
* Logout is silent (CONTEXT D-19): no confirmation modal; tap immediately initiates
* RP-initiated end-session via [AuthSession.logout].
*/
class PostLoginViewModel(
private val authSession: AuthSession,
) : ViewModel() {
fun onSignOutClick() {
viewModelScope.launch {
authSession.logout()
}
}
}

View File

@@ -0,0 +1,52 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_app_name
/**
* Visible during [dev.ulfrx.recipe.auth.AuthState.Loading]. Wordmark + circular progress.
* No marketing copy, no tagline. Background is `surface` so the Login transition has no
* color flash.
*/
@Composable
fun SplashScreen() {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.safeContentPadding()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(Res.string.auth_app_name),
style = MaterialTheme.typography.displaySmall,
)
Spacer(Modifier.height(8.dp))
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
)
}
}
}

View File

@@ -0,0 +1,35 @@
package dev.ulfrx.recipe.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Phase 2 seed theme. Material 3 light/dark schemes with a single seed override on `primary`
* (`#3B6939` light / `#A2D597` dark — see `02-UI-SPEC.md` § Color). All other roles use
* Material 3 baseline values. Phase 11 may rebase the palette around a different seed.
*
* Intentionally minimal: no Haze, no custom typography, no shapes. Per UI-SPEC, Material 3
* defaults satisfy Phase 2's spacing/typography/accessibility contract.
*/
private val LightColors =
lightColorScheme(
primary = Color(0xFF3B6939),
)
private val DarkColors =
darkColorScheme(
primary = Color(0xFFA2D597),
)
@Composable
fun RecipeTheme(content: @Composable () -> Unit) {
val colors = if (isSystemInDarkTheme()) DarkColors else LightColors
MaterialTheme(
colorScheme = colors,
content = content,
)
}

View File

@@ -0,0 +1,240 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
class AuthSessionTest {
@Test
fun emptyStoreInitializesLoadingToUnauthenticated() {
runTest {
val session = newSession(store = FakeAuthStateStore())
assertIs<AuthState.Loading>(session.state.value)
session.initialize()
assertIs<AuthState.Unauthenticated>(session.state.value)
}
}
@Test
fun successfulLoginWritesAuthStateJsonFetchesMeAndEmitsAuthenticatedWithNullHouseholdId() {
runTest {
val store = FakeAuthStateStore()
val oidcClient =
FakeOidcClient(
loginResult =
OidcResult.Success(
authStateJson = AUTH_STATE_JSON,
accessToken = ACCESS_TOKEN,
idToken = "id-token",
expiresAtEpochMillis = 123_456L,
),
)
val meClient = FakeMeClient(user = USER)
val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient)
val result = session.login()
assertEquals(AuthLoginResult.Success, result)
assertEquals(AUTH_STATE_JSON, store.value)
assertEquals(listOf<String?>(ACCESS_TOKEN), meClient.accessTokens)
val authenticated = assertIs<AuthState.Authenticated>(session.state.value)
assertEquals(USER, authenticated.user)
assertNull(authenticated.householdId)
}
}
@Test
fun existingStoreRefreshesBeforeMeAndEmitsAuthenticatedWithoutLogin() {
runTest {
val store = FakeAuthStateStore(value = "stored-auth-state-json")
val oidcClient =
FakeOidcClient(
refreshResult =
OidcResult.Success(
authStateJson = REFRESHED_AUTH_STATE_JSON,
accessToken = REFRESHED_ACCESS_TOKEN,
idToken = null,
expiresAtEpochMillis = 789_000L,
),
)
val meClient = FakeMeClient(user = USER)
val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient)
session.initialize()
assertEquals(emptyList(), oidcClient.loginCalls)
assertEquals(listOf("stored-auth-state-json"), oidcClient.refreshCalls)
assertEquals(REFRESHED_AUTH_STATE_JSON, store.value)
assertEquals(listOf<String?>(REFRESHED_ACCESS_TOKEN), meClient.accessTokens)
val authenticated = assertIs<AuthState.Authenticated>(session.state.value)
assertEquals(USER, authenticated.user)
assertNull(authenticated.householdId)
}
}
@Test
fun refreshInvalidGrantClearsStoreAndEmitsUnauthenticatedWithoutUiError() {
runTest {
val store = FakeAuthStateStore(value = "stored-auth-state-json")
val oidcClient =
FakeOidcClient(
refreshResult = OidcResult.AuthError("invalid_grant"),
)
val session = newSession(store = store, oidcClient = oidcClient)
session.initialize()
assertNull(store.value)
assertIs<AuthState.Unauthenticated>(session.state.value)
}
}
@Test
fun refreshAuthErrorClearsStoreAndEmitsUnauthenticatedWithoutUiError() {
runTest {
val store = FakeAuthStateStore(value = "stored-auth-state-json")
val oidcClient =
FakeOidcClient(
refreshResult = OidcResult.AuthError("token endpoint rejected refresh"),
)
val session = newSession(store = store, oidcClient = oidcClient)
session.initialize()
assertNull(store.value)
assertIs<AuthState.Unauthenticated>(session.state.value)
}
}
@Test
fun logoutCallsEndSessionThenClearsStoreAndEmitsUnauthenticatedWhenLogoutSucceeds() {
runTest {
val store = FakeAuthStateStore(value = AUTH_STATE_JSON)
val oidcClient = FakeOidcClient()
val session = newSession(store = store, oidcClient = oidcClient)
session.logout()
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
assertNull(store.value)
assertIs<AuthState.Unauthenticated>(session.state.value)
}
}
@Test
fun logoutClearsStoreAndEmitsUnauthenticatedEvenWhenEndSessionThrows() {
runTest {
val store = FakeAuthStateStore(value = AUTH_STATE_JSON)
val oidcClient = FakeOidcClient(logoutThrows = true)
val session = newSession(store = store, oidcClient = oidcClient)
session.logout()
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
assertNull(store.value)
assertIs<AuthState.Unauthenticated>(session.state.value)
}
}
@Test
fun loginCancelledMapsToUiRenderableCancelledResult() {
runTest {
val store = FakeAuthStateStore()
val session =
newSession(
store = store,
oidcClient = FakeOidcClient(loginResult = OidcResult.Cancelled),
)
val result = session.login()
assertEquals(AuthLoginResult.Cancelled, result)
assertNull(store.value)
assertIs<AuthState.Unauthenticated>(session.state.value)
}
}
private fun newSession(
store: AuthStateStore = FakeAuthStateStore(),
oidcClient: OidcClientGateway = FakeOidcClient(),
meClient: MeGateway = FakeMeClient(user = USER),
): AuthSession =
AuthSession(
oidcClient = oidcClient,
store = store,
meClient = meClient,
)
private class FakeAuthStateStore(
var value: String? = null,
) : AuthStateStore {
override fun read(): String? = value
override fun write(authStateJson: String) {
value = authStateJson
}
override fun clear() {
value = null
}
}
private class FakeOidcClient(
private val loginResult: OidcResult = OidcResult.AuthError("login not configured"),
private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"),
private val logoutThrows: Boolean = false,
) : OidcClientGateway {
val loginCalls = mutableListOf<Unit>()
val refreshCalls = mutableListOf<String>()
val logoutCalls = mutableListOf<String>()
override suspend fun login(): OidcResult {
loginCalls += Unit
return loginResult
}
override suspend fun refresh(authStateJson: String): OidcResult {
refreshCalls += authStateJson
return refreshResult
}
override suspend fun logout(authStateJson: String) {
logoutCalls += authStateJson
if (logoutThrows) {
error("end-session failed")
}
}
}
private class FakeMeClient(
private val user: User,
) : MeGateway {
val accessTokens = mutableListOf<String?>()
override suspend fun getMe(accessToken: String?): User {
accessTokens += accessToken
return user
}
}
private companion object {
const val AUTH_STATE_JSON = """{"refresh_token":"initial"}"""
const val REFRESHED_AUTH_STATE_JSON = """{"refresh_token":"refreshed"}"""
const val ACCESS_TOKEN = "access-token"
const val REFRESHED_ACCESS_TOKEN = "refreshed-access-token"
val USER =
User(
id = "00000000-0000-0000-0000-000000000001",
sub = "authentik-sub",
email = "user@example.invalid",
displayName = "Recipe User",
)
}
}

View File

@@ -0,0 +1,27 @@
package dev.ulfrx.recipe.auth
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class SecureAuthStateStoreContractTest {
@Test
fun writeOverwritesPreviousValueAndReadReturnsLatest() {
val store = SecureAuthStateStore()
store.write("""{"refresh_token":"first"}""")
store.write("""{"refresh_token":"second"}""")
assertEquals("""{"refresh_token":"second"}""", store.read())
}
@Test
fun clearRemovesStoredValue() {
val store = SecureAuthStateStore()
store.write("""{"refresh_token":"stored"}""")
store.clear()
assertNull(store.read())
}
}

View File

@@ -0,0 +1,167 @@
package dev.ulfrx.recipe.ui.screens.auth
import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthStateStore
import dev.ulfrx.recipe.auth.MeGateway
import dev.ulfrx.recipe.auth.OidcClientGateway
import dev.ulfrx.recipe.auth.OidcResult
import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_error_cancelled
import recipe.composeapp.generated.resources.auth_error_network
import recipe.composeapp.generated.resources.auth_error_unknown
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class LoginViewModelTest {
@Test
fun cancelledAuthFailureMapsToCancelledStringResource() =
runTest {
val session = newSession(loginResult = OidcResult.Cancelled)
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
}
@Test
fun networkAuthFailureMapsToNetworkStringResource() =
runTest {
val session = newSession(loginResult = OidcResult.NetworkError)
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
}
@Test
fun unknownAuthFailureMapsToUnknownStringResource() =
runTest {
val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed"))
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
}
@Test
fun successClearsErrorAndStopsLoading() =
runTest {
val session =
newSession(
loginResult =
OidcResult.Success(
authStateJson = "{}",
accessToken = "access",
idToken = null,
expiresAtEpochMillis = 0L,
),
)
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
assertNull(viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
}
@Test
fun startingNewSignInClearsPreviousErrorAndSetsLoading() =
runTest {
// Queue: first login resolves Cancelled to seed an inline error.
// Second login awaits a gate so we can synchronously observe the
// "loading=true, error=null" intermediate state contract from UI-SPEC.
val gate = CompletableDeferred<OidcResult>()
val queue = mutableListOf<OidcResult>(OidcResult.Cancelled)
val oidc =
object : OidcClientGateway {
override suspend fun login(): OidcResult = if (queue.isNotEmpty()) queue.removeAt(0) else gate.await()
override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used")
override suspend fun logout(authStateJson: String) {}
}
val session = AuthSession(oidc, FakeAuthStateStore(), FakeMeClient(USER))
val viewModel = LoginViewModel(session)
// First attempt: error seeded.
viewModel.onSignInClick().join()
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
// Second attempt: launching the job sets loading=true + clears error
// BEFORE suspending. onSignInClick() does that synchronously before
// returning the launched Job, so we can assert immediately.
val job = viewModel.onSignInClick()
assertTrue(viewModel.state.value.isLoading)
assertNull(viewModel.state.value.errorKey)
// Release the gate; the second login also returns Cancelled.
gate.complete(OidcResult.Cancelled)
job.join()
assertEquals(false, viewModel.state.value.isLoading)
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
}
private fun newSession(
loginResult: OidcResult,
store: AuthStateStore = FakeAuthStateStore(),
meClient: MeGateway = FakeMeClient(USER),
): AuthSession =
AuthSession(
oidcClient = FakeOidcClient(loginResult = loginResult),
store = store,
meClient = meClient,
)
private class FakeAuthStateStore(
var value: String? = null,
) : AuthStateStore {
override fun read(): String? = value
override fun write(authStateJson: String) {
value = authStateJson
}
override fun clear() {
value = null
}
}
private class FakeOidcClient(
private val loginResult: OidcResult = OidcResult.AuthError("login not configured"),
private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"),
) : OidcClientGateway {
override suspend fun login(): OidcResult = loginResult
override suspend fun refresh(authStateJson: String): OidcResult = refreshResult
override suspend fun logout(authStateJson: String) {}
}
private class FakeMeClient(
private val user: User,
) : MeGateway {
override suspend fun getMe(accessToken: String?): User = user
}
private companion object {
val USER =
User(
id = "00000000-0000-0000-0000-000000000001",
sub = "authentik-sub",
email = "user@example.invalid",
displayName = "Recipe User",
)
}
}

View File

@@ -0,0 +1,8 @@
package dev.ulfrx.recipe.auth
import org.koin.dsl.module
val iosAuthModule =
module {
single { createIosLokksmith() }
}

View File

@@ -0,0 +1,98 @@
package dev.ulfrx.recipe.auth
import dev.lokksmith.Lokksmith
import dev.lokksmith.client.Client
import dev.lokksmith.client.InternalClient
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.lokksmith.client.request.flow.AuthFlowStateResponseHandler
import dev.lokksmith.client.request.flow.authorizationCode.AuthorizationCodeFlow
import dev.lokksmith.client.request.flow.endSession.EndSessionFlow
import dev.lokksmith.client.request.parameter.Scope
import dev.lokksmith.discoveryUrl
import dev.lokksmith.id
import dev.ulfrx.recipe.shared.Constants
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.selects.select
internal const val LOKKSMITH_CLIENT_KEY = "recipe-app"
internal const val LOKKSMITH_AUTH_STATE_JSON = "lokksmith:$LOKKSMITH_CLIENT_KEY"
internal suspend fun Lokksmith.recipeClient(): Client =
getOrCreate(LOKKSMITH_CLIENT_KEY) {
id = Constants.OIDC_CLIENT_ID
discoveryUrl = Constants.OIDC_ISSUER + ".well-known/openid-configuration"
}
internal fun Client.recipeAuthorizationCodeFlow(): dev.lokksmith.client.request.flow.AuthFlow =
authorizationCodeFlow(
AuthorizationCodeFlow.Request(
redirectUri = Constants.OIDC_REDIRECT_URI,
scope = setOf(Scope.Profile, Scope.Email, Scope.Custom("offline_access")),
),
)
internal fun Client.recipeEndSessionFlow(): dev.lokksmith.client.request.flow.AuthFlow? =
endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI))
internal suspend fun Client.awaitTerminalAuthFlowResult(): AuthFlowResultProvider.Result =
AuthFlowResultProvider
.forClient(this)
.first { result ->
result is AuthFlowResultProvider.Result.Success ||
result is AuthFlowResultProvider.Result.Cancelled ||
result is AuthFlowResultProvider.Result.Error
}
internal suspend fun Lokksmith.completeAuthFlow(client: Client): AuthFlowResultProvider.Result =
coroutineScope {
val terminal = async { client.awaitTerminalAuthFlowResult() }
val responseUri =
async {
(client as InternalClient)
.snapshots
.map { snapshot -> snapshot.ephemeralFlowState?.responseUri }
.distinctUntilChanged()
.first { responseUri -> responseUri != null }
}
select<AuthFlowResultProvider.Result> {
terminal.onAwait { result ->
responseUri.cancel()
result
}
responseUri.onAwait { uri ->
terminal.cancel()
AuthFlowStateResponseHandler(this@completeAuthFlow).onResponse(checkNotNull(uri))
client.awaitTerminalAuthFlowResult()
}
}
}
internal suspend fun Client.toOidcSuccess(): OidcResult.Success {
var freshTokens: Client.Tokens? = null
runWithTokens { tokens -> freshTokens = tokens }
val tokens = checkNotNull(freshTokens) { "Lokksmith returned no tokens" }
return OidcResult.Success(
authStateJson = LOKKSMITH_AUTH_STATE_JSON,
accessToken = tokens.accessToken.token,
idToken = tokens.idToken.raw,
expiresAtEpochMillis = tokens.accessToken.expiresAt?.let { it * 1_000L } ?: 0L,
)
}
internal fun AuthFlowResultProvider.Result.toOidcFailureOrNull(): OidcResult? =
when (this) {
is AuthFlowResultProvider.Result.Success -> null
is AuthFlowResultProvider.Result.Cancelled -> OidcResult.Cancelled
is AuthFlowResultProvider.Result.Error -> OidcResult.AuthError(message ?: "OIDC flow failed")
AuthFlowResultProvider.Result.Undefined,
is AuthFlowResultProvider.Result.Processing,
-> OidcResult.AuthError("OIDC flow did not complete")
}

View File

@@ -0,0 +1,55 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import dev.lokksmith.Lokksmith
import dev.lokksmith.SingletonLokksmithProvider
import dev.lokksmith.createLokksmith
import dev.lokksmith.ios.launchAuthFlow
import org.koin.mp.KoinPlatform
actual class OidcClient {
private val lokksmith: Lokksmith
get() = KoinPlatform.getKoin().get()
actual suspend fun login(): OidcResult {
val client = lokksmith.recipeClient()
val flow = client.recipeAuthorizationCodeFlow()
val initiation = flow.prepare()
lokksmith.launchAuthFlow(initiation)
return when (val failure = lokksmith.completeAuthFlow(client).toOidcFailureOrNull()) {
null -> runCatching { client.toOidcSuccess() }.getOrElse { OidcResult.AuthError(it.message ?: "OIDC login failed", it) }
else -> failure
}
}
actual suspend fun refresh(authStateJson: String): OidcResult {
if (authStateJson != LOKKSMITH_AUTH_STATE_JSON) {
return OidcResult.AuthError("Stored iOS auth state is not a Lokksmith session")
}
val client = lokksmith.recipeClient()
return runCatching { client.toOidcSuccess() }
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC refresh failed", it) }
}
actual suspend fun logout(authStateJson: String) {
val client = lokksmith.recipeClient()
val flow = client.recipeEndSessionFlow()
if (flow != null) {
runCatching {
lokksmith.launchAuthFlow(flow.prepare())
lokksmith.completeAuthFlow(client)
}
}
client.resetTokens()
}
}
fun createIosLokksmith(): Lokksmith =
createLokksmith().also { lokksmith ->
SingletonLokksmithProvider.set(lokksmith)
}

View File

@@ -0,0 +1,32 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import com.russhwolf.settings.ExperimentalSettingsApi
import com.russhwolf.settings.ExperimentalSettingsImplementation
import com.russhwolf.settings.KeychainSettings
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Security.kSecAttrAccessible
import platform.Security.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
@OptIn(ExperimentalSettingsApi::class, ExperimentalSettingsImplementation::class, ExperimentalForeignApi::class)
actual class SecureAuthStateStore {
private val settings =
KeychainSettings(
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
)
actual fun read(): String? = settings.getStringOrNull(AUTH_STATE_KEY)
actual fun write(authStateJson: String) {
settings.putString(AUTH_STATE_KEY, authStateJson)
}
actual fun clear() {
settings.remove(AUTH_STATE_KEY)
}
private companion object {
const val AUTH_STATE_KEY = "dev.ulfrx.recipe.auth.lokksmith-state"
}
}

View File

@@ -1,8 +1,11 @@
package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.auth.iosAuthModule
import dev.ulfrx.recipe.logging.configureLogging
fun doInitKoin() {
configureLogging()
initKoin()
initKoin {
modules(iosAuthModule)
}
}

View File

@@ -0,0 +1,38 @@
@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"
}
}

View File

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

View File

@@ -0,0 +1,11 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
actual class OidcClient {
actual suspend fun login(): OidcResult = throw NotImplementedError("Wasm OIDC: v2")
actual suspend fun refresh(authStateJson: String): OidcResult = throw NotImplementedError("Wasm OIDC: v2")
actual suspend fun logout(authStateJson: String): Unit = throw NotImplementedError("Wasm OIDC: v2")
}

View File

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