Simplify Lokksmith integration

This commit is contained in:
2026-04-30 22:27:37 +02:00
parent e0af5f4053
commit 95bbeb57d2
39 changed files with 325 additions and 740 deletions

View File

@@ -0,0 +1,20 @@
package dev.ulfrx.recipe.auth
import dev.lokksmith.client.request.flow.AuthFlow
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
/**
* Bridges suspending OIDC orchestration ([OidcClient]) to Lokksmith's
* Compose-native launcher.
*
* Lokksmith owns the platform user-agent step (Custom Tabs / `ASWebAuthenticationSession`)
* via `rememberAuthFlowLauncher()`, which exposes its state as Compose `State`. To keep
* [AuthSession] / [LoginViewModel] callable as plain `suspend` functions, the screen
* wraps the Compose launcher in an [AuthBrowser] (see [ComposeAuthBrowser]) and hands
* it to the ViewModel. Result polling happens via `snapshotFlow`.
*
* Tests can fake this seam without touching Compose or Lokksmith.
*/
interface AuthBrowser {
suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result
}

View File

@@ -3,25 +3,20 @@ 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
import org.koin.plugin.module.dsl.single
import org.koin.plugin.module.dsl.viewModel
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<SecureAuthStateStore>()
single<OidcClient>()
single<MeClient>()
single<AuthSession>()
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()) }
viewModel<LoginViewModel>()
viewModel<PostLoginViewModel>()
}

View File

@@ -6,11 +6,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
interface OidcClientGateway {
suspend fun login(): OidcResult
suspend fun login(browser: AuthBrowser): OidcResult
suspend fun refresh(authStateJson: String): OidcResult
suspend fun logout(authStateJson: String)
suspend fun logout(authStateJson: String, browser: AuthBrowser)
}
interface AuthStateStore {
@@ -45,12 +45,12 @@ class AuthSession(
) : this(
oidcClient =
object : OidcClientGateway {
override suspend fun login(): OidcResult = oidcClient.login()
override suspend fun login(browser: AuthBrowser): OidcResult = oidcClient.login(browser)
override suspend fun refresh(authStateJson: String): OidcResult = oidcClient.refresh(authStateJson)
override suspend fun logout(authStateJson: String) {
oidcClient.logout(authStateJson)
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {
oidcClient.logout(authStateJson, browser)
}
},
store =
@@ -92,8 +92,8 @@ class AuthSession(
}
}
suspend fun login(): AuthLoginResult =
when (val loginResult = oidcClient.login()) {
suspend fun login(browser: AuthBrowser): AuthLoginResult =
when (val loginResult = oidcClient.login(browser)) {
is OidcResult.Success -> {
authenticate(loginResult)
AuthLoginResult.Success
@@ -115,11 +115,11 @@ class AuthSession(
}
}
suspend fun logout() {
suspend fun logout(browser: AuthBrowser) {
val storedJson = store.read()
if (!storedJson.isNullOrBlank()) {
runCatching {
oidcClient.logout(storedJson)
oidcClient.logout(storedJson, browser)
}
}

View File

@@ -0,0 +1,27 @@
package dev.ulfrx.recipe.auth
import androidx.compose.runtime.snapshotFlow
import dev.lokksmith.client.request.flow.AuthFlow
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.lokksmith.compose.AuthFlowLauncher
import kotlinx.coroutines.flow.first
/**
* Adapter that converts Lokksmith's Compose-native [AuthFlowLauncher] (state-based)
* into a suspending [AuthBrowser] (one-shot await). The screen creates this once via
* `remember(launcher)` and passes it to the ViewModel, so call sites stay plain
* `suspend`-friendly.
*/
class ComposeAuthBrowser(
private val launcher: AuthFlowLauncher,
) : AuthBrowser {
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result {
launcher.launch(initiation)
return snapshotFlow { launcher.result }
.first { result ->
result is AuthFlowResultProvider.Result.Success ||
result is AuthFlowResultProvider.Result.Cancelled ||
result is AuthFlowResultProvider.Result.Error
}
}
}

View File

@@ -0,0 +1,57 @@
package dev.ulfrx.recipe.auth
import dev.lokksmith.Lokksmith
import dev.lokksmith.client.Client
import dev.lokksmith.client.request.flow.AuthFlow
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
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
internal const val LOKKSMITH_CLIENT_KEY = "recipe-app"
internal const val LOKKSMITH_AUTH_STATE_MARKER = "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(): AuthFlow =
authorizationCodeFlow(
AuthorizationCodeFlow.Request(
redirectUri = Constants.OIDC_REDIRECT_URI,
scope = setOf(Scope.Profile, Scope.Email, Scope.Custom("offline_access")),
),
)
internal fun Client.recipeEndSessionFlow(): AuthFlow? =
endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI))
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_MARKER,
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

@@ -1,24 +1,52 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import dev.lokksmith.Lokksmith
/**
* Common seam for Authentik OIDC.
* Common Authentik OIDC client built on Lokksmith.
*
* 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.
* Lokksmith owns PKCE, state, nonce, token storage, refresh, and end-session
* (D-06, D-16, D-19, D-20). This class only orchestrates: build the flow request
* and hand its [dev.lokksmith.client.request.flow.AuthFlow.Initiation] to the
* caller-supplied [AuthBrowser] (Lokksmith's `rememberAuthFlowLauncher` on
* mobile; a fake in tests), then map the terminal result.
*
* 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).
* Logout still clears local state if remote end-session fails so users are never
* trapped in a stale session.
*/
expect class OidcClient() {
suspend fun login(): OidcResult
class OidcClient(
private val lokksmith: Lokksmith,
) {
suspend fun login(browser: AuthBrowser): OidcResult {
val client = lokksmith.recipeClient()
val flow = client.recipeAuthorizationCodeFlow()
suspend fun refresh(authStateJson: String): OidcResult
return when (val failure = browser.launchAndAwait(flow.prepare()).toOidcFailureOrNull()) {
null ->
runCatching { client.toOidcSuccess() }
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC login failed", it) }
suspend fun logout(authStateJson: String)
else -> failure
}
}
suspend fun refresh(authStateJson: String): OidcResult {
if (authStateJson != LOKKSMITH_AUTH_STATE_MARKER) {
return OidcResult.AuthError("Stored auth state is not a Lokksmith session")
}
val client = lokksmith.recipeClient()
return runCatching { client.toOidcSuccess() }
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC refresh failed", it) }
}
suspend fun logout(authStateJson: String, browser: AuthBrowser) {
val client = lokksmith.recipeClient()
val flow = client.recipeEndSessionFlow()
if (flow != null) {
runCatching { browser.launchAndAwait(flow.prepare()) }
}
client.resetTokens()
}
}

View File

@@ -1,19 +1,34 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import com.russhwolf.settings.Settings
/**
* Persists the opaque platform auth state marker for the current app install.
* Persists the opaque auth-state marker that signals "this install has logged in".
*
* 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).
* The actual OIDC tokens (access, refresh, id) live in Lokksmith's own platform
* storage (Keychain on iOS, encrypted store on Android). This class only persists
* the literal marker constant ([LOKKSMITH_AUTH_STATE_MARKER]) so [AuthSession]
* can decide whether to attempt a silent refresh on cold start. Because the value
* is non-secret, plain key/value storage is sufficient.
*
* Platform [Settings] are wired in the platform Koin module:
* - Android: [com.russhwolf.settings.SharedPreferencesSettings]
* - iOS: [com.russhwolf.settings.KeychainSettings]
*/
expect class SecureAuthStateStore() {
fun read(): String?
class SecureAuthStateStore(
private val settings: Settings,
) {
fun read(): String? = settings.getStringOrNull(KEY)
fun write(authStateJson: String)
fun write(authStateJson: String) {
settings.putString(KEY, authStateJson)
}
fun clear()
fun clear() {
settings.remove(KEY)
}
private companion object {
const val KEY = "auth_state_marker"
}
}

View File

@@ -17,7 +17,10 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import dev.lokksmith.compose.rememberAuthFlowLauncher
import dev.ulfrx.recipe.auth.ComposeAuthBrowser
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -35,6 +38,8 @@ import recipe.composeapp.generated.resources.auth_sign_in_button
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
val launcher = rememberAuthFlowLauncher()
val browser = remember(launcher) { ComposeAuthBrowser(launcher) }
Surface(
modifier = Modifier.fillMaxSize(),
@@ -55,7 +60,7 @@ fun LoginScreen(viewModel: LoginViewModel) {
)
Spacer(Modifier.height(24.dp))
Button(
onClick = { viewModel.onSignInClick() },
onClick = { viewModel.onSignInClick(browser) },
enabled = !state.isLoading,
) {
if (state.isLoading) {

View File

@@ -2,6 +2,7 @@ package dev.ulfrx.recipe.ui.screens.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.ulfrx.recipe.auth.AuthBrowser
import dev.ulfrx.recipe.auth.AuthLoginResult
import dev.ulfrx.recipe.auth.AuthSession
import kotlinx.coroutines.Job
@@ -26,9 +27,9 @@ data class LoginScreenState(
)
/**
* 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.
* Wraps [AuthSession] to drive the LoginScreen. The screen owns the
* Lokksmith [AuthBrowser] (via `rememberAuthFlowLauncher` + [dev.ulfrx.recipe.auth.ComposeAuthBrowser])
* and hands it in on click — the ViewModel never touches Compose or Lokksmith directly.
*
* Returns the launched [Job] from [onSignInClick] so tests can deterministically await
* completion without dragging a TestDispatcher into commonTest.
@@ -39,12 +40,12 @@ class LoginViewModel(
private val _state = MutableStateFlow(LoginScreenState())
val state: StateFlow<LoginScreenState> = _state.asStateFlow()
fun onSignInClick(): Job {
fun onSignInClick(browser: AuthBrowser): 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()
val result = authSession.login(browser)
_state.value =
LoginScreenState(
isLoading = false,

View File

@@ -12,7 +12,10 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import dev.lokksmith.compose.rememberAuthFlowLauncher
import dev.ulfrx.recipe.auth.ComposeAuthBrowser
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -30,6 +33,8 @@ fun PostLoginPlaceholderScreen(
user: User,
viewModel: PostLoginViewModel,
) {
val launcher = rememberAuthFlowLauncher()
val browser = remember(launcher) { ComposeAuthBrowser(launcher) }
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
@@ -49,7 +54,7 @@ fun PostLoginPlaceholderScreen(
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = { viewModel.onSignOutClick() }) {
OutlinedButton(onClick = { viewModel.onSignOutClick(browser) }) {
Text(text = stringResource(Res.string.auth_sign_out_button))
}
}

View File

@@ -2,20 +2,22 @@ package dev.ulfrx.recipe.ui.screens.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.ulfrx.recipe.auth.AuthBrowser
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].
* RP-initiated end-session via [AuthSession.logout]. The screen supplies the
* Lokksmith-backed [AuthBrowser].
*/
class PostLoginViewModel(
private val authSession: AuthSession,
) : ViewModel() {
fun onSignOutClick() {
fun onSignOutClick(browser: AuthBrowser) {
viewModelScope.launch {
authSession.logout()
authSession.logout(browser)
}
}
}

View File

@@ -14,6 +14,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
@@ -25,6 +26,7 @@ import recipe.composeapp.generated.resources.auth_app_name
* color flash.
*/
@Composable
@Preview
fun SplashScreen() {
Surface(
modifier = Modifier.fillMaxSize(),