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 | | 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 | | 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 | | 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 ### 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-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` - [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-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-04-PLAN.md — Android OIDC 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-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-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 - [ ] 02-07-PLAN.md — Compose auth gate UI, Polish resource strings, and iOS Authentik UAT
**UI hint:** yes **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`) - Images: Coil 3 (`io.coil-kt.coil3:coil-compose`)
- Settings/KV: `com.russhwolf:multiplatform-settings` - Settings/KV: `com.russhwolf:multiplatform-settings`
- Glass/blur: Haze (`dev.chrisbanes.haze:haze`) - 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/`):** **Server (`server/`):**
- Ktor Server 3.x on the user's homelab (alongside Authentik) - 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`) - Images: Coil 3 (`io.coil-kt.coil3:coil-compose`)
- Settings/KV: `com.russhwolf:multiplatform-settings` - Settings/KV: `com.russhwolf:multiplatform-settings`
- Glass/blur: Haze (`dev.chrisbanes.haze:haze`) - 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/`):** **Server (`server/`):**
- Ktor Server 3.x on the user's homelab (alongside Authentik) - Ktor Server 3.x on the user's homelab (alongside Authentik)

View File

@@ -29,8 +29,7 @@ kotlin {
// :composeApp:embedAndSignAppleFrameworkForXcode, which needs `baseName` to // :composeApp:embedAndSignAppleFrameworkForXcode, which needs `baseName` to
// resolve `import ComposeApp` from Swift. `isStatic = true` keeps the link // resolve `import ComposeApp` from Swift. `isStatic = true` keeps the link
// shape unchanged from the previous CocoaPods setup. The `:shared` module is // shape unchanged from the previous CocoaPods setup. The `:shared` module is
// re-exported so the Swift `AuthBridge` can read `Constants` (single source // still re-exported so Swift can read shared constants when needed.
// of truth for OIDC issuer / client id / redirect URI).
listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework { target.binaries.framework {
baseName = "ComposeApp" baseName = "ComposeApp"

View File

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

View File

@@ -18,20 +18,6 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </manifest>

View File

@@ -1,6 +1,7 @@
package dev.ulfrx.recipe package dev.ulfrx.recipe
import android.app.Application import android.app.Application
import dev.ulfrx.recipe.auth.androidAuthModule
import dev.ulfrx.recipe.di.initKoin import dev.ulfrx.recipe.di.initKoin
import dev.ulfrx.recipe.logging.configureLogging import dev.ulfrx.recipe.logging.configureLogging
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
@@ -11,6 +12,7 @@ class MainApplication : Application() {
configureLogging() configureLogging()
initKoin { initKoin {
androidContext(this@MainApplication) 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 package dev.ulfrx.recipe.auth
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import dev.lokksmith.Lokksmith
import android.net.Uri import dev.lokksmith.SingletonLokksmithProvider
import android.os.Build import dev.lokksmith.android.LokksmithAuthFlowActivity
import dev.ulfrx.recipe.shared.Constants import dev.lokksmith.createLokksmith
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 org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
import kotlin.coroutines.resume
actual class OidcClient { actual class OidcClient {
private val context: Context private val context: Context
get() = GlobalContext.get().get<Context>().applicationContext get() = GlobalContext.get().get<Context>().applicationContext
private val lokksmith: Lokksmith
get() = GlobalContext.get().get()
actual suspend fun login(): OidcResult { actual suspend fun login(): OidcResult {
val configuration = val client = lokksmith.recipeClient()
when (val outcome = fetchConfiguration()) { val flow = client.recipeAuthorizationCodeFlow()
is ConfigurationOutcome.Success -> outcome.configuration val initiation = flow.prepare()
is ConfigurationOutcome.Error -> return outcome.exception.toOidcError()
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 = else -> {
AuthorizationRequest failure
.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()
} }
} finally {
service.dispose()
} }
} }
actual suspend fun refresh(authStateJson: String): OidcResult { actual suspend fun refresh(authStateJson: String): OidcResult {
val authState = if (authStateJson != LOKKSMITH_AUTH_STATE_JSON) {
runCatching { AuthState.jsonDeserialize(authStateJson) } return OidcResult.AuthError("Stored Android auth state is not a Lokksmith session")
.getOrElse { return OidcResult.AuthError("Invalid AuthState JSON", it) }
val service = AuthorizationService(context)
return try {
service.freshTokens(authState)
} finally {
service.dispose()
} }
val client = lokksmith.recipeClient()
return runCatching { client.toOidcSuccess() }
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC refresh failed", it) }
} }
actual suspend fun logout(authStateJson: String) { actual suspend fun logout(authStateJson: String) {
val authState = runCatching { AuthState.jsonDeserialize(authStateJson) }.getOrNull() ?: return val client = lokksmith.recipeClient()
val configuration = authState.authorizationServiceConfiguration ?: return val flow = client.recipeEndSessionFlow()
if (configuration.endSessionEndpoint == null) return
val request = if (flow != null) {
EndSessionRequest runCatching {
.Builder(configuration) val initiation = flow.prepare()
.setIdTokenHint(authState.idToken) context.startActivity(
.setPostLogoutRedirectUri(Uri.parse(Constants.OIDC_REDIRECT_URI)) LokksmithAuthFlowActivity
.build() .createCustomTabsIntent(context = context, initiation = initiation)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
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,
),
) )
} lokksmith.completeAuthFlow(client)
}
} }
} }
private suspend fun exchangeCode( client.resetTokens()
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) fun createAndroidLokksmith(context: Context): Lokksmith =
createLokksmith(context).also { lokksmith ->
val completionIntent = SingletonLokksmithProvider.set(lokksmith)
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
}
} }

View File

@@ -5,18 +5,15 @@ package dev.ulfrx.recipe.auth
/** /**
* Common seam for Authentik OIDC. * Common seam for Authentik OIDC.
* *
* Native Android/iOS actuals must use AppAuth (D-01) and bridge AppAuth callback * Native Android/iOS actuals use Lokksmith for browser-based Authorization Code
* APIs with `suspendCancellableCoroutine`, cancelling the underlying AppAuth * + PKCE. Login requests must be public PKCE-compatible OIDC requests with
* request when the coroutine is cancelled (D-04). Login requests must be public * exactly these scopes: `openid profile email offline_access` (D-06).
* PKCE-compatible OIDC requests with exactly these scopes: * Lokksmith owns state and nonce verification.
* `openid profile email offline_access` (D-06). AppAuth owns state and nonce
* verification.
* *
* Refresh must go through AppAuth fresh-token APIs such as * Refresh must go through Lokksmith fresh-token handling, then return an opaque
* `performActionWithFreshTokens`, then return the updated AuthState JSON for * auth-state marker for persistence (D-16). Logout must use RP-initiated
* persistence (D-16). Logout must use AppAuth RP-initiated end-session APIs * end-session before local state is cleared; callers still clear local state if
* before local state is cleared; callers still clear local state if remote * remote logout fails so users are never trapped in a stale session (D-19, D-20).
* logout fails so users are never trapped in a stale session (D-19, D-20).
*/ */
expect class OidcClient() { expect class OidcClient() {
suspend fun login(): OidcResult suspend fun login(): OidcResult

View File

@@ -3,8 +3,8 @@ package dev.ulfrx.recipe.auth
/** /**
* Result returned by platform OIDC clients. * Result returned by platform OIDC clients.
* *
* `authStateJson` is the opaque AppAuth AuthState JSON blob persisted by * `authStateJson` is an opaque platform-auth marker persisted by [SecureAuthStateStore].
* [SecureAuthStateStore]. Callers must not parse token values out of it directly. * Callers must not parse token values out of it directly.
*/ */
sealed interface OidcResult { sealed interface OidcResult {
data class Success( data class Success(

View File

@@ -3,12 +3,12 @@
package dev.ulfrx.recipe.auth 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 * Mobile actuals must use explicit secure platform storage for token material
* (D-13): iOS Keychain and Android encrypted/Keystore-backed storage. Do not use * (D-13): iOS Keychain and Android encrypted/Keystore-backed storage. Do not use
* no-arg or default insecure settings implementations for tokens. The stored * no-arg or default insecure settings implementations for auth state. The stored
* blob is global to the install and must be deleted on logout (D-15). * value is global to the install and must be deleted on logout (D-15).
*/ */
expect class SecureAuthStateStore() { expect class SecureAuthStateStore() {
fun read(): String? 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 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 = val iosAuthModule =
module { module {
single<IosAuthBridge> { single { createIosLokksmith() }
IosAuthBridgeRegistry.instance
?: error(
"IosAuthBridge not registered before Koin init — call IosAuthBridgeRegistry.shared.setInstance(...) in iOSApp.init.",
)
}
} }

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 package dev.ulfrx.recipe.auth
import kotlinx.cinterop.ExperimentalForeignApi import dev.lokksmith.Lokksmith
import kotlinx.coroutines.suspendCancellableCoroutine import dev.lokksmith.SingletonLokksmithProvider
import kotlinx.serialization.SerializationException import dev.lokksmith.createLokksmith
import kotlinx.serialization.json.Json import dev.lokksmith.ios.launchAuthFlow
import org.koin.mp.KoinPlatform import org.koin.mp.KoinPlatform
import platform.UIKit.UIApplication
import platform.UIKit.UIViewController
import kotlin.coroutines.resume
@OptIn(ExperimentalForeignApi::class)
actual class OidcClient { actual class OidcClient {
private val bridge: IosAuthBridge private val lokksmith: Lokksmith
get() = KoinPlatform.getKoin().get() get() = KoinPlatform.getKoin().get()
actual suspend fun login(): OidcResult { actual suspend fun login(): OidcResult {
val presenter = val client = lokksmith.recipeClient()
topViewController() val flow = client.recipeAuthorizationCodeFlow()
?: return OidcResult.AuthError("Unable to find an iOS view controller for OIDC login") val initiation = flow.prepare()
return suspendCancellableCoroutine { continuation -> lokksmith.launchAuthFlow(initiation)
bridge.login(presenter) { result ->
if (continuation.isActive) continuation.resume(result.toOidcResult()) 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 { actual suspend fun refresh(authStateJson: String): OidcResult {
val tokens = if (authStateJson != LOKKSMITH_AUTH_STATE_JSON) {
decodeTokens(authStateJson) return OidcResult.AuthError("Stored iOS auth state is not a Lokksmith session")
?: 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())
}
} }
val client = lokksmith.recipeClient()
return runCatching { client.toOidcSuccess() }
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC refresh failed", it) }
} }
actual suspend fun logout(authStateJson: String) { actual suspend fun logout(authStateJson: String) {
val tokens = decodeTokens(authStateJson) ?: return val client = lokksmith.recipeClient()
val idTokenHint = tokens.idToken ?: return val flow = client.recipeEndSessionFlow()
val presenter = topViewController() ?: return
suspendCancellableCoroutine<Unit> { continuation -> if (flow != null) {
bridge.endSession(presenter, idTokenHint) { runCatching {
if (continuation.isActive) continuation.resume(Unit) 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) fun createIosLokksmith(): Lokksmith =
private fun topViewController(): UIViewController? { createLokksmith().also { lokksmith ->
val root = UIApplication.sharedApplication.keyWindow?.rootViewController SingletonLokksmithProvider.set(lokksmith)
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
} }

View File

@@ -16,18 +16,17 @@ actual class SecureAuthStateStore {
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
) )
actual fun read(): String? = actual fun read(): String? = settings.getStringOrNull(AUTH_STATE_KEY)
settings.getStringOrNull(authStateKey)
actual fun write(authStateJson: String) { actual fun write(authStateJson: String) {
settings.putString(authStateKey, authStateJson) settings.putString(AUTH_STATE_KEY, authStateJson)
} }
actual fun clear() { actual fun clear() {
settings.remove(authStateKey) settings.remove(AUTH_STATE_KEY)
} }
private companion object { 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 | | Where | Mechanism | Phase 2 plan that lands it |
|-------|-----------|----------------------------| |-------|-----------|----------------------------|
| Authentik provider | Redirect URIs textbox (one line) | This document (Plan 02-01) | | 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) | | iOS | `iosApp/iosApp/Info.plist` `CFBundleURLTypes``CFBundleURLSchemes` array containing exactly `recipe` | Plan 02-05 (iOS OIDC 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. | | 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 interception attack non-exploitable in practice. **Universal Links / App Links are
explicitly excluded** from v1 — see [`## Source Audit`](#source-audit). 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 Logout is **RP-initiated end-session** (D-19, D-20). Tapping "Wyloguj się" performs both
of these atomically, in this order: 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). 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 `AuthState` blob from secure storage (Keychain on iOS, EncryptedSharedPreferences on Android per D-13). 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 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 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. 1. Sign in via UAT-01.
2. In Authentik, set the provider's access-token lifetime to ~60 seconds (or wait the default). 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. 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) ### UAT-03 — Logout returns to login (AUTH-05)
@@ -172,17 +172,17 @@ plan number), ✂ explicitly deferred (see end of section).
| Source | Item | Coverage | | 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 | | 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-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-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-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`) | | 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 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: 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"` | | RESEARCH | Open Question resolved: Ktor patch version follows the selected auth client | ✅ Lokksmith requires Ktor 3.4.2 |
| CONTEXT | **D-01** AppAuth on both mobile platforms via expect/actual `OidcClient` | ⤳ 02-03 (expect) + 02-04 (Android actual) + 02-05 (iOS actual) | | 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-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-03** Wasm `actual` is `NotImplementedError("Wasm OIDC: v2")` | ⤳ 02-03 |
| CONTEXT | **D-04** `OidcClient.login()` / `.refresh()` are `suspend` | ⤳ 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-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-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-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-14** iOS Keychain `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | ⤳ 02-05 |
| CONTEXT | **D-15** one AuthState blob per app install | ⤳ 02-03 | | 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-17** Ktor bearer `refreshTokens` 401 fallback | ⤳ 02-03 |
| CONTEXT | **D-18** silent refresh-failure transition | ⤳ 02-03 | | CONTEXT | **D-18** silent refresh-failure transition | ⤳ 02-03 |
| CONTEXT | **D-19** RP-initiated end-session | ✅ Logout | | 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-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-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) | | 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 | | 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 | 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 | | 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) ### Deferred (excluded from Phase 2)

View File

@@ -10,9 +10,6 @@ androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0" androidx-lifecycle = "2.10.0"
androidx-security-crypto = "1.1.0" androidx-security-crypto = "1.1.0"
androidx-testExt = "1.3.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" composeHotReload = "1.0.0"
composeMultiplatform = "1.10.3" composeMultiplatform = "1.10.3"
exposed = "0.55.0" exposed = "0.55.0"
@@ -24,7 +21,8 @@ koin = "4.2.1"
kotlin = "2.3.20" kotlin = "2.3.20"
kotlinx-coroutines = "1.10.2" kotlinx-coroutines = "1.10.2"
kotlinx-serialization = "1.7.3" kotlinx-serialization = "1.7.3"
ktor = "3.4.1" ktor = "3.4.2"
lokksmith = "0.13.0"
logback = "1.5.32" logback = "1.5.32"
material3 = "1.10.0-alpha05" material3 = "1.10.0-alpha05"
multiplatformSettings = "1.3.0" 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-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: AppAuth + Android secure storage + multiplatform-settings (D-01, D-13, AUTH-02) # Phase 2 — Client: Lokksmith OIDC + Android secure storage + multiplatform-settings (D-01, D-13, AUTH-02)
appauth = { module = "net.openid:appauth", version.ref = "appauth" } lokksmith-core = { module = "dev.lokksmith:lokksmith-core", version.ref = "lokksmith" }
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security-crypto" } 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" } multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformSettings" }

View File

@@ -6,10 +6,6 @@
objectVersion = 77; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */
D6D5D1FA2FA11AF8008BF8AF /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = D6D5D1F92FA11AF8008BF8AF /* AppAuth */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
4B3C797CB7B3655AAA3375CB /* recipe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = recipe.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4B3C797CB7B3655AAA3375CB /* recipe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = recipe.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -45,7 +41,6 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D6D5D1FA2FA11AF8008BF8AF /* AppAuth in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -90,7 +85,6 @@
); );
name = iosApp; name = iosApp;
packageProductDependencies = ( packageProductDependencies = (
D6D5D1F92FA11AF8008BF8AF /* AppAuth */,
); );
productName = iosApp; productName = iosApp;
productReference = 4B3C797CB7B3655AAA3375CB /* recipe.app */; productReference = 4B3C797CB7B3655AAA3375CB /* recipe.app */;
@@ -121,7 +115,6 @@
mainGroup = 9AD793E4EFD47C3FC2FBCEBD; mainGroup = 9AD793E4EFD47C3FC2FBCEBD;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
D6D5D1F82FA11AF8008BF8AF /* XCRemoteSwiftPackageReference "AppAuth-iOS" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = DFB8271353F280D44A8EF684 /* Products */; productRefGroup = DFB8271353F280D44A8EF684 /* Products */;
@@ -378,24 +371,6 @@
}; };
/* End XCConfigurationList section */ /* 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 */; 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 @main
struct iOSApp: App { struct iOSApp: App {
init() { 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() KoinIosKt.doInitKoin()
} }
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() 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.serialization.kotlinx.json.json
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.EngineMain
import io.ktor.server.netty.Netty
import io.ktor.server.plugins.calllogging.CallLogging import io.ktor.server.plugins.calllogging.CallLogging
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.request.httpMethod import io.ktor.server.request.httpMethod
@@ -17,10 +16,7 @@ import io.ktor.server.routing.get
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
fun main() { fun main(args: Array<String>): Unit = EngineMain.main(args)
embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
@Serializable @Serializable
private data class Health( private data class Health(

View File

@@ -4,8 +4,8 @@ import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.application.Application import io.ktor.server.application.Application
import org.flywaydb.core.Flyway import org.flywaydb.core.Flyway
import org.jetbrains.exposed.sql.Database as ExposedDatabase
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.jetbrains.exposed.sql.Database as ExposedDatabase
object Database { object Database {
private val log = LoggerFactory.getLogger(Database::class.java) private val log = LoggerFactory.getLogger(Database::class.java)
@@ -51,12 +51,25 @@ object Database {
log.info("Exposed connected via Hikari pool '{}'", ds.poolName) 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 = private fun readConfig(app: Application): Conf =
Conf( Conf(
url = app.environment.config.property("database.url").getString(), url =
user = app.environment.config.property("database.user").getString(), app.environment.config
password = app.environment.config.property("database.password").getString(), .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 = val sub =
principal.payload.subject?.takeIf { it.isNotBlank() } principal.payload.subject?.takeIf { it.isNotBlank() }
?: error("PrincipalResolver invoked with blank sub — should be blocked by AuthPlugin.validate") ?: 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 nameClaim = principal.payload.getClaim("name")?.asString()
val preferredUsername = principal.payload.getClaim("preferred_username")?.asString() val preferredUsername = principal.payload.getClaim("preferred_username")?.asString()
val displayName = nameClaim ?: preferredUsername ?: email.ifBlank { sub } val displayName = nameClaim ?: preferredUsername ?: email.ifBlank { sub }

View File

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

View File

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

View File

@@ -15,7 +15,6 @@ 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.jetbrains.exposed.sql.Database as ExposedDatabase
import org.junit.AfterClass import org.junit.AfterClass
import org.junit.BeforeClass import org.junit.BeforeClass
import org.testcontainers.containers.PostgreSQLContainer import org.testcontainers.containers.PostgreSQLContainer
@@ -23,6 +22,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotEquals import kotlin.test.assertNotEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import org.jetbrains.exposed.sql.Database as ExposedDatabase
/** /**
* Integration test for `GET /api/v1/me` — exercises the full * Integration test for `GET /api/v1/me` — exercises the full

View File

@@ -28,12 +28,18 @@ kotlin {
android { android {
namespace = "dev.ulfrx.recipe.shared" namespace = "dev.ulfrx.recipe.shared"
compileSdk = libs.versions.android.compileSdk.get().toInt() compileSdk =
libs.versions.android.compileSdk
.get()
.toInt()
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
defaultConfig { 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` * Custom URL scheme for the OAuth redirect (D-09). Must match `Info.plist`
* `CFBundleURLTypes` on iOS and the Android manifest `<intent-filter>` byte * `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. * non-exploitable; Universal Links / App Links are explicitly deferred.
*/ */
public const val OIDC_REDIRECT_URI: String = "recipe://callback" public const val OIDC_REDIRECT_URI: String = "recipe://callback"