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

@@ -1,6 +1,12 @@
dependencyResolutionManagement { dependencyResolutionManagement {
repositories { repositories {
google() google {
mavenContent {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
mavenCentral() mavenCentral()
gradlePluginPortal() gradlePluginPortal()
} }

View File

@@ -1,87 +1,22 @@
// Establishes the D-05 target matrix + JVM toolchain + warning policy. import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
// Android bytecode is JVM 11 (D-08); server + desktop + shared/jvm are JVM 21.
//
// This plugin is intentionally dependency-free: shared/ must stay light
// (no Koin, no Kermit), and composeApp adds those in its own build file.
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
id("org.jetbrains.kotlin.multiplatform") id("org.jetbrains.kotlin.multiplatform")
} }
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
// Framework declaration moved here from composeApp/build.gradle.kts when the
// CocoaPods plugin was dropped (2026-04-28). The Xcode run script invokes
// :composeApp:embedAndSignAppleFrameworkForXcode, which needs `baseName` to
// resolve `import ComposeApp` from Swift. `isStatic = true` keeps the link
// shape unchanged from the previous CocoaPods setup. The `:shared` module is
// still re-exported so Swift can read shared constants when needed.
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "ComposeApp"
isStatic = true
// `composeApp` only applies the multiplatform plugin; project deps
// live in its own build file. Skip the export when this convention
// plugin is applied to a module that doesn't depend on `:shared`
// (e.g., shared itself).
project.findProject(":shared")?.let { sharedProject ->
if (project != sharedProject) export(sharedProject)
}
}
}
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs { browser() }
compilerOptions { compilerOptions {
allWarningsAsErrors.set(true) allWarningsAsErrors.set(true)
} }
sourceSets {
commonTest.dependencies {
implementation(libs.findLibrary("kotlin-test").get())
}
}
} }
// Relax allWarningsAsErrors for KLIB-merging metadata tasks. KotlinCompileCommon // KMP metadata tasks can surface duplicate KLIB unique_name warnings from upstream
// aggregates dependency KLIBs and surfaces upstream "duplicated unique_name" // Compose/AndroidX artifacts. Keep warnings-as-errors for source compilation, but
// resolver warnings caused by androidx.lifecycle 2.10.0 (Android-only) and // do not fail metadata aggregation on dependency metadata warnings.
// org.jetbrains.androidx.lifecycle 2.10.0 (CMP) co-publishing artifacts with tasks.withType<KotlinCompilationTask<*>>().configureEach {
// matching KLIB unique_names. This is an upstream Compose-Multiplatform 1.10 +
// lifecycle 2.10 ecosystem condition (KT-62515-style), not actionable in our
// source — so we keep -Werror on real source compilation tasks but disable it
// for the metadata-aggregation step where no user code is being compiled.
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompileCommon>().configureEach {
compilerOptions { compilerOptions {
allWarningsAsErrors.set(false) allWarningsAsErrors.set(!name.endsWith("KotlinMetadata"))
}
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinNativeCompile>().configureEach {
if (name.endsWith("KotlinMetadata")) {
compilerOptions {
allWarningsAsErrors.set(false)
}
} }
} }

View File

@@ -18,23 +18,3 @@ spotless {
trimTrailingWhitespace() trimTrailingWhitespace()
} }
} }
// D-11 redundancy guard: if a module applies recipe.quality alongside a Kotlin plugin
// (multiplatform or jvm), ensure allWarningsAsErrors still applies even if the module
// build didn't already configure it. Guarded with plugins.withId so this plugin is
// safely composable even when applied alone (no KotlinCompilationTask type available
// on the classpath until a Kotlin plugin is present).
plugins.withId("org.jetbrains.kotlin.multiplatform") {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
compilerOptions {
allWarningsAsErrors.set(!name.endsWith("KotlinMetadata"))
}
}
}
plugins.withId("org.jetbrains.kotlin.jvm") {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
compilerOptions {
allWarningsAsErrors.set(true)
}
}
}

View File

@@ -1,9 +1,7 @@
plugins { plugins {
// this is necessary to avoid the plugins to be loaded multiple times // this is necessary to avoid the plugins to be loaded multiple times in each subproject's classloader
// in each subproject's classloader
alias(libs.plugins.androidApplication) apply false alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.composeHotReload) apply false
alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinJvm) apply false alias(libs.plugins.kotlinJvm) apply false

View File

@@ -1,18 +1,14 @@
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
plugins { plugins {
// AGP must apply before recipe.kotlin.multiplatform — the latter calls androidTarget(),
// which requires the Android Gradle Plugin to already be on the project.
alias(libs.plugins.androidApplication) alias(libs.plugins.androidApplication)
id("recipe.kotlin.multiplatform") id("recipe.kotlin.multiplatform")
alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeHotReload)
alias(libs.plugins.kotlinSerialization) alias(libs.plugins.kotlinSerialization)
id("recipe.quality") id("recipe.quality")
} }
// `group` is referenced by Compose Resources package naming — the
// `compose.resources { packageOfResClass }` block below pins the historical package
// regardless, but keep `group` set explicitly. Gradle artifact metadata only.
group = "dev.ulfrx.recipe" group = "dev.ulfrx.recipe"
version = "1.0.0" version = "1.0.0"
@@ -57,8 +53,21 @@ android {
} }
kotlin { kotlin {
androidTarget()
iosArm64()
iosSimulatorArm64()
listOf(targets.getByName("iosArm64"), targets.getByName("iosSimulatorArm64")).forEach { target ->
(target as KotlinNativeTarget).binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
implementation(projects.shared)
implementation(project.dependencies.platform(libs.koin.bom)) implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core) implementation(libs.koin.core)
implementation(libs.koin.compose) implementation(libs.koin.compose)
@@ -72,13 +81,7 @@ kotlin {
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.runtimeCompose)
// `api` so `:shared` types flow through to the exported ObjC
// framework headers when the iOS shell needs them.
api(projects.shared)
// Phase 2: Ktor client + serialization + secure settings (D-13, D-16, D-17).
// The MPP variant of `ktor-serialization-kotlinx-json` is required here; the
// server module keeps the `-jvm` variant via `libs.ktor.serializationKotlinxJson`.
implementation(libs.ktor.clientCore) implementation(libs.ktor.clientCore)
implementation(libs.ktor.clientAuth) implementation(libs.ktor.clientAuth)
implementation(libs.ktor.clientContentNegotiation) implementation(libs.ktor.clientContentNegotiation)
@@ -86,42 +89,23 @@ kotlin {
implementation(libs.ktor.serializationKotlinxJsonMpp) implementation(libs.ktor.serializationKotlinxJsonMpp)
implementation(libs.kotlinx.serializationJson) implementation(libs.kotlinx.serializationJson)
implementation(libs.multiplatform.settings) implementation(libs.multiplatform.settings)
implementation(libs.multiplatform.settings.coroutines) implementation(libs.lokksmith.compose)
} }
commonTest.dependencies { commonTest.dependencies {
// 02-07: kotlinx.coroutines.test.runTest is the multiplatform-safe implementation(libs.kotlin.test)
// alternative to runBlocking (which is JVM/Native-only and breaks the
// wasmJs test target). All commonTest coroutine tests use it.
implementation(libs.kotlinx.coroutinesTest) implementation(libs.kotlinx.coroutinesTest)
} }
androidMain.dependencies { androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.koin.android) implementation(libs.koin.android)
// Phase 2 Android: AndroidX Security Crypto for the SecureAuthStateStore
// actual (D-13). EncryptedSharedPreferences is accepted technical debt per
// Open Question #1; the Keystore-backed implementation can replace it
// without touching AuthSession.
implementation(libs.androidx.security.crypto)
implementation(libs.lokksmith.core)
implementation(libs.ktor.clientOkhttp) implementation(libs.ktor.clientOkhttp)
} }
iosMain.dependencies { iosMain.dependencies {
// Phase 2 iOS: Darwin engine for Ktor. Lokksmith handles the native // Darwin engine for Ktor. Lokksmith handles the native
// ASWebAuthenticationSession integration directly from Kotlin. // ASWebAuthenticationSession integration directly from Kotlin.
implementation(libs.lokksmith.core)
implementation(libs.ktor.clientDarwin) implementation(libs.ktor.clientDarwin)
} }
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
// Phase 2 Desktop: CIO is the JVM Ktor engine for the dev-mode auth stub
// (D-02). The full stub lives in Plan 02-04; this just makes the engine
// available so `composeApp:run` still compiles in Phase 2.
implementation(libs.ktor.clientCio)
}
} }
} }
@@ -129,10 +113,6 @@ dependencies {
debugImplementation(libs.compose.uiTooling) debugImplementation(libs.compose.uiTooling)
} }
// `group = "dev.ulfrx.recipe"` shifts the Compose Resources `Res` class package from
// `recipe.composeapp.generated.resources` to `dev.ulfrx.recipe.composeapp.generated.resources`,
// breaking the Phase 1 `App.kt` import. Lock the historical package so module-naming
// changes don't cascade into UI code.
compose.resources { compose.resources {
packageOfResClass = "recipe.composeapp.generated.resources" packageOfResClass = "recipe.composeapp.generated.resources"
} }

View File

@@ -1,9 +1,23 @@
package dev.ulfrx.recipe.auth package dev.ulfrx.recipe.auth
import android.content.Context
import com.russhwolf.settings.Settings
import com.russhwolf.settings.SharedPreferencesSettings
import dev.lokksmith.Lokksmith
import dev.lokksmith.SingletonLokksmithProvider
import dev.lokksmith.createLokksmith
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
val androidAuthModule = val androidAuthModule =
module { module {
single { createAndroidLokksmith(androidContext().applicationContext) } single<Lokksmith> {
createLokksmith(androidContext().applicationContext).also { lokksmith ->
SingletonLokksmithProvider.set(lokksmith)
}
}
single<Settings> {
val prefs = androidContext().applicationContext.getSharedPreferences("recipe_auth_state", Context.MODE_PRIVATE)
SharedPreferencesSettings(prefs)
}
} }

View File

@@ -1,76 +0,0 @@
@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

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

View File

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

@@ -2,24 +2,17 @@ package dev.ulfrx.recipe.auth
import dev.lokksmith.Lokksmith import dev.lokksmith.Lokksmith
import dev.lokksmith.client.Client import dev.lokksmith.client.Client
import dev.lokksmith.client.InternalClient import dev.lokksmith.client.request.flow.AuthFlow
import dev.lokksmith.client.request.flow.AuthFlowResultProvider 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.authorizationCode.AuthorizationCodeFlow
import dev.lokksmith.client.request.flow.endSession.EndSessionFlow import dev.lokksmith.client.request.flow.endSession.EndSessionFlow
import dev.lokksmith.client.request.parameter.Scope import dev.lokksmith.client.request.parameter.Scope
import dev.lokksmith.discoveryUrl import dev.lokksmith.discoveryUrl
import dev.lokksmith.id import dev.lokksmith.id
import dev.ulfrx.recipe.shared.Constants 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_CLIENT_KEY = "recipe-app"
internal const val LOKKSMITH_AUTH_STATE_JSON = "lokksmith:$LOKKSMITH_CLIENT_KEY" internal const val LOKKSMITH_AUTH_STATE_MARKER = "lokksmith:$LOKKSMITH_CLIENT_KEY"
internal suspend fun Lokksmith.recipeClient(): Client = internal suspend fun Lokksmith.recipeClient(): Client =
getOrCreate(LOKKSMITH_CLIENT_KEY) { getOrCreate(LOKKSMITH_CLIENT_KEY) {
@@ -27,7 +20,7 @@ internal suspend fun Lokksmith.recipeClient(): Client =
discoveryUrl = Constants.OIDC_ISSUER + ".well-known/openid-configuration" discoveryUrl = Constants.OIDC_ISSUER + ".well-known/openid-configuration"
} }
internal fun Client.recipeAuthorizationCodeFlow(): dev.lokksmith.client.request.flow.AuthFlow = internal fun Client.recipeAuthorizationCodeFlow(): AuthFlow =
authorizationCodeFlow( authorizationCodeFlow(
AuthorizationCodeFlow.Request( AuthorizationCodeFlow.Request(
redirectUri = Constants.OIDC_REDIRECT_URI, redirectUri = Constants.OIDC_REDIRECT_URI,
@@ -35,49 +28,15 @@ internal fun Client.recipeAuthorizationCodeFlow(): dev.lokksmith.client.request.
), ),
) )
internal fun Client.recipeEndSessionFlow(): dev.lokksmith.client.request.flow.AuthFlow? = internal fun Client.recipeEndSessionFlow(): AuthFlow? =
endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI)) 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 { internal suspend fun Client.toOidcSuccess(): OidcResult.Success {
var freshTokens: Client.Tokens? = null var freshTokens: Client.Tokens? = null
runWithTokens { tokens -> freshTokens = tokens } runWithTokens { tokens -> freshTokens = tokens }
val tokens = checkNotNull(freshTokens) { "Lokksmith returned no tokens" } val tokens = checkNotNull(freshTokens) { "Lokksmith returned no tokens" }
return OidcResult.Success( return OidcResult.Success(
authStateJson = LOKKSMITH_AUTH_STATE_JSON, authStateJson = LOKKSMITH_AUTH_STATE_MARKER,
accessToken = tokens.accessToken.token, accessToken = tokens.accessToken.token,
idToken = tokens.idToken.raw, idToken = tokens.idToken.raw,
expiresAtEpochMillis = tokens.accessToken.expiresAt?.let { it * 1_000L } ?: 0L, expiresAtEpochMillis = tokens.accessToken.expiresAt?.let { it * 1_000L } ?: 0L,

View File

@@ -1,24 +1,52 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth 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 * Lokksmith owns PKCE, state, nonce, token storage, refresh, and end-session
* + PKCE. Login requests must be public PKCE-compatible OIDC requests with * (D-06, D-16, D-19, D-20). This class only orchestrates: build the flow request
* exactly these scopes: `openid profile email offline_access` (D-06). * and hand its [dev.lokksmith.client.request.flow.AuthFlow.Initiation] to the
* Lokksmith owns state and nonce verification. * 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 * Logout still clears local state if remote end-session fails so users are never
* auth-state marker for persistence (D-16). Logout must use RP-initiated * trapped in a stale session.
* 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() { class OidcClient(
suspend fun login(): OidcResult 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 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 * The actual OIDC tokens (access, refresh, id) live in Lokksmith's own platform
* (D-13): iOS Keychain and Android encrypted/Keystore-backed storage. Do not use * storage (Keychain on iOS, encrypted store on Android). This class only persists
* no-arg or default insecure settings implementations for auth state. The stored * the literal marker constant ([LOKKSMITH_AUTH_STATE_MARKER]) so [AuthSession]
* value is global to the install and must be deleted on logout (D-15). * 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() { class SecureAuthStateStore(
fun read(): String? 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment 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.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -35,6 +38,8 @@ import recipe.composeapp.generated.resources.auth_sign_in_button
@Composable @Composable
fun LoginScreen(viewModel: LoginViewModel) { fun LoginScreen(viewModel: LoginViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val launcher = rememberAuthFlowLauncher()
val browser = remember(launcher) { ComposeAuthBrowser(launcher) }
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -55,7 +60,7 @@ fun LoginScreen(viewModel: LoginViewModel) {
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Button( Button(
onClick = { viewModel.onSignInClick() }, onClick = { viewModel.onSignInClick(browser) },
enabled = !state.isLoading, enabled = !state.isLoading,
) { ) {
if (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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.ulfrx.recipe.auth.AuthBrowser
import dev.ulfrx.recipe.auth.AuthLoginResult import dev.ulfrx.recipe.auth.AuthLoginResult
import dev.ulfrx.recipe.auth.AuthSession import dev.ulfrx.recipe.auth.AuthSession
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -26,9 +27,9 @@ data class LoginScreenState(
) )
/** /**
* Wraps [AuthSession] to drive the LoginScreen. Method-per-action: [onSignInClick] is the * Wraps [AuthSession] to drive the LoginScreen. The screen owns the
* single entry point. Cancellation/network/unknown failures map to user-facing string * Lokksmith [AuthBrowser] (via `rememberAuthFlowLauncher` + [dev.ulfrx.recipe.auth.ComposeAuthBrowser])
* resources per `02-UI-SPEC.md` § Copywriting Contract. * 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 * Returns the launched [Job] from [onSignInClick] so tests can deterministically await
* completion without dragging a TestDispatcher into commonTest. * completion without dragging a TestDispatcher into commonTest.
@@ -39,12 +40,12 @@ class LoginViewModel(
private val _state = MutableStateFlow(LoginScreenState()) private val _state = MutableStateFlow(LoginScreenState())
val state: StateFlow<LoginScreenState> = _state.asStateFlow() 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 — // Clear any previous inline error and enter the loading state before suspending —
// contract from UI-SPEC: tapping the button again clears stale error text. // contract from UI-SPEC: tapping the button again clears stale error text.
_state.value = LoginScreenState(isLoading = true, errorKey = null) _state.value = LoginScreenState(isLoading = true, errorKey = null)
return viewModelScope.launch { return viewModelScope.launch {
val result = authSession.login() val result = authSession.login(browser)
_state.value = _state.value =
LoginScreenState( LoginScreenState(
isLoading = false, isLoading = false,

View File

@@ -12,7 +12,10 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment 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.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -30,6 +33,8 @@ fun PostLoginPlaceholderScreen(
user: User, user: User,
viewModel: PostLoginViewModel, viewModel: PostLoginViewModel,
) { ) {
val launcher = rememberAuthFlowLauncher()
val browser = remember(launcher) { ComposeAuthBrowser(launcher) }
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
@@ -49,7 +54,7 @@ fun PostLoginPlaceholderScreen(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = { viewModel.onSignOutClick() }) { OutlinedButton(onClick = { viewModel.onSignOutClick(browser) }) {
Text(text = stringResource(Res.string.auth_sign_out_button)) 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.ulfrx.recipe.auth.AuthBrowser
import dev.ulfrx.recipe.auth.AuthSession import dev.ulfrx.recipe.auth.AuthSession
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* Trivial in Phase 2 — exists so Phase 3's `HouseholdGate` follows the same VM pattern. * 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 * 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( class PostLoginViewModel(
private val authSession: AuthSession, private val authSession: AuthSession,
) : ViewModel() { ) : ViewModel() {
fun onSignOutClick() { fun onSignOutClick(browser: AuthBrowser) {
viewModelScope.launch { 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.Res
@@ -25,6 +26,7 @@ import recipe.composeapp.generated.resources.auth_app_name
* color flash. * color flash.
*/ */
@Composable @Composable
@Preview
fun SplashScreen() { fun SplashScreen() {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),

View File

@@ -1,5 +1,7 @@
package dev.ulfrx.recipe.auth package dev.ulfrx.recipe.auth
import dev.lokksmith.client.request.flow.AuthFlow
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.ulfrx.recipe.shared.dto.User import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlin.test.Test import kotlin.test.Test
@@ -38,7 +40,7 @@ class AuthSessionTest {
val meClient = FakeMeClient(user = USER) val meClient = FakeMeClient(user = USER)
val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient) val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient)
val result = session.login() val result = session.login(NoopBrowser)
assertEquals(AuthLoginResult.Success, result) assertEquals(AuthLoginResult.Success, result)
assertEquals(AUTH_STATE_JSON, store.value) assertEquals(AUTH_STATE_JSON, store.value)
@@ -119,7 +121,7 @@ class AuthSessionTest {
val oidcClient = FakeOidcClient() val oidcClient = FakeOidcClient()
val session = newSession(store = store, oidcClient = oidcClient) val session = newSession(store = store, oidcClient = oidcClient)
session.logout() session.logout(NoopBrowser)
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls) assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
assertNull(store.value) assertNull(store.value)
@@ -134,7 +136,7 @@ class AuthSessionTest {
val oidcClient = FakeOidcClient(logoutThrows = true) val oidcClient = FakeOidcClient(logoutThrows = true)
val session = newSession(store = store, oidcClient = oidcClient) val session = newSession(store = store, oidcClient = oidcClient)
session.logout() session.logout(NoopBrowser)
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls) assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
assertNull(store.value) assertNull(store.value)
@@ -152,7 +154,7 @@ class AuthSessionTest {
oidcClient = FakeOidcClient(loginResult = OidcResult.Cancelled), oidcClient = FakeOidcClient(loginResult = OidcResult.Cancelled),
) )
val result = session.login() val result = session.login(NoopBrowser)
assertEquals(AuthLoginResult.Cancelled, result) assertEquals(AuthLoginResult.Cancelled, result)
assertNull(store.value) assertNull(store.value)
@@ -171,6 +173,11 @@ class AuthSessionTest {
meClient = meClient, meClient = meClient,
) )
private object NoopBrowser : AuthBrowser {
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result =
AuthFlowResultProvider.Result.Undefined
}
private class FakeAuthStateStore( private class FakeAuthStateStore(
var value: String? = null, var value: String? = null,
) : AuthStateStore { ) : AuthStateStore {
@@ -194,7 +201,7 @@ class AuthSessionTest {
val refreshCalls = mutableListOf<String>() val refreshCalls = mutableListOf<String>()
val logoutCalls = mutableListOf<String>() val logoutCalls = mutableListOf<String>()
override suspend fun login(): OidcResult { override suspend fun login(browser: AuthBrowser): OidcResult {
loginCalls += Unit loginCalls += Unit
return loginResult return loginResult
} }
@@ -204,7 +211,7 @@ class AuthSessionTest {
return refreshResult return refreshResult
} }
override suspend fun logout(authStateJson: String) { override suspend fun logout(authStateJson: String, browser: AuthBrowser) {
logoutCalls += authStateJson logoutCalls += authStateJson
if (logoutThrows) { if (logoutThrows) {
error("end-session failed") error("end-session failed")

View File

@@ -1,13 +1,49 @@
package dev.ulfrx.recipe.auth package dev.ulfrx.recipe.auth
import com.russhwolf.settings.Settings
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNull import kotlin.test.assertNull
private class InMemorySettings : Settings {
private val map = mutableMapOf<String, Any>()
override val keys: Set<String> get() = map.keys
override val size: Int get() = map.size
override fun clear() = map.clear()
override fun remove(key: String) { map.remove(key) }
override fun hasKey(key: String): Boolean = map.containsKey(key)
override fun putInt(key: String, value: Int) { map[key] = value }
override fun getInt(key: String, defaultValue: Int): Int = (map[key] as? Int) ?: defaultValue
override fun getIntOrNull(key: String): Int? = map[key] as? Int
override fun putLong(key: String, value: Long) { map[key] = value }
override fun getLong(key: String, defaultValue: Long): Long = (map[key] as? Long) ?: defaultValue
override fun getLongOrNull(key: String): Long? = map[key] as? Long
override fun putString(key: String, value: String) { map[key] = value }
override fun getString(key: String, defaultValue: String): String = (map[key] as? String) ?: defaultValue
override fun getStringOrNull(key: String): String? = map[key] as? String
override fun putFloat(key: String, value: Float) { map[key] = value }
override fun getFloat(key: String, defaultValue: Float): Float = (map[key] as? Float) ?: defaultValue
override fun getFloatOrNull(key: String): Float? = map[key] as? Float
override fun putDouble(key: String, value: Double) { map[key] = value }
override fun getDouble(key: String, defaultValue: Double): Double = (map[key] as? Double) ?: defaultValue
override fun getDoubleOrNull(key: String): Double? = map[key] as? Double
override fun putBoolean(key: String, value: Boolean) { map[key] = value }
override fun getBoolean(key: String, defaultValue: Boolean): Boolean = (map[key] as? Boolean) ?: defaultValue
override fun getBooleanOrNull(key: String): Boolean? = map[key] as? Boolean
}
class SecureAuthStateStoreContractTest { class SecureAuthStateStoreContractTest {
@Test @Test
fun writeOverwritesPreviousValueAndReadReturnsLatest() { fun writeOverwritesPreviousValueAndReadReturnsLatest() {
val store = SecureAuthStateStore() val store = SecureAuthStateStore(InMemorySettings())
store.write("""{"refresh_token":"first"}""") store.write("""{"refresh_token":"first"}""")
store.write("""{"refresh_token":"second"}""") store.write("""{"refresh_token":"second"}""")
@@ -17,7 +53,7 @@ class SecureAuthStateStoreContractTest {
@Test @Test
fun clearRemovesStoredValue() { fun clearRemovesStoredValue() {
val store = SecureAuthStateStore() val store = SecureAuthStateStore(InMemorySettings())
store.write("""{"refresh_token":"stored"}""") store.write("""{"refresh_token":"stored"}""")
store.clear() store.clear()

View File

@@ -1,5 +1,8 @@
package dev.ulfrx.recipe.ui.screens.auth package dev.ulfrx.recipe.ui.screens.auth
import dev.lokksmith.client.request.flow.AuthFlow
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.ulfrx.recipe.auth.AuthBrowser
import dev.ulfrx.recipe.auth.AuthSession import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthStateStore import dev.ulfrx.recipe.auth.AuthStateStore
import dev.ulfrx.recipe.auth.MeGateway import dev.ulfrx.recipe.auth.MeGateway
@@ -24,7 +27,7 @@ class LoginViewModelTest {
val session = newSession(loginResult = OidcResult.Cancelled) val session = newSession(loginResult = OidcResult.Cancelled)
val viewModel = LoginViewModel(session) val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join() viewModel.onSignInClick(NoopBrowser).join()
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey) assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading) assertEquals(false, viewModel.state.value.isLoading)
@@ -36,7 +39,7 @@ class LoginViewModelTest {
val session = newSession(loginResult = OidcResult.NetworkError) val session = newSession(loginResult = OidcResult.NetworkError)
val viewModel = LoginViewModel(session) val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join() viewModel.onSignInClick(NoopBrowser).join()
assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey) assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading) assertEquals(false, viewModel.state.value.isLoading)
@@ -48,7 +51,7 @@ class LoginViewModelTest {
val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed")) val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed"))
val viewModel = LoginViewModel(session) val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join() viewModel.onSignInClick(NoopBrowser).join()
assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey) assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading) assertEquals(false, viewModel.state.value.isLoading)
@@ -69,7 +72,7 @@ class LoginViewModelTest {
) )
val viewModel = LoginViewModel(session) val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join() viewModel.onSignInClick(NoopBrowser).join()
assertNull(viewModel.state.value.errorKey) assertNull(viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading) assertEquals(false, viewModel.state.value.isLoading)
@@ -85,23 +88,24 @@ class LoginViewModelTest {
val queue = mutableListOf<OidcResult>(OidcResult.Cancelled) val queue = mutableListOf<OidcResult>(OidcResult.Cancelled)
val oidc = val oidc =
object : OidcClientGateway { object : OidcClientGateway {
override suspend fun login(): OidcResult = if (queue.isNotEmpty()) queue.removeAt(0) else gate.await() override suspend fun login(browser: AuthBrowser): OidcResult =
if (queue.isNotEmpty()) queue.removeAt(0) else gate.await()
override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used") override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used")
override suspend fun logout(authStateJson: String) {} override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
} }
val session = AuthSession(oidc, FakeAuthStateStore(), FakeMeClient(USER)) val session = AuthSession(oidc, FakeAuthStateStore(), FakeMeClient(USER))
val viewModel = LoginViewModel(session) val viewModel = LoginViewModel(session)
// First attempt: error seeded. // First attempt: error seeded.
viewModel.onSignInClick().join() viewModel.onSignInClick(NoopBrowser).join()
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey) assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
// Second attempt: launching the job sets loading=true + clears error // Second attempt: launching the job sets loading=true + clears error
// BEFORE suspending. onSignInClick() does that synchronously before // BEFORE suspending. onSignInClick() does that synchronously before
// returning the launched Job, so we can assert immediately. // returning the launched Job, so we can assert immediately.
val job = viewModel.onSignInClick() val job = viewModel.onSignInClick(NoopBrowser)
assertTrue(viewModel.state.value.isLoading) assertTrue(viewModel.state.value.isLoading)
assertNull(viewModel.state.value.errorKey) assertNull(viewModel.state.value.errorKey)
@@ -124,6 +128,11 @@ class LoginViewModelTest {
meClient = meClient, meClient = meClient,
) )
private object NoopBrowser : AuthBrowser {
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result =
AuthFlowResultProvider.Result.Undefined
}
private class FakeAuthStateStore( private class FakeAuthStateStore(
var value: String? = null, var value: String? = null,
) : AuthStateStore { ) : AuthStateStore {
@@ -142,11 +151,11 @@ class LoginViewModelTest {
private val loginResult: OidcResult = OidcResult.AuthError("login not configured"), private val loginResult: OidcResult = OidcResult.AuthError("login not configured"),
private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"), private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"),
) : OidcClientGateway { ) : OidcClientGateway {
override suspend fun login(): OidcResult = loginResult override suspend fun login(browser: AuthBrowser): OidcResult = loginResult
override suspend fun refresh(authStateJson: String): OidcResult = refreshResult override suspend fun refresh(authStateJson: String): OidcResult = refreshResult
override suspend fun logout(authStateJson: String) {} override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
} }
private class FakeMeClient( private class FakeMeClient(

View File

@@ -1,8 +1,30 @@
@file:OptIn(
com.russhwolf.settings.ExperimentalSettingsApi::class,
com.russhwolf.settings.ExperimentalSettingsImplementation::class,
kotlinx.cinterop.ExperimentalForeignApi::class,
)
package dev.ulfrx.recipe.auth package dev.ulfrx.recipe.auth
import com.russhwolf.settings.KeychainSettings
import com.russhwolf.settings.Settings
import dev.lokksmith.Lokksmith
import dev.lokksmith.SingletonLokksmithProvider
import dev.lokksmith.createLokksmith
import org.koin.dsl.module import org.koin.dsl.module
import platform.Security.kSecAttrAccessible
import platform.Security.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
val iosAuthModule = val iosAuthModule =
module { module {
single { createIosLokksmith() } single<Lokksmith> {
createLokksmith().also { lokksmith ->
SingletonLokksmithProvider.set(lokksmith)
}
}
single<Settings> {
KeychainSettings(
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
)
}
} }

View File

@@ -1,98 +0,0 @@
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

@@ -1,55 +0,0 @@
@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

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

@@ -1,17 +0,0 @@
@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

@@ -1,19 +0,0 @@
package dev.ulfrx.recipe
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import dev.ulfrx.recipe.di.initKoin
import dev.ulfrx.recipe.logging.configureLogging
fun main() {
configureLogging()
initKoin()
application {
Window(
onCloseRequest = ::exitApplication,
title = "recipe",
) {
App()
}
}
}

View File

@@ -1,11 +0,0 @@
@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

@@ -1,17 +0,0 @@
@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

@@ -1,15 +0,0 @@
package dev.ulfrx.recipe
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import dev.ulfrx.recipe.di.initKoin
import dev.ulfrx.recipe.logging.configureLogging
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
configureLogging()
initKoin()
ComposeViewport {
App()
}
}

View File

@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>recipe</title>
<link type="text/css" rel="stylesheet" href="styles.css">
</head>
<body style="text-align: center; align-content: center">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 50 50" role="presentation">
<circle cx="25" cy="25" r="20" stroke="#ccc" stroke-width="4" fill="none"/>
<circle cx="25" cy="25" r="20" stroke="#333" stroke-width="4" fill="none" stroke-linecap="round"
stroke-dasharray="90 125">
<animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="1s"
repeatCount="indefinite"/>
</circle>
</svg>
<script type="application/javascript" src="composeApp.js"></script>
</body>
</html>

View File

@@ -1,7 +0,0 @@
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}

View File

@@ -4,18 +4,11 @@ android-compileSdk = "36"
android-minSdk = "24" android-minSdk = "24"
android-targetSdk = "36" android-targetSdk = "36"
androidx-activity = "1.13.0" androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-core = "1.18.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0" androidx-lifecycle = "2.10.0"
androidx-security-crypto = "1.1.0"
androidx-testExt = "1.3.0"
composeHotReload = "1.0.0"
composeMultiplatform = "1.10.3" composeMultiplatform = "1.10.3"
exposed = "0.55.0" exposed = "0.55.0"
flyway = "12.4.0" flyway = "12.4.0"
hikari = "6.2.1" hikari = "6.2.1"
junit = "4.13.2"
kermit = "2.1.0" kermit = "2.1.0"
koin = "4.2.1" koin = "4.2.1"
kotlin = "2.3.20" kotlin = "2.3.20"
@@ -32,15 +25,10 @@ testcontainers = "1.21.4"
[libraries] [libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlin-testJunit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
# kotlinx.serialization (shared DTOs — D-27) # kotlinx.serialization (shared DTOs — D-27)
kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
@@ -93,11 +81,9 @@ ktor-clientDarwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor
ktor-clientCio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-clientCio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-serializationKotlinxJsonMpp = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-serializationKotlinxJsonMpp = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
# Phase 2 — Client: Lokksmith OIDC + Android secure storage + multiplatform-settings (D-01, D-13, AUTH-02) # Phase 2 — Client: Lokksmith OIDC (Compose integration pulls core transitively) + multiplatform-settings (D-01, D-13, AUTH-02)
lokksmith-core = { module = "dev.lokksmith:lokksmith-core", version.ref = "lokksmith" } lokksmith-compose = { module = "dev.lokksmith:lokksmith-compose", version.ref = "lokksmith" }
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security-crypto" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" } multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformSettings" }
# Phase 2 — Server: Exposed DSL + Hikari (D-26) # Phase 2 — Server: Exposed DSL + Hikari (D-26)
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
@@ -112,7 +98,6 @@ testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter", ve
[plugins] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" }
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

View File

@@ -17,6 +17,10 @@ kotlin {
} }
} }
tasks.test {
useJUnitPlatform()
}
application { application {
mainClass.set("dev.ulfrx.recipe.ApplicationKt") mainClass.set("dev.ulfrx.recipe.ApplicationKt")
@@ -36,7 +40,6 @@ dependencies {
implementation(libs.postgresql) implementation(libs.postgresql)
implementation(projects.shared) implementation(projects.shared)
// Phase 2: Ktor auth + JWT validation + observability (D-21..D-23).
implementation(libs.ktor.serverAuth) implementation(libs.ktor.serverAuth)
implementation(libs.ktor.serverAuthJwt) implementation(libs.ktor.serverAuthJwt)
implementation(libs.ktor.serverCallLogging) implementation(libs.ktor.serverCallLogging)
@@ -49,10 +52,8 @@ dependencies {
implementation(libs.hikari) implementation(libs.hikari)
testImplementation(libs.ktor.serverTestHost) testImplementation(libs.ktor.serverTestHost)
testImplementation(libs.kotlin.testJunit) testImplementation(libs.kotlin.testJunit5)
// Phase 2: Testcontainers for JIT user provisioning + JWT auth integration tests
// (AUTH-03, AUTH-06). Wired here so Plan 02-02 only needs to write tests.
testImplementation(libs.testcontainers.postgresql) testImplementation(libs.testcontainers.postgresql)
testImplementation(libs.testcontainers.junit.jupiter) testImplementation(libs.testcontainers.junit.jupiter)
} }

View File

@@ -15,9 +15,11 @@ import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.flywaydb.core.Flyway import org.flywaydb.core.Flyway
import org.junit.AfterClass import org.junit.jupiter.api.AfterAll
import org.junit.BeforeClass import org.junit.jupiter.api.BeforeAll
import org.testcontainers.containers.PostgreSQLContainer import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotEquals import kotlin.test.assertNotEquals
@@ -34,16 +36,18 @@ import org.jetbrains.exposed.sql.Database as ExposedDatabase
* process before any test executes; Exposed is connected through Hikari to * process before any test executes; Exposed is connected through Hikari to
* the container. * the container.
*/ */
@Testcontainers
class MeRouteTest { class MeRouteTest {
companion object { companion object {
@Container
@JvmStatic
private val postgres = PostgreSQLContainer("postgres:16") private val postgres = PostgreSQLContainer("postgres:16")
private lateinit var dataSource: HikariDataSource private lateinit var dataSource: HikariDataSource
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@JvmStatic @JvmStatic
@BeforeClass @BeforeAll
fun setUpClass() { fun setUpClass() {
postgres.start()
Flyway Flyway
.configure() .configure()
.dataSource(postgres.jdbcUrl, postgres.username, postgres.password) .dataSource(postgres.jdbcUrl, postgres.username, postgres.password)
@@ -65,10 +69,9 @@ class MeRouteTest {
} }
@JvmStatic @JvmStatic
@AfterClass @AfterAll
fun tearDownClass() { fun tearDownClass() {
dataSource.close() dataSource.close()
postgres.stop()
} }
} }

View File

@@ -10,6 +10,11 @@ plugins {
kotlin { kotlin {
explicitApi() explicitApi()
androidTarget()
iosArm64()
iosSimulatorArm64()
jvm()
// No iOS framework here — composeApp's umbrella `ComposeApp.framework` // No iOS framework here — composeApp's umbrella `ComposeApp.framework`
// transitively exports shared. Producing a second framework would double-bundle // transitively exports shared. Producing a second framework would double-bundle
// the Kotlin stdlib at link time (PITFALL: duplicate-framework collision). // the Kotlin stdlib at link time (PITFALL: duplicate-framework collision).