Compare commits
2 Commits
673bbaaba3
...
995bdd5ae6
| Author | SHA1 | Date | |
|---|---|---|---|
| 995bdd5ae6 | |||
| 015d8d51d0 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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 */;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user