Compare commits

..

2 Commits

Author SHA1 Message Date
995bdd5ae6 Add authentication 2026-04-29 20:54:13 +02:00
015d8d51d0 Wire project infrastructure 2026-04-29 20:54:01 +02:00
33 changed files with 375 additions and 865 deletions

View File

@@ -143,7 +143,7 @@ A mobile-first meal planning app for a small household — pick recipes for the
| Image loading: Coil 3 (`io.coil-kt.coil3:coil-compose`) | First-class Compose Multiplatform support; modern API | — Pending |
| Key-value settings: `com.russhwolf:multiplatform-settings` | Small prefs (last tab, theme toggles) that don't belong in SQLDelight | — Pending |
| Glass/blur effects: Haze (`dev.chrisbanes.haze:haze`) | Purpose-built for glass UI in CMP; handles content capture + efficient re-blur; multiplatform | — Pending |
| Mobile OIDC: AppAuth on both Android (Kotlin actual) and iOS (Swift bridge over AppAuth-iOS via SwiftPM, invoked from `iosMain` through Koin), exposed via KMP interface | Platform-native OAuth flows; AppAuth is mature on both platforms. iOS dropped CocoaPods on 2026-04-28 (see `.planning/phases/02-authentication-foundation/DECISION-drop-cocoapods.md`) — `embedAndSign` for the shared framework + SwiftPM for AppAuth, mutually exclusive Xcode build modes resolved | — Pending |
| Mobile OIDC: Lokksmith on Android/iOS, exposed via the KMP `OidcClient` interface | Keeps OIDC browser flow, PKCE, state/nonce, refresh, and end-session in Kotlin Multiplatform; removes the Swift AppAuth bridge and SwiftPM package from the iOS shell while still using ASWebAuthenticationSession underneath on iOS | — Pending |
### Server tech stack

View File

@@ -79,8 +79,8 @@ Plans:
- [x] 02-01-PLAN.md — Shared auth contracts, dependency aliases, Authentik setup docs, and source audit
- [x] 02-02-PLAN.md — Server JWT validation, JWKS hardening, JIT users, and `/api/v1/me`
- [ ] 02-03-PLAN.md — Common OIDC/store contracts, JVM/Wasm actuals, and store contract test
- [ ] 02-04-PLAN.md — Android AppAuth actual, Android secure AuthState store, and manifest callback
- [ ] 02-05-PLAN.md — iOS AppAuth actual, iOS Keychain store, URL scheme, Swift callback, and Podfile
- [ ] 02-04-PLAN.md — Android OIDC actual, Android secure AuthState store, and manifest callback
- [ ] 02-05-PLAN.md — iOS OIDC actual, iOS Keychain store, and URL scheme callback
- [ ] 02-06-PLAN.md — AuthSession state machine, bearer HTTP client, refresh/logout behavior, and Koin wiring
- [ ] 02-07-PLAN.md — Compose auth gate UI, Polish resource strings, and iOS Authentik UAT
**UI hint:** yes

View File

@@ -38,7 +38,7 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
- Images: Coil 3 (`io.coil-kt.coil3:coil-compose`)
- Settings/KV: `com.russhwolf:multiplatform-settings`
- Glass/blur: Haze (`dev.chrisbanes.haze:haze`)
- Mobile OIDC: AppAuth on Android; ASWebAuthenticationSession wrapper on iOS (KMP interface)
- Mobile OIDC: Lokksmith on Android/iOS (KMP interface; ASWebAuthenticationSession underneath on iOS)
**Server (`server/`):**
- Ktor Server 3.x on the user's homelab (alongside Authentik)

View File

@@ -38,7 +38,7 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
- Images: Coil 3 (`io.coil-kt.coil3:coil-compose`)
- Settings/KV: `com.russhwolf:multiplatform-settings`
- Glass/blur: Haze (`dev.chrisbanes.haze:haze`)
- Mobile OIDC: AppAuth on both Android (Kotlin actual) and iOS (Swift `AuthBridge` over AppAuth-iOS via SwiftPM, called from `iosMain` through Koin); KMP interface in `commonMain`. iOS dropped CocoaPods on 2026-04-28 — see `.planning/phases/02-authentication-foundation/DECISION-drop-cocoapods.md`
- Mobile OIDC: Lokksmith on Android/iOS through the KMP `OidcClient` interface. iOS uses ASWebAuthenticationSession underneath without a Swift auth bridge.
**Server (`server/`):**
- Ktor Server 3.x on the user's homelab (alongside Authentik)

View File

@@ -29,8 +29,7 @@ kotlin {
// :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
// re-exported so the Swift `AuthBridge` can read `Constants` (single source
// of truth for OIDC issuer / client id / redirect URI).
// still re-exported so Swift can read shared constants when needed.
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "ComposeApp"

View File

@@ -36,13 +36,9 @@ android {
versionCode = 1
versionName = "1.0"
// AppAuth-Android (D-01) bundles a manifest entry for its
// `RedirectUriReceiverActivity` that requires `${appAuthRedirectScheme}` to be
// resolved at merge time. Pin it to the Phase 2 redirect scheme so simply
// pulling AppAuth into the classpath (Plan 02-01) doesn't break AGP's manifest
// merger before Plan 02-04 lands the full `<intent-filter>` registration.
// Must match `dev.ulfrx.recipe.shared.Constants.OIDC_REDIRECT_URI` byte-for-byte.
manifestPlaceholders["appAuthRedirectScheme"] = "recipe"
// Lokksmith's Android redirect activity uses the scheme-only placeholder.
// Must match `dev.ulfrx.recipe.shared.Constants.OIDC_REDIRECT_URI`.
manifestPlaceholders["lokksmithRedirectScheme"] = "recipe"
}
packaging {
resources {
@@ -76,8 +72,8 @@ kotlin {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
// `api` so `:shared` types (notably `Constants`) flow through to the
// exported ObjC framework headers the iOS Swift bridge needs them.
// `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).
@@ -103,18 +99,18 @@ kotlin {
implementation(libs.androidx.activity.compose)
implementation(libs.koin.android)
// Phase 2 Android: AppAuth-Android + AndroidX Security Crypto for the
// SecureAuthStateStore actual (D-01, D-13). EncryptedSharedPreferences is
// accepted technical debt per Open Question #1; the Keystore-backed
// implementation can replace it without touching AuthSession.
implementation(libs.appauth)
// 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)
}
iosMain.dependencies {
// Phase 2 iOS: Darwin engine for Ktor. AppAuth-iOS is delivered via
// SwiftPM in iosApp.xcodeproj and consumed through a Swift bridge —
// no Kotlin-side AppAuth dependency (DECISION-drop-cocoapods, 2026-04-28).
// Phase 2 iOS: Darwin engine for Ktor. Lokksmith handles the native
// ASWebAuthenticationSession integration directly from Kotlin.
implementation(libs.lokksmith.core)
implementation(libs.ktor.clientDarwin)
}
jvmMain.dependencies {

View File

@@ -18,20 +18,6 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:exported="true"
android:name="net.openid.appauth.RedirectUriReceiverActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:host="callback"
android:scheme="recipe"/>
</intent-filter>
</activity>
</application>
</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

@@ -2,330 +2,75 @@
package dev.ulfrx.recipe.auth
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import dev.ulfrx.recipe.shared.Constants
import kotlinx.coroutines.suspendCancellableCoroutine
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.EndSessionRequest
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenResponse
import dev.lokksmith.Lokksmith
import dev.lokksmith.SingletonLokksmithProvider
import dev.lokksmith.android.LokksmithAuthFlowActivity
import dev.lokksmith.createLokksmith
import org.koin.core.context.GlobalContext
import kotlin.coroutines.resume
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 configuration =
when (val outcome = fetchConfiguration()) {
is ConfigurationOutcome.Success -> outcome.configuration
is ConfigurationOutcome.Error -> return outcome.exception.toOidcError()
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)
}
}
val request =
AuthorizationRequest
.Builder(
configuration,
Constants.OIDC_CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse(Constants.OIDC_REDIRECT_URI),
).setScopes("openid", "profile", "email", "offline_access")
.build()
val service = AuthorizationService(context)
return try {
when (val authorization = service.performAuthorization(request)) {
is AuthorizationOutcome.Success -> exchangeCode(service, authorization.response)
is AuthorizationOutcome.Cancelled -> OidcResult.Cancelled
is AuthorizationOutcome.Error -> authorization.exception.toOidcError()
else -> {
failure
}
} finally {
service.dispose()
}
}
actual suspend fun refresh(authStateJson: String): OidcResult {
val authState =
runCatching { AuthState.jsonDeserialize(authStateJson) }
.getOrElse { return OidcResult.AuthError("Invalid AuthState JSON", it) }
val service = AuthorizationService(context)
return try {
service.freshTokens(authState)
} finally {
service.dispose()
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 authState = runCatching { AuthState.jsonDeserialize(authStateJson) }.getOrNull() ?: return
val configuration = authState.authorizationServiceConfiguration ?: return
if (configuration.endSessionEndpoint == null) return
val client = lokksmith.recipeClient()
val flow = client.recipeEndSessionFlow()
val request =
EndSessionRequest
.Builder(configuration)
.setIdTokenHint(authState.idToken)
.setPostLogoutRedirectUri(Uri.parse(Constants.OIDC_REDIRECT_URI))
.build()
val service = AuthorizationService(context)
try {
service.performEndSession(request)
} finally {
service.dispose()
}
}
private suspend fun fetchConfiguration(): ConfigurationOutcome =
suspendCancellableCoroutine { continuation ->
AuthorizationServiceConfiguration.fetchFromIssuer(Uri.parse(Constants.OIDC_ISSUER)) { configuration, exception ->
if (!continuation.isActive) return@fetchFromIssuer
when {
configuration != null -> {
continuation.resume(ConfigurationOutcome.Success(configuration))
}
exception != null -> {
continuation.resume(ConfigurationOutcome.Error(exception))
}
else -> {
continuation.resume(
ConfigurationOutcome.Error(
AuthorizationException.GeneralErrors.INVALID_DISCOVERY_DOCUMENT,
),
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)
}
}
private suspend fun exchangeCode(
service: AuthorizationService,
authorizationResponse: AuthorizationResponse,
): OidcResult =
suspendCancellableCoroutine { continuation ->
val authState = AuthState(authorizationResponse, null)
service.performTokenRequest(authorizationResponse.createTokenExchangeRequest()) { tokenResponse, exception ->
if (!continuation.isActive) return@performTokenRequest
when {
exception != null -> {
continuation.resume(exception.toOidcError())
}
tokenResponse == null -> {
continuation.resume(OidcResult.AuthError("Token exchange returned no response"))
}
tokenResponse.accessToken.isNullOrBlank() -> {
continuation.resume(OidcResult.AuthError("Token exchange returned no access token"))
}
else -> {
authState.update(tokenResponse, null)
continuation.resume(authState.toSuccess(tokenResponse))
}
}
}
continuation.invokeOnCancellation { service.dispose() }
}
private suspend fun AuthorizationService.freshTokens(authState: AuthState): OidcResult =
suspendCancellableCoroutine { continuation ->
authState.performActionWithFreshTokens(this) { accessToken, idToken, exception ->
if (!continuation.isActive) return@performActionWithFreshTokens
when {
exception != null -> {
continuation.resume(exception.toOidcError())
}
accessToken == null -> {
continuation.resume(OidcResult.AuthError("Refresh returned no access token"))
}
else -> {
continuation.resume(
OidcResult.Success(
authStateJson = authState.jsonSerializeString(),
accessToken = accessToken,
idToken = idToken,
expiresAtEpochMillis = authState.accessTokenExpirationTime ?: 0L,
),
)
}
}
}
continuation.invokeOnCancellation { dispose() }
}
private suspend fun AuthorizationService.performAuthorization(request: AuthorizationRequest): AuthorizationOutcome =
suspendCancellableCoroutine { continuation ->
val appContext = context
val action = "${appContext.packageName}.auth.OIDC_AUTH_RESULT.${System.nanoTime()}"
val filter = IntentFilter(action)
val receiver =
object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent,
) {
appContext.unregisterReceiver(this)
if (!continuation.isActive) return
val exception = AuthorizationException.fromIntent(intent)
val response = AuthorizationResponse.fromIntent(intent)
continuation.resume(
when {
exception != null && exception.isCancellation() -> AuthorizationOutcome.Cancelled
exception != null -> AuthorizationOutcome.Error(exception)
response != null -> AuthorizationOutcome.Success(response)
else -> AuthorizationOutcome.Cancelled
},
)
}
}
appContext.registerPrivateReceiver(receiver, filter)
val completionIntent =
PendingIntent.getBroadcast(
appContext,
action.hashCode(),
Intent(action).setPackage(appContext.packageName),
pendingIntentFlags(),
)
val cancelIntent =
PendingIntent.getBroadcast(
appContext,
action.hashCode() + 1,
Intent(action).setPackage(appContext.packageName),
pendingIntentFlags(),
)
continuation.invokeOnCancellation {
runCatching { appContext.unregisterReceiver(receiver) }
dispose()
}
performAuthorizationRequest(request, completionIntent, cancelIntent)
}
private suspend fun AuthorizationService.performEndSession(request: EndSessionRequest) =
suspendCancellableCoroutine { continuation ->
val appContext = context
val action = "${appContext.packageName}.auth.OIDC_END_SESSION_RESULT.${System.nanoTime()}"
val filter = IntentFilter(action)
val receiver =
object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent,
) {
appContext.unregisterReceiver(this)
if (continuation.isActive) continuation.resume(Unit)
}
}
appContext.registerPrivateReceiver(receiver, filter)
val completionIntent =
PendingIntent.getBroadcast(
appContext,
action.hashCode(),
Intent(action).setPackage(appContext.packageName),
pendingIntentFlags(),
)
val cancelIntent =
PendingIntent.getBroadcast(
appContext,
action.hashCode() + 1,
Intent(action).setPackage(appContext.packageName),
pendingIntentFlags(),
)
continuation.invokeOnCancellation {
runCatching { appContext.unregisterReceiver(receiver) }
dispose()
}
performEndSessionRequest(request, completionIntent, cancelIntent)
}
private fun AuthState.toSuccess(tokenResponse: TokenResponse): OidcResult.Success =
OidcResult.Success(
authStateJson = jsonSerializeString(),
accessToken = tokenResponse.accessToken.orEmpty(),
idToken = tokenResponse.idToken,
expiresAtEpochMillis = tokenResponse.accessTokenExpirationTime ?: 0L,
)
private fun AuthorizationException.toOidcError(): OidcResult =
when {
isCancellation() -> OidcResult.Cancelled
isNetworkFailure() -> OidcResult.NetworkError
else -> OidcResult.AuthError("OIDC request failed", this)
}
private fun AuthorizationException.isCancellation(): Boolean =
type == AuthorizationException.TYPE_GENERAL_ERROR &&
(
code == AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW.code ||
code == AuthorizationException.GeneralErrors.PROGRAM_CANCELED_AUTH_FLOW.code
)
private fun AuthorizationException.isNetworkFailure(): Boolean =
type == AuthorizationException.TYPE_GENERAL_ERROR &&
(
code == AuthorizationException.GeneralErrors.NETWORK_ERROR.code ||
code == AuthorizationException.GeneralErrors.SERVER_ERROR.code
)
private fun Context.registerPrivateReceiver(
receiver: BroadcastReceiver,
filter: IntentFilter,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("DEPRECATION")
registerReceiver(receiver, filter)
}
}
private fun pendingIntentFlags(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
private sealed interface AuthorizationOutcome {
data class Success(
val response: AuthorizationResponse,
) : AuthorizationOutcome
data class Error(
val exception: AuthorizationException,
) : AuthorizationOutcome
data object Cancelled : AuthorizationOutcome
}
private sealed interface ConfigurationOutcome {
data class Success(
val configuration: AuthorizationServiceConfiguration,
) : ConfigurationOutcome
data class Error(
val exception: AuthorizationException,
) : ConfigurationOutcome
client.resetTokens()
}
}
fun createAndroidLokksmith(context: Context): Lokksmith =
createLokksmith(context).also { lokksmith ->
SingletonLokksmithProvider.set(lokksmith)
}

View File

@@ -5,18 +5,15 @@ package dev.ulfrx.recipe.auth
/**
* Common seam for Authentik OIDC.
*
* Native Android/iOS actuals must use AppAuth (D-01) and bridge AppAuth callback
* APIs with `suspendCancellableCoroutine`, cancelling the underlying AppAuth
* request when the coroutine is cancelled (D-04). Login requests must be public
* PKCE-compatible OIDC requests with exactly these scopes:
* `openid profile email offline_access` (D-06). AppAuth owns state and nonce
* verification.
* 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 AppAuth fresh-token APIs such as
* `performActionWithFreshTokens`, then return the updated AuthState JSON for
* persistence (D-16). Logout must use AppAuth RP-initiated end-session APIs
* 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).
* 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

View File

@@ -3,8 +3,8 @@ package dev.ulfrx.recipe.auth
/**
* Result returned by platform OIDC clients.
*
* `authStateJson` is the opaque AppAuth AuthState JSON blob persisted by
* [SecureAuthStateStore]. Callers must not parse token values out of it directly.
* `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(

View File

@@ -3,12 +3,12 @@
package dev.ulfrx.recipe.auth
/**
* Persists the full AppAuth AuthState JSON blob for the current app install.
* 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 tokens. The stored
* blob is global to the install and must be deleted on logout (D-15).
* 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?

View File

@@ -1,93 +0,0 @@
@file:OptIn(ExperimentalObjCName::class, ExperimentalForeignApi::class)
package dev.ulfrx.recipe.auth
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.serialization.Serializable
import platform.UIKit.UIViewController
import kotlin.experimental.ExperimentalObjCName
import kotlin.native.ObjCName
/**
* iOS auth bridge implemented in Swift on top of AppAuth-iOS.
*
* AppAuth lives in `iosApp/` (delivered via SwiftPM) since 2026-04-28; Kotlin
* code never imports `cocoapods.AppAuth.*`. The Swift implementation is handed
* to Kotlin at app startup via [IosAuthBridgeRegistry] and resolved through
* Koin in [OidcClient].
*
* Methods are callback-style on purpose: it gives a stable Obj-C selector for
* Swift to override and skips Kotlin/Native suspend-protocol machinery. The
* Kotlin caller wraps each call in `suspendCancellableCoroutine`.
*/
@ObjCName("IosAuthBridge")
interface IosAuthBridge {
fun login(
presentingViewController: UIViewController,
completion: (IosAuthBridgeResult) -> Unit,
)
fun refresh(
refreshToken: String,
completion: (IosAuthBridgeResult) -> Unit,
)
fun endSession(
presentingViewController: UIViewController,
idTokenHint: String,
completion: () -> Unit,
)
/**
* Called by `iOSApp.swift` from `onOpenURL` so the Swift side can resume
* an in-flight authorization session. Mirrors AppAuth's
* `currentAuthorizationFlow.resumeExternalUserAgentFlow(with:)`.
*/
fun resumeExternalUserAgentFlow(url: String): Boolean
}
/**
* Sum type returned by [IosAuthBridge.login] and [IosAuthBridge.refresh].
*
* Mapped to [OidcResult] inside [OidcClient]. Kept iOS-local so the bridge can
* evolve without touching the common contract.
*/
sealed class IosAuthBridgeResult {
data class Success(
val tokens: IosAuthTokens,
) : IosAuthBridgeResult()
data object Cancelled : IosAuthBridgeResult()
data object NetworkError : IosAuthBridgeResult()
data class Failed(
val message: String,
) : IosAuthBridgeResult()
}
/**
* Token bundle persisted by [SecureAuthStateStore] as JSON.
*
* Replaces the AppAuth `OIDAuthState` `NSKeyedArchiver` blob — Kotlin now owns
* the persistence format end-to-end and can read token expiry locally.
*/
@Serializable
data class IosAuthTokens(
val accessToken: String,
val refreshToken: String? = null,
val idToken: String? = null,
val expiresAtEpochMillis: Long = 0L,
)
/**
* Hand-off slot from `iOSApp.swift` to Kotlin Koin.
*
* `iOSApp.init` instantiates the Swift `AuthBridge`, sets it here, then calls
* `KoinIosKt.doInitKoin()`. The iOS auth Koin module reads from this slot when
* resolving `IosAuthBridge`.
*/
@ObjCName("IosAuthBridgeRegistry")
object IosAuthBridgeRegistry {
var instance: IosAuthBridge? = null
}

View File

@@ -2,18 +2,7 @@ package dev.ulfrx.recipe.auth
import org.koin.dsl.module
/**
* iOS-only Koin module that exposes the Swift-implemented [IosAuthBridge] to
* Kotlin DI. The Swift `AuthBridge` instance is registered in
* [IosAuthBridgeRegistry] from `iOSApp.swift` *before* `doInitKoin()` runs, so
* `single<IosAuthBridge>` always finds it.
*/
val iosAuthModule =
module {
single<IosAuthBridge> {
IosAuthBridgeRegistry.instance
?: error(
"IosAuthBridge not registered before Koin init — call IosAuthBridgeRegistry.shared.setInstance(...) in iOSApp.init.",
)
}
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

@@ -2,115 +2,54 @@
package dev.ulfrx.recipe.auth
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import dev.lokksmith.Lokksmith
import dev.lokksmith.SingletonLokksmithProvider
import dev.lokksmith.createLokksmith
import dev.lokksmith.ios.launchAuthFlow
import org.koin.mp.KoinPlatform
import platform.UIKit.UIApplication
import platform.UIKit.UIViewController
import kotlin.coroutines.resume
@OptIn(ExperimentalForeignApi::class)
actual class OidcClient {
private val bridge: IosAuthBridge
private val lokksmith: Lokksmith
get() = KoinPlatform.getKoin().get()
actual suspend fun login(): OidcResult {
val presenter =
topViewController()
?: return OidcResult.AuthError("Unable to find an iOS view controller for OIDC login")
val client = lokksmith.recipeClient()
val flow = client.recipeAuthorizationCodeFlow()
val initiation = flow.prepare()
return suspendCancellableCoroutine { continuation ->
bridge.login(presenter) { result ->
if (continuation.isActive) continuation.resume(result.toOidcResult())
}
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 {
val tokens =
decodeTokens(authStateJson)
?: return OidcResult.AuthError("Stored iOS auth state is not readable")
val refreshToken =
tokens.refreshToken
?: return OidcResult.AuthError("Stored iOS auth state has no refresh token")
return suspendCancellableCoroutine { continuation ->
bridge.refresh(refreshToken) { result ->
if (continuation.isActive) continuation.resume(result.toOidcResult())
}
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 tokens = decodeTokens(authStateJson) ?: return
val idTokenHint = tokens.idToken ?: return
val presenter = topViewController() ?: return
val client = lokksmith.recipeClient()
val flow = client.recipeEndSessionFlow()
suspendCancellableCoroutine<Unit> { continuation ->
bridge.endSession(presenter, idTokenHint) {
if (continuation.isActive) continuation.resume(Unit)
if (flow != null) {
runCatching {
lokksmith.launchAuthFlow(flow.prepare())
lokksmith.completeAuthFlow(client)
}
}
client.resetTokens()
}
}
/**
* Forwarded from `iOSApp.swift`'s `onOpenURL` so the Swift bridge can complete
* an in-flight authorization. Returns `true` if the URL was consumed.
*/
@OptIn(ExperimentalForeignApi::class)
object IosOidcUrlHandler {
fun resume(urlString: String): Boolean {
val bridge = KoinPlatform.getKoinOrNull()?.getOrNull<IosAuthBridge>() ?: return false
return bridge.resumeExternalUserAgentFlow(urlString)
}
}
@OptIn(ExperimentalForeignApi::class)
private fun topViewController(): UIViewController? {
val root = UIApplication.sharedApplication.keyWindow?.rootViewController
var current = root
while (current?.presentedViewController != null) {
current = current.presentedViewController
}
return current
}
private fun IosAuthBridgeResult.toOidcResult(): OidcResult =
when (this) {
is IosAuthBridgeResult.Success -> {
OidcResult.Success(
authStateJson = encodeTokens(tokens),
accessToken = tokens.accessToken,
idToken = tokens.idToken,
expiresAtEpochMillis = tokens.expiresAtEpochMillis,
)
}
IosAuthBridgeResult.Cancelled -> {
OidcResult.Cancelled
}
IosAuthBridgeResult.NetworkError -> {
OidcResult.NetworkError
}
is IosAuthBridgeResult.Failed -> {
OidcResult.AuthError(message)
}
}
private val tokensJson = Json { ignoreUnknownKeys = true }
private fun encodeTokens(tokens: IosAuthTokens): String = tokensJson.encodeToString(IosAuthTokens.serializer(), tokens)
private fun decodeTokens(value: String): IosAuthTokens? =
try {
tokensJson.decodeFromString(IosAuthTokens.serializer(), value)
} catch (_: SerializationException) {
null
} catch (_: IllegalArgumentException) {
null
fun createIosLokksmith(): Lokksmith =
createLokksmith().also { lokksmith ->
SingletonLokksmithProvider.set(lokksmith)
}

View File

@@ -16,18 +16,17 @@ actual class SecureAuthStateStore {
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
)
actual fun read(): String? =
settings.getStringOrNull(authStateKey)
actual fun read(): String? = settings.getStringOrNull(AUTH_STATE_KEY)
actual fun write(authStateJson: String) {
settings.putString(authStateKey, authStateJson)
settings.putString(AUTH_STATE_KEY, authStateJson)
}
actual fun clear() {
settings.remove(authStateKey)
settings.remove(AUTH_STATE_KEY)
}
private companion object {
const val authStateKey = "dev.ulfrx.recipe.auth.appauth-state"
const val AUTH_STATE_KEY = "dev.ulfrx.recipe.auth.lokksmith-state"
}
}

View File

@@ -66,10 +66,10 @@ redirect failures (D-09).
| Where | Mechanism | Phase 2 plan that lands it |
|-------|-----------|----------------------------|
| Authentik provider | Redirect URIs textbox (one line) | This document (Plan 02-01) |
| iOS | `iosApp/iosApp/Info.plist` `CFBundleURLTypes``CFBundleURLSchemes` array containing exactly `recipe` | Plan 02-05 (iOS AppAuth actual) |
| Android | `composeApp/src/androidMain/AndroidManifest.xml` `<intent-filter>` on AppAuth's `RedirectUriReceiverActivity` with `android:scheme="recipe"` and `android:host="callback"` | Plan 02-04 (Android AppAuth actual). Plan 02-01 already supplies the `appAuthRedirectScheme=recipe` manifest placeholder so the AppAuth dependency merges cleanly without yet wiring the receiver. |
| iOS | `iosApp/iosApp/Info.plist` `CFBundleURLTypes``CFBundleURLSchemes` array containing exactly `recipe` | Plan 02-05 (iOS OIDC actual) |
| Android | Lokksmith's manifest callback using `lokksmithRedirectScheme=recipe` and the `callback` host | Plan 02-04 (Android OIDC actual). |
PKCE S256 + AppAuth's state/nonce handling makes the well-known custom-scheme
PKCE S256 + Lokksmith's state/nonce handling makes the well-known custom-scheme
interception attack non-exploitable in practice. **Universal Links / App Links are
explicitly excluded** from v1 — see [`## Source Audit`](#source-audit).
@@ -95,8 +95,8 @@ environment v1 acceptance from PITFALLS.md tech-debt table).
Logout is **RP-initiated end-session** (D-19, D-20). Tapping "Wyloguj się" performs both
of these atomically, in this order:
1. Call Authentik's `end_session_endpoint` (advertised by `<issuer>/.well-known/openid-configuration`) with the user's `id_token_hint`. AppAuth's `EndSessionRequest` API drives this on both mobile platforms (D-20).
2. Delete the persisted `AuthState` blob from secure storage (Keychain on iOS, EncryptedSharedPreferences on Android per D-13).
1. Call Authentik's `end_session_endpoint` (advertised by `<issuer>/.well-known/openid-configuration`) with the user's `id_token_hint`. Lokksmith's end-session flow drives this on both mobile platforms (D-20).
2. Delete the persisted auth-state marker from secure storage (Keychain on iOS, EncryptedSharedPreferences on Android per D-13).
If step 1 fails (network unreachable, Authentik down), step 2 still runs so the user
isn't trapped in a half-logged-out state — correct semantics for a shared household
@@ -128,7 +128,7 @@ cannot reach: real Authentik, real browser handoff, real Keychain.
1. Sign in via UAT-01.
2. In Authentik, set the provider's access-token lifetime to ~60 seconds (or wait the default).
3. Backgroud the app for ~2 minutes; relaunch.
4. Confirm the app returns directly to `Witaj, {displayName}!` with no login prompt — the AppAuth `performActionWithFreshTokens` path silently exchanged the refresh token (D-16, D-17).
4. Confirm the app returns directly to `Witaj, {displayName}!` with no login prompt — Lokksmith silently exchanged the refresh token (D-16, D-17).
### UAT-03 — Logout returns to login (AUTH-05)
@@ -172,17 +172,17 @@ plan number), ✂ explicitly deferred (see end of section).
| Source | Item | Coverage |
|--------|------|----------|
| GOAL | Phase 2 goal: end-to-end OIDC+PKCE login with server JWT validation and JIT users | ✅ Provider + Scopes + Redirect URI + Server Env Vars + Manual UAT |
| REQ | **AUTH-01** sign in via Authentik OIDC + PKCE | ✅ Provider; ⤳ 02-04 (Android AppAuth) + 02-05 (iOS AppAuth) |
| REQ | **AUTH-01** sign in via Authentik OIDC + PKCE | ✅ Provider; ⤳ 02-04 (Android OIDC) + 02-05 (iOS OIDC) |
| REQ | **AUTH-02** secure token storage | ⤳ 02-03 (common contract) + 02-04 (Android EncryptedSharedPreferences) + 02-05 (iOS Keychain) |
| REQ | **AUTH-03** server JWT validation via JWKS | ✅ Provider (RS256, single-string aud, JWKS); ⤳ 02-02 (Ktor JWT install + tests) |
| REQ | **AUTH-04** session persists across launches via refresh | ✅ Scopes (`offline_access`); ⤳ 02-03 (AuthSession refresh wiring) + Manual UAT-02 |
| REQ | **AUTH-05** logout returns to login | ✅ Logout section; ⤳ 02-04/02-05 (AppAuth end-session per platform) + Manual UAT-03 |
| REQ | **AUTH-05** logout returns to login | ✅ Logout section; ⤳ 02-04/02-05 (Lokksmith end-session per platform) + Manual UAT-03 |
| REQ | **AUTH-06** JIT user provisioning by `sub` | ⤳ 02-02 (`V1__users.sql` + upsert by sub + `/api/v1/me`) |
| RESEARCH | Standard stack: AppAuth, Ktor JWT, multiplatform-settings, Exposed DSL, Flyway | ✅ Server Env Vars (Ktor); ⤳ 02-01 catalog wiring (this plan, task 2) + per-platform plans |
| RESEARCH | Standard stack: Lokksmith, Ktor JWT, multiplatform-settings, Exposed DSL, Flyway | ✅ Server Env Vars (Ktor); ⤳ 02-01 catalog wiring (this plan, task 2) + per-platform plans |
| RESEARCH | Open Questions resolved: Android secure storage = EncryptedSharedPreferences behind `SecureAuthStateStore` seam | ⤳ 02-03 (seam) + 02-04 (Android impl) |
| RESEARCH | Open Question resolved: Exposed `newSuspendedTransaction` import verified at impl time | ⤳ 02-02 |
| RESEARCH | Open Question resolved: Ktor stays at 3.4.1 (no patch bump) | ✅ Task 2 catalog keeps `ktor = "3.4.1"` |
| CONTEXT | **D-01** AppAuth on both mobile platforms via expect/actual `OidcClient` | ⤳ 02-03 (expect) + 02-04 (Android actual) + 02-05 (iOS actual) |
| RESEARCH | Open Question resolved: Ktor patch version follows the selected auth client | ✅ Lokksmith requires Ktor 3.4.2 |
| CONTEXT | **D-01** Lokksmith on both mobile platforms via expect/actual `OidcClient` | ⤳ 02-03 (expect) + 02-04 (Android actual) + 02-05 (iOS actual) |
| CONTEXT | **D-02** JVM `actual` is `DEV_AUTH_TOKEN` env-var stub | ⤳ 02-03 |
| CONTEXT | **D-03** Wasm `actual` is `NotImplementedError("Wasm OIDC: v2")` | ⤳ 02-03 |
| CONTEXT | **D-04** `OidcClient.login()` / `.refresh()` are `suspend` | ⤳ 02-03 |
@@ -194,14 +194,14 @@ plan number), ✂ explicitly deferred (see end of section).
| CONTEXT | **D-10** this document is a Phase 2 deliverable | ✅ this document |
| CONTEXT | **D-11** client OIDC config in `shared/commonMain/Constants.kt` | ✅ Server Env Vars (relationship spelled out); ⤳ 02-01 task 1 (Constants.kt landed) |
| CONTEXT | **D-12** server OIDC config via env vars | ✅ Server Env Vars |
| CONTEXT | **D-13** persist full AppAuth `AuthState` JSON via `multiplatform-settings` | ⤳ 02-03 + 02-04 + 02-05 |
| CONTEXT | **D-13** persist opaque auth-state marker via `multiplatform-settings` | ⤳ 02-03 + 02-04 + 02-05 |
| CONTEXT | **D-14** iOS Keychain `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | ⤳ 02-05 |
| CONTEXT | **D-15** one AuthState blob per app install | ⤳ 02-03 |
| CONTEXT | **D-16** proactive refresh via `performActionWithFreshTokens` | ⤳ 02-04 + 02-05 |
| CONTEXT | **D-16** proactive refresh via Lokksmith token refresh | ⤳ 02-04 + 02-05 |
| CONTEXT | **D-17** Ktor bearer `refreshTokens` 401 fallback | ⤳ 02-03 |
| CONTEXT | **D-18** silent refresh-failure transition | ⤳ 02-03 |
| CONTEXT | **D-19** RP-initiated end-session | ✅ Logout |
| CONTEXT | **D-20** AppAuth `EndSessionRequest` drives logout | ✅ Logout; ⤳ 02-04 + 02-05 |
| CONTEXT | **D-20** Lokksmith end-session flow drives logout | ✅ Logout; ⤳ 02-04 + 02-05 |
| CONTEXT | **D-21** Ktor `jwt("authentik")` install with leeway 30s and `sub` validation | ⤳ 02-02 |
| CONTEXT | **D-22** JWKS provider cache 10 / 15min, rate limit 10/min | ⤳ 02-02 |
| CONTEXT | **D-23** never log Authorization header / token bodies | ✅ Manual UAT-04 step 5; ⤳ 02-02 (server filter) + 02-03 (client logger discipline) |
@@ -219,7 +219,7 @@ plan number), ✂ explicitly deferred (see end of section).
| UI-SPEC | Auth screen contract: SplashScreen / LoginScreen / PostLoginPlaceholderScreen | ⤳ 02-06 |
| VALIDATION | Wave 0 tests: AuthJwtTest, MeRouteTest, AuthSessionTest, SecureAuthStateStoreTest | ⤳ 02-02 (server tests) + 02-03 (client tests) |
| VALIDATION | Manual UAT checklist in `docs/authentik-setup.md` | ✅ Manual UAT |
| PATTERNS | File map: shared DTO/Constants location, Koin authModule, Ktor JWT install, Exposed table, AppAuth platform actuals | ⤳ 02-01 task 1 (shared) + 02-02 (server) + 02-03 (client common) + 02-04 (Android) + 02-05 (iOS) + 02-06 (UI) |
| PATTERNS | File map: shared DTO/Constants location, Koin authModule, Ktor JWT install, Exposed table, Lokksmith platform actuals | ⤳ 02-01 task 1 (shared) + 02-02 (server) + 02-03 (client common) + 02-04 (Android) + 02-05 (iOS) + 02-06 (UI) |
### Deferred (excluded from Phase 2)

View File

@@ -10,9 +10,6 @@ androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0"
androidx-security-crypto = "1.1.0"
androidx-testExt = "1.3.0"
appauth = "0.11.1"
# AppAuth-iOS version is pinned in iosApp.xcodeproj's Package.resolved (SwiftPM)
# since 2026-04-28 — see .planning/phases/02-authentication-foundation/DECISION-drop-cocoapods.md.
composeHotReload = "1.0.0"
composeMultiplatform = "1.10.3"
exposed = "0.55.0"
@@ -24,7 +21,8 @@ koin = "4.2.1"
kotlin = "2.3.20"
kotlinx-coroutines = "1.10.2"
kotlinx-serialization = "1.7.3"
ktor = "3.4.1"
ktor = "3.4.2"
lokksmith = "0.13.0"
logback = "1.5.32"
material3 = "1.10.0-alpha05"
multiplatformSettings = "1.3.0"
@@ -95,8 +93,8 @@ ktor-clientDarwin = { module = "io.ktor:ktor-client-darwin", 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" }
# Phase 2 — Client: AppAuth + Android secure storage + multiplatform-settings (D-01, D-13, AUTH-02)
appauth = { module = "net.openid:appauth", version.ref = "appauth" }
# Phase 2 — Client: Lokksmith OIDC + Android secure storage + multiplatform-settings (D-01, D-13, AUTH-02)
lokksmith-core = { module = "dev.lokksmith:lokksmith-core", 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-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformSettings" }

View File

@@ -6,10 +6,6 @@
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
D6D5D1FA2FA11AF8008BF8AF /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = D6D5D1F92FA11AF8008BF8AF /* AppAuth */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
4B3C797CB7B3655AAA3375CB /* recipe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = recipe.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@@ -45,7 +41,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D6D5D1FA2FA11AF8008BF8AF /* AppAuth in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -90,7 +85,6 @@
);
name = iosApp;
packageProductDependencies = (
D6D5D1F92FA11AF8008BF8AF /* AppAuth */,
);
productName = iosApp;
productReference = 4B3C797CB7B3655AAA3375CB /* recipe.app */;
@@ -121,7 +115,6 @@
mainGroup = 9AD793E4EFD47C3FC2FBCEBD;
minimizedProjectReferenceProxies = 1;
packageReferences = (
D6D5D1F82FA11AF8008BF8AF /* XCRemoteSwiftPackageReference "AppAuth-iOS" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = DFB8271353F280D44A8EF684 /* Products */;
@@ -378,24 +371,6 @@
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
D6D5D1F82FA11AF8008BF8AF /* XCRemoteSwiftPackageReference "AppAuth-iOS" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/openid/AppAuth-iOS";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.0.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
D6D5D1F92FA11AF8008BF8AF /* AppAuth */ = {
isa = XCSwiftPackageProductDependency;
package = D6D5D1F82FA11AF8008BF8AF /* XCRemoteSwiftPackageReference "AppAuth-iOS" */;
productName = AppAuth;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 64D626B4C2477EC6512D1B55 /* Project object */;
}

View File

@@ -1,15 +0,0 @@
{
"originHash" : "c2c3123823fbf9ecb5ff108c887e3a41cb72f13d86620f12b66cac13738096c1",
"pins" : [
{
"identity" : "appauth-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/openid/AppAuth-iOS",
"state" : {
"revision" : "145104f5ea9d58ae21b60add007c33c1cc0c948e",
"version" : "2.0.0"
}
}
],
"version" : 3
}

View File

@@ -1,223 +0,0 @@
// AuthBridge.swift Swift implementation of `IosAuthBridge` (declared in
// composeApp/iosMain). Owns all AppAuth-iOS calls so Kotlin code never imports
// AppAuth (DECISION-drop-cocoapods, 2026-04-28).
//
// The instance is registered into `IosAuthBridgeRegistry` from `iOSApp.init`
// before `KoinIosKt.doInitKoin()` so Koin can resolve it.
import AppAuth
import ComposeApp
import Foundation
import UIKit
@objc final class AuthBridge: NSObject, IosAuthBridge {
private var serviceConfig: OIDServiceConfiguration?
private var currentSession: OIDExternalUserAgentSession?
func login(
presentingViewController: UIViewController,
completion: @escaping (IosAuthBridgeResult) -> Void
) {
discoverConfiguration { [weak self] config, error in
guard let self else { return }
if let error {
completion(self.mapError(error))
return
}
guard let config else {
completion(IosAuthBridgeResult.Failed(message: "Discovery returned no configuration"))
return
}
guard let redirectURL = URL(string: Constants.shared.OIDC_REDIRECT_URI) else {
completion(IosAuthBridgeResult.Failed(message: "Redirect URI is not a valid URL"))
return
}
let request = OIDAuthorizationRequest(
configuration: config,
clientId: Constants.shared.OIDC_CLIENT_ID,
clientSecret: nil,
scopes: ["openid", "profile", "email", "offline_access"],
redirectURL: redirectURL,
responseType: OIDResponseTypeCode,
additionalParameters: nil
)
self.currentSession = OIDAuthState.authState(
byPresenting: request,
presenting: presentingViewController
) { [weak self] authState, error in
guard let self else { return }
self.currentSession = nil
if let error {
completion(self.mapError(error))
return
}
guard let authState else {
completion(IosAuthBridgeResult.Failed(message: "Authorization completed without an auth state"))
return
}
completion(self.successResult(from: authState))
}
}
}
func refresh(
refreshToken: String,
completion: @escaping (IosAuthBridgeResult) -> Void
) {
discoverConfiguration { [weak self] config, error in
guard let self else { return }
if let error {
completion(self.mapError(error))
return
}
guard let config else {
completion(IosAuthBridgeResult.Failed(message: "Discovery returned no configuration"))
return
}
guard let redirectURL = URL(string: Constants.shared.OIDC_REDIRECT_URI) else {
completion(IosAuthBridgeResult.Failed(message: "Redirect URI is not a valid URL"))
return
}
let request = OIDTokenRequest(
configuration: config,
grantType: OIDGrantTypeRefreshToken,
authorizationCode: nil,
redirectURL: redirectURL,
clientID: Constants.shared.OIDC_CLIENT_ID,
clientSecret: nil,
scope: nil,
refreshToken: refreshToken,
codeVerifier: nil,
additionalParameters: nil
)
OIDAuthorizationService.perform(request) { [weak self] response, error in
guard let self else { return }
if let error {
completion(self.mapError(error))
return
}
guard let response, let accessToken = response.accessToken else {
completion(IosAuthBridgeResult.Failed(message: "Refresh returned no access token"))
return
}
let tokens = IosAuthTokens(
accessToken: accessToken,
refreshToken: response.refreshToken ?? refreshToken,
idToken: response.idToken,
expiresAtEpochMillis: epochMillis(response.accessTokenExpirationDate)
)
completion(IosAuthBridgeResult.Success(tokens: tokens))
}
}
}
func endSession(
presentingViewController: UIViewController,
idTokenHint: String,
completion: @escaping () -> Void
) {
discoverConfiguration { [weak self] config, _ in
guard let self else { completion(); return }
guard let config, config.endSessionEndpoint != nil else {
completion()
return
}
guard let redirectURL = URL(string: Constants.shared.OIDC_REDIRECT_URI) else {
completion()
return
}
guard let agent = OIDExternalUserAgentIOS(presenting: presentingViewController) else {
completion()
return
}
let request = OIDEndSessionRequest(
configuration: config,
idTokenHint: idTokenHint,
postLogoutRedirectURL: redirectURL,
additionalParameters: nil
)
self.currentSession = OIDAuthorizationService.present(
request,
externalUserAgent: agent
) { [weak self] _, _ in
self?.currentSession = nil
completion()
}
}
}
func resumeExternalUserAgentFlow(url urlString: String) -> Bool {
guard let url = URL(string: urlString) else { return false }
guard let session = currentSession else { return false }
let consumed = session.resumeExternalUserAgentFlow(with: url)
if consumed { currentSession = nil }
return consumed
}
// MARK: - helpers
private func discoverConfiguration(
completion: @escaping (OIDServiceConfiguration?, Error?) -> Void
) {
if let cached = serviceConfig {
completion(cached, nil)
return
}
guard let issuer = URL(string: Constants.shared.OIDC_ISSUER) else {
completion(nil, NSError(
domain: "AuthBridge",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "OIDC_ISSUER is not a valid URL"]
))
return
}
OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { [weak self] config, error in
self?.serviceConfig = config
completion(config, error)
}
}
private func mapError(_ error: Error) -> IosAuthBridgeResult {
let nsError = error as NSError
if nsError.domain == OIDGeneralErrorDomain {
switch nsError.code {
case OIDErrorCode.userCanceledAuthorizationFlow.rawValue,
OIDErrorCode.programCanceledAuthorizationFlow.rawValue:
return IosAuthBridgeResult.Cancelled.shared
case OIDErrorCode.networkError.rawValue:
return IosAuthBridgeResult.NetworkError.shared
default:
return IosAuthBridgeResult.Failed(message: nsError.localizedDescription)
}
}
return IosAuthBridgeResult.Failed(message: nsError.localizedDescription)
}
private func successResult(from authState: OIDAuthState) -> IosAuthBridgeResult {
let tokenResponse = authState.lastTokenResponse
let authorizationResponse = authState.lastAuthorizationResponse
guard let accessToken = tokenResponse?.accessToken ?? authorizationResponse.accessToken else {
return IosAuthBridgeResult.Failed(message: "Auth state has no access token")
}
let refreshToken = tokenResponse?.refreshToken ?? authState.refreshToken
let idToken = tokenResponse?.idToken ?? authorizationResponse.idToken
let tokens = IosAuthTokens(
accessToken: accessToken,
refreshToken: refreshToken,
idToken: idToken,
expiresAtEpochMillis: epochMillis(tokenResponse?.accessTokenExpirationDate)
)
return IosAuthBridgeResult.Success(tokens: tokens)
}
}
private func epochMillis(_ date: Date?) -> Int64 {
guard let date else { return 0 }
return Int64((date.timeIntervalSince1970 * 1000.0).rounded())
}

View File

@@ -4,21 +4,12 @@ import ComposeApp
@main
struct iOSApp: App {
init() {
// Register the Swift AppAuth bridge before Koin starts so the iOS auth
// module can resolve `IosAuthBridge` (DECISION-drop-cocoapods).
IosAuthBridgeRegistry.shared.instance = AuthBridge()
KoinIosKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
guard url.scheme == "recipe", url.host == "callback" else {
return
}
_ = IosOidcUrlHandler.shared.resume(urlString: url.absoluteString)
}
}
}
}

View File

@@ -6,8 +6,7 @@ import dev.ulfrx.recipe.auth.meRoute
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.netty.EngineMain
import io.ktor.server.plugins.calllogging.CallLogging
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.request.httpMethod
@@ -17,10 +16,7 @@ import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import kotlinx.serialization.Serializable
fun main() {
embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
fun main(args: Array<String>): Unit = EngineMain.main(args)
@Serializable
private data class Health(

View File

@@ -4,8 +4,8 @@ import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.application.Application
import org.flywaydb.core.Flyway
import org.jetbrains.exposed.sql.Database as ExposedDatabase
import org.slf4j.LoggerFactory
import org.jetbrains.exposed.sql.Database as ExposedDatabase
object Database {
private val log = LoggerFactory.getLogger(Database::class.java)
@@ -51,12 +51,25 @@ object Database {
log.info("Exposed connected via Hikari pool '{}'", ds.poolName)
}
private data class Conf(val url: String, val user: String, val password: String)
private data class Conf(
val url: String,
val user: String,
val password: String,
)
private fun readConfig(app: Application): Conf =
Conf(
url = app.environment.config.property("database.url").getString(),
user = app.environment.config.property("database.user").getString(),
password = app.environment.config.property("database.password").getString(),
url =
app.environment.config
.property("database.url")
.getString(),
user =
app.environment.config
.property("database.user")
.getString(),
password =
app.environment.config
.property("database.password")
.getString(),
)
}

View File

@@ -28,7 +28,11 @@ public class PrincipalResolver {
val sub =
principal.payload.subject?.takeIf { it.isNotBlank() }
?: error("PrincipalResolver invoked with blank sub — should be blocked by AuthPlugin.validate")
val email = principal.payload.getClaim("email")?.asString().orEmpty()
val email =
principal.payload
.getClaim("email")
?.asString()
.orEmpty()
val nameClaim = principal.payload.getClaim("name")?.asString()
val preferredUsername = principal.payload.getClaim("preferred_username")?.asString()
val displayName = nameClaim ?: preferredUsername ?: email.ifBlank { sub }

View File

@@ -25,7 +25,7 @@ oidc {
audience = "recipe-app"
audience = ${?OIDC_AUDIENCE}
# Optional override; if blank, AuthConfig.fromApplicationConfig derives `${issuer}jwks/`.
jwksUrl = ""
jwksUrl = "https://auth.ulfrx.dev/application/o/recipe-app/jwks/"
jwksUrl = ${?OIDC_JWKS_URL}
leewaySeconds = "30"
}

View File

@@ -72,7 +72,8 @@ internal class JwtTestSupport(
): String {
val now = System.currentTimeMillis()
val builder =
JWT.create()
JWT
.create()
.withKeyId(keyId)
.withIssuer(iss)
.withAudience(aud)

View File

@@ -15,7 +15,6 @@ import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
import org.flywaydb.core.Flyway
import org.jetbrains.exposed.sql.Database as ExposedDatabase
import org.junit.AfterClass
import org.junit.BeforeClass
import org.testcontainers.containers.PostgreSQLContainer
@@ -23,6 +22,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
import org.jetbrains.exposed.sql.Database as ExposedDatabase
/**
* Integration test for `GET /api/v1/me` — exercises the full

View File

@@ -28,12 +28,18 @@ kotlin {
android {
namespace = "dev.ulfrx.recipe.shared"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compileSdk =
libs.versions.android.compileSdk
.get()
.toInt()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
minSdk =
libs.versions.android.minSdk
.get()
.toInt()
}
}

View File

@@ -45,7 +45,7 @@ public object Constants {
/**
* Custom URL scheme for the OAuth redirect (D-09). Must match `Info.plist`
* `CFBundleURLTypes` on iOS and the Android manifest `<intent-filter>` byte
* for byte. PKCE S256 + AppAuth state (D-05) make custom-scheme interception
* for byte. PKCE S256 + Lokksmith state (D-05) make custom-scheme interception
* non-exploitable; Universal Links / App Links are explicitly deferred.
*/
public const val OIDC_REDIRECT_URI: String = "recipe://callback"