Compare commits
83 Commits
995bdd5ae6
...
673bbaaba3
| Author | SHA1 | Date | |
|---|---|---|---|
| 673bbaaba3 | |||
| 0a15c9d9b5 | |||
| 570652c744 | |||
| 88f489800d | |||
| 466e4c7f7a | |||
| d69cb1caee | |||
| 938f324bb8 | |||
| 0a24be9a95 | |||
| 06e5eaf94e | |||
| b364c3056e | |||
| 88dc8d719a | |||
| ac9fc61410 | |||
| 8d1c34c2f6 | |||
| 11a5eeb3ff | |||
| 6385453653 | |||
| fa78ee31b4 | |||
| a94f803ca6 | |||
| 0dbd374f46 | |||
| edc2a1d4c8 | |||
| 3122fdaf37 | |||
| 7ef222e71e | |||
| 8cf112a68a | |||
| 36c1b2c822 | |||
| 614b57c34d | |||
| fe8c0b6823 | |||
| 9f7cadda7b | |||
| 62040d461a | |||
| c1cc713bbb | |||
| 7e73a9a820 | |||
| 6504b46e40 | |||
| 1246e12012 | |||
| 37450291c6 | |||
| 0b01bc8bbb | |||
| f0462cbca1 | |||
| 29d655828d | |||
| cca3ab7923 | |||
| ab69cc1dff | |||
| 090027224c | |||
| 6ab7960e16 | |||
| 31b4f4d57e | |||
| 830097f5c1 | |||
| f3569b41d6 | |||
| 04b3d9b1d5 | |||
| 42d134a997 | |||
| 68655eae1a | |||
| b36058fa79 | |||
| 81bff1db17 | |||
| eaa88fff36 | |||
| fd3e7e1584 | |||
| 129ee616d5 | |||
| 8cd608a981 | |||
| cc5002d1df | |||
| d7ee6b83fc | |||
| 61885455bb | |||
| 6972839fd0 | |||
| c79f9218aa | |||
| 2c786b2fc2 | |||
| f9d3a0c2d4 | |||
| b8671d6dbb | |||
| 59d069591b | |||
| 60221f66a2 | |||
| 37f6191523 | |||
| f691400f2b | |||
| daefe6c26d | |||
| d316a4805e | |||
| 24018efe67 | |||
| 4e6192293f | |||
| 6a69910aa7 | |||
| af4428fd8a | |||
| 7d750af710 | |||
| d76dcea18d | |||
| 4d9aefd4c2 | |||
| aaa8042aee | |||
| d873c31e19 | |||
| b609cb6362 | |||
| 875055a5ef | |||
| 8ef2dbfae4 | |||
| 0ca22f9e36 | |||
| d104d3da87 | |||
| d6cec3fe07 | |||
| 7ac1555a4c | |||
| 6f9d7d7ee5 | |||
| 9738621f77 |
@@ -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: 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 |
|
| 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 |
|
||||||
|
|
||||||
### Server tech stack
|
### Server tech stack
|
||||||
|
|
||||||
|
|||||||
@@ -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 OIDC actual, Android secure AuthState store, and manifest callback
|
- [ ] 02-04-PLAN.md — Android AppAuth actual, Android secure AuthState store, and manifest callback
|
||||||
- [ ] 02-05-PLAN.md — iOS OIDC actual, iOS Keychain store, and URL scheme callback
|
- [ ] 02-05-PLAN.md — iOS AppAuth actual, iOS Keychain store, URL scheme, Swift callback, and Podfile
|
||||||
- [ ] 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
|
||||||
|
|||||||
@@ -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: Lokksmith on Android/iOS (KMP interface; ASWebAuthenticationSession underneath on iOS)
|
- Mobile OIDC: AppAuth on Android; ASWebAuthenticationSession wrapper on iOS (KMP interface)
|
||||||
|
|
||||||
**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)
|
||||||
|
|||||||
@@ -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: Lokksmith on Android/iOS through the KMP `OidcClient` interface. iOS uses ASWebAuthenticationSession underneath without a Swift auth bridge.
|
- 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`
|
||||||
|
|
||||||
**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)
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ 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
|
||||||
// still re-exported so Swift can read shared constants when needed.
|
// re-exported so the Swift `AuthBridge` can read `Constants` (single source
|
||||||
|
// 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"
|
||||||
|
|||||||
@@ -36,9 +36,13 @@ android {
|
|||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
// Lokksmith's Android redirect activity uses the scheme-only placeholder.
|
// AppAuth-Android (D-01) bundles a manifest entry for its
|
||||||
// Must match `dev.ulfrx.recipe.shared.Constants.OIDC_REDIRECT_URI`.
|
// `RedirectUriReceiverActivity` that requires `${appAuthRedirectScheme}` to be
|
||||||
manifestPlaceholders["lokksmithRedirectScheme"] = "recipe"
|
// 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"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
@@ -72,8 +76,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 flow through to the exported ObjC
|
// `api` so `:shared` types (notably `Constants`) flow through to the
|
||||||
// framework headers when the iOS shell needs them.
|
// exported ObjC framework headers — the iOS Swift bridge 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).
|
||||||
@@ -99,18 +103,18 @@ kotlin {
|
|||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.koin.android)
|
implementation(libs.koin.android)
|
||||||
|
|
||||||
// Phase 2 Android: AndroidX Security Crypto for the SecureAuthStateStore
|
// Phase 2 Android: AppAuth-Android + AndroidX Security Crypto for the
|
||||||
// actual (D-13). EncryptedSharedPreferences is accepted technical debt per
|
// SecureAuthStateStore actual (D-01, D-13). EncryptedSharedPreferences is
|
||||||
// Open Question #1; the Keystore-backed implementation can replace it
|
// accepted technical debt per Open Question #1; the Keystore-backed
|
||||||
// without touching AuthSession.
|
// implementation can replace it 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. Lokksmith handles the native
|
// Phase 2 iOS: Darwin engine for Ktor. AppAuth-iOS is delivered via
|
||||||
// ASWebAuthenticationSession integration directly from Kotlin.
|
// SwiftPM in iosApp.xcodeproj and consumed through a Swift bridge —
|
||||||
implementation(libs.lokksmith.core)
|
// no Kotlin-side AppAuth dependency (DECISION-drop-cocoapods, 2026-04-28).
|
||||||
implementation(libs.ktor.clientDarwin)
|
implementation(libs.ktor.clientDarwin)
|
||||||
}
|
}
|
||||||
jvmMain.dependencies {
|
jvmMain.dependencies {
|
||||||
|
|||||||
@@ -18,6 +18,20 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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
|
||||||
@@ -12,7 +11,6 @@ class MainApplication : Application() {
|
|||||||
configureLogging()
|
configureLogging()
|
||||||
initKoin {
|
initKoin {
|
||||||
androidContext(this@MainApplication)
|
androidContext(this@MainApplication)
|
||||||
modules(androidAuthModule)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
package dev.ulfrx.recipe.auth
|
|
||||||
|
|
||||||
import org.koin.android.ext.koin.androidContext
|
|
||||||
import org.koin.dsl.module
|
|
||||||
|
|
||||||
val androidAuthModule =
|
|
||||||
module {
|
|
||||||
single { createAndroidLokksmith(androidContext().applicationContext) }
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package dev.ulfrx.recipe.auth
|
|
||||||
|
|
||||||
import dev.lokksmith.Lokksmith
|
|
||||||
import dev.lokksmith.client.Client
|
|
||||||
import dev.lokksmith.client.InternalClient
|
|
||||||
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
|
||||||
import dev.lokksmith.client.request.flow.AuthFlowStateResponseHandler
|
|
||||||
import dev.lokksmith.client.request.flow.authorizationCode.AuthorizationCodeFlow
|
|
||||||
import dev.lokksmith.client.request.flow.endSession.EndSessionFlow
|
|
||||||
import dev.lokksmith.client.request.parameter.Scope
|
|
||||||
import dev.lokksmith.discoveryUrl
|
|
||||||
import dev.lokksmith.id
|
|
||||||
import dev.ulfrx.recipe.shared.Constants
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.selects.select
|
|
||||||
|
|
||||||
internal const val LOKKSMITH_CLIENT_KEY = "recipe-app"
|
|
||||||
internal const val LOKKSMITH_AUTH_STATE_JSON = "lokksmith:$LOKKSMITH_CLIENT_KEY"
|
|
||||||
|
|
||||||
internal suspend fun Lokksmith.recipeClient(): Client =
|
|
||||||
getOrCreate(LOKKSMITH_CLIENT_KEY) {
|
|
||||||
id = Constants.OIDC_CLIENT_ID
|
|
||||||
discoveryUrl = Constants.OIDC_ISSUER + ".well-known/openid-configuration"
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun Client.recipeAuthorizationCodeFlow(): dev.lokksmith.client.request.flow.AuthFlow =
|
|
||||||
authorizationCodeFlow(
|
|
||||||
AuthorizationCodeFlow.Request(
|
|
||||||
redirectUri = Constants.OIDC_REDIRECT_URI,
|
|
||||||
scope = setOf(Scope.Profile, Scope.Email, Scope.Custom("offline_access")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun Client.recipeEndSessionFlow(): dev.lokksmith.client.request.flow.AuthFlow? =
|
|
||||||
endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI))
|
|
||||||
|
|
||||||
internal suspend fun Client.awaitTerminalAuthFlowResult(): AuthFlowResultProvider.Result =
|
|
||||||
AuthFlowResultProvider
|
|
||||||
.forClient(this)
|
|
||||||
.first { result ->
|
|
||||||
result is AuthFlowResultProvider.Result.Success ||
|
|
||||||
result is AuthFlowResultProvider.Result.Cancelled ||
|
|
||||||
result is AuthFlowResultProvider.Result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
internal suspend fun Lokksmith.completeAuthFlow(client: Client): AuthFlowResultProvider.Result =
|
|
||||||
coroutineScope {
|
|
||||||
val terminal = async { client.awaitTerminalAuthFlowResult() }
|
|
||||||
val responseUri =
|
|
||||||
async {
|
|
||||||
(client as InternalClient)
|
|
||||||
.snapshots
|
|
||||||
.map { snapshot -> snapshot.ephemeralFlowState?.responseUri }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.first { responseUri -> responseUri != null }
|
|
||||||
}
|
|
||||||
|
|
||||||
select<AuthFlowResultProvider.Result> {
|
|
||||||
terminal.onAwait { result ->
|
|
||||||
responseUri.cancel()
|
|
||||||
result
|
|
||||||
}
|
|
||||||
responseUri.onAwait { uri ->
|
|
||||||
terminal.cancel()
|
|
||||||
AuthFlowStateResponseHandler(this@completeAuthFlow).onResponse(checkNotNull(uri))
|
|
||||||
client.awaitTerminalAuthFlowResult()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal suspend fun Client.toOidcSuccess(): OidcResult.Success {
|
|
||||||
var freshTokens: Client.Tokens? = null
|
|
||||||
runWithTokens { tokens -> freshTokens = tokens }
|
|
||||||
val tokens = checkNotNull(freshTokens) { "Lokksmith returned no tokens" }
|
|
||||||
return OidcResult.Success(
|
|
||||||
authStateJson = LOKKSMITH_AUTH_STATE_JSON,
|
|
||||||
accessToken = tokens.accessToken.token,
|
|
||||||
idToken = tokens.idToken.raw,
|
|
||||||
expiresAtEpochMillis = tokens.accessToken.expiresAt?.let { it * 1_000L } ?: 0L,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun AuthFlowResultProvider.Result.toOidcFailureOrNull(): OidcResult? =
|
|
||||||
when (this) {
|
|
||||||
is AuthFlowResultProvider.Result.Success -> null
|
|
||||||
|
|
||||||
is AuthFlowResultProvider.Result.Cancelled -> OidcResult.Cancelled
|
|
||||||
|
|
||||||
is AuthFlowResultProvider.Result.Error -> OidcResult.AuthError(message ?: "OIDC flow failed")
|
|
||||||
|
|
||||||
AuthFlowResultProvider.Result.Undefined,
|
|
||||||
is AuthFlowResultProvider.Result.Processing,
|
|
||||||
-> OidcResult.AuthError("OIDC flow did not complete")
|
|
||||||
}
|
|
||||||
@@ -2,75 +2,330 @@
|
|||||||
|
|
||||||
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 dev.lokksmith.Lokksmith
|
import android.content.IntentFilter
|
||||||
import dev.lokksmith.SingletonLokksmithProvider
|
import android.net.Uri
|
||||||
import dev.lokksmith.android.LokksmithAuthFlowActivity
|
import android.os.Build
|
||||||
import dev.lokksmith.createLokksmith
|
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 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 client = lokksmith.recipeClient()
|
val configuration =
|
||||||
val flow = client.recipeAuthorizationCodeFlow()
|
when (val outcome = fetchConfiguration()) {
|
||||||
val initiation = flow.prepare()
|
is ConfigurationOutcome.Success -> outcome.configuration
|
||||||
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
val request =
|
||||||
failure
|
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()
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
service.dispose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun refresh(authStateJson: String): OidcResult {
|
actual suspend fun refresh(authStateJson: String): OidcResult {
|
||||||
if (authStateJson != LOKKSMITH_AUTH_STATE_JSON) {
|
val authState =
|
||||||
return OidcResult.AuthError("Stored Android auth state is not a Lokksmith session")
|
runCatching { AuthState.jsonDeserialize(authStateJson) }
|
||||||
|
.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 client = lokksmith.recipeClient()
|
val authState = runCatching { AuthState.jsonDeserialize(authStateJson) }.getOrNull() ?: return
|
||||||
val flow = client.recipeEndSessionFlow()
|
val configuration = authState.authorizationServiceConfiguration ?: return
|
||||||
|
if (configuration.endSessionEndpoint == null) return
|
||||||
|
|
||||||
if (flow != null) {
|
val request =
|
||||||
runCatching {
|
EndSessionRequest
|
||||||
val initiation = flow.prepare()
|
.Builder(configuration)
|
||||||
context.startActivity(
|
.setIdTokenHint(authState.idToken)
|
||||||
LokksmithAuthFlowActivity
|
.setPostLogoutRedirectUri(Uri.parse(Constants.OIDC_REDIRECT_URI))
|
||||||
.createCustomTabsIntent(context = context, initiation = initiation)
|
.build()
|
||||||
.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)
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client.resetTokens()
|
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
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createAndroidLokksmith(context: Context): Lokksmith =
|
appContext.registerPrivateReceiver(receiver, filter)
|
||||||
createLokksmith(context).also { lokksmith ->
|
|
||||||
SingletonLokksmithProvider.set(lokksmith)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ package dev.ulfrx.recipe.auth
|
|||||||
/**
|
/**
|
||||||
* Common seam for Authentik OIDC.
|
* Common seam for Authentik OIDC.
|
||||||
*
|
*
|
||||||
* Native Android/iOS actuals use Lokksmith for browser-based Authorization Code
|
* Native Android/iOS actuals must use AppAuth (D-01) and bridge AppAuth callback
|
||||||
* + PKCE. Login requests must be public PKCE-compatible OIDC requests with
|
* APIs with `suspendCancellableCoroutine`, cancelling the underlying AppAuth
|
||||||
* exactly these scopes: `openid profile email offline_access` (D-06).
|
* request when the coroutine is cancelled (D-04). Login requests must be public
|
||||||
* Lokksmith owns state and nonce verification.
|
* PKCE-compatible OIDC requests with exactly these scopes:
|
||||||
|
* `openid profile email offline_access` (D-06). AppAuth owns state and nonce
|
||||||
|
* verification.
|
||||||
*
|
*
|
||||||
* Refresh must go through Lokksmith fresh-token handling, then return an opaque
|
* Refresh must go through AppAuth fresh-token APIs such as
|
||||||
* auth-state marker for persistence (D-16). Logout must use RP-initiated
|
* `performActionWithFreshTokens`, then return the updated AuthState JSON for
|
||||||
* end-session before local state is cleared; callers still clear local state if
|
* persistence (D-16). Logout must use AppAuth RP-initiated end-session APIs
|
||||||
* remote logout fails so users are never trapped in a stale session (D-19, D-20).
|
* 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() {
|
expect class OidcClient() {
|
||||||
suspend fun login(): OidcResult
|
suspend fun login(): OidcResult
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package dev.ulfrx.recipe.auth
|
|||||||
/**
|
/**
|
||||||
* Result returned by platform OIDC clients.
|
* Result returned by platform OIDC clients.
|
||||||
*
|
*
|
||||||
* `authStateJson` is an opaque platform-auth marker persisted by [SecureAuthStateStore].
|
* `authStateJson` is the opaque AppAuth AuthState JSON blob persisted by
|
||||||
* Callers must not parse token values out of it directly.
|
* [SecureAuthStateStore]. Callers must not parse token values out of it directly.
|
||||||
*/
|
*/
|
||||||
sealed interface OidcResult {
|
sealed interface OidcResult {
|
||||||
data class Success(
|
data class Success(
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
package dev.ulfrx.recipe.auth
|
package dev.ulfrx.recipe.auth
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists the opaque platform auth state marker for the current app install.
|
* Persists the full AppAuth AuthState JSON blob 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 auth state. The stored
|
* no-arg or default insecure settings implementations for tokens. The stored
|
||||||
* value is global to the install and must be deleted on logout (D-15).
|
* blob 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?
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
@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
|
||||||
|
}
|
||||||
@@ -2,7 +2,18 @@ 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 { createIosLokksmith() }
|
single<IosAuthBridge> {
|
||||||
|
IosAuthBridgeRegistry.instance
|
||||||
|
?: error(
|
||||||
|
"IosAuthBridge not registered before Koin init — call IosAuthBridgeRegistry.shared.setInstance(...) in iOSApp.init.",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
package dev.ulfrx.recipe.auth
|
|
||||||
|
|
||||||
import dev.lokksmith.Lokksmith
|
|
||||||
import dev.lokksmith.client.Client
|
|
||||||
import dev.lokksmith.client.InternalClient
|
|
||||||
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
|
||||||
import dev.lokksmith.client.request.flow.AuthFlowStateResponseHandler
|
|
||||||
import dev.lokksmith.client.request.flow.authorizationCode.AuthorizationCodeFlow
|
|
||||||
import dev.lokksmith.client.request.flow.endSession.EndSessionFlow
|
|
||||||
import dev.lokksmith.client.request.parameter.Scope
|
|
||||||
import dev.lokksmith.discoveryUrl
|
|
||||||
import dev.lokksmith.id
|
|
||||||
import dev.ulfrx.recipe.shared.Constants
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.selects.select
|
|
||||||
|
|
||||||
internal const val LOKKSMITH_CLIENT_KEY = "recipe-app"
|
|
||||||
internal const val LOKKSMITH_AUTH_STATE_JSON = "lokksmith:$LOKKSMITH_CLIENT_KEY"
|
|
||||||
|
|
||||||
internal suspend fun Lokksmith.recipeClient(): Client =
|
|
||||||
getOrCreate(LOKKSMITH_CLIENT_KEY) {
|
|
||||||
id = Constants.OIDC_CLIENT_ID
|
|
||||||
discoveryUrl = Constants.OIDC_ISSUER + ".well-known/openid-configuration"
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun Client.recipeAuthorizationCodeFlow(): dev.lokksmith.client.request.flow.AuthFlow =
|
|
||||||
authorizationCodeFlow(
|
|
||||||
AuthorizationCodeFlow.Request(
|
|
||||||
redirectUri = Constants.OIDC_REDIRECT_URI,
|
|
||||||
scope = setOf(Scope.Profile, Scope.Email, Scope.Custom("offline_access")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun Client.recipeEndSessionFlow(): dev.lokksmith.client.request.flow.AuthFlow? =
|
|
||||||
endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI))
|
|
||||||
|
|
||||||
internal suspend fun Client.awaitTerminalAuthFlowResult(): AuthFlowResultProvider.Result =
|
|
||||||
AuthFlowResultProvider
|
|
||||||
.forClient(this)
|
|
||||||
.first { result ->
|
|
||||||
result is AuthFlowResultProvider.Result.Success ||
|
|
||||||
result is AuthFlowResultProvider.Result.Cancelled ||
|
|
||||||
result is AuthFlowResultProvider.Result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
internal suspend fun Lokksmith.completeAuthFlow(client: Client): AuthFlowResultProvider.Result =
|
|
||||||
coroutineScope {
|
|
||||||
val terminal = async { client.awaitTerminalAuthFlowResult() }
|
|
||||||
val responseUri =
|
|
||||||
async {
|
|
||||||
(client as InternalClient)
|
|
||||||
.snapshots
|
|
||||||
.map { snapshot -> snapshot.ephemeralFlowState?.responseUri }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.first { responseUri -> responseUri != null }
|
|
||||||
}
|
|
||||||
|
|
||||||
select<AuthFlowResultProvider.Result> {
|
|
||||||
terminal.onAwait { result ->
|
|
||||||
responseUri.cancel()
|
|
||||||
result
|
|
||||||
}
|
|
||||||
responseUri.onAwait { uri ->
|
|
||||||
terminal.cancel()
|
|
||||||
AuthFlowStateResponseHandler(this@completeAuthFlow).onResponse(checkNotNull(uri))
|
|
||||||
client.awaitTerminalAuthFlowResult()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal suspend fun Client.toOidcSuccess(): OidcResult.Success {
|
|
||||||
var freshTokens: Client.Tokens? = null
|
|
||||||
runWithTokens { tokens -> freshTokens = tokens }
|
|
||||||
val tokens = checkNotNull(freshTokens) { "Lokksmith returned no tokens" }
|
|
||||||
return OidcResult.Success(
|
|
||||||
authStateJson = LOKKSMITH_AUTH_STATE_JSON,
|
|
||||||
accessToken = tokens.accessToken.token,
|
|
||||||
idToken = tokens.idToken.raw,
|
|
||||||
expiresAtEpochMillis = tokens.accessToken.expiresAt?.let { it * 1_000L } ?: 0L,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun AuthFlowResultProvider.Result.toOidcFailureOrNull(): OidcResult? =
|
|
||||||
when (this) {
|
|
||||||
is AuthFlowResultProvider.Result.Success -> null
|
|
||||||
|
|
||||||
is AuthFlowResultProvider.Result.Cancelled -> OidcResult.Cancelled
|
|
||||||
|
|
||||||
is AuthFlowResultProvider.Result.Error -> OidcResult.AuthError(message ?: "OIDC flow failed")
|
|
||||||
|
|
||||||
AuthFlowResultProvider.Result.Undefined,
|
|
||||||
is AuthFlowResultProvider.Result.Processing,
|
|
||||||
-> OidcResult.AuthError("OIDC flow did not complete")
|
|
||||||
}
|
|
||||||
@@ -2,54 +2,115 @@
|
|||||||
|
|
||||||
package dev.ulfrx.recipe.auth
|
package dev.ulfrx.recipe.auth
|
||||||
|
|
||||||
import dev.lokksmith.Lokksmith
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
import dev.lokksmith.SingletonLokksmithProvider
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import dev.lokksmith.createLokksmith
|
import kotlinx.serialization.SerializationException
|
||||||
import dev.lokksmith.ios.launchAuthFlow
|
import kotlinx.serialization.json.Json
|
||||||
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 lokksmith: Lokksmith
|
private val bridge: IosAuthBridge
|
||||||
get() = KoinPlatform.getKoin().get()
|
get() = KoinPlatform.getKoin().get()
|
||||||
|
|
||||||
actual suspend fun login(): OidcResult {
|
actual suspend fun login(): OidcResult {
|
||||||
val client = lokksmith.recipeClient()
|
val presenter =
|
||||||
val flow = client.recipeAuthorizationCodeFlow()
|
topViewController()
|
||||||
val initiation = flow.prepare()
|
?: return OidcResult.AuthError("Unable to find an iOS view controller for OIDC login")
|
||||||
|
|
||||||
lokksmith.launchAuthFlow(initiation)
|
return suspendCancellableCoroutine { continuation ->
|
||||||
|
bridge.login(presenter) { result ->
|
||||||
return when (val failure = lokksmith.completeAuthFlow(client).toOidcFailureOrNull()) {
|
if (continuation.isActive) continuation.resume(result.toOidcResult())
|
||||||
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 {
|
||||||
if (authStateJson != LOKKSMITH_AUTH_STATE_JSON) {
|
val tokens =
|
||||||
return OidcResult.AuthError("Stored iOS auth state is not a Lokksmith session")
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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 client = lokksmith.recipeClient()
|
val tokens = decodeTokens(authStateJson) ?: return
|
||||||
val flow = client.recipeEndSessionFlow()
|
val idTokenHint = tokens.idToken ?: return
|
||||||
|
val presenter = topViewController() ?: return
|
||||||
|
|
||||||
if (flow != null) {
|
suspendCancellableCoroutine<Unit> { continuation ->
|
||||||
runCatching {
|
bridge.endSession(presenter, idTokenHint) {
|
||||||
lokksmith.launchAuthFlow(flow.prepare())
|
if (continuation.isActive) continuation.resume(Unit)
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createIosLokksmith(): Lokksmith =
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
createLokksmith().also { lokksmith ->
|
private fun topViewController(): UIViewController? {
|
||||||
SingletonLokksmithProvider.set(lokksmith)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,17 +16,18 @@ actual class SecureAuthStateStore {
|
|||||||
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||||
)
|
)
|
||||||
|
|
||||||
actual fun read(): String? = settings.getStringOrNull(AUTH_STATE_KEY)
|
actual fun read(): String? =
|
||||||
|
settings.getStringOrNull(authStateKey)
|
||||||
|
|
||||||
actual fun write(authStateJson: String) {
|
actual fun write(authStateJson: String) {
|
||||||
settings.putString(AUTH_STATE_KEY, authStateJson)
|
settings.putString(authStateKey, authStateJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun clear() {
|
actual fun clear() {
|
||||||
settings.remove(AUTH_STATE_KEY)
|
settings.remove(authStateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val AUTH_STATE_KEY = "dev.ulfrx.recipe.auth.lokksmith-state"
|
const val authStateKey = "dev.ulfrx.recipe.auth.appauth-state"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 OIDC actual) |
|
| iOS | `iosApp/iosApp/Info.plist` `CFBundleURLTypes` → `CFBundleURLSchemes` array containing exactly `recipe` | Plan 02-05 (iOS AppAuth actual) |
|
||||||
| Android | Lokksmith's manifest callback using `lokksmithRedirectScheme=recipe` and the `callback` host | Plan 02-04 (Android 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. |
|
||||||
|
|
||||||
PKCE S256 + Lokksmith's state/nonce handling makes the well-known custom-scheme
|
PKCE S256 + AppAuth'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`. Lokksmith's end-session flow 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`. AppAuth's `EndSessionRequest` API 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).
|
2. Delete the persisted `AuthState` blob 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 — Lokksmith silently exchanged the refresh token (D-16, D-17).
|
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).
|
||||||
|
|
||||||
### 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 OIDC) + 02-05 (iOS OIDC) |
|
| REQ | **AUTH-01** sign in via Authentik OIDC + PKCE | ✅ Provider; ⤳ 02-04 (Android AppAuth) + 02-05 (iOS AppAuth) |
|
||||||
| 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 (Lokksmith end-session per platform) + Manual UAT-03 |
|
| REQ | **AUTH-05** logout returns to login | ✅ Logout section; ⤳ 02-04/02-05 (AppAuth 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: Lokksmith, 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: AppAuth, 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 patch version follows the selected auth client | ✅ Lokksmith requires Ktor 3.4.2 |
|
| RESEARCH | Open Question resolved: Ktor stays at 3.4.1 (no patch bump) | ✅ Task 2 catalog keeps `ktor = "3.4.1"` |
|
||||||
| 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-01** AppAuth 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 opaque auth-state marker via `multiplatform-settings` | ⤳ 02-03 + 02-04 + 02-05 |
|
| CONTEXT | **D-13** persist full AppAuth `AuthState` JSON 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 Lokksmith token refresh | ⤳ 02-04 + 02-05 |
|
| CONTEXT | **D-16** proactive refresh via `performActionWithFreshTokens` | ⤳ 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** Lokksmith end-session flow drives logout | ✅ Logout; ⤳ 02-04 + 02-05 |
|
| CONTEXT | **D-20** AppAuth `EndSessionRequest` 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, Lokksmith 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, AppAuth 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)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ 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"
|
||||||
@@ -21,8 +24,7 @@ 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.2"
|
ktor = "3.4.1"
|
||||||
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"
|
||||||
@@ -93,8 +95,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: Lokksmith OIDC + Android secure storage + multiplatform-settings (D-01, D-13, AUTH-02)
|
# Phase 2 — Client: AppAuth + Android secure storage + multiplatform-settings (D-01, D-13, AUTH-02)
|
||||||
lokksmith-core = { module = "dev.lokksmith:lokksmith-core", version.ref = "lokksmith" }
|
appauth = { module = "net.openid:appauth", version.ref = "appauth" }
|
||||||
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" }
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
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 */
|
||||||
@@ -41,6 +45,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
D6D5D1FA2FA11AF8008BF8AF /* AppAuth in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -85,6 +90,7 @@
|
|||||||
);
|
);
|
||||||
name = iosApp;
|
name = iosApp;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
D6D5D1F92FA11AF8008BF8AF /* AppAuth */,
|
||||||
);
|
);
|
||||||
productName = iosApp;
|
productName = iosApp;
|
||||||
productReference = 4B3C797CB7B3655AAA3375CB /* recipe.app */;
|
productReference = 4B3C797CB7B3655AAA3375CB /* recipe.app */;
|
||||||
@@ -115,6 +121,7 @@
|
|||||||
mainGroup = 9AD793E4EFD47C3FC2FBCEBD;
|
mainGroup = 9AD793E4EFD47C3FC2FBCEBD;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
|
D6D5D1F82FA11AF8008BF8AF /* XCRemoteSwiftPackageReference "AppAuth-iOS" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = DFB8271353F280D44A8EF684 /* Products */;
|
productRefGroup = DFB8271353F280D44A8EF684 /* Products */;
|
||||||
@@ -371,6 +378,24 @@
|
|||||||
};
|
};
|
||||||
/* 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 */;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "c2c3123823fbf9ecb5ff108c887e3a41cb72f13d86620f12b66cac13738096c1",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "appauth-ios",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/openid/AppAuth-iOS",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "145104f5ea9d58ae21b60add007c33c1cc0c948e",
|
||||||
|
"version" : "2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
223
iosApp/iosApp/Auth/AuthBridge.swift
Normal file
223
iosApp/iosApp/Auth/AuthBridge.swift
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
// 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())
|
||||||
|
}
|
||||||
@@ -4,12 +4,21 @@ 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ 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.netty.EngineMain
|
import io.ktor.server.engine.embeddedServer
|
||||||
|
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
|
||||||
@@ -16,7 +17,10 @@ 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(args: Array<String>): Unit = EngineMain.main(args)
|
fun main() {
|
||||||
|
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(
|
||||||
|
|||||||
@@ -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.slf4j.LoggerFactory
|
|
||||||
import org.jetbrains.exposed.sql.Database as ExposedDatabase
|
import org.jetbrains.exposed.sql.Database as ExposedDatabase
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
object Database {
|
object Database {
|
||||||
private val log = LoggerFactory.getLogger(Database::class.java)
|
private val log = LoggerFactory.getLogger(Database::class.java)
|
||||||
@@ -51,25 +51,12 @@ object Database {
|
|||||||
log.info("Exposed connected via Hikari pool '{}'", ds.poolName)
|
log.info("Exposed connected via Hikari pool '{}'", ds.poolName)
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class Conf(
|
private data class Conf(val url: String, val user: String, val password: String)
|
||||||
val url: String,
|
|
||||||
val user: String,
|
|
||||||
val password: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun readConfig(app: Application): Conf =
|
private fun readConfig(app: Application): Conf =
|
||||||
Conf(
|
Conf(
|
||||||
url =
|
url = app.environment.config.property("database.url").getString(),
|
||||||
app.environment.config
|
user = app.environment.config.property("database.user").getString(),
|
||||||
.property("database.url")
|
password = app.environment.config.property("database.password").getString(),
|
||||||
.getString(),
|
|
||||||
user =
|
|
||||||
app.environment.config
|
|
||||||
.property("database.user")
|
|
||||||
.getString(),
|
|
||||||
password =
|
|
||||||
app.environment.config
|
|
||||||
.property("database.password")
|
|
||||||
.getString(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,7 @@ 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 =
|
val email = principal.payload.getClaim("email")?.asString().orEmpty()
|
||||||
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 }
|
||||||
|
|||||||
@@ -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 = "https://auth.ulfrx.dev/application/o/recipe-app/jwks/"
|
jwksUrl = ""
|
||||||
jwksUrl = ${?OIDC_JWKS_URL}
|
jwksUrl = ${?OIDC_JWKS_URL}
|
||||||
leewaySeconds = "30"
|
leewaySeconds = "30"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,8 +72,7 @@ internal class JwtTestSupport(
|
|||||||
): String {
|
): String {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val builder =
|
val builder =
|
||||||
JWT
|
JWT.create()
|
||||||
.create()
|
|
||||||
.withKeyId(keyId)
|
.withKeyId(keyId)
|
||||||
.withIssuer(iss)
|
.withIssuer(iss)
|
||||||
.withAudience(aud)
|
.withAudience(aud)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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
|
||||||
@@ -22,7 +23,6 @@ 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
|
||||||
|
|||||||
@@ -28,18 +28,12 @@ kotlin {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "dev.ulfrx.recipe.shared"
|
namespace = "dev.ulfrx.recipe.shared"
|
||||||
compileSdk =
|
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||||
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 =
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
libs.versions.android.minSdk
|
|
||||||
.get()
|
|
||||||
.toInt()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 + Lokksmith state (D-05) make custom-scheme interception
|
* for byte. PKCE S256 + AppAuth 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user