Compare commits

..

83 Commits

Author SHA1 Message Date
673bbaaba3 Drop cocoapods 2026-04-28 21:41:52 +02:00
0a15c9d9b5 docs(02-07): record auth UI gate progress, awaiting iOS Authentik UAT
Tasks 1+2 (auto) complete: Compose Resources strings, RecipeTheme seed,
SplashScreen/LoginScreen/PostLoginPlaceholderScreen, LoginViewModel +
PostLoginViewModel registered in authModule, App.kt auth gate via
collectAsStateWithLifecycle. Task 3 (checkpoint:human-verify) requires
manual iOS Authentik UAT per docs/authentik-setup.md before Phase 2
can be marked complete.
2026-04-28 17:51:17 +02:00
570652c744 fix(02-07): use kotlinx.coroutines.test.runTest in commonTest for wasmJs
- Add kotlinx-coroutines-test to commonTest dependencies in composeApp
- Refactor AuthSessionTest and LoginViewModelTest from runBlocking to runTest
  so the wasmJs test target compiles (runBlocking is JVM/Native-only)
- App.kt picks up spotless-imposed brace blocks under the auth-gate when
- Log pre-existing SecureAuthStateStoreContractTest and ios SecureAuthStateStore
  ktlint failures to deferred-items.md (out of scope per gsd scope-boundary rule)

Rule 3 (blocking): adding the multiplatform test runtime is needed so
./gradlew check on commonTest sources can compile across all KMP targets.
2026-04-28 17:41:18 +02:00
88f489800d feat(02-07): implement auth screens, ViewModels, and App auth gate
- Replace template App() body with RecipeTheme + when over AuthSession.state
  rendering SplashScreen / LoginScreen / PostLoginPlaceholderScreen
- LaunchedEffect kicks AuthSession.initialize() once at composition start so
  the persisted-session restore actually progresses Loading -> Auth/Unauth
- LoginViewModel.onSignInClick() returns the launched Job and maps
  Cancelled/NetworkError/Failed to auth_error_cancelled/network/unknown
- LoginScreenState clears the previous error and sets isLoading=true before
  awaiting AuthSession.login(), per UI-SPEC inline-error rules
- PostLoginViewModel.onSignOutClick() delegates to AuthSession.logout()
- Screens use Material 3 stdlib only (Surface, Button, OutlinedButton,
  CircularProgressIndicator); no Scaffold, no Haze, all strings via
  stringResource(Res.string.*)
- Register LoginViewModel + PostLoginViewModel in authModule via
  org.koin.core.module.dsl.viewModel
2026-04-28 17:36:48 +02:00
466e4c7f7a test(02-07): add Compose Resources, theme seed, and failing LoginViewModel tests
- Add Phase 2 auth strings.xml with Polish scaffold copy from UI-SPEC
- Add RecipeTheme with #3B6939 / #A2D597 seed and isSystemInDarkTheme
- Add LoginViewModelTest covering cancelled/network/unknown error mapping
  and the clear-error-on-retry behavior; tests fail compile until Task 2
2026-04-28 17:35:11 +02:00
d69cb1caee docs(02-06): complete common auth runtime plan
- add 02-06 execution summary and self-check
- update GSD state progress for completed plan
2026-04-28 16:59:25 +02:00
938f324bb8 feat(02-06): wire auth session into koin
- provide authModule singletons for store, OIDC, MeClient, AuthSession, and HttpClient
- include authModule from appModule bootstrap
2026-04-28 16:55:36 +02:00
0a24be9a95 feat(02-06): implement auth session runtime
- add AuthState and AuthSession restore/login/logout state machine
- add MeClient and token-redacting Ktor bearer HTTP client
2026-04-28 16:54:39 +02:00
06e5eaf94e test(02-06): add failing auth session state tests
- cover restore, login, refresh failure, logout, and cancellation
- assert Phase 2 authenticated householdId remains null
2026-04-28 16:53:46 +02:00
b364c3056e docs(02-05): complete iOS auth actuals plan
- Summarize iOS AppAuth and Keychain implementation
- Record verification results and deviations
2026-04-28 16:21:01 +02:00
88dc8d719a feat(02-05): wire iOS OIDC callback
- Register recipe URL scheme in Info.plist
- Forward SwiftUI openURL callbacks to the AppAuth bridge
2026-04-28 16:18:21 +02:00
ac9fc61410 feat(02-05): implement iOS AppAuth client
- Add AppAuth login, refresh, logout actual
- Add iOS Keychain auth state store to unblock native compile
- Add iOS Podfile AppAuth integration
2026-04-28 16:14:04 +02:00
8d1c34c2f6 docs(02-04): complete Android auth actuals plan
- Add execution summary with verification results
- Document Android expect/actual ordering deviation and token mapping fix
2026-04-28 16:01:57 +02:00
11a5eeb3ff fix(02-04): harden Android OIDC token result mapping
- Treat missing access tokens as auth failures
- Preserve AppAuth discovery errors for correct network/auth classification
2026-04-28 16:00:20 +02:00
6385453653 feat(02-04): register Android OIDC callback
- Add AppAuth redirect receiver for recipe://callback
- Preserve existing launcher activity manifest wiring
2026-04-28 15:58:12 +02:00
fa78ee31b4 feat(02-04): implement Android AppAuth OIDC client
- Add Android AppAuth login, fresh-token refresh, and end-session logout
- Add Android secure AuthState store actual required for Android compilation
2026-04-28 15:57:16 +02:00
a94f803ca6 docs(02-03): complete common auth seams plan
Tasks completed: 2/2
- Define common OIDC and secure store contracts
- Add JVM and Wasm actuals

SUMMARY: .planning/phases/02-authentication-foundation/02-03-SUMMARY.md
2026-04-28 14:16:47 +02:00
0dbd374f46 feat(02-03): add Wasm auth stubs
- Keep Wasm OIDC behind explicit v2 NotImplementedError boundaries
- Add non-persistent Wasm AuthState store actual so the target compiles
2026-04-28 13:49:14 +02:00
edc2a1d4c8 feat(02-03): define common auth contracts
- Add OIDC result and expect client seam with pinned native AppAuth semantics
- Add secure AuthState JSON store contract and JVM dev actuals for test compilation
2026-04-28 13:48:25 +02:00
3122fdaf37 docs(02-02): complete server auth boundary plan
- add execution summary with verification and deviations

- update state, roadmap progress, and completed auth requirements
2026-04-28 13:46:46 +02:00
7ef222e71e test(02-03): add failing secure auth state store contract
- Covers write overwrite semantics
- Covers clear removing stored AuthState JSON
2026-04-28 13:36:48 +02:00
8cf112a68a feat(02-02): add users migration, JIT PrincipalResolver, /api/v1/me route 2026-04-28 13:14:59 +02:00
36c1b2c822 feat(02-02): wire AuthConfig, JWT verifier, and CallLogging redaction 2026-04-28 13:06:50 +02:00
614b57c34d test(02-02): add failing JWT validation tests for AuthPlugin 2026-04-28 13:04:04 +02:00
fe8c0b6823 docs(phase-02): update tracking after wave 1 2026-04-28 11:00:51 +02:00
9f7cadda7b docs(02-01): complete shared auth contracts and Authentik setup plan
Adds the per-plan SUMMARY for 02-01: shared MeResponse/User DTOs +
Constants, full Phase 2 dependency catalog wired into composeApp/
server without bumping Ktor 3.4.1, and docs/authentik-setup.md
reproducible-provider playbook with multi-source audit.

3 tasks (Task 1 ran TDD: RED + GREEN), 4 commits, 6 deviations
auto-fixed (5 × Rule 3 blocking, 1 × Rule 1 bug), 0 scope creep.
All plan-level verifications PASS.

Per parallel-execution rules, this commit does not modify STATE.md
or ROADMAP.md — the orchestrator owns those updates after the wave
completes.
2026-04-28 10:59:07 +02:00
62040d461a docs(02-01): add Authentik provider setup and Phase 2 source audit
Task 02-01-03. Creates docs/authentik-setup.md as the load-bearing
Phase 2 deliverable (D-10): a reproducible playbook for the
homelab Authentik provider plus the multi-source audit that ties
every Phase 2 input to a covering plan.

Sections (in mandated order):
- Provider — Public + PKCE S256, recipe-app client_id, RS256, single-
  string aud, JWKS URI, end-session endpoint, Issuer trailing slash.
- Scopes — exactly `openid profile email offline_access`; explains
  why offline_access must be both requested AND mapped on the
  provider for refresh tokens (PITFALLS.md Phase 2 Pitfall 2).
- Redirect URI — recipe://callback, registered byte-for-byte in
  Authentik + iOS Info.plist + Android <intent-filter>.
- Server Env Vars — OIDC_ISSUER / OIDC_AUDIENCE / OIDC_JWKS_URL with
  override semantics matching Phase 1's DATABASE_URL pattern.
- Logout — RP-initiated end-session sequence (D-19, D-20).
- Manual UAT — UAT-01 fresh login, UAT-02 reopen with refresh,
  UAT-03 logout returns to login, UAT-04 curl/HTTP verification of
  GET /api/v1/me 200/401 cases including wrong-aud and never-log-
  Authorization assertion.
- Source Audit — exhaustive table mapping GOAL Phase 2, REQ
  AUTH-01..AUTH-06, RESEARCH constraints, CONTEXT D-01..D-34,
  UI-SPEC, VALIDATION Wave 0, and PATTERNS file map to either this
  doc () or a downstream Phase 2 plan (⤳). All deferred ideas
  listed as ✂ excluded: Universal Links/App Links, real Desktop
  OIDC, Wasm OIDC, Apple Sign-in, Authentik provisioning automation,
  per-user AuthState, modal refresh-failure UX, background refresh,
  two-tier logout, BuildConfig OIDC injection, real-Authentik
  integration tests.

Verification:
- grep -E 'openid profile email offline_access|PKCE S256|single-string
  |recipe://callback|/api/v1/me|Source Audit' docs/authentik-setup.md:
  hits all six tokens.
- All Task 3 grep acceptance criteria PASS, including
  AUTH-01.*AUTH-02.*AUTH-03.*AUTH-04.*AUTH-05.*AUTH-06 on a single
  audit-table line and "Universal Links / App Links.*excluded".
2026-04-28 10:55:38 +02:00
c1cc713bbb feat(02-01): wire Phase 2 dependency aliases without bumping Ktor
Task 02-01-02. Adds Phase 2 deps to the version catalog and routes
them into composeApp + server build files. Ktor stays pinned at
3.4.1 per the resolved Open Question — patch bump deferred unless a
concrete incompatibility appears.

Catalog (gradle/libs.versions.toml):
- Versions: appauth, appauth-ios, androidx-security-crypto, exposed,
  hikari, multiplatformSettings, testcontainers, plus the
  kotlinCocoapods plugin alias.
- Libraries: ktor server auth/auth-jwt/call-logging/status-pages,
  ktor client core/auth/content-negotiation/logging/okhttp/darwin/cio,
  ktor-serializationKotlinxJsonMpp (the multiplatform variant; the
  -jvm one stays for server), AppAuth, AndroidX Security Crypto,
  multiplatform-settings + coroutines, Exposed core/jdbc/java-time,
  HikariCP, Testcontainers postgresql + junit-jupiter.

composeApp/build.gradle.kts:
- Apply kotlinSerialization (alias) and kotlin.native.cocoapods (by
  id — the plugin is shipped inside the Kotlin Gradle plugin already
  on the classpath via recipe.kotlin.multiplatform; alias-applying
  it would request a fresh version and fail).
- Cocoapods block: ComposeApp baseName + isStatic, ../iosApp/Podfile,
  iOS deployment target 15.0, AppAuth pod pulled from
  libs.versions.appauth.ios.get() — no literal pin in the build
  file (verify-no-version-literals.sh stays green).
- Common deps: Ktor client family, MPP serialization, multiplatform
  settings; Android: AppAuth-Android + Security Crypto + OkHttp
  engine; iOS: Darwin engine; JVM: CIO engine.

server/build.gradle.kts: Adds Ktor server auth/JWT/CallLogging/
StatusPages, Exposed DSL trio, Hikari, kotlinx.serialization-json,
plus testImplementation testcontainers postgresql + junit-jupiter.

Deviations:
- Rule 3 (blocking): manifestPlaceholders["appAuthRedirectScheme"]
  = "recipe" added to Android defaultConfig because AppAuth-Android's
  bundled manifest declares a ${appAuthRedirectScheme} placeholder
  that breaks AGP merge before Plan 02-04 lands the full <intent-filter>.
- Rule 3 (blocking): top-level group/version on composeApp (required
  by the cocoapods podspec generator) pushes the Compose Resources
  Res-class package off recipe.composeapp.generated.resources, breaking
  Phase 1 App.kt imports. Lock the package via compose.resources {
  packageOfResClass = "recipe.composeapp.generated.resources" }.
- Rule 3 (housekeeping): *.podspec is generated by the cocoapods
  plugin on every build; ignored.

Verification:
- ./gradlew :composeApp:dependencies --configuration debugCompileClasspath
  :server:dependencies --configuration runtimeClasspath: PASS
  (the plan-stated androidMainCompileClasspath name doesn't exist
  under this AGP/Gradle combo; debugCompileClasspath is the
  functional equivalent and resolves all new deps).
- ./gradlew :composeApp:compileDebugKotlinAndroid :server:compileKotlin: PASS
- ./gradlew :composeApp:compileKotlinIosSimulatorArm64: PASS (cinterop
  pulls AppAuth pod cleanly).
- ./tools/verify-no-version-literals.sh: PASS
- ./tools/verify-shared-pure.sh: PASS
- All Task 2 grep acceptance criteria satisfied.
2026-04-28 10:52:40 +02:00
7e73a9a820 feat(02-01): land Constants and MeResponse/User DTOs in shared
GREEN phase of TDD task 02-01-01. Adds the load-bearing Phase 2
contract that downstream plans compile against:

- Constants.kt: OIDC_ISSUER (trailing slash, placeholder homelab host),
  OIDC_CLIENT_ID = recipe-app, OIDC_REDIRECT_URI = recipe://callback,
  API_BASE_URL, plus moved SERVER_PORT for one shared config object.
- dto/User.kt: domain identity (id/sub/email/displayName), id is String
  to keep shared free of UUID library deps (D-19 / INFRA-06).
- dto/MeResponse.kt: @Serializable wire DTO for GET /api/v1/me with a
  one-to-one toUser() mapper. Stable for Phase 3 to add householdId
  via ignoreUnknownKeys.
- Removes the now-redundant shared/.gitkeep placeholder.

Verification:
- ./gradlew :shared:jvmTest :shared:compileCommonMainKotlinMetadata: PASS
- ./tools/verify-shared-pure.sh: PASS
- All grep acceptance criteria for Task 1 satisfied
2026-04-28 10:45:04 +02:00
6504b46e40 test(02-01): add failing serialization test for MeResponse DTO
RED phase of TDD task 02-01-01. Locks the wire-format contract for
GET /api/v1/me before the DTO exists:

- camelCase JSON keys (id, sub, email, displayName) per D-27
- ignoreUnknownKeys forward compat for Phase 3 householdId per D-28
- MeResponse.toUser() one-to-one mapping

Wires kotlinx.serialization into shared/build.gradle.kts (api scope so
both client and server inherit the @Serializable runtime) and adds the
kotlinx-serializationJson catalog alias. The shared module remains
pure: only kotlin stdlib + kotlinx.serialization-json are pulled into
commonMain (D-19 / INFRA-06 still holds).

Test currently fails: MeResponse and User unresolved; GREEN follows.
2026-04-28 10:43:15 +02:00
1246e12012 docs(state): mark phase 2 ready for execution 2026-04-28 10:31:02 +02:00
37450291c6 docs(02): fix auth plan verification blockers 2026-04-27 21:11:46 +02:00
0b01bc8bbb fix(02): split auth platform plans 2026-04-27 21:07:18 +02:00
f0462cbca1 docs(02): add missing auth store actuals to plan 2026-04-27 20:59:41 +02:00
29d655828d docs(02): resolve planning verification artifacts 2026-04-27 20:57:05 +02:00
cca3ab7923 docs(02): create authentication foundation plans 2026-04-27 20:54:21 +02:00
ab69cc1dff docs(02): add validation strategy 2026-04-27 20:42:04 +02:00
090027224c docs(02): research phase domain 2026-04-27 20:41:15 +02:00
6ab7960e16 docs(02): approve UI design contract
UI-SPEC verified — all 6 dimensions PASS. No flags. Frontmatter
status flipped from draft to approved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:02:19 +02:00
31b4f4d57e docs(02): UI design contract for auth foundation
Locks scaffold-level visual contract for Phase 2: spacing scale,
typography roles, Material 3 color seed, copywriting keys, and
auth-gate routing — without committing to Liquid-Glass / Haze
(Phase 10) or final font + Polish polish (Phase 11).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 19:59:41 +02:00
830097f5c1 docs(state): record phase 2 context session 2026-04-27 19:29:04 +02:00
f3569b41d6 docs(02): capture phase context 2026-04-27 19:28:57 +02:00
04b3d9b1d5 Remove unnecessary convention plugins 2026-04-26 22:22:28 +02:00
42d134a997 docs(01-07): complete phase gate plan 2026-04-24 20:59:21 +02:00
68655eae1a Phase 1 work 2026-04-24 20:21:03 +02:00
b36058fa79 chore(01-07): add shared package scaffold placeholder
- Create dev.ulfrx.recipe.shared sub-package with .gitkeep marker
- Phase 2+ will populate with cross-target DTOs / domain models
- Satisfies INFRA-06 file-existence criterion for empty package scaffold
2026-04-24 19:46:30 +02:00
81bff1db17 merge(01-04): Koin + Kermit bootstrap across all platforms 2026-04-24 19:45:25 +02:00
eaa88fff36 docs(01-04): add SUMMARY for Koin + Kermit bootstrap plan 2026-04-24 19:44:47 +02:00
fd3e7e1584 feat(01-04): wire JVM + Wasm main + Swift iOSApp to bootstrap Koin + Kermit
- JVM main: configureLogging() + initKoin() before application { Window }
- Wasm main: configureLogging() + initKoin() before ComposeViewport (PITFALL #8)
- iOSApp.swift: import ComposeApp + init { KoinIosKt.doInitKoin() } (PITFALL #4)
2026-04-24 19:41:51 +02:00
129ee616d5 docs(01-05): add SUMMARY for server /health + Flyway + HOCON plan 2026-04-24 19:41:47 +02:00
8cd608a981 feat(01-04): add Android MainApplication + manifest registration
- MainApplication.onCreate calls configureLogging() then initKoin { androidContext(...) }
- AndroidManifest registers android:name=".MainApplication"
2026-04-24 19:41:22 +02:00
cc5002d1df feat(01-04): add Koin + Kermit bootstrap commonMain + iOS bridge
- initKoin() helper with optional KoinAppDeclaration config
- empty appModule placeholder (Phase 2+ extends)
- configureLogging() sets Kermit tag 'recipe' (D-15)
- iOS doInitKoin() bridge → Swift symbol KoinIosKt.doInitKoin
2026-04-24 19:41:05 +02:00
d7ee6b83fc Add summary for plan phase 1.2 2026-04-24 19:26:41 +02:00
61885455bb merge(01-06): docker-compose + README Local development 2026-04-24 18:41:51 +02:00
6972839fd0 merge(01-05): server /health + Flyway + HOCON + fail-loud DB boot 2026-04-24 18:41:51 +02:00
c79f9218aa merge(01-03): module refactor to recipe.* conventions + drop js 2026-04-24 18:41:51 +02:00
2c786b2fc2 merge(01-02): build-logic scaffold + 5 precompiled plugins 2026-04-24 18:41:43 +02:00
f9d3a0c2d4 docs(01-06): add SUMMARY for dev-ergonomics plan 2026-04-24 18:24:24 +02:00
b8671d6dbb docs(01-03): summary of module build-script conventions wiring
- composeApp/build.gradle.kts: 114 -> 28 lines (role declaration with 4 recipe.* IDs)
- shared/build.gradle.kts: 55 -> 36 lines (3 plugins, explicitApi, Framework baseName "Shared")
- server/build.gradle.kts: 23 -> 18 lines (recipe.jvm.server + recipe.quality + module-only config)
- shared/src/jsMain/ deleted (D-01)
- 0 deviations; both verify-*.sh scripts pass; INFRA-02 + INFRA-06 structural prerequisites delivered
2026-04-24 18:23:41 +02:00
59d069591b test(01-05): rewrite ApplicationTest to assert GET /health without Postgres
- Replace testRoot template assertion with 'health endpoint returns 200 with status ok'
- Compose only configureRouting() in testApplication — NOT Application.module()
- This keeps the test independent of Database.migrate / running Postgres (D-11 test invariant)
- Install ContentNegotiation { json() } inside application { } — production module() does it,
  but the test composes routing directly and must install the plugin itself
- All imports explicit (D-11 allWarningsAsErrors); no wildcards
- Body checked via substring for "status" + "ok" — robust to JSON field ordering

Note: ./gradlew :server:test runtime verification deferred to Plan 07 (integration build)
since build-logic/recipe.jvm.server plugin is being authored in parallel Plan 02 worktree.
2026-04-24 18:23:14 +02:00
60221f66a2 feat(01-02): wire build-logic into root settings + add spotless/flyway classloader hints
- settings.gradle.kts: includeBuild("build-logic") placed inside pluginManagement { } (PITFALL #9)
- build.gradle.kts: 2 new alias(...) apply false entries (spotless, flywayPlugin)
- Existing repositories, module includes, and 8 original apply-false entries preserved verbatim
2026-04-24 18:22:56 +02:00
37f6191523 feat(01-04): wire JVM + Wasm main + Swift iOSApp to bootstrap Koin + Kermit
- Desktop main() calls configureLogging() → initKoin() before application { Window { App() } }
- Wasm main() calls configureLogging() → initKoin() before ComposeViewport { App() } (PITFALL #8 future-proof)
- iOSApp.swift imports ComposeApp and calls KoinIosKt.doInitKoin() in init() — single iOS call site (PITFALL #4)
- MainViewController.kt and App.kt unmodified (anti-pattern guards)
2026-04-24 18:22:47 +02:00
f691400f2b docs(01-06): add Local development section and drop js target
- New "Local development" section documents docker compose + gradlew dev loop
- Covers /health smoke test, env-var overrides (DATABASE_* and PORT)
- Adds spotlessApply + check + down -v reference commands
- Removes legacy js target docs (D-01); wasmJs target preserved
2026-04-24 18:22:41 +02:00
daefe6c26d refactor(01-05): rewrite Application.kt with ContentNegotiation, Flyway boot, /health
- Remove wildcard Ktor imports (D-11 allWarningsAsErrors safety) — all imports explicit
- Install ContentNegotiation { json() } for @Serializable response bodies
- Call Database.migrate(this) at boot — fails loudly if Postgres unreachable
- Extract configureRouting() extension so tests can compose routing without DB
- Replace template root greeting with GET /health → {"status":"ok"} (D-16)
- main() shape unchanged: embeddedServer(Netty, SERVER_PORT, "0.0.0.0", ...)
2026-04-24 18:22:37 +02:00
d316a4805e refactor(01-03): apply recipe.jvm.server + recipe.quality to server module
- Replaces alias(kotlinJvm) + alias(ktor) + application with id("recipe.jvm.server") + id("recipe.quality") — application plugin now applied by the convention
- Removes per-module dep lines (logback, ktor-serverCore, ktor-serverNetty, ktor-serverTestHost, kotlin-testJunit) — all bundled in recipe.jvm.server
- Keeps module-only config: group/version coordinates, application { mainClass.set } + applicationDefaultJvmArgs, implementation(projects.shared)
- File shrinks 23 -> 18 lines; no version literals leak
2026-04-24 18:22:09 +02:00
24018efe67 feat(01-05): add HOCON config, Flyway migration dir, fail-loud Database.migrate
- application.conf: HOCON with ktor.deployment.port (8080 + ${?PORT}) and
  database.url/user/password (localhost defaults + ${?DATABASE_URL/USER/PASSWORD})
- db/migration/.gitkeep: placeholder so classpath:db/migration resolves
- Database.kt: object Database.migrate(app) reads HOCON config, runs Flyway
  with baselineOnMigrate + validateOnMigrate + cleanDisabled, throws
  IllegalStateException on any failure (D-16 fail-loud contract)
- SLF4J (not Kermit); server logs url+user only, never password
2026-04-24 18:22:08 +02:00
4e6192293f feat(01-04): add Android MainApplication + manifest registration
- Create MainApplication : Application() running configureLogging() then initKoin { androidContext(this@MainApplication) } in onCreate
- Register android:name=".MainApplication" on <application> element (MainActivity entry preserved)
- Establishes the canonical init order for Android process boot
2026-04-24 18:22:03 +02:00
6a69910aa7 feat(01-02): scaffold build-logic included build with 5 precompiled plugins
- build-logic/settings.gradle.kts resolves parent catalog via files("../gradle/libs.versions.toml")
- build-logic/build.gradle.kts declares kotlin-dsl + 9 compileOnly asDependency entries
- recipe.quality: Spotless + ktlint + D-11 allWarningsAsErrors safety net (plugins.withId guard)
- recipe.kotlin.multiplatform: D-05 target matrix (androidTarget, iosArm64, iosSimulatorArm64, jvm, wasmJs) + JVM 11/21 split + baseName "ComposeApp" + Koin/Kermit/kotlin-test deps
- recipe.compose.multiplatform: layers on recipe.kotlin.multiplatform (PITFALL #2 avoided) + hot-reload + Compose deps
- recipe.android.application: namespace dev.ulfrx.recipe + findVersion catalog accessor (PITFALL #1)
- recipe.jvm.server: Ktor + Flyway + Postgres with quoted "implementation" configs + cleanDisabled guard
2026-04-24 18:22:02 +02:00
af4428fd8a feat(01-06): add docker-compose.yml with postgres:16 for local dev
- Single postgres service pinned to postgres:16
- Credentials recipe/recipe/recipe match application.conf HOCON defaults
- Named volume recipe-pgdata for persistence across restarts
- Healthcheck via pg_isready enables docker compose up --wait usage
- No version key (modern compose v2); Authentik stays on homelab (D-17)
2026-04-24 18:21:40 +02:00
7d750af710 feat(01-04): add Koin + Kermit bootstrap commonMain + iOS bridge
- Add initKoin(config) helper wrapping startKoin { modules(appModule) } (PITFALL #4 single entry)
- Add empty appModule placeholder (D-14) — Phase 2+ extends
- Add configureLogging() setting Kermit tag "recipe" (D-15)
- Add iosMain doInitKoin() bridge — Swift-accessible as KoinIosKt.doInitKoin()
- configureLogging() always runs before initKoin() so module loading can log
2026-04-24 18:21:36 +02:00
d76dcea18d refactor(01-03): apply recipe.* conventions to composeApp + shared, drop js
- composeApp/build.gradle.kts now applies 4 recipe.* IDs (kotlin.multiplatform, compose.multiplatform, android.application, quality); removes all structural target/android/nativeDistributions blocks (114 -> 28 lines)
- shared/build.gradle.kts applies recipe.kotlin.multiplatform + recipe.quality + androidLibrary; adds explicitApi() (D-12) and KotlinNativeTarget/Framework baseName = "Shared" override (D-07 / PITFALL #10); keeps android { } block per Open Question #1
- Adds libs.koin.android to androidMain dependencies (for Plan 04's MainApplication androidContext)
- Drops js target per D-01: removes js { browser() } from both modules and deletes shared/src/jsMain/Platform.js.kt
- iosX64 remains absent per D-02
- No version literals leak; tools/verify-no-version-literals.sh + verify-shared-pure.sh both pass
2026-04-24 18:21:35 +02:00
4d9aefd4c2 docs(01-01): complete foundations plan — catalog + iOS flags + invariants
Summarizes Plan 01-01 execution:
- 3 task commits (b609cb6, d873c31, aaa8042)
- 1 Rule 3 auto-fix (refined verify-no-version-literals.sh to
  exclude top-level project-version metadata while still
  catching indented library/plugin version literals)
- Self-check PASSED (all files + commits verified)

Requirements: INFRA-01, INFRA-03
2026-04-24 18:18:20 +02:00
aaa8042aee feat(01-01): add Phase 1 invariant verification scripts
Three executable bash scripts under tools/ that Wave 0 and every
subsequent Phase 1 plan's <automated> block rely on:

- verify-no-version-literals.sh (INFRA-01 SC#2 / D-09): no literal
  library/plugin version strings in any *.gradle.kts. Excludes
  build-logic/build.gradle.kts (needs asDependency() literals) and
  top-level project-version assignments ("^version = \"x.y.z\"")
  which are artifact metadata, not library pins.
- verify-shared-pure.sh (INFRA-06 / D-19): shared/commonMain must
  not import Ktor/Compose/SQLDelight. Returns OK if the directory
  does not exist yet (pre-scaffold tolerance for Plan 07).
- verify-ios-flags.sh (INFRA-03 / D-18): both K/N iOS binary flags
  present in gradle.properties.

All three use bash (#!/usr/bin/env bash + set -euo pipefail) and
are marked chmod +x. Scripts exit 0 against the current repo state.
2026-04-24 18:16:29 +02:00
d873c31e19 feat(01-01): add iOS Kotlin/Native binary flags to gradle.properties
- kotlin.native.binary.gc=cms (concurrent mark-sweep collector)
- kotlin.native.binary.objcDisposeOnMain=false (off-main-thread
  Obj-C deinit) — avoids UI-thread pause spikes in CMP on iOS
- Enforces INFRA-03 / D-18 / CLAUDE.md convention #7 /
  PITFALLS.md #1 on day 1 before any iOS code is compiled
2026-04-24 18:14:12 +02:00
b609cb6362 feat(01-01): extend version catalog with Phase 1 aliases
- Add versions: flyway=12.4.0, kermit=2.1.0, koin=4.2.1,
  kotlinx-serialization=1.7.3, postgresql=42.7.10, spotless=8.4.0
- Add libraries: 5 koin-* (BOM-managed for -core/-compose/-
  composeViewmodel/-android), kermit, 2 ktor server-side
  (content-negotiation + kotlinx-json), 2 flyway (core + postgres
  database module), postgresql JDBC driver
- Add plugins: spotless (Diffplug) + flywayPlugin
- No existing version refs modified; additive only (D-09)
2026-04-24 18:13:49 +02:00
875055a5ef docs(state): begin Phase 1 execution
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:11:29 +02:00
8ef2dbfae4 chore: clear auto-chain flag before phase 1 execution
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:11:14 +02:00
0ca22f9e36 Plan phase 1 2026-04-24 18:08:36 +02:00
d104d3da87 refactor(01): address plan-checker revisions (1 blocker, 5 warnings)
- 01-02: wave 1→2, depends_on [01]; drop unused androidLibrary classpath
  entry; guard Kotlin compilerOptions with plugins.withId listeners
- 01-05: remove misleading 'gradle exit' echo from verify block
- 01-06: harden credential check on docker-compose.yml alone
- 01-07: drop hardcoded /Users/rwilk/dev/repo/recipe cd prefix
- 01-RESEARCH: rename Open Questions → (RESOLVED); replace
  'Recommendation:' with 'RESOLVED:' per gsd Dimension 11 convention
2026-04-24 16:40:32 +02:00
d6cec3fe07 Plan phase 1 2026-04-24 16:21:25 +02:00
7ac1555a4c docs(01): add validation strategy 2026-04-24 15:46:08 +02:00
6f9d7d7ee5 docs(01): research phase infrastructure domain
Capture Phase 1 research covering build-logic convention plugin mechanics,
Koin/Kermit bootstrap for CMP+iOS, Ktor /health + Flyway wiring, version
catalog access from precompiled plugins, iOS K/N binary flags, and
validation commands mapped to all 5 roadmap success criteria + INFRA-01/02/03/06.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:43:29 +02:00
9738621f77 docs(state): record phase 1 context session 2026-04-24 15:27:17 +02:00
33 changed files with 866 additions and 376 deletions

View File

@@ -143,7 +143,7 @@ A mobile-first meal planning app for a small household — pick recipes for the
| Image loading: Coil 3 (`io.coil-kt.coil3:coil-compose`) | First-class Compose Multiplatform support; modern API | — Pending | | Image loading: Coil 3 (`io.coil-kt.coil3:coil-compose`) | First-class Compose Multiplatform support; modern API | — Pending |
| Key-value settings: `com.russhwolf:multiplatform-settings` | Small prefs (last tab, theme toggles) that don't belong in SQLDelight | — Pending | | Key-value settings: `com.russhwolf:multiplatform-settings` | Small prefs (last tab, theme toggles) that don't belong in SQLDelight | — Pending |
| Glass/blur effects: Haze (`dev.chrisbanes.haze:haze`) | Purpose-built for glass UI in CMP; handles content capture + efficient re-blur; multiplatform | — Pending | | Glass/blur effects: Haze (`dev.chrisbanes.haze:haze`) | Purpose-built for glass UI in CMP; handles content capture + efficient re-blur; multiplatform | — Pending |
| Mobile OIDC: 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

View File

@@ -79,8 +79,8 @@ Plans:
- [x] 02-01-PLAN.md — Shared auth contracts, dependency aliases, Authentik setup docs, and source audit - [x] 02-01-PLAN.md — Shared auth contracts, dependency aliases, Authentik setup docs, and source audit
- [x] 02-02-PLAN.md — Server JWT validation, JWKS hardening, JIT users, and `/api/v1/me` - [x] 02-02-PLAN.md — Server JWT validation, JWKS hardening, JIT users, and `/api/v1/me`
- [ ] 02-03-PLAN.md — Common OIDC/store contracts, JVM/Wasm actuals, and store contract test - [ ] 02-03-PLAN.md — Common OIDC/store contracts, JVM/Wasm actuals, and store contract test
- [ ] 02-04-PLAN.md — Android 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

View File

@@ -38,7 +38,7 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
- Images: Coil 3 (`io.coil-kt.coil3:coil-compose`) - Images: Coil 3 (`io.coil-kt.coil3:coil-compose`)
- Settings/KV: `com.russhwolf:multiplatform-settings` - Settings/KV: `com.russhwolf:multiplatform-settings`
- Glass/blur: Haze (`dev.chrisbanes.haze:haze`) - Glass/blur: Haze (`dev.chrisbanes.haze:haze`)
- Mobile OIDC: 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)

View File

@@ -38,7 +38,7 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
- Images: Coil 3 (`io.coil-kt.coil3:coil-compose`) - Images: Coil 3 (`io.coil-kt.coil3:coil-compose`)
- Settings/KV: `com.russhwolf:multiplatform-settings` - Settings/KV: `com.russhwolf:multiplatform-settings`
- Glass/blur: Haze (`dev.chrisbanes.haze:haze`) - Glass/blur: Haze (`dev.chrisbanes.haze:haze`)
- Mobile OIDC: 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)

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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)
} }
} }
} }

View File

@@ -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) }
}

View File

@@ -1,98 +0,0 @@
package dev.ulfrx.recipe.auth
import dev.lokksmith.Lokksmith
import dev.lokksmith.client.Client
import dev.lokksmith.client.InternalClient
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.lokksmith.client.request.flow.AuthFlowStateResponseHandler
import dev.lokksmith.client.request.flow.authorizationCode.AuthorizationCodeFlow
import dev.lokksmith.client.request.flow.endSession.EndSessionFlow
import dev.lokksmith.client.request.parameter.Scope
import dev.lokksmith.discoveryUrl
import dev.lokksmith.id
import dev.ulfrx.recipe.shared.Constants
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.selects.select
internal const val LOKKSMITH_CLIENT_KEY = "recipe-app"
internal const val LOKKSMITH_AUTH_STATE_JSON = "lokksmith:$LOKKSMITH_CLIENT_KEY"
internal suspend fun Lokksmith.recipeClient(): Client =
getOrCreate(LOKKSMITH_CLIENT_KEY) {
id = Constants.OIDC_CLIENT_ID
discoveryUrl = Constants.OIDC_ISSUER + ".well-known/openid-configuration"
}
internal fun Client.recipeAuthorizationCodeFlow(): dev.lokksmith.client.request.flow.AuthFlow =
authorizationCodeFlow(
AuthorizationCodeFlow.Request(
redirectUri = Constants.OIDC_REDIRECT_URI,
scope = setOf(Scope.Profile, Scope.Email, Scope.Custom("offline_access")),
),
)
internal fun Client.recipeEndSessionFlow(): dev.lokksmith.client.request.flow.AuthFlow? =
endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI))
internal suspend fun Client.awaitTerminalAuthFlowResult(): AuthFlowResultProvider.Result =
AuthFlowResultProvider
.forClient(this)
.first { result ->
result is AuthFlowResultProvider.Result.Success ||
result is AuthFlowResultProvider.Result.Cancelled ||
result is AuthFlowResultProvider.Result.Error
}
internal suspend fun Lokksmith.completeAuthFlow(client: Client): AuthFlowResultProvider.Result =
coroutineScope {
val terminal = async { client.awaitTerminalAuthFlowResult() }
val responseUri =
async {
(client as InternalClient)
.snapshots
.map { snapshot -> snapshot.ephemeralFlowState?.responseUri }
.distinctUntilChanged()
.first { responseUri -> responseUri != null }
}
select<AuthFlowResultProvider.Result> {
terminal.onAwait { result ->
responseUri.cancel()
result
}
responseUri.onAwait { uri ->
terminal.cancel()
AuthFlowStateResponseHandler(this@completeAuthFlow).onResponse(checkNotNull(uri))
client.awaitTerminalAuthFlowResult()
}
}
}
internal suspend fun Client.toOidcSuccess(): OidcResult.Success {
var freshTokens: Client.Tokens? = null
runWithTokens { tokens -> freshTokens = tokens }
val tokens = checkNotNull(freshTokens) { "Lokksmith returned no tokens" }
return OidcResult.Success(
authStateJson = LOKKSMITH_AUTH_STATE_JSON,
accessToken = tokens.accessToken.token,
idToken = tokens.idToken.raw,
expiresAtEpochMillis = tokens.accessToken.expiresAt?.let { it * 1_000L } ?: 0L,
)
}
internal fun AuthFlowResultProvider.Result.toOidcFailureOrNull(): OidcResult? =
when (this) {
is AuthFlowResultProvider.Result.Success -> null
is AuthFlowResultProvider.Result.Cancelled -> OidcResult.Cancelled
is AuthFlowResultProvider.Result.Error -> OidcResult.AuthError(message ?: "OIDC flow failed")
AuthFlowResultProvider.Result.Undefined,
is AuthFlowResultProvider.Result.Processing,
-> OidcResult.AuthError("OIDC flow did not complete")
}

View File

@@ -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
},
)
}
}
appContext.registerPrivateReceiver(receiver, filter)
val completionIntent =
PendingIntent.getBroadcast(
appContext,
action.hashCode(),
Intent(action).setPackage(appContext.packageName),
pendingIntentFlags(),
)
val cancelIntent =
PendingIntent.getBroadcast(
appContext,
action.hashCode() + 1,
Intent(action).setPackage(appContext.packageName),
pendingIntentFlags(),
)
continuation.invokeOnCancellation {
runCatching { appContext.unregisterReceiver(receiver) }
dispose()
}
performAuthorizationRequest(request, completionIntent, cancelIntent)
}
private suspend fun AuthorizationService.performEndSession(request: EndSessionRequest) =
suspendCancellableCoroutine { continuation ->
val appContext = context
val action = "${appContext.packageName}.auth.OIDC_END_SESSION_RESULT.${System.nanoTime()}"
val filter = IntentFilter(action)
val receiver =
object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent,
) {
appContext.unregisterReceiver(this)
if (continuation.isActive) continuation.resume(Unit)
}
}
appContext.registerPrivateReceiver(receiver, filter)
val completionIntent =
PendingIntent.getBroadcast(
appContext,
action.hashCode(),
Intent(action).setPackage(appContext.packageName),
pendingIntentFlags(),
)
val cancelIntent =
PendingIntent.getBroadcast(
appContext,
action.hashCode() + 1,
Intent(action).setPackage(appContext.packageName),
pendingIntentFlags(),
)
continuation.invokeOnCancellation {
runCatching { appContext.unregisterReceiver(receiver) }
dispose()
}
performEndSessionRequest(request, completionIntent, cancelIntent)
}
private fun AuthState.toSuccess(tokenResponse: TokenResponse): OidcResult.Success =
OidcResult.Success(
authStateJson = jsonSerializeString(),
accessToken = tokenResponse.accessToken.orEmpty(),
idToken = tokenResponse.idToken,
expiresAtEpochMillis = tokenResponse.accessTokenExpirationTime ?: 0L,
)
private fun AuthorizationException.toOidcError(): OidcResult =
when {
isCancellation() -> OidcResult.Cancelled
isNetworkFailure() -> OidcResult.NetworkError
else -> OidcResult.AuthError("OIDC request failed", this)
}
private fun AuthorizationException.isCancellation(): Boolean =
type == AuthorizationException.TYPE_GENERAL_ERROR &&
(
code == AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW.code ||
code == AuthorizationException.GeneralErrors.PROGRAM_CANCELED_AUTH_FLOW.code
)
private fun AuthorizationException.isNetworkFailure(): Boolean =
type == AuthorizationException.TYPE_GENERAL_ERROR &&
(
code == AuthorizationException.GeneralErrors.NETWORK_ERROR.code ||
code == AuthorizationException.GeneralErrors.SERVER_ERROR.code
)
private fun Context.registerPrivateReceiver(
receiver: BroadcastReceiver,
filter: IntentFilter,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("DEPRECATION")
registerReceiver(receiver, filter)
}
}
private fun pendingIntentFlags(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
private sealed interface AuthorizationOutcome {
data class Success(
val response: AuthorizationResponse,
) : AuthorizationOutcome
data class Error(
val exception: AuthorizationException,
) : AuthorizationOutcome
data object Cancelled : AuthorizationOutcome
}
private sealed interface ConfigurationOutcome {
data class Success(
val configuration: AuthorizationServiceConfiguration,
) : ConfigurationOutcome
data class Error(
val exception: AuthorizationException,
) : ConfigurationOutcome
} }
} }
fun createAndroidLokksmith(context: Context): Lokksmith =
createLokksmith(context).also { lokksmith ->
SingletonLokksmithProvider.set(lokksmith)
}

View File

@@ -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

View File

@@ -3,8 +3,8 @@ package dev.ulfrx.recipe.auth
/** /**
* Result returned by platform OIDC clients. * Result returned by platform OIDC clients.
* *
* `authStateJson` is 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(

View File

@@ -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?

View File

@@ -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
}

View File

@@ -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.",
)
}
} }

View File

@@ -1,98 +0,0 @@
package dev.ulfrx.recipe.auth
import dev.lokksmith.Lokksmith
import dev.lokksmith.client.Client
import dev.lokksmith.client.InternalClient
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.lokksmith.client.request.flow.AuthFlowStateResponseHandler
import dev.lokksmith.client.request.flow.authorizationCode.AuthorizationCodeFlow
import dev.lokksmith.client.request.flow.endSession.EndSessionFlow
import dev.lokksmith.client.request.parameter.Scope
import dev.lokksmith.discoveryUrl
import dev.lokksmith.id
import dev.ulfrx.recipe.shared.Constants
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.selects.select
internal const val LOKKSMITH_CLIENT_KEY = "recipe-app"
internal const val LOKKSMITH_AUTH_STATE_JSON = "lokksmith:$LOKKSMITH_CLIENT_KEY"
internal suspend fun Lokksmith.recipeClient(): Client =
getOrCreate(LOKKSMITH_CLIENT_KEY) {
id = Constants.OIDC_CLIENT_ID
discoveryUrl = Constants.OIDC_ISSUER + ".well-known/openid-configuration"
}
internal fun Client.recipeAuthorizationCodeFlow(): dev.lokksmith.client.request.flow.AuthFlow =
authorizationCodeFlow(
AuthorizationCodeFlow.Request(
redirectUri = Constants.OIDC_REDIRECT_URI,
scope = setOf(Scope.Profile, Scope.Email, Scope.Custom("offline_access")),
),
)
internal fun Client.recipeEndSessionFlow(): dev.lokksmith.client.request.flow.AuthFlow? =
endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI))
internal suspend fun Client.awaitTerminalAuthFlowResult(): AuthFlowResultProvider.Result =
AuthFlowResultProvider
.forClient(this)
.first { result ->
result is AuthFlowResultProvider.Result.Success ||
result is AuthFlowResultProvider.Result.Cancelled ||
result is AuthFlowResultProvider.Result.Error
}
internal suspend fun Lokksmith.completeAuthFlow(client: Client): AuthFlowResultProvider.Result =
coroutineScope {
val terminal = async { client.awaitTerminalAuthFlowResult() }
val responseUri =
async {
(client as InternalClient)
.snapshots
.map { snapshot -> snapshot.ephemeralFlowState?.responseUri }
.distinctUntilChanged()
.first { responseUri -> responseUri != null }
}
select<AuthFlowResultProvider.Result> {
terminal.onAwait { result ->
responseUri.cancel()
result
}
responseUri.onAwait { uri ->
terminal.cancel()
AuthFlowStateResponseHandler(this@completeAuthFlow).onResponse(checkNotNull(uri))
client.awaitTerminalAuthFlowResult()
}
}
}
internal suspend fun Client.toOidcSuccess(): OidcResult.Success {
var freshTokens: Client.Tokens? = null
runWithTokens { tokens -> freshTokens = tokens }
val tokens = checkNotNull(freshTokens) { "Lokksmith returned no tokens" }
return OidcResult.Success(
authStateJson = LOKKSMITH_AUTH_STATE_JSON,
accessToken = tokens.accessToken.token,
idToken = tokens.idToken.raw,
expiresAtEpochMillis = tokens.accessToken.expiresAt?.let { it * 1_000L } ?: 0L,
)
}
internal fun AuthFlowResultProvider.Result.toOidcFailureOrNull(): OidcResult? =
when (this) {
is AuthFlowResultProvider.Result.Success -> null
is AuthFlowResultProvider.Result.Cancelled -> OidcResult.Cancelled
is AuthFlowResultProvider.Result.Error -> OidcResult.AuthError(message ?: "OIDC flow failed")
AuthFlowResultProvider.Result.Undefined,
is AuthFlowResultProvider.Result.Processing,
-> OidcResult.AuthError("OIDC flow did not complete")
}

View File

@@ -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()
} }
} }
fun createIosLokksmith(): Lokksmith = /**
createLokksmith().also { lokksmith -> * Forwarded from `iOSApp.swift`'s `onOpenURL` so the Swift bridge can complete
SingletonLokksmithProvider.set(lokksmith) * an in-flight authorization. Returns `true` if the URL was consumed.
*/
@OptIn(ExperimentalForeignApi::class)
object IosOidcUrlHandler {
fun resume(urlString: String): Boolean {
val bridge = KoinPlatform.getKoinOrNull()?.getOrNull<IosAuthBridge>() ?: return false
return bridge.resumeExternalUserAgentFlow(urlString)
}
}
@OptIn(ExperimentalForeignApi::class)
private fun topViewController(): UIViewController? {
val root = UIApplication.sharedApplication.keyWindow?.rootViewController
var current = root
while (current?.presentedViewController != null) {
current = current.presentedViewController
}
return current
}
private fun IosAuthBridgeResult.toOidcResult(): OidcResult =
when (this) {
is IosAuthBridgeResult.Success -> {
OidcResult.Success(
authStateJson = encodeTokens(tokens),
accessToken = tokens.accessToken,
idToken = tokens.idToken,
expiresAtEpochMillis = tokens.expiresAtEpochMillis,
)
}
IosAuthBridgeResult.Cancelled -> {
OidcResult.Cancelled
}
IosAuthBridgeResult.NetworkError -> {
OidcResult.NetworkError
}
is IosAuthBridgeResult.Failed -> {
OidcResult.AuthError(message)
}
}
private val tokensJson = Json { ignoreUnknownKeys = true }
private fun encodeTokens(tokens: IosAuthTokens): String = tokensJson.encodeToString(IosAuthTokens.serializer(), tokens)
private fun decodeTokens(value: String): IosAuthTokens? =
try {
tokensJson.decodeFromString(IosAuthTokens.serializer(), value)
} catch (_: SerializationException) {
null
} catch (_: IllegalArgumentException) {
null
} }

View File

@@ -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"
} }
} }

View File

@@ -66,10 +66,10 @@ redirect failures (D-09).
| Where | Mechanism | Phase 2 plan that lands it | | Where | Mechanism | Phase 2 plan that lands it |
|-------|-----------|----------------------------| |-------|-----------|----------------------------|
| Authentik provider | Redirect URIs textbox (one line) | This document (Plan 02-01) | | Authentik provider | Redirect URIs textbox (one line) | This document (Plan 02-01) |
| iOS | `iosApp/iosApp/Info.plist` `CFBundleURLTypes``CFBundleURLSchemes` array containing exactly `recipe` | Plan 02-05 (iOS 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)

View File

@@ -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" }

View File

@@ -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 */;
} }

View File

@@ -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
}

View 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())
}

View File

@@ -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)
}
} }
} }
} }

View File

@@ -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(

View File

@@ -4,8 +4,8 @@ import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.application.Application import io.ktor.server.application.Application
import org.flywaydb.core.Flyway import org.flywaydb.core.Flyway
import org.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(),
) )
} }

View File

@@ -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 }

View File

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

View File

@@ -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)

View File

@@ -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

View File

@@ -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()
} }
} }

View File

@@ -45,7 +45,7 @@ public object Constants {
/** /**
* Custom URL scheme for the OAuth redirect (D-09). Must match `Info.plist` * Custom URL scheme for the OAuth redirect (D-09). Must match `Info.plist`
* `CFBundleURLTypes` on iOS and the Android manifest `<intent-filter>` byte * `CFBundleURLTypes` on iOS and the Android manifest `<intent-filter>` byte
* for byte. PKCE S256 + 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"