Compare commits

...

44 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
99 changed files with 8880 additions and 347 deletions

3
.gitignore vendored
View File

@@ -17,3 +17,6 @@ captures
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
node_modules/
# Generated by Kotlin CocoaPods plugin (Phase 2 D-01); regenerated on every Gradle sync.
*.podspec

View File

@@ -143,7 +143,7 @@ A mobile-first meal planning app for a small household — pick recipes for the
| Image loading: Coil 3 (`io.coil-kt.coil3:coil-compose`) | First-class Compose Multiplatform support; modern API | — Pending |
| Key-value settings: `com.russhwolf:multiplatform-settings` | Small prefs (last tab, theme toggles) that don't belong in SQLDelight | — Pending |
| Glass/blur effects: Haze (`dev.chrisbanes.haze:haze`) | Purpose-built for glass UI in CMP; handles content capture + efficient re-blur; multiplatform | — Pending |
| Mobile OIDC: AppAuth (Android) + ASWebAuthenticationSession wrapper (iOS), exposed via KMP interface | Platform-native OAuth flows; no cross-platform auth library mature enough yet for this in 2026 | — 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

View File

@@ -7,12 +7,12 @@
### Authentication & identity
- [ ] **AUTH-01**: User can sign in via the self-hosted Authentik instance using OIDC (authorization code flow with PKCE)
- [ ] **AUTH-02**: Client stores access + refresh tokens securely (iOS Keychain / Android EncryptedSharedPreferences)
- [ ] **AUTH-03**: Ktor server validates incoming access tokens via Authentik's JWKS endpoint (issuer, audience, expiry, signature, clock skew leeway)
- [ ] **AUTH-04**: User session persists across app launches without re-authentication (token refresh handled transparently)
- [ ] **AUTH-05**: User can sign out, which revokes local tokens and returns to the login screen
- [ ] **AUTH-06**: Users are JIT-provisioned in the server database on first successful login (by OIDC `sub` claim)
- [x] **AUTH-01**: User can sign in via the self-hosted Authentik instance using OIDC (authorization code flow with PKCE)
- [x] **AUTH-02**: Client stores access + refresh tokens securely (iOS Keychain / Android EncryptedSharedPreferences)
- [x] **AUTH-03**: Ktor server validates incoming access tokens via Authentik's JWKS endpoint (issuer, audience, expiry, signature, clock skew leeway)
- [x] **AUTH-04**: User session persists across app launches without re-authentication (token refresh handled transparently)
- [x] **AUTH-05**: User can sign out, which revokes local tokens and returns to the login screen
- [x] **AUTH-06**: Users are JIT-provisioned in the server database on first successful login (by OIDC `sub` claim)
### Household sharing
@@ -96,12 +96,12 @@
### Infrastructure & build
- [ ] **INFRA-01**: Gradle version catalog at `gradle/libs.versions.toml` is the single source of truth for library versions
- [ ] **INFRA-02**: `build-logic/` convention plugins centralize Kotlin/Compose/test configuration across modules
- [ ] **INFRA-03**: iOS Kotlin/Native binary options set from day 1: `kotlin.native.binary.objcDisposeOnMain=false`, `gc=cms`
- [x] **INFRA-01**: Gradle version catalog at `gradle/libs.versions.toml` is the single source of truth for library versions
- [x] **INFRA-02**: `build-logic/` convention plugins centralize Kotlin/Compose/test configuration across modules
- [x] **INFRA-03**: iOS Kotlin/Native binary options set from day 1: `kotlin.native.binary.objcDisposeOnMain=false`, `gc=cms`
- [ ] **INFRA-04**: Server Docker image builds and deploys to user's homelab alongside Authentik
- [ ] **INFRA-05**: Flyway migrations run automatically on server startup in a known order
- [ ] **INFRA-06**: `shared/commonMain` contains only domain models + API DTOs — no UI, no HTTP, no DB code
- [x] **INFRA-06**: `shared/commonMain` contains only domain models + API DTOs — no UI, no HTTP, no DB code
- [ ] **INFRA-07**: App is distributed to partner via TestFlight (iOS) for initial dogfooding
## v2 Requirements
@@ -159,11 +159,11 @@ Populated during roadmap creation. Each v1 requirement maps to exactly one phase
| Requirement | Phase | Status |
|-------------|-------|--------|
| AUTH-01 | Phase 2: Authentication Foundation | Pending |
| AUTH-02 | Phase 2: Authentication Foundation | Pending |
| AUTH-01 | Phase 2: Authentication Foundation | Complete |
| AUTH-02 | Phase 2: Authentication Foundation | Complete |
| AUTH-03 | Phase 2: Authentication Foundation | Pending |
| AUTH-04 | Phase 2: Authentication Foundation | Pending |
| AUTH-05 | Phase 2: Authentication Foundation | Pending |
| AUTH-04 | Phase 2: Authentication Foundation | Complete |
| AUTH-05 | Phase 2: Authentication Foundation | Complete |
| AUTH-06 | Phase 2: Authentication Foundation | Pending |
| HSHD-01 | Phase 3: Households, Membership & Server Data Foundation | Pending |
| HSHD-02 | Phase 3: Households, Membership & Server Data Foundation | Pending |
@@ -224,12 +224,12 @@ Populated during roadmap creation. Each v1 requirement maps to exactly one phase
| UI-07 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
| UI-08 | Phase 5: Recipe Catalog (Read Path) | Pending |
| UI-09 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
| INFRA-01 | Phase 1: Project Infrastructure & Module Wiring | Pending |
| INFRA-02 | Phase 1: Project Infrastructure & Module Wiring | Pending |
| INFRA-03 | Phase 1: Project Infrastructure & Module Wiring | Pending |
| INFRA-01 | Phase 1: Project Infrastructure & Module Wiring | Complete |
| INFRA-02 | Phase 1: Project Infrastructure & Module Wiring | Complete |
| INFRA-03 | Phase 1: Project Infrastructure & Module Wiring | Complete |
| INFRA-04 | Phase 11: Localization & iOS Deployment | Pending |
| INFRA-05 | Phase 3: Households, Membership & Server Data Foundation | Pending |
| INFRA-06 | Phase 1: Project Infrastructure & Module Wiring | Pending |
| INFRA-06 | Phase 1: Project Infrastructure & Module Wiring | Complete |
| INFRA-07 | Phase 11: Localization & iOS Deployment | Pending |
**Coverage:**

View File

@@ -8,7 +8,7 @@
## Phases
- [ ] **Phase 1: Project Infrastructure & Module Wiring** — Running-but-empty KMP client + Ktor server with all build infra baked in
- [x] **Phase 1: Project Infrastructure & Module Wiring** — Running-but-empty KMP client + Ktor server with all build infra baked in
- [ ] **Phase 2: Authentication Foundation** — User signs in through Authentik (OIDC+PKCE) and the server validates tokens
- [ ] **Phase 3: Households, Membership & Server Data Foundation** — Users create/join households; server enforces household scope
- [ ] **Phase 4: Sync Engine Skeleton** — Offline-first read/write with outbox-backed LWW sync on a sentinel table
@@ -73,13 +73,22 @@ Plans:
3. I tap "Wyloguj się"; the app returns to the login screen and the stored tokens are gone from Keychain/EncryptedSharedPreferences.
4. Calling `GET /api/v1/me` with a valid token returns my user record; the same call with a missing, expired, or wrong-audience token returns 401.
5. My user row exists in the server DB after my first successful login, keyed by the OIDC `sub` claim (no manual user creation needed).
**Plans:** TBD
**Plans:** 7 plans
Plans:
- [x] 02-01-PLAN.md — Shared auth contracts, dependency aliases, Authentik setup docs, and source audit
- [x] 02-02-PLAN.md — Server JWT validation, JWKS hardening, JIT users, and `/api/v1/me`
- [ ] 02-03-PLAN.md — Common OIDC/store contracts, JVM/Wasm actuals, and store contract test
- [ ] 02-04-PLAN.md — Android AppAuth actual, Android secure AuthState store, and manifest callback
- [ ] 02-05-PLAN.md — iOS AppAuth actual, iOS Keychain store, URL scheme, Swift callback, and Podfile
- [ ] 02-06-PLAN.md — AuthSession state machine, bearer HTTP client, refresh/logout behavior, and Koin wiring
- [ ] 02-07-PLAN.md — Compose auth gate UI, Polish resource strings, and iOS Authentik UAT
**UI hint:** yes
**Research flag:** yes
### Phase 3: Households, Membership & Server Data Foundation
**Goal:** Introduce the tenancy model before any feature tables land — `users`, `households`, `memberships`, `invites` with Flyway migrations; server's `PrincipalResolver` maps JWT `sub` to an active `householdId`; client finishes onboarding by creating or joining a household.
**Goal:** Introduce the tenancy model before any feature tables land — `households`, `memberships`, `invites` with Flyway migrations; server's `PrincipalResolver` maps JWT `sub` to an active `householdId`; client finishes onboarding by creating or joining a household.
**Depends on:** Phase 2
**Requirements:** HSHD-01, HSHD-02, HSHD-03, HSHD-04, HSHD-05, HSHD-06, HSHD-07, INFRA-05
**Success Criteria** (what must be TRUE):
@@ -212,8 +221,8 @@ Plans:
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Project Infrastructure & Module Wiring | 0/0 | Not started | - |
| 2. Authentication Foundation | 0/0 | Not started | - |
| 1. Project Infrastructure & Module Wiring | 7/7 | Complete | 2026-04-24 |
| 2. Authentication Foundation | 2/7 | Executing | - |
| 3. Households, Membership & Server Data Foundation | 0/0 | Not started | - |
| 4. Sync Engine Skeleton | 0/0 | Not started | - |
| 5. Recipe Catalog (Read Path) | 0/0 | Not started | - |

View File

@@ -2,15 +2,15 @@
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
current_plan: 1
current_plan: 7
status: executing
last_updated: "2026-04-24T17:39:22.205Z"
last_updated: "2026-04-28T14:57:40.504Z"
progress:
total_phases: 11
completed_phases: 0
total_plans: 7
completed_plans: 4
percent: 57
completed_phases: 1
total_plans: 14
completed_plans: 13
percent: 93
---
# Project State: Recipe
@@ -25,13 +25,13 @@ progress:
## Current Position
Phase: --phase (01) — EXECUTING
Plan: 1 of --name
**Current focus:** Phase --phase — 01
**Current plan:** 1
**Status:** Executing Phase --phase
**Phase progress:** 0 / 11 phases complete
**Progress bar:** `░░░░░░░░░░░░░░░░░░░░` 0%
Phase: 02 (authentication-foundation) — EXECUTING
Plan: 7 of 7
**Current focus:** Phase 02 — authentication-foundation
**Current plan:** 7
**Status:** Ready to execute
**Phase progress:** 6 / 7 plans complete
**Progress bar:** `[█████████░] 93%`
## Performance Metrics
@@ -40,8 +40,11 @@ Plan: 1 of --name
| Phases planned | 11 |
| v1 requirements | 72 |
| Coverage | 100% |
| Phases complete | 0 |
| Plans complete | 0 |
| Phases complete | 1 |
| Plans complete | 13 |
| Phase 02 P02 | 13min | 3 tasks | 14 files |
| Phase 02-authentication-foundation P03 | 31m | 2 tasks | 8 files |
| Phase 02-authentication-foundation P06 | 34m | 3 tasks | 7 files |
## Accumulated Context
@@ -51,7 +54,7 @@ All locked tech-stack decisions are captured in `.planning/PROJECT.md § Key Dec
### Open todos
- None yet — first action is `/gsd-plan-phase 1`.
- None.
### Blockers
@@ -59,17 +62,17 @@ All locked tech-stack decisions are captured in `.planning/PROJECT.md § Key Dec
## Session Continuity
**Last session:** --stopped-at
**Last session:** 2026-04-28T14:57:40.504Z
**Next action:** `/gsd-plan-phase 1`decompose Phase 1 (Project Infrastructure & Module Wiring) into plans.
**Next action:** `/gsd-execute-phase 2`Authentication Foundation plan 07.
**Research flags to revisit during phase planning:**
**Research flags to revisit during future phase planning:**
- Phase 2 (Auth): Authentik-specific OIDC setup; iOS OIDC wrapper library choice; token refresh behavior.
- Phase 4 (SyncEngine): concrete cursor format, outbox schema ordering guarantees, retry/backoff policy.
- Phase 10 (UI chrome): current Haze CMP-iOS perf on iPhone 11/12-era hardware; liquid-glass approximation patterns.
---
*Last updated: 2026-04-23*
*Last updated: 2026-04-28*
**Planned Phase:** 1 (Project Infrastructure & Module Wiring) — 7 plans — 2026-04-24T16:07:36.289Z
**Planned Phase:** 2 (Authentication Foundation) — 7 plans — 2026-04-28T08:30:48.000Z

View File

@@ -0,0 +1,150 @@
---
phase: 01-project-infrastructure-module-wiring
plan: 07
subsystem: infra-verification
tags: [gradle, kmp, compose-multiplatform, ios, android, spotless, verification]
dependency_graph:
requires:
- phase: 01-project-infrastructure-module-wiring
provides: "Plans 01-06 delivered catalog aliases, convention plugins, module rewrites, app bootstrap, server health/Flyway config, and local Postgres docs"
provides:
- "Empty dev.ulfrx.recipe.shared package scaffold marker for Phase 2+ DTOs"
- "Full automated Phase 1 verification gate: spotlessApply, invariant scripts, build, artifact checks, check"
- "Proof that Android APK and iOS simulator framework artifacts build from the current repo"
affects:
- "Phase 2 Authentication Foundation"
- "All future KMP/server build work"
tech_stack:
added: []
patterns:
- "Phase gate runs formatting, custom invariants, full build, artifact existence checks, and check before marking infra complete"
key_files:
created:
- "shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep"
modified:
- "gradle/libs.versions.toml"
- "build.gradle.kts"
- "build-logic/build.gradle.kts"
- "build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts"
- "build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts"
- "build-logic/src/main/kotlin/recipe.quality.gradle.kts"
- ".planning/STATE.md"
- ".planning/ROADMAP.md"
- ".planning/REQUIREMENTS.md"
- ".planning/phases/01-project-infrastructure-module-wiring/01-07-SUMMARY.md"
key_decisions:
- "Accepted ./gradlew build success as SC4 proof for convention plugin application, per plan guidance, because :composeApp task listing does not enumerate applied plugin IDs."
- "Deferred the iOS simulator boot smoke check because 01-VALIDATION.md classifies it as manual-only."
requirements_completed: [INFRA-01, INFRA-02, INFRA-03, INFRA-06]
metrics:
duration_seconds: 1090
duration_human: "18m10s"
tasks_completed: 2
files_created: 1
files_modified: 1
completed_at: "2026-04-24T18:55:45Z"
---
# Phase 01 Plan 07: Shared scaffold + green build gate summary
Created the empty `dev.ulfrx.recipe.shared` package marker and proved Phase 1 integrates cleanly across the KMP client, shared module, and Ktor server with the full automated gate.
## Performance
- **Duration:** 18m10s
- **Started:** 2026-04-24T18:37:35Z
- **Completed:** 2026-04-24T18:55:45Z
- **Tasks:** 2
- **Files modified:** 1 scaffold marker commit, 6 Gradle integration fixes, 3 GSD bookkeeping files, and this summary
## Accomplishments
- Confirmed `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep` exists while preserving the template `Greeting.kt`, `Platform.kt`, and `Constants.kt`.
- Ran all three invariant scripts successfully: no Gradle version literals outside the catalog, shared/commonMain purity, and mandatory iOS K/N flags.
- Ran `./gradlew build` successfully and verified both proof artifacts:
- `composeApp/build/outputs/apk/debug/composeApp-debug.apk`
- `composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework`
- Ran `./gradlew check` successfully.
## Task Commits
1. **Task 1: Create shared package scaffold placeholder** - `b36058f` (`chore(01-07): add shared package scaffold placeholder`)
2. **Task 2: Run Spotless apply + full build gate + invariant scripts** - not separately committed; verification-only task produced no planned source edits.
## Files Created/Modified
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep` - Empty marker preserving the future DTO/domain subpackage in git.
- `.planning/phases/01-project-infrastructure-module-wiring/01-07-SUMMARY.md` - This execution summary.
- `gradle/libs.versions.toml`, `build.gradle.kts`, `build-logic/build.gradle.kts`, `build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts` - Serialization plugin alias/application needed by the server build.
- `build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts`, `build-logic/src/main/kotlin/recipe.quality.gradle.kts` - Metadata warning handling so the all-warnings-as-errors policy does not fail generated KMP metadata tasks.
- `.planning/STATE.md`, `.planning/ROADMAP.md`, `.planning/REQUIREMENTS.md` - Phase 1 completion bookkeeping.
## Decisions Made
- Accepted `./gradlew build` success as the convention-plugin proof for SC4, matching the plan note that recent Gradle help/tasks output may not list plugin IDs directly.
- Did not run `docker compose`, `:server:run`, or an iOS simulator boot; the plan explicitly excludes those from the automated gate.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Added missing Kotlin serialization plugin wiring**
- **Found during:** Task 2 (green build gate), before inline recovery completed
- **Issue:** The server-side Phase 1 setup needs the Kotlin serialization compiler plugin available through the catalog/build-logic stack; without it, the Ktor JSON serialization path is not a complete build contract.
- **Fix:** Added `kotlinSerialization` to `gradle/libs.versions.toml`, root `build.gradle.kts`, `build-logic/build.gradle.kts`, and applied `org.jetbrains.kotlin.plugin.serialization` in `recipe.jvm.server`.
- **Files modified:** `gradle/libs.versions.toml`, `build.gradle.kts`, `build-logic/build.gradle.kts`, `build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts`
- **Verification:** `./gradlew build` and `./gradlew check` both passed.
**2. [Rule 3 - Blocking] Scoped warnings-as-errors away from generated metadata tasks**
- **Found during:** Task 2 (green build gate), before inline recovery completed
- **Issue:** KMP metadata tasks can emit generated/dependency warnings that block the phase gate under global `allWarningsAsErrors`.
- **Fix:** Preserved warnings-as-errors for normal compilation while disabling it for `*KotlinMetadata` tasks in the convention/quality plugins.
- **Files modified:** `build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts`, `build-logic/src/main/kotlin/recipe.quality.gradle.kts`
- **Verification:** `./gradlew build` and `./gradlew check` both passed.
---
**Total deviations:** 2 auto-fixed blocking integration issues.
**Impact on plan:** Both fixes stay inside Phase 1 build infrastructure and were required for the automated gate to pass. No product scope added.
## Issues Encountered
- The first spawned `gsd-executor` did not return status after repeated waits and a direct status ping. The orchestrator closed it and completed the plan inline.
- Before shutdown, that executor appears to have left the Gradle integration fixes above in the main worktree; they were reviewed via `git diff`, kept because the build gate passed with them, and documented here.
- `./gradlew build` emitted a Kotlin/Native bundle ID warning for `ComposeApp`; the build still succeeded. This is not the legacy memory-management warning that INFRA-03 guards against.
- Two locked `.claude/worktrees/agent-*` worktrees remain from prior executor activity and were left untouched to avoid destructive cleanup without explicit approval.
## User Setup Required
None - no external service configuration required.
## Verification
| Command | Result |
|---------|--------|
| `./gradlew spotlessApply` | PASS (`BUILD SUCCESSFUL`) |
| `bash tools/verify-no-version-literals.sh` | PASS (`OK: no version literals outside catalog.`) |
| `bash tools/verify-shared-pure.sh` | PASS (`OK: shared/commonMain is pure.`) |
| `bash tools/verify-ios-flags.sh` | PASS (`OK: iOS binary flags present.`) |
| `./gradlew build` | PASS (`BUILD SUCCESSFUL in 2m 28s`) |
| `test -f composeApp/build/outputs/apk/debug/composeApp-debug.apk` | PASS |
| `test -d composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework` | PASS |
| `./gradlew check` | PASS (`BUILD SUCCESSFUL in 2s`) |
## Requirements addressed
- **INFRA-01** — catalog-only version invariant passed.
- **INFRA-02** — convention plugin wiring proved by full build/check success across modules.
- **INFRA-03** — iOS K/N flags invariant passed.
- **INFRA-06** — shared/commonMain purity invariant passed and package scaffold exists.
## Next Phase Readiness
Phase 1's automated gate is green. Phase 2 can begin planning/execution against a working KMP + Ktor + shared-module infrastructure baseline.
## Self-Check: PASSED
- `01-07-SUMMARY.md` exists.
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep` exists.
- All plan acceptance criteria were checked manually through shell commands.
- No `BUILD FAILED` appeared in the final gate transcript.

View File

@@ -0,0 +1,220 @@
---
phase: 02-authentication-foundation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- gradle/libs.versions.toml
- shared/build.gradle.kts
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt
- shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt
- composeApp/build.gradle.kts
- server/build.gradle.kts
- docs/authentik-setup.md
autonomous: true
requirements: [AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06]
user_setup:
- service: authentik
why: "OIDC provider for mobile login and server JWT validation"
env_vars:
- name: OIDC_ISSUER
source: "Authentik provider issuer URL"
- name: OIDC_AUDIENCE
source: "Authentik OAuth2 provider client ID"
- name: OIDC_JWKS_URL
source: "Optional JWKS URI from Authentik OpenID configuration"
dashboard_config:
- task: "Create public OAuth2/OIDC provider with PKCE S256, redirect URI recipe://callback, scopes openid profile email offline_access, RS256 signing, single-string audience equal to client_id"
location: "Authentik Admin -> Applications -> Providers"
must_haves:
truths:
- "All Phase 2 plans compile against one shared OIDC config and one /api/v1/me DTO contract"
- "Authentik provider setup documents public client + PKCE S256, scopes openid profile email offline_access, RS256, single-string audience, JWKS, and end-session"
- "Android secure token storage is explicit: auth code must not use no-arg Settings() for tokens"
artifacts:
- path: "shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt"
provides: "OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_REDIRECT_URI, API_BASE_URL per D-11"
contains: "OIDC_REDIRECT_URI"
- path: "shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt"
provides: "Serializable /api/v1/me response per D-27"
contains: "@Serializable"
- path: "docs/authentik-setup.md"
provides: "Provider scope mapping and manual UAT checklist per D-10"
contains: "offline_access"
key_links:
- from: "shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt"
to: "docs/authentik-setup.md"
via: "same issuer/client/redirect values"
pattern: "recipe://callback"
- from: "gradle/libs.versions.toml"
to: "composeApp/build.gradle.kts and server/build.gradle.kts"
via: "catalog aliases only; no version literals in module build files"
pattern: "ktor-serverAuthJwt|appauth|androidx-security-crypto"
---
<objective>
Create the shared contract and dependency foundation for Authentication Foundation.
Purpose: every downstream plan needs the same DTOs, dependency aliases, and Authentik provider contract before implementation starts.
Output: shared DTO/config files, build dependency wiring, and `docs/authentik-setup.md`.
</objective>
<execution_context>
@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md
@/Users/rwilk/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/02-authentication-foundation/02-CONTEXT.md
@.planning/phases/02-authentication-foundation/02-RESEARCH.md
@.planning/phases/02-authentication-foundation/02-VALIDATION.md
@.planning/phases/02-authentication-foundation/02-PATTERNS.md
@AGENTS.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add shared DTO/config contract and serialization test</name>
<read_first>
- shared/build.gradle.kts
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt
- shared/src/commonTest/kotlin/dev/ulfrx/recipe/SharedCommonTest.kt
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-11, D-27, D-28)
</read_first>
<files>shared/build.gradle.kts, shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt, shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt, shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt, shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt</files>
<behavior>
- `Constants.OIDC_REDIRECT_URI` equals exactly `recipe://callback` per D-09.
- `Constants.OIDC_ISSUER` ends with `/`; use placeholder `https://auth.example.invalid/application/o/recipe/` until real homelab value is substituted.
- `Constants.OIDC_CLIENT_ID` equals `recipe-app`.
- `MeResponse` serializes fields `id`, `sub`, `email`, `displayName`, and maps to `User`.
- `shared/commonMain` imports only allowed dependencies: Kotlin stdlib and kotlinx.serialization.
</behavior>
<action>
Apply `alias(libs.plugins.kotlinSerialization)` to `shared/build.gradle.kts`; add `api(libs.kotlinx.serializationJson)` in `commonMain.dependencies`.
Add `dev.ulfrx.recipe.shared.Constants` as a public object with `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_REDIRECT_URI`, and `API_BASE_URL`. Add `dev.ulfrx.recipe.shared.dto.User` and `MeResponse` as public `@Serializable` data classes using `String` for the server UUID, with `MeResponse.toUser()`.
Create `MeResponseSerializationTest` covering round trip, `displayName` wire name, and `ignoreUnknownKeys` compatibility with future `householdId`.
</action>
<verify>
<automated>./gradlew :shared:jvmTest :shared:compileCommonMainKotlinMetadata</automated>
</verify>
<acceptance_criteria>
- `grep -q 'OIDC_REDIRECT_URI: String = "recipe://callback"' shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt`
- `grep -q '@Serializable' shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt`
- `grep -q 'public fun toUser()' shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt`
- `./tools/verify-shared-pure.sh` exits 0
- `./gradlew :shared:jvmTest :shared:compileCommonMainKotlinMetadata` exits 0
</acceptance_criteria>
<done>Shared config and DTO contract exists and is tested without violating shared module purity.</done>
</task>
<task type="auto">
<name>Task 2: Add Phase 2 dependency aliases without Ktor patch bump</name>
<read_first>
- gradle/libs.versions.toml
- composeApp/build.gradle.kts
- server/build.gradle.kts
- .planning/phases/02-authentication-foundation/02-RESEARCH.md (Standard Stack, Open Questions)
</read_first>
<files>gradle/libs.versions.toml, composeApp/build.gradle.kts, server/build.gradle.kts</files>
<action>
In `gradle/libs.versions.toml`, keep existing `ktor = "3.4.1"` unchanged. Add version keys and aliases for:
`appauth = "0.11.1"`, `appauth-ios = "2.0.0"`, `androidx-security-crypto = "1.1.0"`, `multiplatformSettings = "1.3.0"`, `exposed = "0.55.0"`, `hikari = "6.2.1"`, `testcontainers = "1.21.4"`, plus `kotlinCocoapods` plugin.
Add libraries: `appauth`, `androidx-security-crypto`, `multiplatform-settings`, `multiplatform-settings-coroutines`, Ktor client core/auth/content-negotiation/logging/okhttp/darwin/cio, `ktor-serializationKotlinxJsonMpp` using `io.ktor:ktor-serialization-kotlinx-json`, Ktor server auth/auth-jwt/call-logging/status-pages, Exposed core/jdbc/java-time, Hikari, `kotlinx-serializationJson`, and Testcontainers `org.testcontainers:postgresql` + `org.testcontainers:junit-jupiter`.
In `composeApp/build.gradle.kts`, apply `alias(libs.plugins.kotlinSerialization)` and `alias(libs.plugins.kotlinCocoapods)`. Add common deps for settings, Ktor client, serialization, and platform deps: Android AppAuth + AndroidX Security Crypto + OkHttp, iOS Darwin, JVM CIO. Configure CocoaPods with `podfile = project.file("../iosApp/Podfile")`, framework `baseName = "ComposeApp"`, `isStatic = true`, and `pod("AppAuth") { version = libs.versions.appauth.ios.get() }`. Do not put a literal `version = "2.0.0"` in any `*.gradle.kts`; the CocoaPods version must come from the version catalog so `./tools/verify-no-version-literals.sh` can pass.
In `server/build.gradle.kts`, add server auth/JWT/call logging/status pages, Exposed, Hikari, serialization deps, and test deps `testImplementation(libs.testcontainers.postgresql)` plus `testImplementation(libs.testcontainers.junit.jupiter)` from catalog. Do not add inline versions in build files.
</action>
<verify>
<automated>./gradlew :composeApp:dependencies --configuration androidMainCompileClasspath :server:dependencies --configuration runtimeClasspath</automated>
</verify>
<acceptance_criteria>
- `grep -q 'ktor = "3.4.1"' gradle/libs.versions.toml`
- `grep -q 'appauth-ios = "2.0.0"' gradle/libs.versions.toml`
- `grep -q 'androidx-security-crypto' gradle/libs.versions.toml`
- `grep -q 'testcontainers = "1.21.4"' gradle/libs.versions.toml`
- `grep -q 'pod("AppAuth")' composeApp/build.gradle.kts`
- `grep -q 'libs.versions.appauth.ios.get()' composeApp/build.gradle.kts`
- `! grep -q 'version = "2.0.0"' composeApp/build.gradle.kts`
- `grep -q 'libs.androidx.security.crypto' composeApp/build.gradle.kts`
- `grep -q 'libs.ktor.serverAuthJwt' server/build.gradle.kts`
- `grep -q 'libs.exposed.jdbc' server/build.gradle.kts`
- `grep -q 'libs.testcontainers.postgresql' server/build.gradle.kts`
- `./tools/verify-no-version-literals.sh` exits 0
</acceptance_criteria>
<done>Phase 2 dependencies are cataloged and wired while preserving the pinned Ktor version.</done>
</task>
<task type="auto">
<name>Task 3: Document Authentik provider setup and source audit</name>
<read_first>
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-05 through D-10, D-19, D-21 through D-23)
- .planning/phases/02-authentication-foundation/02-VALIDATION.md
- .planning/ROADMAP.md Phase 2 success criteria
</read_first>
<files>docs/authentik-setup.md</files>
<action>
Create `docs/authentik-setup.md` with these exact sections:
`## Provider`, `## Scopes`, `## Redirect URI`, `## Server Env Vars`, `## Logout`, `## Manual UAT`, `## Source Audit`.
Provider section must specify: OAuth2/OIDC public client, authorization code with PKCE S256, no client secret in the app, redirect URI `recipe://callback`, RS256 signing, single-string `aud` equal to `recipe-app`, JWKS URI from the provider's OpenID configuration, and end-session endpoint.
Scopes section must state the app requests exactly `openid profile email offline_access` and that Authentik must map/allow `offline_access` for refresh tokens. Manual UAT must cover fresh iOS login, reopen/refresh after access-token expiry, logout returning to login, and curl/HTTP verification of `/api/v1/me` returning 200 with valid token and 401 without/wrong-audience token.
Source Audit must mark all Phase 2 sources covered: GOAL Phase 2, REQ AUTH-01..AUTH-06, RESEARCH constraints, CONTEXT D-01..D-34, UI-SPEC auth screens, VALIDATION Wave 0 tests, PATTERNS file map. Deferred ideas must be listed as excluded: Universal Links/App Links, real Desktop OIDC, Wasm OIDC, Apple Sign-in, Authentik automation.
</action>
<verify>
<automated>grep -E 'openid profile email offline_access|PKCE S256|single-string|recipe://callback|/api/v1/me|Source Audit' docs/authentik-setup.md</automated>
</verify>
<acceptance_criteria>
- `grep -q 'openid profile email offline_access' docs/authentik-setup.md`
- `grep -q 'offline_access.*refresh' docs/authentik-setup.md`
- `grep -q 'single-string.*aud' docs/authentik-setup.md`
- `grep -q 'AUTH-01.*AUTH-02.*AUTH-03.*AUTH-04.*AUTH-05.*AUTH-06' docs/authentik-setup.md`
- `grep -q 'Universal Links / App Links.*excluded' docs/authentik-setup.md`
</acceptance_criteria>
<done>Authentik setup and multi-source audit are reproducible and trace every locked requirement/decision.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| app -> Authentik | Mobile app launches system browser and receives authorization callback through custom URL scheme |
| app -> OS secure storage | Refresh tokens cross from process memory to persistent device storage |
| client -> server | Bearer access tokens cross HTTP boundary |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-01-01 | Spoofing/Elevation | OIDC provider setup | mitigate | Document public client + PKCE S256 + AppAuth state handling + exact `recipe://callback` registration |
| T-02-01-02 | Information Disclosure | token storage dependencies | mitigate | Explicit AndroidX Security Crypto and iOS Keychain store plan; forbid no-arg `Settings()` for auth tokens |
| T-02-01-03 | Elevation | JWT audience config | mitigate | Document single-string `aud` equal to `recipe-app`; server tests in Plan 02 enforce wrong audience 401 |
| T-02-01-04 | Information Disclosure | logs/docs | mitigate | Docs state never log `Authorization` or token bodies; server/client implementation plans include redaction |
</threat_model>
<verification>
Run `./gradlew :shared:jvmTest :shared:compileCommonMainKotlinMetadata`, `./tools/verify-shared-pure.sh`, and `./tools/verify-no-version-literals.sh`.
</verification>
<success_criteria>
Downstream server, client, and UI plans have stable imports/config, Authentik setup is documented, Ktor remains at 3.4.1, and Android token security is explicit.
</success_criteria>
<output>
After completion, create `.planning/phases/02-authentication-foundation/02-01-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,253 @@
---
phase: 02-authentication-foundation
plan: 01
subsystem: auth
tags: [oidc, authentik, kotlinx-serialization, kmp, ktor, gradle-version-catalog, cocoapods, appauth, exposed, testcontainers]
requires:
- phase: 01-project-infrastructure-module-wiring
provides: gradle version catalog, recipe.kotlin.multiplatform convention plugin, shared module scaffold (D-19), Koin/Kermit bootstrap, Ktor server skeleton with ContentNegotiation + Database.migrate, verify-shared-pure.sh + verify-no-version-literals.sh
provides:
- dev.ulfrx.recipe.shared.Constants with OIDC_ISSUER (trailing slash), OIDC_CLIENT_ID = recipe-app, OIDC_REDIRECT_URI = recipe://callback, API_BASE_URL, SERVER_PORT (D-11)
- dev.ulfrx.recipe.shared.dto.User (domain identity)
- dev.ulfrx.recipe.shared.dto.MeResponse (@Serializable wire DTO with toUser() per D-27)
- shared/build.gradle.kts wired with kotlinSerialization plugin and api(libs.kotlinx.serializationJson)
- Phase 2 dependency aliases in gradle/libs.versions.toml (AppAuth, AndroidX Security Crypto, multiplatform-settings + coroutines, Exposed core/jdbc/java-time, HikariCP, Testcontainers postgresql + junit-jupiter, Ktor server auth/JWT/CallLogging/StatusPages, Ktor client core/auth/content-negotiation/logging/okhttp/darwin/cio + MPP serialization-kotlinx-json) without bumping Ktor (stays 3.4.1)
- composeApp/build.gradle.kts with kotlinSerialization + kotlin.native.cocoapods applied, cocoapods block bringing AppAuth-iOS via libs.versions.appauth.ios.get(), Phase 2 commonMain/androidMain/iosMain/jvmMain dependency wiring, manifestPlaceholders["appAuthRedirectScheme"] = "recipe", and locked compose.resources packageOfResClass
- server/build.gradle.kts with Ktor auth/JWT/CallLogging/StatusPages, Exposed DSL trio, Hikari, kotlinx.serialization-json, plus Testcontainers test dependencies
- docs/authentik-setup.md — reproducible Authentik OIDC provider playbook (D-10) with mandatory sections Provider/Scopes/Redirect URI/Server Env Vars/Logout/Manual UAT/Source Audit, plus an exhaustive multi-source audit table mapping AUTH-01..AUTH-06, CONTEXT D-01..D-34, RESEARCH constraints, UI-SPEC, VALIDATION Wave 0, and PATTERNS file map to either this doc or a downstream Phase 2 plan, with all deferred ideas explicitly excluded
affects:
- 02-02-PLAN.md (server JWT validation, JIT users, /api/v1/me — depends on shared MeResponse DTO + ktor server auth/jwt/exposed/hikari/testcontainers aliases)
- 02-03-PLAN.md (common OIDC/store contracts — depends on Constants, multiplatform-settings, ktor client, Coroutines stack)
- 02-04-PLAN.md (Android AppAuth actual + secure store — depends on libs.appauth + libs.androidx.security.crypto + manifestPlaceholders bootstrap)
- 02-05-PLAN.md (iOS AppAuth actual — depends on cocoapods AppAuth pod + libs.ktor.clientDarwin)
- 02-06-PLAN.md (LoginScreen/PostLoginPlaceholder UI — depends on MeResponse DTO and AuthSession contract from 02-03)
- 02-07-PLAN.md (integration glue / phase verification — depends on every prior plan)
tech-stack:
added:
- kotlinx-serialization-json (api scope in shared/commonMain)
- kotlinSerialization Gradle plugin on shared/composeApp/server (server already had it)
- kotlin.native.cocoapods plugin on composeApp (applied by id; bundled with KGP)
- AppAuth-Android (net.openid:appauth 0.11.1)
- AppAuth-iOS (CocoaPod 2.0.0) — Gradle CocoaPods DSL pulls it via libs.versions.appauth.ios.get()
- androidx.security:security-crypto 1.1.0 (Android secure AuthState store)
- com.russhwolf:multiplatform-settings + multiplatform-settings-coroutines 1.3.0
- Ktor client family (core/auth/content-negotiation/logging) + engines (okhttp Android, darwin iOS, cio JVM)
- Ktor server auth + auth-jwt + call-logging + status-pages (3.4.1, no patch bump)
- Exposed 0.55.0 (core + jdbc + java-time) and HikariCP 6.2.1
- Testcontainers 1.21.4 (postgresql + junit-jupiter)
patterns:
- Shared DTO contract pattern — kotlinx.serialization @Serializable data class with explicit camelCase wire keys, decoded with ignoreUnknownKeys for forward compat (Phase 3 will add householdId)
- Catalog-only version pinning — every Phase 2 dependency declared in gradle/libs.versions.toml; module build files reference libs.* only; verify-no-version-literals.sh enforces it
- Cocoapods-via-catalog pattern — pod("AppAuth") { version = libs.versions.appauth.ios.get() } keeps the build script literal-free even with native Pod integration
- Compose Resources package locking — explicit compose.resources { packageOfResClass = "..." } isolates UI code from build-script identity changes (group/version)
- Authentik provider audit — markdown audit table that traces every locked source (REQ/CONTEXT/RESEARCH/UI-SPEC/VALIDATION/PATTERNS) to either an in-doc anchor or a downstream plan, with deferred ideas explicitly listed
key-files:
created:
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt
- shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt
- docs/authentik-setup.md
modified:
- gradle/libs.versions.toml
- shared/build.gradle.kts
- composeApp/build.gradle.kts
- server/build.gradle.kts
- .gitignore (added *.podspec — generated by cocoapods plugin)
key-decisions:
- "Apply kotlin.native.cocoapods plugin by id, not via libs.plugins alias — the plugin ships inside the Kotlin Gradle plugin already on the classpath via recipe.kotlin.multiplatform; aliasing it forces a duplicate version request that Gradle rejects with 'already on the classpath, compatibility cannot be checked'."
- "Add manifestPlaceholders[\"appAuthRedirectScheme\"] = \"recipe\" to composeApp Android defaultConfig from Plan 02-01 (not Plan 02-04). AppAuth-Android's bundled manifest declares a ${appAuthRedirectScheme} placeholder that breaks AGP merge as soon as the dependency is on the classpath, even before any auth wiring. Setting it here is a Rule 3 prerequisite for the dependency to be cataloged."
- "Lock compose.resources packageOfResClass to the Phase 1 historical name. Adding top-level group = \"dev.ulfrx.recipe\" (required by the cocoapods plugin's podspec generator) shifts the generated Res-class package from recipe.composeapp.generated.resources to dev.ulfrx.recipe.composeapp.generated.resources, breaking Phase 1 App.kt imports. Locking the package keeps the diff inside Plan 02-01's stated files."
- "Ship a *.podspec gitignore entry. The Kotlin CocoaPods plugin regenerates composeApp/composeApp.podspec on every Gradle sync and that file legitimately contains 'AppAuth', '2.0.0' as a literal pin (CocoaPods semantics). Tracking it would either fail verify-no-version-literals.sh (if the verifier ever extends to *.podspec) or churn on every clean build."
- "kotlinx.serialization-json declared as api(...) in shared/commonMain so consumers (composeApp, server) inherit the @Serializable runtime without each re-declaring it. shared/commonMain stays free of Ktor / Compose / SQLDelight / Koin / Kermit per D-19 / INFRA-06."
- "Use the MPP variant of ktor-serialization-kotlinx-json for composeApp/commonMain (io.ktor:ktor-serialization-kotlinx-json) and keep the -jvm variant for the server module. Mixing variants between modules is the supported pattern; introducing a single MPP variant on the server breaks the existing ktor.serializationKotlinxJson alias used by the (jvm-only) server."
- "Server-side OIDC config (OIDC_ISSUER / OIDC_AUDIENCE / OIDC_JWKS_URL) is documented as env-var-driven in docs/authentik-setup.md (D-12) but the actual application.conf wiring is deferred to Plan 02-02. Plan 02-01 establishes the contract; 02-02 implements it."
patterns-established:
- "Shared DTO purity: shared/commonMain depends only on kotlin stdlib + kotlinx.serialization. Verified by ./tools/verify-shared-pure.sh."
- "Catalog discipline: every library and plugin version lives in gradle/libs.versions.toml; Gradle artifact identity (group/version) is allowed at the top of module build files but library/plugin pins are not. Verified by ./tools/verify-no-version-literals.sh."
- "TDD gate sequence: RED commit (test(02-01)) followed by GREEN commit (feat(02-01)) — the Phase 2 plans don't all use TDD but Plan 02-01's Task 1 sets the precedent for downstream auth plans."
- "Multi-source audit pattern: docs/authentik-setup.md ## Source Audit table is the template. Future phase docs that span multiple sources (REQ + CONTEXT + RESEARCH + VALIDATION + PATTERNS) should mirror this structure so audits stay reproducible."
requirements-completed: []
duration: 16m
completed: 2026-04-28
---
# Phase 02 Plan 01: Shared Auth Contracts, Dependency Aliases, and Authentik Setup Summary
**Phase 2 foundation: shared MeResponse/User DTOs + Constants, full Phase 2 dependency catalog (AppAuth/Exposed/Testcontainers/Ktor auth) wired into composeApp/server without bumping Ktor 3.4.1, plus the docs/authentik-setup.md reproducible-provider playbook with multi-source audit.**
## Performance
- **Duration:** 16 min
- **Started:** 2026-04-28T08:40:29Z
- **Completed:** 2026-04-28T08:55:58Z
- **Tasks:** 3 (Task 1 ran TDD: RED + GREEN)
- **Files modified:** 9 (5 created, 4 modified)
## Accomplishments
- Locked the `/api/v1/me` wire contract: `MeResponse` is `@Serializable`, decodes with `ignoreUnknownKeys` so Phase 3 can add `householdId` without breaking Phase 2 clients, and round-trips through `toUser()` to a stable domain `User`.
- Stood up `dev.ulfrx.recipe.shared.Constants` with `OIDC_ISSUER` (trailing slash placeholder host), `OIDC_CLIENT_ID = "recipe-app"`, `OIDC_REDIRECT_URI = "recipe://callback"`, plus `API_BASE_URL` and `SERVER_PORT` — the single config object every Phase 2 plan compiles against.
- Cataloged every Phase 2 dependency (AppAuth Android + iOS pod, AndroidX Security Crypto, multiplatform-settings, Ktor client/server auth family, Exposed DSL trio, Hikari, Testcontainers) and wired them into composeApp/server without bumping Ktor off `3.4.1`. CocoaPods integration brings AppAuth-iOS via `libs.versions.appauth.ios.get()` so no version literals leak into build files (`./tools/verify-no-version-literals.sh` stays green).
- Shipped `docs/authentik-setup.md` as a 240-line reproducible Authentik provider playbook covering Provider, Scopes, Redirect URI, Server Env Vars, Logout, Manual UAT (UAT-01..UAT-04), and a Source Audit table that traces every Phase 2 input (GOAL, AUTH-01..AUTH-06, CONTEXT D-01..D-34, RESEARCH, UI-SPEC, VALIDATION Wave 0, PATTERNS) to either this doc or a downstream Phase 2 plan, with all deferred ideas explicitly excluded.
## Task Commits
1. **Task 1 RED — Failing serialization test for MeResponse DTO**`6504b46` (test)
2. **Task 1 GREEN — Constants and MeResponse/User DTOs in shared**`7e73a9a` (feat)
3. **Task 2 — Phase 2 dependency aliases without bumping Ktor**`c1cc713` (feat)
4. **Task 3 — Authentik provider setup and Phase 2 source audit**`62040d4` (docs)
_Note: TDD task 1 produced two commits (RED then GREEN); no REFACTOR commit was needed because the GREEN implementation is already minimal._
## Files Created/Modified
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt` — created. OIDC + API config object (D-11). Trailing-slash issuer, exact `recipe://callback` redirect URI, `recipe-app` client id (also `aud` per D-07).
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt` — created. Domain identity DTO; id is `String` (server UUID) so shared/commonMain stays free of UUID library deps.
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt` — created. `@Serializable` wire DTO for `GET /api/v1/me` (D-27). One-to-one `toUser()` mapper. Forward-compatible with Phase 3 `householdId` via `ignoreUnknownKeys` decoders.
- `shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt` — created. Three-test contract: round-trip via camelCase wire keys, Phase 3 forward-compat decode, `toUser()` no-data-loss mapping.
- `shared/build.gradle.kts` — modified. Applied `alias(libs.plugins.kotlinSerialization)`; added `api(libs.kotlinx.serializationJson)` so consumers inherit the runtime; `shared/commonMain` purity preserved (still no Ktor/Compose/SQLDelight/Koin/Kermit imports).
- `gradle/libs.versions.toml` — modified. Added Phase 2 versions/libraries/plugins per D-13/D-26/research; Ktor stays at 3.4.1.
- `composeApp/build.gradle.kts` — modified. Added kotlinSerialization + kotlin.native.cocoapods plugins; cocoapods block (AppAuth pod via catalog version); per-source-set Phase 2 deps; manifestPlaceholders for AppAuth-Android scheme; `compose.resources.packageOfResClass` lock to keep Phase 1 App.kt imports valid.
- `server/build.gradle.kts` — modified. Added Ktor server auth/JWT/CallLogging/StatusPages, Exposed core/jdbc/java-time, HikariCP, kotlinx.serialization-json, plus Testcontainers postgresql + junit-jupiter test deps.
- `docs/authentik-setup.md` — created. Reproducible Authentik playbook + Phase 2 source audit (D-10).
- `.gitignore` — modified. Ignore `*.podspec` (regenerated on every Gradle sync by the cocoapods plugin).
## Decisions Made
See frontmatter `key-decisions` for the load-bearing list. Highlights:
- **kotlin.native.cocoapods applied by id, not by alias** — the plugin ships inside KGP already on the classpath via `recipe.kotlin.multiplatform`, so a `libs.plugins.kotlinCocoapods` alias triggers a duplicate-version-request error.
- **manifestPlaceholders["appAuthRedirectScheme"] = "recipe"** lands in this plan, not 02-04 — it's a Rule 3 prerequisite for the AppAuth dependency to be cataloged at all.
- **`compose.resources.packageOfResClass` locked to Phase 1's historical package** — adding `group = "dev.ulfrx.recipe"` (mandatory for the cocoapods podspec generator) would otherwise rewrite the generated `Res` package and break `App.kt` imports.
- **Ktor stays at 3.4.1** — Open Question resolved during planning; auth artifacts catalog against the same `version.ref = "ktor"`. Patch bump deferred unless a concrete incompatibility appears.
- **Server OIDC config wiring deferred to Plan 02-02** — Plan 02-01 documents the env-var contract in `docs/authentik-setup.md` (`OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_JWKS_URL`); Plan 02-02 implements it in `application.conf`.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Apply kotlin.native.cocoapods by id, not via `alias(libs.plugins.kotlinCocoapods)`**
- **Found during:** Task 2 verification (`:composeApp:dependencies`)
- **Issue:** `Error resolving plugin [id: 'org.jetbrains.kotlin.native.cocoapods', version: '2.3.20']: The request for this plugin could not be satisfied because the plugin is already on the classpath with an unknown version, so compatibility cannot be checked.` The cocoapods plugin ships bundled with the Kotlin Gradle plugin classpath that `recipe.kotlin.multiplatform` already brings in.
- **Fix:** Apply via `id("org.jetbrains.kotlin.native.cocoapods")` (no version) inside the `plugins { ... }` block of `composeApp/build.gradle.kts`. Kept the `kotlinCocoapods` alias in `gradle/libs.versions.toml` per the plan's stated catalog additions; downstream plans can still reference `libs.versions.kotlinCocoapods.version` if they ever need the version programmatically.
- **Files modified:** composeApp/build.gradle.kts
- **Verification:** `:composeApp:dependencies` and `:composeApp:compileKotlinIosSimulatorArm64` (with `cinteropAppAuthIosSimulatorArm64`) now pass.
- **Committed in:** `c1cc713`
**2. [Rule 3 - Blocking] Add `manifestPlaceholders["appAuthRedirectScheme"] = "recipe"` to composeApp Android defaultConfig**
- **Found during:** Task 2 (`:composeApp:compileDebugKotlinAndroid`)
- **Issue:** `Manifest merger failed : Attribute data@scheme at AndroidManifest.xml requires a placeholder substitution but no value for <appAuthRedirectScheme> is provided.` AppAuth-Android's bundled manifest declares an unsubstituted `${appAuthRedirectScheme}` placeholder that breaks AGP's merger as soon as the dependency is on the classpath, even before Plan 02-04 wires the full `<intent-filter>`.
- **Fix:** Added the placeholder to `defaultConfig.manifestPlaceholders`. Value is `"recipe"`, byte-for-byte consistent with `Constants.OIDC_REDIRECT_URI = "recipe://callback"`. Plan 02-04 will still land the explicit `<intent-filter>` in the Android manifest; this placeholder satisfies AppAuth's *built-in* manifest entry until then.
- **Files modified:** composeApp/build.gradle.kts
- **Verification:** `:composeApp:compileDebugKotlinAndroid` now passes.
- **Committed in:** `c1cc713`
**3. [Rule 3 - Blocking] Lock `compose.resources.packageOfResClass` to the Phase 1 historical package**
- **Found during:** Task 2 (`:composeApp:compileDebugKotlinAndroid`, after the AppAuth manifest fix)
- **Issue:** Adding `group = "dev.ulfrx.recipe"` and `version = "1.0.0"` at the top of `composeApp/build.gradle.kts` (mandatory for the Kotlin CocoaPods plugin's podspec generator — `cocoapods` requires `project.version` if the block doesn't override it) shifted the Compose Resources `Res` class generated package from `recipe.composeapp.generated.resources` (Phase 1) to `dev.ulfrx.recipe.composeapp.generated.resources`, breaking `App.kt`'s `import recipe.composeapp.generated.resources.Res` and `compose_multiplatform`.
- **Fix:** Added an explicit `compose.resources { packageOfResClass = "recipe.composeapp.generated.resources" }` block to `composeApp/build.gradle.kts`, locking the generated package to the Phase 1 name regardless of `group`. This keeps Plan 02-01's diff inside its stated files; Plan 02-06 will replace `App.kt`'s template body with the real auth gate (D-30) and can choose to migrate the package then.
- **Files modified:** composeApp/build.gradle.kts
- **Verification:** `:composeApp:compileDebugKotlinAndroid` now passes; `App.kt` imports unchanged.
- **Committed in:** `c1cc713`
**4. [Rule 3 - Housekeeping] Ignore `*.podspec` files generated by the cocoapods plugin**
- **Found during:** Task 2 (`git status` after first cocoapods Gradle invocation)
- **Issue:** Adding the cocoapods plugin causes `composeApp/composeApp.podspec` to be regenerated on every Gradle sync. The file legitimately embeds `'AppAuth', '2.0.0'` as a literal CocoaPods version pin (CocoaPods Ruby DSL semantics), which would either fail `./tools/verify-no-version-literals.sh` if it ever extended to `.podspec` or churn on every clean build.
- **Fix:** Added `*.podspec` to `.gitignore` with an explanatory comment.
- **Files modified:** .gitignore
- **Verification:** `git status --short` shows no untracked `composeApp.podspec`.
- **Committed in:** `c1cc713`
**5. [Rule 1 - Bug] Strip "version = \"2.0.0\"" substring from a comment in composeApp/build.gradle.kts**
- **Found during:** Task 2 (`./tools/verify-no-version-literals.sh`)
- **Issue:** A comment paraphrased the Plan 02-01 acceptance criterion using the literal text `version = "2.0.0"`. The verifier doesn't distinguish comments from code and flagged the line. The plan's `! grep -q 'version = "2.0.0"' composeApp/build.gradle.kts` acceptance criterion ALSO reads from comments, so the comment was fundamentally incompatible with the rule.
- **Fix:** Rewrote the comment to describe the rule without quoting the forbidden literal pattern.
- **Files modified:** composeApp/build.gradle.kts
- **Verification:** `./tools/verify-no-version-literals.sh` exits 0; both `! grep` acceptance criteria now hold.
- **Committed in:** `c1cc713`
**6. [Rule 1 - Bug] Use `debugCompileClasspath` instead of nonexistent `androidMainCompileClasspath` in Task 2 verify command**
- **Found during:** Task 2 (`:composeApp:dependencies --configuration androidMainCompileClasspath`)
- **Issue:** Plan 02-01 Task 2 specifies `--configuration androidMainCompileClasspath`, but under the current AGP/Gradle/Kotlin combination the actual configuration name is `debugCompileClasspath` (or `releaseCompileClasspath`). The plan's command name doesn't exist in the configuration container.
- **Fix:** Ran the functionally equivalent command (`./gradlew :composeApp:dependencies --configuration debugCompileClasspath :server:dependencies --configuration runtimeClasspath`) which resolves the same Phase 2 deps the plan was checking for. Documented in the Task 2 commit message so a future planner can update the plan if needed.
- **Files modified:** none — this is a verification-command rename, not a code change.
- **Verification:** Both classpaths resolve cleanly and contain every Phase 2 dep (AppAuth, AndroidX Security Crypto, Ktor client family, Exposed core/jdbc/java-time, Hikari, Ktor server auth-jwt/call-logging/status-pages, Testcontainers).
- **Committed in:** N/A (procedural; no code change)
---
**Total deviations:** 6 auto-fixed (5 × Rule 3 blocking, 1 × Rule 1 bug, 1 × procedural rename).
**Impact on plan:** All deviations were unavoidable consequences of cataloging the AppAuth/CocoaPods dependency stack and integrating it with Phase 1's existing build setup. Net diff stays inside Plan 02-01's `files_modified` frontmatter list (plus `.gitignore`, which is a build-hygiene artifact). Zero scope creep into Plan 02-02..02-07.
## Issues Encountered
- **STATE.md drift from orchestrator init.** Running `gsd-sdk query init.execute-phase` at agent start mutated `.planning/STATE.md` (advanced `current_plan: 0 → 1`, status `planned → executing`). Per the parallel-execution rules, worktree agents must not modify `STATE.md`; the orchestrator owns those writes. Reverted via `git checkout -- .planning/STATE.md` before staging the first commit so the orchestrator's later state update is the single source of truth. No follow-up needed.
## User Setup Required
None — Plan 02-01 is wiring + docs only. The `docs/authentik-setup.md` Manual UAT section documents what the user will need to configure in Authentik before Plan 02-02..02-07 can be exercised end-to-end, but Plan 02-01 itself doesn't require any external service interaction.
## Next Phase Readiness
- **Plan 02-02 (server JWT validation, JIT users, /api/v1/me)** — All catalog dependencies and DTOs are in place. `MeResponse` DTO is importable from `dev.ulfrx.recipe.shared.dto`; Ktor server auth/JWT/CallLogging/StatusPages, Exposed DSL trio, Hikari, and Testcontainers are wired into `server/build.gradle.kts`. `application.conf` env-var contract is documented in `docs/authentik-setup.md` § Server Env Vars; Plan 02-02 implements it.
- **Plan 02-03 (common OIDC/store contracts, JVM/Wasm actuals)** — `Constants` and `multiplatform-settings` (+ coroutines) are available in `shared`/`composeApp/commonMain`. Ktor client core/auth/content-negotiation/logging are wired into commonMain.
- **Plan 02-04 (Android AppAuth actual + secure store)** — `libs.appauth` and `libs.androidx.security.crypto` are wired into `androidMain`. The `appAuthRedirectScheme=recipe` manifest placeholder is already set; Plan 02-04 only needs to add the explicit `<intent-filter>` and the `RedirectUriReceiverActivity` registration.
- **Plan 02-05 (iOS AppAuth actual)** — The cocoapods block is configured with the `AppAuth` pod at the catalog version; `libs.ktor.clientDarwin` is in `iosMain` deps. The `Info.plist` `CFBundleURLTypes` registration is the remaining iOS step.
- **Plan 02-06 (UI: SplashScreen / LoginScreen / PostLoginPlaceholderScreen)** — No blockers; Compose Resources package is locked to the Phase 1 historical name so existing `App.kt` keeps compiling. Plan 02-06 will replace `App.kt`'s template body with the auth gate (D-30) and optionally migrate the resources package then.
- **Plan 02-07 (integration glue / phase verification)** — All Phase 2 source files in `02-VALIDATION.md` Wave 0 will exist by the end of 02-02..02-06; this plan establishes the catalog and DTOs they depend on.
**No outstanding blockers.** Phase 2's per-plan execution can proceed.
## Self-Check: PASSED
- Created files exist:
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt` — FOUND
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt` — FOUND
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt` — FOUND
- `shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt` — FOUND
- `docs/authentik-setup.md` — FOUND
- Modified files reflect intended changes:
- `gradle/libs.versions.toml` — FOUND (Phase 2 aliases present)
- `shared/build.gradle.kts` — FOUND (kotlinSerialization plugin + api(libs.kotlinx.serializationJson))
- `composeApp/build.gradle.kts` — FOUND (cocoapods + Phase 2 deps)
- `server/build.gradle.kts` — FOUND (Ktor auth/JWT, Exposed, Hikari, Testcontainers)
- `.gitignore` — FOUND (`*.podspec` ignore)
- Commits exist:
- `6504b46` (test RED) — FOUND
- `7e73a9a` (feat GREEN) — FOUND
- `c1cc713` (Task 2 wiring) — FOUND
- `62040d4` (Task 3 docs) — FOUND
- Plan-level verification:
- `./gradlew :shared:jvmTest :shared:compileCommonMainKotlinMetadata` — PASS
- `./tools/verify-shared-pure.sh` — PASS
- `./tools/verify-no-version-literals.sh` — PASS
- `./gradlew :composeApp:compileDebugKotlinAndroid :server:compileKotlin` — PASS
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64` — PASS (`cinteropAppAuthIosSimulatorArm64` exercised the AppAuth pod end-to-end)
## TDD Gate Compliance
Plan 02-01 frontmatter has `type: execute` (not `type: tdd`), so plan-level RED/GREEN/REFACTOR enforcement does not apply. However, Task 1 was tagged `tdd="true"` and produced the expected gate sequence inside its scope:
- RED: `6504b46` (`test(02-01): add failing serialization test for MeResponse DTO`) — confirmed failing on `MeResponse` / `User` unresolved references.
- GREEN: `7e73a9a` (`feat(02-01): land Constants and MeResponse/User DTOs in shared`) — test now passes.
- REFACTOR: omitted; the GREEN implementation is already minimal.
---
*Phase: 02-authentication-foundation*
*Completed: 2026-04-28*

View File

@@ -0,0 +1,224 @@
---
phase: 02-authentication-foundation
plan: 02
type: execute
wave: 2
depends_on: [02-01]
files_modified:
- server/src/main/resources/application.conf
- server/src/main/resources/db/migration/V1__users.sql
- server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
- server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
- server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt
- server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt
- server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt
- server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt
- server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt
- server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt
- server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt
- server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt
autonomous: true
requirements: [AUTH-03, AUTH-06]
must_haves:
truths:
- "GET /api/v1/me with a valid Authentik-style token returns the JIT-provisioned user record"
- "GET /api/v1/me with missing, expired, wrong-issuer, wrong-audience, or blank-sub token returns 401"
- "First valid request creates a users row keyed by OIDC sub; later request updates email/display_name for the same sub"
- "Authorization headers and bearer token values are not logged"
artifacts:
- path: "server/src/main/resources/db/migration/V1__users.sql"
provides: "users table per D-24"
contains: "CREATE TABLE users"
- path: "server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt"
provides: "Ktor jwt(\"authentik\") verifier with issuer, audience, leeway, JWKS cache/rate limit, non-empty sub"
exports: ["configureAuthentication"]
- path: "server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt"
provides: "Exposed DSL JIT user upsert by sub per D-25/D-26"
exports: ["PrincipalResolver"]
- path: "server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt"
provides: "Protected /api/v1/me route per D-27"
exports: ["meRoute"]
key_links:
- from: "server/src/main/kotlin/dev/ulfrx/recipe/Application.kt"
to: "server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt"
via: "install Authentication before route registration"
pattern: "configureAuthentication"
- from: "server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt"
to: "server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt"
via: "authenticated JWT principal resolves to users row"
pattern: "resolve"
---
<objective>
Implement the Ktor server authentication boundary: Authentik JWT validation, JIT user provisioning, and the protected `/api/v1/me` endpoint.
Purpose: satisfy AUTH-03 and AUTH-06 while establishing safe server auth patterns for Phase 3 household scoping.
Output: Flyway users migration, auth plugin, resolver, route, and negative JWT/JIT tests.
</objective>
<execution_context>
@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md
@/Users/rwilk/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/ROADMAP.md
@.planning/phases/02-authentication-foundation/02-CONTEXT.md
@.planning/phases/02-authentication-foundation/02-RESEARCH.md
@.planning/phases/02-authentication-foundation/02-VALIDATION.md
@.planning/phases/02-authentication-foundation/02-PATTERNS.md
@.planning/phases/02-authentication-foundation/02-01-SUMMARY.md
@AGENTS.md
@server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
@server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
@server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create JWT validation tests before auth implementation</name>
<read_first>
- server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt
- .planning/phases/02-authentication-foundation/02-VALIDATION.md (Wave 0 server tests)
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-21, D-22, D-23)
</read_first>
<files>server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt, server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt</files>
<behavior>
- No Authorization header returns 401.
- Expired token returns 401.
- Wrong issuer returns 401.
- Wrong audience returns 401.
- Blank `sub` returns 401.
- Valid RS256 test token returns 200 from a protected test route.
</behavior>
<action>
Create `JwtTestSupport` to generate an RSA keypair, expose a local JWKS endpoint in `testApplication`, and mint RS256 JWTs with configurable `iss`, `aud`, `sub`, `email`, `name`, and expiry.
Create `AuthJwtTest` that installs `ContentNegotiation`, `configureAuthentication(AuthConfig(...test issuer/audience/jwks...))`, and a protected test route under `authenticate("authentik")`. Tests must assert the status codes listed in `<behavior>`. Keep tests independent of Postgres and `Database.migrate`.
These tests should fail before `AuthPlugin.kt` exists; then continue to Task 2.
</action>
<verify>
<automated>./gradlew :server:test --tests "*AuthJwtTest*"</automated>
</verify>
<acceptance_criteria>
- `grep -q 'wrong audience' server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt`
- `grep -q 'blank sub' server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt`
- `grep -q 'RS256' server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt`
- After Task 2, `./gradlew :server:test --tests "*AuthJwtTest*"` exits 0
</acceptance_criteria>
<done>JWT negative coverage exists for AUTH-03 and blocks wrong-audience/issuer regressions.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement AuthConfig, JWT plugin, logging redaction, and verify Exposed suspend API</name>
<read_first>
- server/src/main/resources/application.conf
- server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
- server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
- .planning/phases/02-authentication-foundation/02-RESEARCH.md (Pitfall 4 Exposed API drift)
</read_first>
<files>server/src/main/resources/application.conf, server/src/main/kotlin/dev/ulfrx/recipe/Application.kt, server/src/main/kotlin/dev/ulfrx/recipe/Database.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt</files>
<action>
Add `oidc { issuer, audience, jwksUrl, leewaySeconds }` to `application.conf` with env overrides `OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_JWKS_URL` and default leeway `30`.
Create `AuthConfig.fromApplicationConfig(config)` and `configureAuthentication(authConfig: AuthConfig = AuthConfig.fromApplicationConfig(environment.config))`. `AuthPlugin.kt` must install `jwt("authentik")` using `JwkProviderBuilder(jwksUrl or issuer).cached(10, 15, TimeUnit.MINUTES).rateLimited(10, 1, TimeUnit.MINUTES)`, `.withIssuer(issuer)`, `.withAudience(audience)`, `.acceptLeeway(30)`, and a validate block rejecting null/blank `sub`.
Install `CallLogging` in `Application.module()` using Ktor 3.4.1 APIs only. Configure `format { call -> "${call.request.httpMethod.value} ${call.request.path()} -> ${call.response.status()?.value ?: "-"}" }` so request/response method, path, and status are logged but headers are never included. Do not use `redactHeader(...)`; that API is not available on Ktor server `CallLoggingConfig` in the pinned Ktor 3.4.1 dependency. Never log token bodies or raw Authorization headers.
Before implementing Task 3 DB code, verify the pinned Exposed suspend transaction API/import against the dependency used by this repo. Run:
`./gradlew :server:dependencyInsight --dependency exposed-jdbc --configuration runtimeClasspath`
Then inspect IDE/Gradle source or compile probe and use whichever import compiles for pinned Exposed: expected for the chosen catalog version is `org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction`; if the pinned version requires `suspendTransaction`, use that exact import and note it in `02-02-SUMMARY.md`. Do not use blocking `transaction {}` inside suspend route code.
</action>
<verify>
<automated>./gradlew :server:test --tests "*AuthJwtTest*"</automated>
</verify>
<acceptance_criteria>
- `grep -q 'OIDC_ISSUER' server/src/main/resources/application.conf`
- `grep -q 'jwt("authentik")' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt`
- `grep -q 'withAudience' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt`
- `grep -q 'acceptLeeway(30' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt`
- `grep -q 'rateLimited(10, 1, TimeUnit.MINUTES)' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt`
- `grep -q 'install(CallLogging)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`
- `grep -q 'format { call ->' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`
- `! grep -q 'redactHeader' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`
- `! grep -q 'HttpHeaders.Authorization' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`
- `./gradlew :server:test --tests "*AuthJwtTest*"` exits 0
</acceptance_criteria>
<done>Server rejects invalid JWTs, accepts valid Authentik-style JWTs, and redacts Authorization headers.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Add users migration, Exposed resolver, /api/v1/me route, and JIT tests</name>
<read_first>
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-24 through D-27)
- server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
- server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt
</read_first>
<files>server/src/main/resources/db/migration/V1__users.sql, server/src/main/kotlin/dev/ulfrx/recipe/Database.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt, server/src/main/kotlin/dev/ulfrx/recipe/Application.kt, server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt</files>
<action>
Create `V1__users.sql` exactly with `users(id UUID PRIMARY KEY DEFAULT gen_random_uuid(), sub TEXT NOT NULL UNIQUE, email TEXT NOT NULL, display_name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())` plus `CREATE INDEX users_sub_idx ON users(sub);`.
Add Exposed `UsersTable` using DSL only. Add `Database.connect(app)` using Hikari or direct Exposed connection after Flyway migration.
Implement `PrincipalResolver.resolve(jwtPrincipal)` as a suspend function that extracts non-empty `sub`, `email`, and `name`/`preferred_username` fallback, then performs atomic Postgres upsert by `sub` updating `email`, `display_name`, `updated_at = now()` and returning a `User`/`MeResponse`. Use the verified suspend transaction import from Task 2. Do not select-then-insert.
Add `meRoute(principalResolver)` under `authenticate("authentik") { get("/api/v1/me") { ... } }`. Wire route from `configureRouting`.
Create `MeRouteTest` with an explicit runnable PostgreSQL test database using Testcontainers, not ambient local Postgres. Use `org.testcontainers.containers.PostgreSQLContainer` with image `postgres:16`, start it in the test fixture, set the server/database config to the container JDBC URL, username, and password before installing routes, and stop it after tests. Run Flyway against the container before assertions. Tests must cover: valid token creates row, second token same `sub` updates email/display name without duplicating, and valid response body contains `id`, `sub`, `email`, `displayName`.
</action>
<verify>
<automated>./gradlew :server:test --tests "*MeRouteTest*" --tests "*AuthJwtTest*"</automated>
</verify>
<acceptance_criteria>
- `grep -q 'CREATE TABLE users' server/src/main/resources/db/migration/V1__users.sql`
- `grep -q 'sub TEXT NOT NULL UNIQUE' server/src/main/resources/db/migration/V1__users.sql`
- `! grep -R 'org.jetbrains.exposed.dao' server/src/main/kotlin/dev/ulfrx/recipe/auth`
- `! grep -R 'transaction {' server/src/main/kotlin/dev/ulfrx/recipe/auth`
- `grep -q 'ON CONFLICT (sub) DO UPDATE' server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt`
- `grep -q 'get("/api/v1/me")' server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt`
- `grep -q 'PostgreSQLContainer' server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt`
- `grep -q 'postgres:16' server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt`
- `grep -q 'Flyway' server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt`
- `./gradlew :server:test --tests "*MeRouteTest*" --tests "*AuthJwtTest*"` exits 0
</acceptance_criteria>
<done>`/api/v1/me` validates tokens, JIT-provisions users atomically, and returns the shared DTO.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client -> Ktor | Untrusted bearer token arrives in Authorization header |
| Ktor -> Authentik JWKS | Server fetches signing keys from Authentik |
| Ktor -> Postgres | Authenticated claims become persisted user rows |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-02-01 | Elevation | JWT verifier | mitigate | Validate issuer, audience, expiry, RS256 signature, 30s leeway, and non-empty `sub`; negative tests for wrong audience/issuer/blank sub |
| T-02-02-02 | Denial of Service | JWKS provider | mitigate | Configure cache size 10 / 15 min and rate limit 10 per minute per D-22 |
| T-02-02-03 | Information Disclosure | server logs | mitigate | Ktor CallLogging redacts Authorization and code never logs bearer token bodies |
| T-02-02-04 | Tampering | JIT provisioning | mitigate | Atomic upsert on unique `sub`; no client-supplied user ID |
| T-02-02-05 | Repudiation | user updates | accept | Phase 2 records current `updated_at`; full audit log is out of scope for small household v1 |
</threat_model>
<verification>
Run `./gradlew :server:test --tests "*AuthJwtTest*" --tests "*MeRouteTest*"` and then `./gradlew :server:test`.
</verification>
<success_criteria>
AUTH-03 and AUTH-06 are satisfied: valid tokens return `/api/v1/me`, invalid tokens return 401, and user rows are created/updated by OIDC `sub`.
</success_criteria>
<output>
After completion, create `.planning/phases/02-authentication-foundation/02-02-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,169 @@
---
phase: 02-authentication-foundation
plan: 02
subsystem: auth
tags: [ktor, jwt, authentik, jwks, postgres, flyway, exposed, testcontainers]
# Dependency graph
requires:
- phase: 02-01
provides: shared auth DTOs, dependency aliases, and Authentik setup context
provides:
- Authentik-style JWT validation with issuer, audience, expiry, RS256 signature, JWKS caching, and non-empty sub enforcement
- Flyway users table migration keyed by OIDC sub
- Exposed DSL JIT user upsert and protected GET /api/v1/me route
- Server auth integration tests for JWT rejection and user provisioning
affects: [phase-03-households, server-auth, principal-resolution, api-v1]
# Tech tracking
tech-stack:
added: [ktor-server-auth-jwt, jwks-rsa, hikari, testcontainers-postgresql]
patterns: [Ktor jwt("authentik") provider, cached/rate-limited JWKS provider, newSuspendedTransaction for route DB work, Postgres ON CONFLICT upsert]
key-files:
created:
- server/src/main/resources/db/migration/V1__users.sql
- server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt
- server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt
- server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt
- server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt
- server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt
- server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt
- server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt
- server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt
modified:
- server/src/main/resources/application.conf
- server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
- server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
- server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt
key-decisions:
- "Pinned Exposed runtime is 0.55.0; the suspend transaction import used is org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction."
- "PrincipalResolver uses Postgres INSERT ... ON CONFLICT ... RETURNING via Exposed exec because the resolver must atomically upsert and return the generated user id."
- "CallLogging uses a custom method/path/status format and omits all headers because Ktor 3.4.1 server CallLogging has no redactHeader API."
patterns-established:
- "Protected server routes sit inside authenticate(\"authentik\") and resolve JWTPrincipal through PrincipalResolver before returning user data."
- "Server-side user identity is derived only from JWT claims, never request bodies."
- "Server auth tests use in-process RSA/JWKS support for JWT verifier coverage and Testcontainers Postgres for JIT provisioning coverage."
requirements-completed: [AUTH-03, AUTH-06]
# Metrics
duration: 13min
completed: 2026-04-28
---
# Phase 02 Plan 02: Server JWT Validation and JIT Users Summary
**Ktor Authentik JWT validation with cached JWKS, atomic Postgres user provisioning by OIDC sub, and protected `/api/v1/me`.**
## Performance
- **Duration:** 13 min for final executor verification and summary; task commits already existed on this branch when this executor resumed.
- **Started:** 2026-04-28T11:18:15Z
- **Completed:** 2026-04-28T11:31:08Z
- **Tasks:** 3 completed
- **Files modified:** 13 code/config/test files plus this summary
## Accomplishments
- Added JWT validation coverage for missing, expired, wrong-issuer, wrong-audience, blank-sub, and valid RS256 tokens.
- Installed Ktor `jwt("authentik")` with issuer/audience checks, 30-second max leeway, non-empty `sub`, cached JWKS, and rate limiting.
- Added `users` Flyway migration, Exposed table mapping, Hikari-backed Exposed connection, atomic JIT upsert by `sub`, and protected `/api/v1/me`.
- Added Testcontainers Postgres integration coverage proving first request creates a user row and later requests update mutable claims without duplication.
## Task Commits
Each task was committed atomically:
1. **Task 1: Create JWT validation tests before auth implementation** - `614b57c` (`test`)
2. **Task 2: Implement AuthConfig, JWT plugin, logging redaction, and verify Exposed suspend API** - `36c1b2c` (`feat`)
3. **Task 3: Add users migration, Exposed resolver, /api/v1/me route, and JIT tests** - `8cf112a` (`feat`)
No tracked file deletions were present in the task commits.
## Files Created/Modified
- `server/src/main/resources/application.conf` - Adds OIDC issuer/audience/JWKS/leeway config with env overrides.
- `server/src/main/resources/db/migration/V1__users.sql` - Creates the `users` table and `users_sub_idx`.
- `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` - Adds Hikari-backed Exposed connection after Flyway migration.
- `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` - Installs safe CallLogging, authentication, DB migration/connection, and auth route wiring.
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt` - Reads server OIDC config from HOCON.
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` - Installs the Authentik JWT verifier.
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt` - Exposed DSL mapping for `users`.
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt` - Resolves `JWTPrincipal` to `MeResponse` through atomic upsert.
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt` - Provides protected `GET /api/v1/me`.
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt` - Generates RSA keys, JWKS provider, and configurable RS256 JWTs for tests.
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt` - Covers JWT validation positive and negative cases.
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt` - Covers JIT provisioning against Testcontainers Postgres.
- `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` - Keeps `/health` test wiring compatible with authenticated route registration.
## Decisions Made
- Used `newSuspendedTransaction` from `org.jetbrains.exposed.sql.transactions.experimental` after confirming `org.jetbrains.exposed:exposed-jdbc:0.55.0`.
- Used raw SQL through Exposed `exec` for `INSERT ... ON CONFLICT ... RETURNING`, because the resolver needs the returned row and generated UUID in one atomic operation.
- Kept logging to method, path, and status only; no header logging or bearer-token redaction API is used.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Kept `/health` test route registration compatible with authenticated routes**
- **Found during:** Task 3
- **Issue:** Once `configureRouting()` registered `meRoute`, tests that installed routing without Authentication would fail route setup.
- **Fix:** Updated `ApplicationTest` to install the test JWT authentication plugin before calling `configureRouting()`.
- **Files modified:** `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt`
- **Verification:** `./gradlew :server:test`
- **Committed in:** `8cf112a`
**2. [Rule 3 - Blocking] Used Exposed `StatementType.SELECT` for Postgres upsert returning rows**
- **Found during:** Task 3
- **Issue:** `INSERT ... RETURNING` must be executed as a result-producing statement; otherwise Postgres reports that a result was returned when none was expected.
- **Fix:** Added `explicitStatementType = StatementType.SELECT` to the Exposed `exec` call.
- **Files modified:** `server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt`
- **Verification:** `./gradlew :server:test --tests "*MeRouteTest*" --tests "*AuthJwtTest*"`
- **Committed in:** `8cf112a`
---
**Total deviations:** 2 auto-fixed (1 bug, 1 blocking issue)
**Impact on plan:** Both fixes were required for the planned tests and route behavior. No extra feature scope was added.
## Issues Encountered
- Testcontainers Postgres made the first filtered test run take several minutes while the container image/runtime initialized. Subsequent server test runs completed from cache.
## Authentication Gates
None.
## Known Stubs
None.
## User Setup Required
None for this plan. Real Authentik provider setup remains covered by the Phase 2 setup documentation from plan `02-01`.
## Verification
- `./gradlew :server:dependencyInsight --dependency exposed-jdbc --configuration runtimeClasspath` - passed; Exposed JDBC version is `0.55.0`.
- `./gradlew :server:test --tests "*AuthJwtTest*" --tests "*MeRouteTest*"` - passed.
- `./gradlew :server:test` - passed.
- Task acceptance greps for OIDC config, JWT verifier settings, logging safety, migration shape, no DAO imports, no blocking `transaction {}` in auth code, `/api/v1/me`, Testcontainers, `postgres:16`, and Flyway all passed.
## Next Phase Readiness
Phase 3 can extend `PrincipalResolver` from user identity to household-scoped principal resolution. The server now has the stable `users.sub` anchor and `/api/v1/me` boundary that Phase 3 onboarding and household membership can build on.
## Self-Check: PASSED
- Created/modified key files exist.
- Task commits found: `614b57c`, `36c1b2c`, `8cf112a`.
- Required verification commands passed.
- No unplanned tracked file deletions were detected in task commits.
---
*Phase: 02-authentication-foundation*
*Completed: 2026-04-28*

View File

@@ -0,0 +1,177 @@
---
phase: 02-authentication-foundation
plan: 03
type: execute
wave: 2
depends_on: [02-01]
files_modified:
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt
- composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt
- composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt
- composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt
- composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt
autonomous: true
requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
must_haves:
truths:
- "Common auth code compiles against one expect OidcClient seam with login, refresh, and logout"
- "Requested native OIDC scopes are documented in the common contract as exactly openid profile email offline_access"
- "Every configured non-mobile target has actuals so JVM and Wasm builds compile"
- "JVM target uses explicit DEV_AUTH_TOKEN dev behavior and does not hardcode a usable bearer token"
- "Wasm target preserves the v2 boundary with NotImplementedError(\"Wasm OIDC: v2\")"
- "SecureAuthStateStore read/write/clear semantics are locked by a common contract test"
artifacts:
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt"
provides: "expect OIDC seam with suspend login/refresh/logout per D-01..D-04"
contains: "expect class OidcClient"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt"
provides: "common OIDC result model consumed by AuthSession and LoginViewModel"
contains: "sealed"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt"
provides: "expect secure AuthState JSON store per D-13..D-15"
contains: "expect class SecureAuthStateStore"
- path: "composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt"
provides: "JVM dev-only token stub per D-02"
contains: "DEV_AUTH_TOKEN"
- path: "composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt"
provides: "Wasm v2 stub per D-03"
contains: "NotImplementedError(\"Wasm OIDC: v2\")"
key_links:
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt"
to: "composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt"
via: "actual class implements common suspend login/refresh/logout contract"
pattern: "actual class OidcClient"
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt"
to: "composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt"
via: "contract test validates read/write/clear behavior without platform secure storage"
pattern: "read.*write.*clear"
---
<objective>
Define the common OIDC and AuthState storage contracts, plus JVM/Wasm actuals that keep secondary targets compiling.
Purpose: downstream mobile plans implement platform AppAuth behind a stable seam, while AuthSession can be built without platform-specific APIs.
Output: common auth contracts, JVM dev actuals, Wasm v2 stubs, and a common store contract test.
</objective>
<execution_context>
@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md
@/Users/rwilk/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/phases/02-authentication-foundation/02-CONTEXT.md
@.planning/phases/02-authentication-foundation/02-RESEARCH.md
@.planning/phases/02-authentication-foundation/02-VALIDATION.md
@.planning/phases/02-authentication-foundation/02-PATTERNS.md
@.planning/phases/02-authentication-foundation/02-01-SUMMARY.md
@AGENTS.md
@composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt
@composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Define common OIDC and secure store contracts</name>
<read_first>
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01..D-06, D-13..D-20)
- .planning/phases/02-authentication-foundation/02-RESEARCH.md (Pitfall 1 and secure storage recommendation)
</read_first>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt, composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt</files>
<behavior>
- `OidcResult.Success` carries `authStateJson`, `accessToken`, nullable `idToken`, and `expiresAtEpochMillis`.
- `OidcClient` exposes suspend `login()`, `refresh(authStateJson)`, and `logout(authStateJson)`.
- Common contract text states native actuals use AppAuth and request exactly `openid profile email offline_access` per D-01/D-06.
- `SecureAuthStateStore` exposes `read()`, `write(authStateJson)`, and `clear()`.
- Contract test proves write overwrites previous value, read returns latest value, and clear removes it.
</behavior>
<action>
Create `OidcResult` as a sealed interface or sealed class with `Success(authStateJson: String, accessToken: String, idToken: String?, expiresAtEpochMillis: Long)`, `Cancelled`, `NetworkError`, and `AuthError(message: String, cause: Throwable? = null)`.
Create `expect class OidcClient` with `suspend fun login(): OidcResult`, `suspend fun refresh(authStateJson: String): OidcResult`, and `suspend fun logout(authStateJson: String): Unit`. The common KDoc must pin D-01, D-04, D-06, D-16, D-19, and D-20: native implementations use AppAuth, bridge callbacks with `suspendCancellableCoroutine`, request exactly `openid profile email offline_access`, refresh through AppAuth fresh-token APIs, and logout through RP-initiated end-session before local clear.
Create `expect class SecureAuthStateStore` with `fun read(): String?`, `fun write(authStateJson: String)`, and `fun clear()`. The KDoc must state it persists the full AppAuth AuthState JSON blob per D-13 and must not use no-arg insecure settings for tokens.
Add `SecureAuthStateStoreContractTest` using a fake in-memory implementation in commonTest to lock the store behavior. Keep this test platform-free; Android and iOS secure implementations are created in Plans 02-04 and 02-05.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest</automated>
</verify>
<acceptance_criteria>
- `grep -q 'expect class OidcClient' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt`
- `grep -q 'openid profile email offline_access' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt`
- `grep -q 'expect class SecureAuthStateStore' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt`
- `grep -q 'AuthState JSON' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt`
- `grep -q 'clear' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt`
- `./gradlew :composeApp:jvmTest` exits 0
</acceptance_criteria>
<done>Common auth seams exist with exact scope/logout/storage semantics and testable store behavior.</done>
</task>
<task type="auto">
<name>Task 2: Add JVM and Wasm actuals</name>
<read_first>
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-02, D-03)
- composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt
- composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt
</read_first>
<files>composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt, composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt, composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt, composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt</files>
<action>
JVM actual reads `DEV_AUTH_TOKEN` from the environment and returns `OidcResult.Success(authStateJson = "dev:$token", accessToken = token, idToken = null, expiresAtEpochMillis = Long.MAX_VALUE)` when present. If missing, return `OidcResult.AuthError("DEV_AUTH_TOKEN is not set")`; do not hardcode a usable bearer token.
JVM `SecureAuthStateStore` actual must compile for desktop dev/tests without pretending to be production secure storage. Implement `actual class SecureAuthStateStore` with a private nullable in-memory `authStateJson` property and exact methods `read()`, `write(authStateJson: String)`, and `clear()`.
Wasm `OidcClient` actual throws exactly `NotImplementedError("Wasm OIDC: v2")` for login, refresh, and logout per D-03. Wasm `SecureAuthStateStore` actual must also exist so `:composeApp:compileKotlinWasmJs` compiles; implement the same non-persistent in-memory store shape used by JVM.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs</automated>
</verify>
<acceptance_criteria>
- `grep -q 'DEV_AUTH_TOKEN' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt`
- `grep -q 'actual class SecureAuthStateStore' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt`
- `grep -q 'NotImplementedError("Wasm OIDC: v2")' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt`
- `grep -q 'actual class SecureAuthStateStore' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt`
- `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs` exits 0
</acceptance_criteria>
<done>Secondary targets compile without expanding Phase 2 into real Desktop or Wasm OIDC.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| common auth contract -> platform actuals | Common AuthSession code delegates browser/token behavior to target-specific implementations |
| app process -> dev environment | JVM dev stub reads bearer token from `DEV_AUTH_TOKEN` |
| app process -> non-persistent stubs | JVM/Wasm stores satisfy contracts without claiming production secure storage |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-03-01 | Spoofing/Elevation | OidcClient contract | mitigate | Common KDoc pins AppAuth, PKCE-compatible native flow, exact scopes, state/nonce ownership, and RP-initiated logout semantics for platform plans |
| T-02-03-02 | Information Disclosure | SecureAuthStateStore contract | mitigate | Contract states full AuthState JSON must use explicit secure platform storage; Android/iOS plans implement the secure actuals |
| T-02-03-03 | Spoofing | JVM dev stub | accept | Desktop is dev tool only; stub requires explicit `DEV_AUTH_TOKEN` and never hardcodes a usable bearer token |
| T-02-03-04 | Scope Creep | Wasm OIDC | accept | Wasm actual throws `NotImplementedError("Wasm OIDC: v2")` per D-03 and does not implement browser OIDC in Phase 2 |
</threat_model>
<verification>
Run `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs`.
</verification>
<success_criteria>
Common OIDC/storage contracts exist below the file-count threshold, JVM/Wasm targets compile, and downstream Android/iOS/AuthSession plans can depend on stable auth seams.
</success_criteria>
<output>
After completion, create `.planning/phases/02-authentication-foundation/02-03-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,159 @@
---
phase: 02-authentication-foundation
plan: 03
subsystem: auth
tags: [oidc, appauth, kmp, wasm, jvm, authstate]
requires:
- phase: 02-authentication-foundation
provides: 02-01 shared OIDC constants and Phase 2 client dependencies
provides:
- Common `OidcClient` expect seam with suspend login, refresh, and logout
- Common `OidcResult` model for AuthSession and LoginViewModel consumers
- Common `SecureAuthStateStore` expect contract for opaque AppAuth AuthState JSON
- JVM dev-only `DEV_AUTH_TOKEN` OIDC actual and in-memory AuthState store actual
- Wasm v2 OIDC stubs and in-memory AuthState store actual
- SecureAuthStateStore common contract tests for write, overwrite, read, and clear
affects: [02-04-android-auth-actuals, 02-05-ios-auth-actuals, 02-06-auth-session-ui]
tech-stack:
added: []
patterns:
- "OIDC seam pattern: common expects pin AppAuth/scopes/logout semantics while target actuals own platform mechanics."
- "Secondary target pattern: JVM uses explicit DEV_AUTH_TOKEN dev behavior; Wasm throws the documented v2 NotImplementedError boundary."
key-files:
created:
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt
- composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt
- composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt
- composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt
- composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt
modified: []
key-decisions:
- "JVM actuals were added with Task 1 because the required `:composeApp:jvmTest` acceptance gate cannot compile common expect classes without JVM actual declarations."
- "Kotlin expect/actual beta diagnostics are suppressed at the auth seam file level to satisfy the existing `-Werror` build without changing Gradle configuration."
- "Wasm OIDC remains an explicit v2 boundary by throwing `NotImplementedError(\"Wasm OIDC: v2\")` from login, refresh, and logout."
patterns-established:
- "AuthState JSON is treated as opaque common data; secure mobile storage actuals remain owned by Android/iOS plans."
- "Desktop auth is dev-only and requires an externally supplied `DEV_AUTH_TOKEN`; no usable bearer token is hardcoded."
requirements-completed: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
duration: 31m
completed: 2026-04-28
---
# Phase 02 Plan 03: Common OIDC and AuthState Store Contracts Summary
**Stable KMP auth seams for AppAuth-backed mobile login, explicit JVM dev-token behavior, Wasm v2 stubs, and contract-tested AuthState JSON storage semantics.**
## Performance
- **Duration:** 31 min
- **Started:** 2026-04-28T11:18:45Z
- **Completed:** 2026-04-28T11:49:40Z
- **Tasks:** 2
- **Files modified:** 8
## Accomplishments
- Added common auth contracts: `OidcClient`, `OidcResult`, and `SecureAuthStateStore`.
- Pinned native OIDC behavior in common KDoc: AppAuth, `suspendCancellableCoroutine`, exact `openid profile email offline_access` scopes, fresh-token refresh, and RP-initiated logout.
- Added JVM actuals for desktop/dev test compilation with explicit `DEV_AUTH_TOKEN` behavior and no hardcoded bearer token.
- Added Wasm actuals that preserve the documented v2 OIDC boundary while keeping `compileKotlinWasmJs` green.
- Added common contract tests proving store write overwrite, latest read, and clear semantics.
## Task Commits
1. **Task 1 RED: SecureAuthStateStore contract test** - `7ef222e` (test)
2. **Task 1 GREEN: Common auth contracts plus JVM actuals** - `edc2a1d` (feat)
3. **Task 2: Wasm auth stubs** - `0dbd374` (feat)
_Note: Task 1 was TDD and produced RED + GREEN commits. No refactor commit was needed._
## Files Created/Modified
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt` - Common expect OIDC client seam with pinned AppAuth/scopes/refresh/logout semantics.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt` - Sealed result model for success, cancellation, network failure, and auth failure.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt` - Common expect secure store contract for opaque AppAuth AuthState JSON.
- `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt` - Desktop dev actual using `DEV_AUTH_TOKEN`.
- `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt` - In-memory desktop AuthState store actual.
- `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt` - Wasm v2 OIDC boundary stubs.
- `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt` - In-memory Wasm AuthState store actual.
- `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt` - Store read/write/overwrite/clear contract tests.
## Decisions Made
See frontmatter `key-decisions`.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Added JVM actuals during Task 1 GREEN**
- **Found during:** Task 1 verification
- **Issue:** The plan required `./gradlew :composeApp:jvmTest` to pass after adding common `expect class` declarations, but JVM compilation requires matching JVM `actual` declarations.
- **Fix:** Added the JVM dev `OidcClient` actual and in-memory `SecureAuthStateStore` actual in the Task 1 GREEN commit. Task 2 then added the Wasm actuals as planned.
- **Files modified:** `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt`, `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt`
- **Verification:** `./gradlew :composeApp:jvmTest`
- **Committed in:** `edc2a1d`
**2. [Rule 3 - Blocking] Suppressed expect/actual beta diagnostics at file level**
- **Found during:** Task 1 verification
- **Issue:** Kotlin emitted expect/actual beta warnings and the project treats warnings as errors.
- **Fix:** Added targeted `@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")` to the auth expect/actual files.
- **Files modified:** `OidcClient.kt`, `SecureAuthStateStore.kt`, JVM actual files, Wasm actual files
- **Verification:** `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs`
- **Committed in:** `edc2a1d`, `0dbd374`
---
**Total deviations:** 2 auto-fixed (2 x Rule 3).
**Impact on plan:** No behavior scope changed. The deviations only made the required verification gates compatible with Kotlin expect/actual compilation under the project build settings.
## Known Stubs
| File | Line | Reason |
|------|------|--------|
| `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt` | 7 | Intentional v2 boundary per D-03 and plan acceptance criteria. |
| `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt` | 11 | Intentional v2 boundary per D-03 and plan acceptance criteria. |
| `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt` | 15 | Intentional v2 boundary per D-03 and plan acceptance criteria. |
## Issues Encountered
- Concurrent Wave 2 work landed `02-02` commits while this plan was executing. No conflicts touched this plan's owned files.
- `gsd-sdk query init.execute-phase 02` updated `.planning/STATE.md` at startup before task work began. Final state updates are handled in the metadata step.
## User Setup Required
None.
## Verification
- `./gradlew :composeApp:jvmTest` - PASS
- `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs` - PASS
- Task 1 acceptance greps - PASS
- Task 2 acceptance greps - PASS
## Next Phase Readiness
Android and iOS auth actual plans can now implement AppAuth behind stable common seams. AuthSession/UI plans can consume `OidcResult` and `SecureAuthStateStore` without platform-specific APIs.
## Self-Check: PASSED
- Created files exist: all 8 plan-owned source/test files plus this summary were found.
- Commits exist: `7ef222e`, `edc2a1d`, and `0dbd374` were found in git history.
- Acceptance criteria: all required grep checks passed.
- Plan-level verification: `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs` passed.
---
*Phase: 02-authentication-foundation*
*Completed: 2026-04-28*

View File

@@ -0,0 +1,161 @@
---
phase: 02-authentication-foundation
plan: 04
type: execute
wave: 3
depends_on: [02-01, 02-03]
files_modified:
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt
- composeApp/src/androidMain/AndroidManifest.xml
autonomous: true
requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
must_haves:
truths:
- "Android login uses AppAuth authorization-code flow with PKCE through system browser and recipe://callback"
- "Android requested scopes are exactly openid profile email offline_access"
- "Android persists full AppAuth AuthState JSON through EncryptedSharedPreferences-backed SecureAuthStateStore"
- "Android refresh uses AppAuth fresh-token behavior and persists updated AuthState JSON"
- "Android logout uses AppAuth end-session when metadata exposes an endpoint"
artifacts:
- path: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt"
provides: "Android AppAuth actual per D-01, D-04, D-16, D-19, D-20"
contains: "AuthorizationService"
- path: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt"
provides: "Android explicit secure token storage per AUTH-02"
contains: "EncryptedSharedPreferences"
- path: "composeApp/src/androidMain/AndroidManifest.xml"
provides: "recipe://callback registration for AppAuth redirect receiver"
contains: "RedirectUriReceiverActivity"
key_links:
- from: "composeApp/src/androidMain/AndroidManifest.xml"
to: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt"
via: "AppAuth redirect receiver for recipe://callback"
pattern: "RedirectUriReceiverActivity|recipe"
- from: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt"
to: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt"
via: "AuthState JSON returned by AppAuth is what AuthSession persists through the store"
pattern: "jsonSerializeString|jsonDeserialize"
---
<objective>
Implement the Android OIDC and secure storage actuals.
Purpose: satisfy Android's side of AUTH-01/AUTH-02/AUTH-04/AUTH-05 behind the common contracts from Plan 02-03 without mixing iOS work into the same execution plan.
Output: Android AppAuth OidcClient actual, Android secure AuthState store, and Android callback manifest registration.
</objective>
<execution_context>
@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md
@/Users/rwilk/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/phases/02-authentication-foundation/02-CONTEXT.md
@.planning/phases/02-authentication-foundation/02-RESEARCH.md
@.planning/phases/02-authentication-foundation/02-VALIDATION.md
@.planning/phases/02-authentication-foundation/02-PATTERNS.md
@.planning/phases/02-authentication-foundation/02-01-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-03-SUMMARY.md
@AGENTS.md
@composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt
@composeApp/src/androidMain/AndroidManifest.xml
</context>
<tasks>
<task type="auto">
<name>Task 1: Implement Android AppAuth OidcClient actual</name>
<read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01, D-04, D-05, D-06, D-09, D-16, D-19, D-20)
</read_first>
<files>composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt</files>
<action>
Implement Android `actual class OidcClient` using AppAuth-Android. Use `AuthorizationServiceConfiguration.fetchFromIssuer`, `AuthorizationRequest.Builder`, `ResponseTypeValues.CODE`, `setScopes("openid", "profile", "email", "offline_access")`, AppAuth PKCE defaults, and `suspendCancellableCoroutine` so cancellation cancels the underlying AppAuth request.
Token exchange and refresh must serialize/deserialize the AppAuth `AuthState` JSON with `AuthState.jsonSerializeString()` and `AuthState.jsonDeserialize(...)`. Refresh must use `performActionWithFreshTokens` so updated AuthState is persisted by AuthSession. Logout must build and execute `EndSessionRequest` when the discovery metadata exposes an end-session endpoint; if unavailable, return without throwing so AuthSession can still clear local state per D-19.
Map user cancellation to `OidcResult.Cancelled`, network failures to `OidcResult.NetworkError`, and token/auth failures to `OidcResult.AuthError`. Never log AuthState JSON, access tokens, refresh tokens, ID tokens, or Authorization headers.
</action>
<verify>
<automated>./gradlew :composeApp:compileDebugKotlinAndroid</automated>
</verify>
<acceptance_criteria>
- `grep -q 'AuthorizationServiceConfiguration.fetchFromIssuer' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
- `grep -q 'setScopes("openid", "profile", "email", "offline_access")' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
- `grep -q 'suspendCancellableCoroutine' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
- `grep -q 'performActionWithFreshTokens' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
- `grep -q 'EndSessionRequest' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
- `./gradlew :composeApp:compileDebugKotlinAndroid` exits 0
</acceptance_criteria>
<done>Android AppAuth login, refresh, and logout compile behind the common OidcClient contract.</done>
</task>
<task type="auto">
<name>Task 2: Implement Android secure AuthState store and callback manifest</name>
<read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt
- composeApp/src/androidMain/AndroidManifest.xml
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-09, D-13, D-15)
- .planning/phases/02-authentication-foundation/02-RESEARCH.md (Android secure storage correction)
</read_first>
<files>composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt, composeApp/src/androidMain/AndroidManifest.xml</files>
<action>
Implement Android `actual class SecureAuthStateStore` using AndroidX Security Crypto `EncryptedSharedPreferences`. Store one opaque AuthState JSON string per app install under a private key. Add a short code comment noting AndroidX Security Crypto deprecation is contained behind this abstraction because AUTH-02 explicitly calls for Android EncryptedSharedPreferences in v1.
Do not use no-arg `Settings()`, ordinary `SharedPreferences`, or plaintext file storage for auth tokens.
Register AppAuth redirect handling in `composeApp/src/androidMain/AndroidManifest.xml` with `net.openid.appauth.RedirectUriReceiverActivity` and an intent filter for scheme `recipe` and host `callback`, matching D-09 exactly (`recipe://callback`).
</action>
<verify>
<automated>./gradlew :composeApp:compileDebugKotlinAndroid</automated>
</verify>
<acceptance_criteria>
- `grep -q 'EncryptedSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt`
- `! grep -R 'Settings()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth`
- `! grep -R 'getSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt`
- `grep -q 'RedirectUriReceiverActivity' composeApp/src/androidMain/AndroidManifest.xml`
- `grep -q 'android:scheme="recipe"' composeApp/src/androidMain/AndroidManifest.xml`
- `grep -q 'android:host="callback"' composeApp/src/androidMain/AndroidManifest.xml`
- `./gradlew :composeApp:compileDebugKotlinAndroid` exits 0
</acceptance_criteria>
<done>Android token storage is explicit and the custom URL callback is registered for AppAuth.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| system browser -> Android app | Authorization code returns through custom URL scheme |
| Android app -> OS secure storage | AuthState JSON containing refresh token is persisted |
| Android app -> Authentik | Refresh and end-session requests exchange tokens with IdP |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-04-01 | Spoofing/Elevation | custom URL callback | mitigate | AppAuth handles state/nonce and PKCE S256; Android manifest byte-matches `recipe://callback` |
| T-02-04-02 | Information Disclosure | Android token store | mitigate | Use EncryptedSharedPreferences behind `SecureAuthStateStore`; grep forbids no-arg `Settings()` and plaintext SharedPreferences in auth |
| T-02-04-03 | Information Disclosure | AppAuth diagnostics | mitigate | Android actual must not log AuthState JSON, access tokens, refresh tokens, ID tokens, or Authorization headers |
| T-02-04-04 | Denial of Service | refresh path | mitigate | Use AppAuth `performActionWithFreshTokens` so expiry refresh is handled before authenticated calls |
</threat_model>
<verification>
Run `./gradlew :composeApp:compileDebugKotlinAndroid`.
</verification>
<success_criteria>
Android AppAuth login/refresh/logout and Android secure AuthState persistence compile independently below the file-count threshold.
</success_criteria>
<output>
After completion, create `.planning/phases/02-authentication-foundation/02-04-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,159 @@
---
phase: 02-authentication-foundation
plan: 04
subsystem: auth
tags: [android, oidc, appauth, encryptedsharedpreferences, authstate]
requires:
- phase: 02-authentication-foundation
provides: 02-01 Phase 2 Android AppAuth/Security Crypto dependencies and OIDC constants
- phase: 02-authentication-foundation
provides: 02-03 common OidcClient, OidcResult, and SecureAuthStateStore expect contracts
provides:
- Android AppAuth authorization-code + PKCE login through the system browser
- Android AppAuth AuthState JSON serialization for login and fresh-token refresh
- Android RP-initiated logout through AppAuth EndSessionRequest when discovery metadata exposes end-session
- Android EncryptedSharedPreferences-backed SecureAuthStateStore for opaque AuthState JSON
- Android manifest registration for recipe://callback via RedirectUriReceiverActivity
affects: [02-06-auth-session-ui, 02-07-auth-integration-verification]
tech-stack:
added: []
patterns:
- "Android OIDC actual resolves Context from Koin's Android context while preserving the no-arg common expect constructor."
- "AppAuth callback bridge uses private dynamic broadcast PendingIntents and suspendCancellableCoroutine."
- "AuthState JSON remains opaque; storage and refresh paths never log token-bearing values."
key-files:
created:
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt
modified:
- composeApp/src/androidMain/AndroidManifest.xml
key-decisions:
- "Use Koin's registered Android Context from the no-arg Android actuals instead of changing common constructor contracts from 02-03."
- "Task 1 included the Android SecureAuthStateStore actual because Android target compilation cannot pass with only one of the auth expect actuals present."
- "Treat missing access tokens from token exchange/refresh as AuthError, not Success with an empty token."
patterns-established:
- "Android AppAuth login/request scopes are pinned exactly to openid profile email offline_access."
- "Android token persistence is contained behind SecureAuthStateStore so the deprecated AndroidX Security Crypto implementation can be replaced later without touching AuthSession."
requirements-completed: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
duration: 8 min
completed: 2026-04-28
---
# Phase 02 Plan 04: Android OIDC Actuals Summary
**Android AppAuth login, refresh, logout, and encrypted AuthState persistence wired behind the Phase 2 common auth contracts.**
## Performance
- **Duration:** 8 min
- **Started:** 2026-04-28T13:52:41Z
- **Completed:** 2026-04-28T14:00:47Z
- **Tasks:** 2
- **Files modified:** 3
## Accomplishments
- Added Android `OidcClient` actual using AppAuth discovery, authorization-code flow, exact `openid profile email offline_access` scopes, token exchange, `AuthState.jsonSerializeString()`, `AuthState.jsonDeserialize(...)`, and `performActionWithFreshTokens`.
- Added Android `SecureAuthStateStore` actual backed by `EncryptedSharedPreferences`.
- Registered `net.openid.appauth.RedirectUriReceiverActivity` for `recipe://callback` in the Android manifest.
- Kept auth diagnostics token-safe: no logging of AuthState JSON, access tokens, refresh tokens, ID tokens, bearer headers, or authorization headers.
## Task Commits
1. **Task 1: Implement Android AppAuth OidcClient actual** - `fa78ee3` (feat)
2. **Task 2: Implement Android secure AuthState store and callback manifest** - `6385453` (feat)
3. **Rule 1 fix: Harden Android OIDC token result mapping** - `11a5eeb` (fix)
## Files Created/Modified
- `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` - Android AppAuth actual for login, refresh, logout, AuthState JSON serialization/deserialization, and OidcResult mapping.
- `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt` - Android encrypted storage actual for one opaque AuthState JSON blob per install.
- `composeApp/src/androidMain/AndroidManifest.xml` - Explicit AppAuth redirect receiver registration for `recipe://callback`.
## Decisions Made
See frontmatter `key-decisions`.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Added Android SecureAuthStateStore actual during Task 1**
- **Found during:** Task 1 verification
- **Issue:** `./gradlew :composeApp:compileDebugKotlinAndroid` failed because `SecureAuthStateStore` had a common `expect` declaration but no Android `actual`. The plan listed the store in Task 2, but the Android target cannot compile any auth expect declarations until both Android actuals exist.
- **Fix:** Implemented `SecureAuthStateStore.android.kt` with `EncryptedSharedPreferences` during Task 1 so the required Task 1 Android compile gate could pass.
- **Files modified:** `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt`
- **Verification:** `./gradlew :composeApp:compileDebugKotlinAndroid`
- **Committed in:** `fa78ee3`
**2. [Rule 1 - Bug] Hardened token result mapping**
- **Found during:** Final correctness pass
- **Issue:** The initial token exchange path could report success if AppAuth returned a `TokenResponse` with a missing access token.
- **Fix:** Added explicit missing-token guards and preserved AppAuth discovery exceptions so network failures and auth failures map cleanly.
- **Files modified:** `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
- **Verification:** `./gradlew :composeApp:compileDebugKotlinAndroid`; all Task 1 and Task 2 grep gates re-run.
- **Committed in:** `11a5eeb`
---
**Total deviations:** 2 auto-fixed (1 x Rule 3 blocking, 1 x Rule 1 bug).
**Impact on plan:** No scope expansion beyond Android auth ownership. The Rule 3 change only corrected task ordering required by Kotlin expect/actual compilation.
## Known Stubs
None.
## Threat Flags
None - all new trust-boundary surfaces were already listed in the plan threat model.
## Issues Encountered
- Concurrent iOS plan work appeared as untracked `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/` and `iosApp/Podfile`. These files were not read, staged, modified, or committed by this plan.
- Pre-existing untracked `.claude/` and `AGENTS.md` were left untouched.
- STATE.md/ROADMAP.md updates were intentionally not performed by this spawned Android executor because the user constrained writes to Android-owned files plus this summary; central planning state remains orchestrator-owned.
## User Setup Required
None.
## Verification
- `./gradlew :composeApp:compileDebugKotlinAndroid` - PASS
- `grep -q 'AuthorizationServiceConfiguration.fetchFromIssuer' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` - PASS
- `grep -q 'setScopes("openid", "profile", "email", "offline_access")' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` - PASS
- `grep -q 'suspendCancellableCoroutine' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` - PASS
- `grep -q 'performActionWithFreshTokens' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` - PASS
- `grep -q 'EndSessionRequest' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` - PASS
- `grep -q 'EncryptedSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt` - PASS
- `! grep -R 'Settings()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth` - PASS
- `! grep -R 'getSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt` - PASS
- `grep -q 'RedirectUriReceiverActivity' composeApp/src/androidMain/AndroidManifest.xml` - PASS
- `grep -q 'android:scheme="recipe"' composeApp/src/androidMain/AndroidManifest.xml` - PASS
- `grep -q 'android:host="callback"' composeApp/src/androidMain/AndroidManifest.xml` - PASS
- Token/log scan for `Logger`, `println`, `printStackTrace`, `Authorization:`, and `Bearer` under Android auth files - PASS
## Next Phase Readiness
Android auth actuals now compile behind the common contracts. AuthSession/UI integration in 02-06 can call login, refresh, logout, and the secure store without Android-specific APIs.
## Self-Check: PASSED
- Created files exist: `OidcClient.android.kt`, `SecureAuthStateStore.android.kt`, and this summary were found.
- Modified files exist: `AndroidManifest.xml` contains `RedirectUriReceiverActivity`, `android:scheme="recipe"`, and `android:host="callback"`.
- Commits exist: `fa78ee3`, `6385453`, and `11a5eeb` were found in git history.
- Acceptance criteria: all required grep checks passed.
- Plan-level verification: `./gradlew :composeApp:compileDebugKotlinAndroid` passed.
---
*Phase: 02-authentication-foundation*
*Completed: 2026-04-28*

View File

@@ -0,0 +1,169 @@
---
phase: 02-authentication-foundation
plan: 05
type: execute
wave: 3
depends_on: [02-01, 02-03]
files_modified:
- composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt
- composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt
- iosApp/iosApp/Info.plist
- iosApp/iosApp/iOSApp.swift
- iosApp/Podfile
autonomous: true
requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
must_haves:
truths:
- "iOS login uses AppAuth authorization-code flow with PKCE through system browser and recipe://callback"
- "iOS requested scopes are exactly openid profile email offline_access"
- "iOS persists full AppAuth AuthState JSON in Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly"
- "SwiftUI callback wiring forwards recipe://callback to the current AppAuth flow"
- "iOS logout uses AppAuth end-session when metadata exposes an endpoint"
artifacts:
- path: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt"
provides: "iOS AppAuth actual per D-01, D-04, D-16, D-19, D-20"
contains: "OIDAuthorizationService"
- path: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt"
provides: "iOS Keychain storage per D-14"
contains: "kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly"
- path: "iosApp/iosApp/Info.plist"
provides: "recipe URL scheme registration"
contains: "CFBundleURLSchemes"
- path: "iosApp/iosApp/iOSApp.swift"
provides: "SwiftUI openURL callback forwarding to AppAuth"
contains: "onOpenURL"
- path: "iosApp/Podfile"
provides: "AppAuth CocoaPod integration if required by chosen KMP CocoaPods setup"
contains: "AppAuth"
key_links:
- from: "iosApp/iosApp/iOSApp.swift"
to: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt"
via: "openURL forwards callback to current AppAuth external user-agent session"
pattern: "onOpenURL|currentAuthorizationFlow"
- from: "iosApp/iosApp/Info.plist"
to: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt"
via: "registered URL scheme matches redirect URI consumed by AppAuth"
pattern: "CFBundleURLSchemes|recipe"
---
<objective>
Implement the iOS OIDC and secure storage actuals.
Purpose: satisfy iOS-primary AUTH-01/AUTH-02/AUTH-04/AUTH-05 behind the common contracts from Plan 02-03 without mixing Android work into the same execution plan.
Output: iOS AppAuth OidcClient actual, iOS Keychain AuthState store, URL scheme registration, Swift callback wiring, and Podfile integration.
</objective>
<execution_context>
@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md
@/Users/rwilk/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/phases/02-authentication-foundation/02-CONTEXT.md
@.planning/phases/02-authentication-foundation/02-RESEARCH.md
@.planning/phases/02-authentication-foundation/02-VALIDATION.md
@.planning/phases/02-authentication-foundation/02-PATTERNS.md
@.planning/phases/02-authentication-foundation/02-01-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-03-SUMMARY.md
@AGENTS.md
@iosApp/iosApp/Info.plist
@iosApp/iosApp/iOSApp.swift
</context>
<tasks>
<task type="auto">
<name>Task 1: Implement iOS AppAuth OidcClient actual and CocoaPods bridge</name>
<read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt
- iosApp/Podfile
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01, D-04, D-05, D-06, D-09, D-16, D-19, D-20)
</read_first>
<files>composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt, iosApp/Podfile</files>
<action>
Implement iOS `actual class OidcClient` via AppAuth-iOS interop using `OIDAuthorizationService`, `OIDAuthState`, `OIDAuthorizationRequest`, token refresh/fresh-token helpers, and `OIDEndSessionRequest`. Use `suspendCancellableCoroutine` so cancellation cancels the current AppAuth request.
Request scopes exactly `openid`, `profile`, `email`, and `offline_access`. Serialize and deserialize the full `OIDAuthState` JSON blob per D-13. Refresh must use AppAuth fresh-token behavior and return updated AuthState JSON for AuthSession persistence. Logout must attempt RP-initiated end-session with `id_token_hint` when available; if end-session is unavailable or fails, surface no local-token-clearing responsibility here because AuthSession clears local state after calling logout.
Ensure AppAuth CocoaPod integration is present through the existing Gradle CocoaPods setup from Plan 02-01 and/or `iosApp/Podfile` as required by the repo's KMP CocoaPods wiring. Do not introduce an additional OIDC library.
</action>
<verify>
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64</automated>
</verify>
<acceptance_criteria>
- `grep -q 'OIDAuthorizationService' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- `grep -q 'offline_access' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- `grep -q 'suspendCancellableCoroutine' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- `grep -q 'OIDEndSessionRequest' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- `grep -q 'AppAuth' iosApp/Podfile composeApp/build.gradle.kts`
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64` exits 0
</acceptance_criteria>
<done>iOS AppAuth login, refresh, and logout compile behind the common OidcClient contract.</done>
</task>
<task type="auto">
<name>Task 2: Implement iOS Keychain store and callback wiring</name>
<read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt
- iosApp/iosApp/Info.plist
- iosApp/iosApp/iOSApp.swift
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-09, D-13, D-14, D-15)
</read_first>
<files>composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt, iosApp/iosApp/Info.plist, iosApp/iosApp/iOSApp.swift</files>
<action>
Implement iOS `actual class SecureAuthStateStore` with Keychain read/write/delete for one opaque AuthState JSON string per app install. Use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` exactly per D-14; do not store AuthState in UserDefaults or plaintext files.
Add `CFBundleURLTypes` to `iosApp/iosApp/Info.plist` registering scheme `recipe`, matching redirect URI `recipe://callback`.
Add SwiftUI `.onOpenURL` or an app delegate bridge in `iOSApp.swift` that forwards incoming `recipe://callback` URLs to the current AppAuth external user-agent session held by the KMP iOS OidcClient bridge. Keep existing Koin initialization intact.
</action>
<verify>
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64</automated>
</verify>
<acceptance_criteria>
- `grep -q 'kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt`
- `! grep -R 'NSUserDefaults\\|UserDefaults' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth`
- `grep -q 'CFBundleURLSchemes' iosApp/iosApp/Info.plist`
- `grep -q 'recipe' iosApp/iosApp/Info.plist`
- `grep -q 'onOpenURL\\|application(.*open' iosApp/iosApp/iOSApp.swift`
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64` exits 0
</acceptance_criteria>
<done>iOS token storage is explicit and the custom URL callback is wired back into AppAuth.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| system browser -> iOS app | Authorization code returns through custom URL scheme |
| iOS app -> Keychain | AuthState JSON containing refresh token is persisted |
| Swift shell -> KMP auth bridge | openURL callback crosses from SwiftUI into KMP/AppAuth flow state |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-05-01 | Spoofing/Elevation | custom URL callback | mitigate | AppAuth handles state/nonce and PKCE S256; Info.plist byte-matches `recipe://callback` |
| T-02-05-02 | Information Disclosure | iOS token store | mitigate | Keychain item uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`; grep forbids UserDefaults in auth |
| T-02-05-03 | Information Disclosure | AppAuth diagnostics | mitigate | iOS actual must not log AuthState JSON, access tokens, refresh tokens, ID tokens, or Authorization headers |
| T-02-05-04 | Spoofing | Swift callback bridge | mitigate | `onOpenURL` forwards only registered callback URLs to the active AppAuth session |
</threat_model>
<verification>
Run `./gradlew :composeApp:compileKotlinIosSimulatorArm64`.
</verification>
<success_criteria>
iOS AppAuth login/refresh/logout, iOS Keychain AuthState persistence, URL scheme registration, and callback forwarding compile independently below the file-count threshold.
</success_criteria>
<output>
After completion, create `.planning/phases/02-authentication-foundation/02-05-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,153 @@
---
phase: 02-authentication-foundation
plan: 05
subsystem: auth
tags: [oidc, appauth, ios, keychain, swiftui, callback]
requires:
- phase: 02-authentication-foundation
provides: 02-01 CocoaPods/AppAuth dependency wiring and OIDC constants
- phase: 02-authentication-foundation
provides: 02-03 common OidcClient and SecureAuthStateStore contracts
provides:
- iOS AppAuth OidcClient actual with login, refresh, logout, and callback bridge
- iOS Keychain-backed SecureAuthStateStore using kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
- recipe URL scheme registration in Info.plist
- SwiftUI onOpenURL callback forwarding into the current AppAuth flow
- iosApp Podfile with AppAuth pod integration
affects: [02-06-auth-session-ui, 02-07-auth-integration-verification]
tech-stack:
added:
- AppAuth CocoaPod reference in iosApp/Podfile
patterns:
- "iOS AppAuth bridge: Kotlin singleton holds currentAuthorizationFlow; SwiftUI forwards recipe://callback URLs by absolute string."
- "iOS AuthState persistence: full OIDAuthState NSSecureCoding archive wrapped in an opaque JSON string and stored through KeychainSettings."
key-files:
created:
- composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt
- composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt
- iosApp/Podfile
modified:
- iosApp/iosApp/Info.plist
- iosApp/iosApp/iOSApp.swift
key-decisions:
- "AppAuth-iOS AuthState persistence uses NSSecureCoding wrapped in JSON because AppAuth-iOS 2.0.0 does not expose the Android-style serialize()/jsonDeserialize API."
- "SecureAuthStateStore was implemented in the first task commit because the Task 1 compile gate cannot pass while the common expect class lacks an iOS actual."
- "SwiftUI forwards only recipe://callback URLs to the KMP bridge; other URLs are ignored before AppAuth sees them."
patterns-established:
- "Never log token-bearing values in iOS auth actuals; token variables are only returned through OidcResult or stored in Keychain."
- "Mobile callback state remains inside AppAuth's current external user-agent session and is consumed once."
requirements-completed: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
duration: 27m
completed: 2026-04-28
---
# Phase 02 Plan 05: iOS AppAuth Actuals Summary
**iOS AppAuth login, fresh-token refresh, RP-initiated logout, Keychain AuthState persistence, and recipe://callback forwarding behind the Phase 02 common auth contracts.**
## Performance
- **Duration:** 27 min
- **Started:** 2026-04-28T13:52:54Z
- **Completed:** 2026-04-28T14:19:03Z
- **Tasks:** 2
- **Files modified:** 5
## Accomplishments
- Added the iOS `OidcClient` actual using AppAuth discovery, authorization-code flow with PKCE, exact `openid profile email offline_access` scopes, fresh-token refresh, and end-session logout.
- Added the iOS secure store actual using Keychain-backed settings with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`.
- Registered the `recipe` URL scheme and wired SwiftUI `.onOpenURL` to forward only `recipe://callback` URLs to the active AppAuth external user-agent session.
- Added `iosApp/Podfile` with `AppAuth` so the iOS shell has explicit pod integration alongside the existing KMP CocoaPods block.
## Task Commits
1. **Task 1: Implement iOS AppAuth OidcClient actual and CocoaPods bridge** - `ac9fc61` (feat)
2. **Task 2: Implement iOS Keychain store and callback wiring** - `88dc8d7` (feat)
## Files Created/Modified
- `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` - AppAuth-iOS login, refresh, logout, AuthState archive/restore, and callback bridge.
- `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt` - Keychain-backed opaque AuthState store with the required accessibility class.
- `iosApp/Podfile` - iOS target Podfile declaring the `AppAuth` pod.
- `iosApp/iosApp/Info.plist` - `CFBundleURLTypes` registration for the `recipe` custom URL scheme.
- `iosApp/iosApp/iOSApp.swift` - SwiftUI `.onOpenURL` forwarding for `recipe://callback`.
## Decisions Made
See frontmatter `key-decisions`.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Implemented `SecureAuthStateStore.ios.kt` during Task 1**
- **Found during:** Task 1 verification
- **Issue:** `./gradlew :composeApp:compileKotlinIosSimulatorArm64` cannot pass after adding only `OidcClient.ios.kt` because the common `SecureAuthStateStore` expect class also requires an iOS actual.
- **Fix:** Added the Keychain-backed iOS secure store in the Task 1 commit, then Task 2 added the URL scheme and Swift callback wiring.
- **Files modified:** `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt`
- **Verification:** `./gradlew :composeApp:compileKotlinIosSimulatorArm64`
- **Committed in:** `ac9fc61`
**2. [Rule 3 - Blocking] Wrapped AppAuth-iOS secure archive in JSON**
- **Found during:** Task 1 implementation
- **Issue:** AppAuth-iOS 2.0.0 exposes `OIDAuthState` as `NSSecureCoding`; it does not expose the Android-style `serialize()` / JSON-deserialize API assumed by the plan.
- **Fix:** Persisted a JSON wrapper containing the full `NSKeyedArchiver` secure archive of `OIDAuthState`, preserving the common opaque `authStateJson` contract while using AppAuth-iOS' supported persistence mechanism.
- **Files modified:** `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- **Verification:** `./gradlew :composeApp:compileKotlinIosSimulatorArm64`
- **Committed in:** `ac9fc61`
---
**Total deviations:** 2 auto-fixed (2 x Rule 3).
**Impact on plan:** No auth behavior was reduced. Both fixes were required for the iOS target to compile against the actual AppAuth-iOS API and the existing common expect contracts.
## Known Stubs
None.
## Threat Flags
None beyond the plan's threat model. This plan intentionally touches the browser callback, Keychain storage, and Swift-to-KMP callback trust boundaries already listed in `02-05-PLAN.md`.
## Issues Encountered
- `iosApp/Podfile` did not exist even though the plan listed it in `read_first`; it was created in Task 1.
- A parallel `git add` attempt briefly hit Git's index lock. Staging was retried sequentially; no repository state was lost.
- `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64` passed as an extra confidence check and confirmed `IosAppAuthBridge.shared.resumeExternalUserAgentFlow(urlString:)` is exported to Swift.
## User Setup Required
None for this plan. Real login still requires the Authentik provider configuration documented in `docs/authentik-setup.md`.
## Verification
- Task 1 acceptance greps - PASS
- Task 2 acceptance greps - PASS
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64` - PASS
- Extra: `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64` - PASS
- Token/logging scan - PASS; no `Logger`, `println`, or token/AuthState logging calls were added.
## Next Phase Readiness
Plan 02-06 can consume the common `OidcClient` and `SecureAuthStateStore` on iOS. Plan 02-07 should still run real iOS/Authenik UAT for browser handoff, refresh across relaunch, and end-session behavior.
## Self-Check: PASSED
- Created/modified files exist: all five plan-owned source/config files plus this summary were found.
- Commits exist: `ac9fc61` and `88dc8d7` were found in git history.
- Acceptance criteria: all Task 1 and Task 2 grep checks passed.
- Plan-level verification: `./gradlew :composeApp:compileKotlinIosSimulatorArm64` passed.
---
*Phase: 02-authentication-foundation*
*Completed: 2026-04-28*

View File

@@ -0,0 +1,196 @@
---
phase: 02-authentication-foundation
plan: 06
type: execute
wave: 4
depends_on: [02-01, 02-02, 02-03, 02-04, 02-05]
files_modified:
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt
autonomous: true
requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
must_haves:
truths:
- "AuthSession starts in Loading and restores persisted AuthState JSON before deciding Authenticated or Unauthenticated"
- "Authenticated state contains User and householdId = null in Phase 2"
- "Authenticated API calls get fresh access tokens proactively and Ktor bearer auth can reactively refresh on 401"
- "Refresh invalid_grant transitions silently to Unauthenticated"
- "logout() attempts RP end-session and clears local AuthState even if end-session fails"
- "AuthSession is a Koin singleton in authModule and wired into appModule"
artifacts:
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt"
provides: "Loading/Unauthenticated/Authenticated(user, householdId?) state model per D-28"
contains: "householdId"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
provides: "StateFlow auth owner per D-29"
exports: ["AuthSession"]
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt"
provides: "Ktor client bearer auth with refreshTokens per D-17"
contains: "refreshTokens"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt"
provides: "GET /api/v1/me client returning MeResponse"
key_links:
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt"
via: "login/refresh/logout delegate to platform AppAuth seam"
pattern: "oidcClient"
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt"
via: "after login/restore, fetch /api/v1/me to build Authenticated(user, null)"
pattern: "meClient"
---
<objective>
Build the common client auth runtime: `AuthSession`, authenticated Ktor client, `/api/v1/me` client, and Koin wiring.
Purpose: compose common contracts from Plan 03, Android/iOS OIDC/storage from Plans 04/05, and server `/api/v1/me` from Plan 02 into persistent app session behavior.
Output: tested common auth state machine and DI module.
</objective>
<execution_context>
@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md
@/Users/rwilk/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/phases/02-authentication-foundation/02-CONTEXT.md
@.planning/phases/02-authentication-foundation/02-RESEARCH.md
@.planning/phases/02-authentication-foundation/02-VALIDATION.md
@.planning/phases/02-authentication-foundation/02-PATTERNS.md
@.planning/phases/02-authentication-foundation/02-01-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-02-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-03-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-04-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-05-SUMMARY.md
@AGENTS.md
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Write AuthSession state-machine tests</name>
<read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt
- .planning/phases/02-authentication-foundation/02-VALIDATION.md (AuthSessionTest)
</read_first>
<files>composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt</files>
<behavior>
- Empty store initializes `Loading -> Unauthenticated`.
- Successful login writes AuthState JSON, calls `/api/v1/me`, and emits `Authenticated(user, householdId = null)`.
- Existing store refreshes before `/api/v1/me` and emits Authenticated without login.
- Refresh `invalid_grant` or AuthError clears store and emits Unauthenticated without UI error.
- Logout calls `OidcClient.logout(authStateJson)` then clears store and emits Unauthenticated even when logout throws.
- Login cancelled maps to a result the UI can render as cancelled.
</behavior>
<action>
Create fakes for `OidcClient`, `SecureAuthStateStore`, and `MeClient`. Write tests for the exact behaviors above before production implementation. Keep tests in commonTest and avoid platform AppAuth classes.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"</automated>
</verify>
<acceptance_criteria>
- `grep -q 'invalid_grant\\|AuthError' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt`
- `grep -q 'householdId' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt`
- After Task 2, `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` exits 0
</acceptance_criteria>
<done>State-machine tests cover AUTH-04/AUTH-05 and validation Wave 0 requirements.</done>
</task>
<task type="auto">
<name>Task 2: Implement AuthState, AuthSession, MeClient, and bearer HTTP client</name>
<read_first>
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-16, D-17, D-18, D-28, D-29)
</read_first>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt</files>
<action>
Create sealed `AuthState` with `Loading`, `Unauthenticated`, `Authenticated(user: User, householdId: HouseholdId? = null)` and `typealias HouseholdId = String`.
Implement `MeClient.getMe(accessToken: String? = null)` calling `GET ${Constants.API_BASE_URL}/api/v1/me`, decoding `MeResponse`, and mapping to `User`. If `accessToken` is supplied for tests/simple calls, attach `Authorization: Bearer <token>` without logging it.
Implement `AuthHttpClient.create(authSession)` using Ktor Client `Auth { bearer { loadTokens { ... }; refreshTokens { ... }; sendWithoutRequest { request.url.host == Url(Constants.API_BASE_URL).host } } }`, ContentNegotiation JSON, and logging that redacts token-bearing headers.
Implement `AuthSession` with `state: StateFlow<AuthState>`, `initialize()`, `login()`, `logout()`, `getAccessToken()`, `currentBearerTokens()`, and `refreshBearerTokens()`. `initialize()` reads stored AuthState JSON, refreshes with AppAuth, persists updated JSON, calls `/api/v1/me`, and emits `Authenticated(user, null)`. On refresh failure, clear the store and emit Unauthenticated silently. On logout, call `OidcClient.logout(storedJson)` first, then always clear.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"</automated>
</verify>
<acceptance_criteria>
- `grep -q 'StateFlow<AuthState>' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt`
- `grep -q 'refreshTokens' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt`
- `grep -q 'sendWithoutRequest' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt`
- `! grep -R 'Authorization.*\\$' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth`
- `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` exits 0
</acceptance_criteria>
<done>Common auth runtime passes the state-machine tests and supports transparent refresh.</done>
</task>
<task type="auto">
<name>Task 3: Wire authModule into Koin</name>
<read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-29)
</read_first>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt</files>
<action>
Create `authModule = module { ... }` providing singleton `SecureAuthStateStore`, `OidcClient`, `MeClient`, `AuthSession`, and auth-related ViewModels only if their classes already exist. Wire `appModule` to include auth definitions without starting Koin from composables. If target-specific constructors need Android context/activity, use Koin platform APIs already available in Phase 1 Android bootstrap.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64</automated>
</verify>
<acceptance_criteria>
- `grep -q 'val authModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt`
- `grep -q 'authModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`
- `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0
</acceptance_criteria>
<done>AuthSession and collaborators are available as Koin singletons for the UI gate.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| AuthSession -> server | Access token attached to `/api/v1/me` |
| AuthSession -> secure store | Refresh-capable AuthState JSON persists across app launches |
| AuthSession -> UI | Auth failures influence rendered state and messages |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-06-01 | Information Disclosure | AuthHttpClient logging | mitigate | Redact Authorization and never log token values |
| T-02-06-02 | Information Disclosure | AuthSession logout | mitigate | Always clear stored AuthState after logout attempt, including end-session failure |
| T-02-06-03 | Denial of Service | refresh path | mitigate | Proactive refresh before calls and Ktor bearer reactive refresh on 401 |
| T-02-06-04 | Spoofing | `/api/v1/me` response | mitigate | Authenticated state is built only from server `MeResponse`, not client-decoded token claims |
| T-02-06-05 | Repudiation | silent invalid_grant | accept | Silent return to login is a locked UX decision D-18; auth warnings may log without secrets |
</threat_model>
<verification>
Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`.
</verification>
<success_criteria>
AUTH-04 and AUTH-05 work at the session layer: persisted tokens restore, refresh failures clear state, and logout wipes recoverable refresh tokens.
</success_criteria>
<output>
After completion, create `.planning/phases/02-authentication-foundation/02-06-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,164 @@
---
phase: 02-authentication-foundation
plan: 06
subsystem: auth
tags: [kmp, auth-session, ktor-client, bearer-auth, koin, oidc]
requires:
- phase: 02-authentication-foundation
provides: 02-01 shared Constants, User, MeResponse, Ktor client dependencies
- phase: 02-authentication-foundation
provides: 02-03 common OidcClient and SecureAuthStateStore contracts
- phase: 02-authentication-foundation
provides: 02-04 Android AppAuth and secure store actuals
- phase: 02-authentication-foundation
provides: 02-05 iOS AppAuth and Keychain actuals
provides:
- AuthState Loading / Unauthenticated / Authenticated(user, householdId?) model
- AuthSession StateFlow owner for restore, login, logout, proactive refresh, and Ktor reactive refresh
- MeClient for GET /api/v1/me mapped to User
- AuthHttpClient Ktor bearer client with token-redacting logging
- authModule Koin singleton wiring included from appModule
affects: [02-07-auth-integration-verification, phase-03-households]
tech-stack:
added: []
patterns:
- "AuthSession depends on small common gateways so state-machine tests use fakes while production constructors delegate to platform expect classes."
- "Authenticated state is built from server MeResponse only; Phase 2 householdId remains null."
- "Ktor bearer loadTokens/refreshTokens delegates to AuthSession, with Authorization header sanitization and message redaction."
key-files:
created:
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt
modified:
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
key-decisions:
- "Use lightweight common gateway interfaces for AuthSession tests instead of changing OidcClient/SecureAuthStateStore expect/actual contracts."
- "MeClient accepts an optional access token for AuthSession's explicit /me calls; other authenticated clients use AuthHttpClient bearer auth."
- "Koin provides AuthSession and AuthHttpClient as singletons from authModule; Koin startup remains platform bootstrap-owned."
patterns-established:
- "AuthSession.restore/login refreshes through OidcClient before /api/v1/me and persists the updated opaque AuthState JSON."
- "Refresh failures, including invalid_grant/AuthError, silently clear the store and emit Unauthenticated."
- "logout() attempts end-session first, then always clears the secure store and emits Unauthenticated."
requirements-completed: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
duration: 34m
completed: 2026-04-28
---
# Phase 02 Plan 06: Common Auth Runtime Summary
**AuthSession state machine, token-safe Ktor bearer client, /api/v1/me client, and Koin singleton wiring for persisted OIDC sessions.**
## Performance
- **Duration:** 34 min
- **Started:** 2026-04-28T14:22:01Z
- **Completed:** 2026-04-28T14:56:05Z
- **Tasks:** 3
- **Files modified:** 7
## Accomplishments
- Added common AuthSession behavior for Loading -> restored Authenticated/Unauthenticated, login, logout, proactive refresh, and Ktor reactive refresh support.
- Added AuthState with Phase 3-ready `householdId: HouseholdId? = null`, with tests asserting Phase 2 authenticated sessions keep it null.
- Added MeClient for `GET /api/v1/me`, mapping server MeResponse to User so authenticated state is built from the server, not token claims.
- Added AuthHttpClient with Ktor bearer `loadTokens`, `refreshTokens`, `sendWithoutRequest`, ContentNegotiation JSON, and token-redacting logging.
- Wired authModule into appModule as Koin singletons without changing Koin startup ownership.
## Task Commits
1. **Task 1: Write AuthSession state-machine tests** - `06e5eaf` (test)
2. **Task 2: Implement AuthState, AuthSession, MeClient, and bearer HTTP client** - `0a24be9` (feat)
3. **Task 3: Wire authModule into Koin** - `938f324` (feat)
## Files Created/Modified
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt` - Loading/Unauthenticated/Authenticated auth model with nullable household id.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt` - StateFlow auth owner with restore/login/logout/token refresh behavior and testable gateway seams.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt` - Ktor client factory with bearer auth refresh and token redaction.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt` - `/api/v1/me` client mapped to shared User.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt` - Koin singleton definitions for auth runtime collaborators.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` - Includes authModule from the app bootstrap module.
- `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt` - State-machine tests for restore, login, invalid_grant/AuthError, logout, and cancellation.
## Decisions Made
- Kept platform OidcClient and SecureAuthStateStore expect/actual contracts unchanged; AuthSession uses gateway interfaces internally so common tests can fake dependencies.
- Used explicit token passing only for AuthSession's `/me` call. Broader authenticated API access goes through AuthHttpClient and its bearer plugin.
- No auth UI ViewModels were registered because they do not exist yet in this plan's input set.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Added testable gateway seams for AuthSession dependencies**
- **Found during:** Task 1/2 (state-machine tests and implementation)
- **Issue:** The plan required fakes for OidcClient and SecureAuthStateStore, but the existing common contracts are concrete expect classes. Changing expect/actual signatures would have touched platform files outside this plan's write scope.
- **Fix:** Added small common interfaces (`OidcClientGateway`, `AuthStateStore`, `MeGateway`) and made AuthSession's production constructor delegate concrete platform classes through adapters.
- **Files modified:** `AuthSession.kt`, `MeClient.kt`, `AuthSessionTest.kt`
- **Verification:** `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` and full plan gate passed.
- **Committed in:** `0a24be9`
**2. [Rule 3 - Blocking] Added explicit Koin generic types**
- **Found during:** Task 3 verification
- **Issue:** Koin's `single { ... }` calls could not infer expect-class singleton types under the KMP compile targets.
- **Fix:** Changed definitions to `single<SecureAuthStateStore>`, `single<OidcClient>`, `single<MeClient>`, and `single<AuthSession>`, with typed `get<...>()` calls.
- **Files modified:** `AuthModule.kt`
- **Verification:** `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`
- **Committed in:** `938f324`
---
**Total deviations:** 2 auto-fixed (2 x Rule 3).
**Impact on plan:** No scope expansion beyond common auth runtime and DI wiring. Both fixes were required to satisfy the planned tests and cross-target compile gate while respecting the write scope.
## Issues Encountered
- The RED test commit was amended before GREEN to make JUnit test methods return void while still failing on missing production auth runtime. This preserved the TDD red gate without adding a separate formatting-only commit.
- Pre-existing untracked `.claude/` and `AGENTS.md` remain untouched.
## Known Stubs
None.
## Threat Flags
None beyond the plan's threat model. The new network client, bearer refresh, secure-store access, and AuthSession UI state surfaces were all covered by T-02-06-01 through T-02-06-05.
## User Setup Required
None for this plan. Real OIDC login still requires the Authentik provider setup documented in `docs/authentik-setup.md`.
## Verification
- `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` - PASS
- `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` - PASS
- Task acceptance greps for `invalid_grant|AuthError`, `householdId`, `StateFlow<AuthState>`, `refreshTokens`, `sendWithoutRequest`, no `Authorization.*$`, `val authModule`, and appModule `authModule` - PASS
- Token/logging scan - PASS; no bearer token values or AuthState JSON are logged.
## Next Phase Readiness
Plan 02-07 can run integration verification against the common AuthSession + platform AppAuth actuals. Phase 3 can extend `/api/v1/me` with household data and fill `AuthState.Authenticated.householdId` without changing the sealed auth state shape.
## Self-Check: PASSED
- Created/modified files exist: all seven plan-owned source/test files plus this summary were found.
- Commits exist: `06e5eaf`, `0a24be9`, and `938f324` were found in git history.
- Acceptance criteria: all task grep checks passed.
- Plan-level verification: `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` passed.
---
*Phase: 02-authentication-foundation*
*Completed: 2026-04-28*

View File

@@ -0,0 +1,202 @@
---
phase: 02-authentication-foundation
plan: 07
type: execute
wave: 5
depends_on: [02-06]
files_modified:
- composeApp/src/commonMain/composeResources/values/strings.xml
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt
autonomous: false
requirements: [AUTH-01, AUTH-04, AUTH-05]
must_haves:
truths:
- "Fresh launch in Loading shows SplashScreen with Recipe wordmark and progress indicator"
- "Unauthenticated state shows LoginScreen with Polish Authentik sign-in button"
- "Login errors render inline below the button and retry clears stale error"
- "Authenticated state shows Witaj, {displayName}! and Wyloguj się"
- "Wyloguj się returns to LoginScreen through AuthSession.logout()"
- "All Phase 2 user-facing strings come from Compose Resources"
artifacts:
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt"
provides: "Auth gate rendering Splash/Login/PostLogin by AuthState"
contains: "when"
- path: "composeApp/src/commonMain/composeResources/values/strings.xml"
provides: "auth_app_name/auth_sign_in_button/auth_sign_out_button/auth_welcome_format/auth_error_*"
contains: "auth_sign_in_button"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt"
provides: "UI-SPEC login layout and inline error state"
contains: "auth_sign_in_button"
key_links:
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt"
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
via: "collectAsState over AuthSession.state"
pattern: "collectAsState"
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt"
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
via: "onSignOutClick delegates to logout"
pattern: "logout"
---
<objective>
Deliver the user-facing Phase 2 auth experience and final validation gate.
Purpose: make end-to-end auth observable: login button, loading screen, welcome confirmation, logout button, and manual iOS Authentik UAT.
Output: auth screens, auth gate, resource strings, UI ViewModels, and validation checklist execution.
</objective>
<execution_context>
@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md
@/Users/rwilk/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/ROADMAP.md
@.planning/phases/02-authentication-foundation/02-CONTEXT.md
@.planning/phases/02-authentication-foundation/02-UI-SPEC.md
@.planning/phases/02-authentication-foundation/02-VALIDATION.md
@.planning/phases/02-authentication-foundation/02-PATTERNS.md
@.planning/phases/02-authentication-foundation/02-06-SUMMARY.md
@AGENTS.md
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add Compose Resources, theme seed, and ViewModel tests</name>
<read_first>
- .planning/phases/02-authentication-foundation/02-UI-SPEC.md
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt
</read_first>
<files>composeApp/src/commonMain/composeResources/values/strings.xml, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt, composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt</files>
<behavior>
- String keys exist with exact Polish scaffold copy from UI-SPEC.
- `RecipeTheme` uses Material 3 light/dark schemes with primary seed `#3B6939` / dark variant `#A2D597`.
- LoginViewModel maps cancelled/network/unknown auth failures to the correct string resource keys.
- Starting a new login clears previous inline error and sets loading.
</behavior>
<action>
Create `strings.xml` keys: `auth_app_name`, `auth_sign_in_button`, `auth_sign_out_button`, `auth_welcome_format`, `auth_error_cancelled`, `auth_error_network`, `auth_error_unknown` with exact UI-SPEC copy.
Add `RecipeTheme(content)` with `lightColorScheme(primary = Color(0xFF3B6939))`, `darkColorScheme(primary = Color(0xFFA2D597))`, `isSystemInDarkTheme()`, and Material 3 typography defaults. Do not add Haze, blur, images, icons, Scaffold, or marketing copy.
Write `LoginViewModelTest` against a fake `AuthSession` result interface before implementing the ViewModel in Task 2.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest --tests "*LoginViewModelTest*"</automated>
</verify>
<acceptance_criteria>
- `grep -q 'name="auth_sign_in_button">Zaloguj się przez Authentik' composeApp/src/commonMain/composeResources/values/strings.xml`
- `grep -q '0xFF3B6939' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt`
- `grep -q 'auth_error_cancelled' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt`
- After Task 2, `./gradlew :composeApp:jvmTest --tests "*LoginViewModelTest*"` exits 0
</acceptance_criteria>
<done>Resource and theme foundations match UI-SPEC and login error mapping is tested.</done>
</task>
<task type="auto">
<name>Task 2: Implement auth screens, ViewModels, and App auth gate</name>
<read_first>
- .planning/phases/02-authentication-foundation/02-UI-SPEC.md (Component Inventory, Layout Contract, Auth Gate Routing Contract)
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
</read_first>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt</files>
<action>
Replace template `App()` body with `RecipeTheme { val authState by authSession.state.collectAsState(); when(authState) { Loading -> SplashScreen(); Unauthenticated -> LoginScreen(koinViewModel()); Authenticated -> PostLoginPlaceholderScreen(user, koinViewModel()) } }`. State changes drive recomposition; no manual navigation or Scaffold.
Implement `SplashScreen`, `LoginScreen`, and `PostLoginPlaceholderScreen` exactly from UI-SPEC: centered column, `safeContentPadding`, horizontal 16.dp, displaySmall wordmark, Login button with loading indicator, inline bodyLarge error text below button, welcome `headlineSmall`, logout `OutlinedButton`. All strings must use `stringResource(Res.string.*)`.
Implement `LoginViewModel` with method `onSignInClick()` and immutable `LoginScreenState(isLoading: Boolean, errorKey: StringResource?)`. Implement `PostLoginViewModel.onSignOutClick()` delegating to `AuthSession.logout()`. Register ViewModels in `authModule` using existing Koin Compose ViewModel pattern.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64</automated>
</verify>
<acceptance_criteria>
- `! grep -R 'Click me!' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`
- `grep -q 'collectAsState' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`
- `grep -q 'SplashScreen' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`
- `grep -q 'auth_welcome_format' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt`
- `! grep -R 'Zaloguj\\|Wyloguj\\|Witaj\\|Nie można\\|Coś poszło' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe --include='*.kt'`
- `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0
</acceptance_criteria>
<done>Auth gate UI compiles, uses resources, and has no dangling reference to a missing plan.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Manual iOS Authentik UAT</name>
<read_first>
- docs/authentik-setup.md
- .planning/phases/02-authentication-foundation/02-VALIDATION.md (Manual-Only Verifications)
</read_first>
<files>docs/authentik-setup.md, .planning/phases/02-authentication-foundation/02-07-SUMMARY.md</files>
<action>
Run automated gate first: `./gradlew check`.
Then perform the manual UAT from `docs/authentik-setup.md` on iOS simulator/device with the real Authentik provider:
1. Fresh install opens Splash then LoginScreen.
2. Tap `Zaloguj się przez Authentik`; hosted Authentik login opens and returns through `recipe://callback`.
3. App shows `Witaj, {displayName}!`.
4. Restart after access-token expiry or shortened token lifetime; app returns to authenticated screen without credentials.
5. Tap `Wyloguj się`; app returns to LoginScreen; restart does not silently authenticate.
6. `GET /api/v1/me` returns 200 with valid token and 401 without token or with wrong-audience token.
</action>
<verify>
<automated>./gradlew check</automated>
</verify>
<acceptance_criteria>
- `./gradlew check` exits 0
- Manual UAT result recorded in `.planning/phases/02-authentication-foundation/02-07-SUMMARY.md`
- If any UAT step fails, record exact step, observed behavior, logs with tokens redacted, and do not mark Phase 2 complete
</acceptance_criteria>
<what-built>Phase 2 end-to-end auth flow: Authentik login, secure token persistence, server /me, and logout UI.</what-built>
<how-to-verify>Follow the six UAT steps in the action block using the real Authentik provider configured from docs/authentik-setup.md.</how-to-verify>
<resume-signal>Type "approved" if UAT passes, or describe the failing step and observed behavior.</resume-signal>
<done>Automated tests are green and the user confirms fresh login, persisted session refresh, logout, and /api/v1/me behavior.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| UI -> AuthSession | User taps login/logout and triggers token-bearing flows |
| AuthSession -> UI | Auth errors are mapped to user-visible strings |
| Human UAT -> logs | Manual validation may inspect logs while tokens exist |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-07-01 | Information Disclosure | UI/log validation | mitigate | UAT summary must redact tokens and Authorization headers |
| T-02-07-02 | Information Disclosure | logout UX | mitigate | Logout button delegates to AuthSession.logout; UAT verifies no silent restore after relaunch |
| T-02-07-03 | Spoofing | login UX | mitigate | Button explicitly opens Authentik; AppAuth handles browser flow and callback |
| T-02-07-04 | Denial of Service | refresh UX | mitigate | Reopen-after-expiry UAT verifies transparent refresh path |
| T-02-07-05 | Tampering | raw strings | mitigate | All auth copy comes from Compose Resources, preventing ad hoc UI string drift |
</threat_model>
<verification>
Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`, then `./gradlew check`, then complete iOS Authentik UAT.
</verification>
<success_criteria>
The app visibly satisfies Phase 2 roadmap criteria: sign in, stay signed in, sign out, and prove server `/api/v1/me` works with valid/invalid tokens.
</success_criteria>
<output>
After completion, create `.planning/phases/02-authentication-foundation/02-07-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,189 @@
---
phase: 02-authentication-foundation
plan: 07
subsystem: auth
tags: [kmp, compose-multiplatform, material3, koin-viewmodel, compose-resources, auth-gate]
requires:
- phase: 02-authentication-foundation
provides: 02-06 AuthSession StateFlow, AuthState model, authModule Koin singletons
provides:
- SplashScreen / LoginScreen / PostLoginPlaceholderScreen Phase 2 auth gate
- LoginViewModel + LoginScreenState + PostLoginViewModel mapping AuthSession results to Compose Resources
- Compose Resources strings for the seven Phase 2 auth keys
- RecipeTheme Material 3 light/dark seed with primary `#3B6939` / `#A2D597`
affects: [phase-03-households]
tech-stack:
added:
- kotlinx-coroutines-test (commonTest only) for the multiplatform `runTest` runtime
patterns:
- "App.kt observes AuthSession.state via collectAsStateWithLifecycle and renders one of three screens; no manual navigation."
- "LoginViewModel.onSignInClick() returns the launched Job so commonTest can join() deterministically without dragging in a TestDispatcher."
- "ViewModels registered in authModule via org.koin.core.module.dsl.viewModel; consumed via koinViewModel<T>()."
- "All commonTest coroutine tests use kotlinx.coroutines.test.runTest so wasmJs can compile (runBlocking is JVM/Native-only)."
key-files:
created:
- composeApp/src/commonMain/composeResources/values/strings.xml
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt
- .planning/phases/02-authentication-foundation/deferred-items.md
modified:
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt
- composeApp/build.gradle.kts
- gradle/libs.versions.toml
key-decisions:
- "ViewModels registered in authModule (alongside AuthSession) instead of a new uiModule — keeps the single Koin module that owns AuthSession also owning its UI consumers."
- "LoginViewModel.onSignInClick() returns Job rather than swallowing it so tests deterministically join without a TestDispatcher; production callers ignore the returned Job."
- "AuthSession.initialize() is launched from a LaunchedEffect in App.kt rather than a Koin lifecycle hook; keeps Phase 2 startup explicit and easy to trace."
- "Pre-existing ./gradlew check failures (Android JVM SecureAuthStateStoreContractTest, ios SecureAuthStateStore ktlint) are out of scope for 02-07 and tracked in deferred-items.md per scope-boundary rule."
requirements-completed: [AUTH-01, AUTH-04, AUTH-05]
duration: 10m
completed: 2026-04-28
---
# Phase 02 Plan 07: Auth UI Gate Summary
**Phase 2 auth UI gate — SplashScreen / LoginScreen / PostLoginPlaceholderScreen wired to AuthSession via koinViewModel, with externalized Polish strings and a Material 3 seed theme.**
## Performance
- **Duration:** ~10 min (automated tasks)
- **Started:** 2026-04-28T15:31:20Z
- **Automated work completed:** 2026-04-28T15:41:31Z
- **Tasks completed:** 2 of 3 (Task 3 awaits manual iOS Authentik UAT)
- **Files created:** 9
- **Files modified:** 5
## Accomplishments
- Added all seven Phase 2 Compose Resources keys with the Polish scaffold copy from `02-UI-SPEC.md`.
- Added `RecipeTheme` with light/dark Material 3 schemes seeded by `#3B6939` / `#A2D597` and `isSystemInDarkTheme()`.
- Replaced the JetBrains template `App()` body with the auth-gate `when` over `AuthSession.state`, observing via `collectAsStateWithLifecycle` and kicking `AuthSession.initialize()` from `LaunchedEffect`.
- Implemented `SplashScreen`, `LoginScreen`, and `PostLoginPlaceholderScreen` using Material 3 stdlib only — no Scaffold, no Haze, all strings via `stringResource(Res.string.*)`.
- Implemented `LoginViewModel` (mapping AuthSession failures → `auth_error_*` `StringResource` keys, clearing stale errors on retry) and trivial `PostLoginViewModel.onSignOutClick()` delegating to `AuthSession.logout()`.
- Registered both ViewModels in `authModule` via `org.koin.core.module.dsl.viewModel`.
- Added `kotlinx-coroutines-test` to `commonTest` so the wasmJs target can compile coroutine tests (replacing JVM-only `runBlocking` with multiplatform `runTest` in both `LoginViewModelTest` and the existing `AuthSessionTest`).
## Task Commits
1. **Task 1 (RED): Compose Resources, theme seed, failing LoginViewModel tests**`466e4c7` (test)
2. **Task 2 (GREEN): Auth screens, ViewModels, App auth gate**`88f4898` (feat)
3. **Task 2 follow-up: switch commonTest to runTest for wasmJs compatibility**`570652c` (fix)
4. **Task 3 (manual UAT): pending — see Awaiting User UAT below**
## Files Created/Modified
### Created
- `composeApp/src/commonMain/composeResources/values/strings.xml` — Phase 2 auth strings (Polish scaffold).
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` — Material 3 seed theme.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt` — wordmark + progress.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt` — wordmark + button + inline error.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt``LoginScreenState` + `onSignInClick()` mapping.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt` — welcome + logout.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt``onSignOutClick()``AuthSession.logout()`.
- `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt` — five tests covering cancelled/network/unknown/success and clear-error-on-retry.
- `.planning/phases/02-authentication-foundation/deferred-items.md` — log of pre-existing failures.
### Modified
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` — auth-gate `when` body.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt``viewModel { ... }` registrations.
- `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt``runBlocking``runTest`.
- `composeApp/build.gradle.kts``commonTest` `kotlinx-coroutines-test` dependency.
- `gradle/libs.versions.toml` — added `kotlinx-coroutinesTest` library entry.
## Decisions Made
See frontmatter `key-decisions`.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 — Blocking] commonTest coroutine tests must use `runTest`, not `runBlocking`**
- **Found during:** Task 2 verification, when `./gradlew check` ran the wasmJs test target for the first time.
- **Issue:** `kotlinx.coroutines.runBlocking` is JVM/Native-only and breaks `:composeApp:compileTestKotlinWasmJs`. The pre-existing `AuthSessionTest` (committed in Plan 02-06) used the same pattern and was never wasmJs-tested — `02-06` only ran `:composeApp:jvmTest`. Phase 02-07's verification gate is the first one to catch it.
- **Fix:** Added `org.jetbrains.kotlinx:kotlinx-coroutines-test` to `commonTest`, switched both `AuthSessionTest` and the new `LoginViewModelTest` from `runBlocking` to `runTest`.
- **Files modified:** `composeApp/build.gradle.kts`, `gradle/libs.versions.toml`, `AuthSessionTest.kt`, `LoginViewModelTest.kt`.
- **Verification:** `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileTestKotlinWasmJs` exits 0.
- **Committed in:** `570652c`.
### Out-of-scope discoveries (not fixed; logged)
See `deferred-items.md`:
- `SecureAuthStateStoreContractTest` (Android JVM unit) fails on `master` HEAD before any 02-07 change — Android Keystore unavailable in plain JVM unit tests; needs Robolectric or `androidTest`.
- `composeApp/src/iosMain/.../SecureAuthStateStore.ios.kt:L31` ktlint `property-naming` violation pre-exists on `master`.
Both originate in Plans 02-04 / 02-05 and are out of scope for this UI plan per the executor scope-boundary rule.
## Issues Encountered
- `./gradlew spotlessApply` reformatted many pre-existing files unrelated to 02-07 (because the repo had pre-existing format drift). Those reformats were reverted before commit so the 02-07 commits stay scope-clean. Spotless's failure on the unrelated `SecureAuthStateStore.ios.kt` ktlint rule is logged in `deferred-items.md`.
## Known Stubs
None. The auth gate is fully wired end to end; all rendered text is sourced from Compose Resources, and ViewModels delegate to the real `AuthSession` Koin singleton.
The `PostLoginPlaceholderScreen` itself is a Phase 2 placeholder by design — Phase 3's `HouseholdGate` replaces it. This is documented in `02-UI-SPEC.md` and `02-CONTEXT.md` and is not a stub.
## Threat Flags
None beyond the plan's threat model. The new UI surfaces only render strings and dispatch to `AuthSession`; tokens are never logged or rendered (T-02-07-01). Logout (T-02-07-02) is the only state-changing action wired in `PostLoginViewModel`. Login button explicitly mentions Authentik (T-02-07-03). Refresh failures route silently to `LoginScreen` per `02-UI-SPEC.md`'s refresh-failure UX (T-02-07-04). All copy comes from `composeResources/values/strings.xml` (T-02-07-05).
## User Setup Required
**Manual iOS Authentik UAT (Task 3 — checkpoint:human-verify, blocking):**
Per `02-VALIDATION.md` § Manual-Only Verifications and `docs/authentik-setup.md`:
1. Fresh install opens Splash then `LoginScreen`.
2. Tap `Zaloguj się przez Authentik` — hosted Authentik login opens and returns through `recipe://callback`.
3. App shows `Witaj, {displayName}!`.
4. Restart after access-token expiry (or short token lifetime) — app returns to authenticated screen without credentials.
5. Tap `Wyloguj się` — app returns to LoginScreen; restart does not silently authenticate.
6. `GET /api/v1/me` returns 200 with valid token; 401 without token or with wrong-audience token.
Reply with `approved` to mark Phase 2 complete, or describe the failing step (with tokens redacted) so the gate can be re-opened.
## Verification
### Automated — passing
- `./gradlew :composeApp:jvmTest --tests "*LoginViewModelTest*"` — PASS (5 tests).
- `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileTestKotlinWasmJs` — PASS.
- Acceptance grep checks: `auth_sign_in_button` Polish copy present, `0xFF3B6939` in `RecipeTheme`, `auth_error_cancelled` referenced in `LoginViewModelTest`, no `Click me!` in `App.kt`, `collectAsState` + `SplashScreen` present in `App.kt`, `auth_welcome_format` in `PostLoginPlaceholderScreen`, no raw Polish strings in any `.kt` source under `dev/ulfrx/recipe/`.
### Automated — pre-existing failures (not introduced by 02-07; tracked in deferred-items.md)
- `:composeApp:testDebugUnitTest` — 2 failures in `SecureAuthStateStoreContractTest`.
- `:composeApp:spotlessKotlinCheck` — 1 ktlint violation in `SecureAuthStateStore.ios.kt`.
### Manual — pending
- iOS Authentik UAT (Task 3 — see User Setup Required above).
## Next Phase Readiness
Phase 3 (households) can now extend `AuthState.Authenticated.householdId` and replace `PostLoginPlaceholderScreen` with `HouseholdGate` without touching `AuthSession` or the auth-gate `when` (it already handles the `is Authenticated` branch).
## Self-Check: PASSED
- All listed created/modified files exist on disk.
- Commits `466e4c7`, `88f4898`, `570652c` exist in `git log`.
- Acceptance grep checks all pass (run inline above).
- `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileTestKotlinWasmJs` exits 0.
- Pre-existing failures unrelated to 02-07 are documented in `deferred-items.md` (verified via `git stash` reproduction on `master` HEAD).
---
*Phase: 02-authentication-foundation*
*Status: Tasks 1+2 complete; Task 3 (manual iOS Authentik UAT) awaiting user verification.*

View File

@@ -0,0 +1,237 @@
# Phase 2: Authentication Foundation - Context
**Gathered:** 2026-04-27
**Status:** Ready for planning
<domain>
## Phase Boundary
End-to-end OIDC + PKCE login to Authentik. App opens Authentik in the system browser via AppAuth, returns with tokens stored securely (Keychain on iOS, EncryptedSharedPreferences on Android), Ktor server validates JWTs via JWKS, JIT-provisions a user row by `sub`, and `GET /api/v1/me` returns the user. "Wyloguj się" wipes local tokens AND calls Authentik's RP-initiated `end_session_endpoint`. Token refresh runs transparently across launches.
**In scope:** OIDC client (AppAuth on iOS+Android, stubs on JVM/Wasm), token storage, token refresh, server JWT validation, JIT user provisioning, `users` table migration, `/api/v1/me` route, login + post-login screens with error handling, `docs/authentik-setup.md`.
**Out of scope (Phase 3):** Households, memberships, invites, household-scoped principal, household onboarding screen. Phase 2's post-login UI is a placeholder; `AuthSession.householdId` is always `null` until Phase 3 lands.
**Out of scope (Phase 4+):** Sync engine, outbox, household-scoped data tables. Phase 2 has no offline write path because there is no household-scoped data yet.
</domain>
<decisions>
## Implementation Decisions
### Client OIDC implementation
- **D-01:** **AppAuth on both mobile platforms.** iOS uses AppAuth-iOS via CocoaPod added to `iosApp/Podfile`; Android uses AppAuth-Android (`net.openid:appauth`). Symmetric `expect class OidcClient` in `composeApp/commonMain/.../auth/`, with `actual` impls in `iosMain` and `androidMain` wrapping each platform's AppAuth. Uses AppAuth's `OIDAuthState` / `AuthState` as the in-memory session shape behind the seam.
- **D-02:** **JVM (Desktop) `actual`: dev-mode env-var stub.** Reads `DEV_AUTH_TOKEN` env var (or hardcoded dev user fallback). Bypasses real OIDC. Desktop is a hot-reload dev tool per Phase 1 D-03, not a release surface — this stub exists to keep `./gradlew :composeApp:run` working without standing up the full Authentik flow on dev machines.
- **D-03:** **Wasm `actual`: `NotImplementedError("Wasm OIDC: v2")` stub.** Preserves `wasmJs` as a build target without committing to a browser-redirect implementation. If/when Wasm becomes a release surface, this gets replaced with a `window.location.href`-based browser-redirect flow (different code path from native AppAuth).
- **D-04:** **Coroutine bridge.** `OidcClient.login()` and `.refresh()` are `suspend` functions. iOS/Android `actual` impls use `suspendCancellableCoroutine` to bridge AppAuth's callback API. Cancellation cancels the underlying AppAuth request.
### Authentik provider configuration
- **D-05:** **Provider type: Public + PKCE S256.** Mobile apps are public clients per OAuth 2 RFC 8252 — no shippable secret. PITFALLS.md #8 enforces this.
- **D-06:** **Scopes requested: `openid profile email offline_access`.** `offline_access` is required for AUTH-04 (token refresh across launches); without it, Authentik may not issue a refresh token. `profile` + `email` populate `display_name` and `email` for JIT-provisioning.
- **D-07:** **`aud` claim shape pinned to single string equal to client_id.** Authentik can emit array OR string per provider config (PITFALLS.md #7). Pin to string in the provider config; Ktor `JWTAuth.withAudience(clientId)` validates against it. Document the pin in `docs/authentik-setup.md` and add an integration test that asserts wrong-`aud` → 401.
- **D-08:** **Signing alg: RS256.** Default for Authentik. Verify `kid` resolves via JWKS cache. Document in setup guide.
- **D-09:** **Redirect URI: custom URL scheme `recipe://callback`.** iOS: `CFBundleURLTypes` in `iosApp/iosApp/Info.plist`. Android: `<intent-filter>` with `android:scheme="recipe" android:host="callback"` in `composeApp/src/androidMain/AndroidManifest.xml`. AppAuth + PKCE state/nonce makes the theoretical interception attack non-exploitable. Universal Links / App Links explicitly deferred (see Deferred Ideas).
- **D-10:** **`docs/authentik-setup.md` is a Phase 2 deliverable.** Documents the exact provider config: Public + PKCE S256, redirect URIs registered (`recipe://callback`), scopes, audience pinned to single string, RS256 signing, JWKS endpoint URL. Goal: anyone (or future-you on a new homelab) can recreate the Authentik provider from scratch in ~5 minutes by following the doc.
### Configuration plumbing
- **D-11:** **Client OIDC config hardcoded in `shared/commonMain/Constants.kt`.** Constants: `OIDC_ISSUER` (e.g., `https://auth.<homelab>.tld/application/o/recipe/`), `OIDC_CLIENT_ID`, `OIDC_REDIRECT_URI` (`recipe://callback`). PITFALLS.md tech-debt table marks this "Acceptable: v1 single-environment only." Promote to BuildConfig-style Gradle injection only if a staging Authentik appears.
- **D-12:** **Server OIDC config via env vars in `application.conf`.** Variables: `OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_JWKS_URL` (optional — derive from issuer if absent). Matches Phase 1 D-16's `DATABASE_URL` pattern. Localhost defaults match Authentik in user's homelab.
### Token storage
- **D-13:** **Persistence: full AppAuth `AuthState` JSON blob via `multiplatform-settings`.** AppAuth's `AuthState.serialize()` returns a ~2KB JSON containing tokens + provider config + last error + registration response. Restoring across launches is one-line: `AuthState.jsonDeserialize(serialized)`. Settings backend: Keychain on iOS, EncryptedSharedPreferences on Android — both handled by `multiplatform-settings`'s platform-secure adapters.
- **D-14:** **iOS Keychain accessibility: `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`.** Standard for OAuth refresh tokens. Excluded from iCloud Keychain backup. Background refresh would work pre-unlock if v2 ever adds it; v1 has no background work but this doesn't hurt.
- **D-15:** **One AuthState blob per app install.** No per-user keying — the user is whoever last logged in. Logout deletes the blob entirely.
### Token refresh
- **D-16:** **Proactive refresh via AppAuth `performActionWithFreshTokens`.** Wrap every authenticated Ktor call in this. AppAuth refreshes if access token expiry is within its threshold (~60s). Returns a fresh access token to the caller; updates the persisted `AuthState`.
- **D-17:** **Reactive 401 fallback via Ktor `Auth { bearer { refreshTokens { ... } } }`.** Catches the rare case where proactive refresh missed (clock drift, mid-call expiry). Coalesces concurrent refreshes (single-flight is library-provided on both Ktor's plugin and AppAuth's `performActionWithFreshTokens`).
- **D-18:** **Refresh-failure UX: silent.** When refresh returns `invalid_grant` (revoked / expired / Authentik forgot us), `AuthSession.state` transitions `Authenticated → Unauthenticated`. App routes back to the login screen. No modal, no toast. Logged at `Kermit.w` for diagnostics.
### Logout
- **D-19:** **RP-initiated end-session.** "Wyloguj się" does two things atomically: (a) call Authentik's `end_session_endpoint` (per OIDC spec) with `id_token_hint`; (b) delete the persisted `AuthState` blob from secure storage. Order: end-session first, then local wipe — if end-session fails (network), still wipe locally so the user isn't stuck. Correct semantics for shared household devices: next "Zaloguj się" forces fresh credentials, doesn't silently SSO.
- **D-20:** **AppAuth's `EndSessionRequest` API drives this on both platforms.** Android: `AuthorizationService.performEndSessionRequest(...)`. iOS: `OIDExternalUserAgent` with the end-session endpoint.
### Server-side validation (carries forward from PITFALLS.md #7)
- **D-21:** **`install(Authentication) { jwt("authentik") { ... } }`** with explicit `verifier(jwkProvider, issuer)`, `.withIssuer(issuer)`, `.withAudience(clientId)`, `acceptLeeway(30)` (seconds), and validate-by-claims block that asserts `sub` is non-null. Provider name `"authentik"` is the route auth scope.
- **D-22:** **JWKS provider configuration.** `JwkProviderBuilder(issuerUrl).cached(10, 15, MINUTES).rateLimited(10, 1, MINUTES).build()`. Cache size 10 (one issuer × ~3 active keys with rotation headroom). Rate limit defends against pathological JWKS-thrashing during key rotation.
- **D-23:** **Audit-grade logging discipline.** Never log the `Authorization` header. Custom Ktor `CallLogging` filter redacts it. `Kermit` on the client never logs token bodies. Token-related debug uses `Authorization: Bearer <token>``Authorization: Bearer <redacted>`.
### Server data model + JIT provisioning
- **D-24:** **Phase 2 ships `V1__users.sql`** (Flyway migration). Schema:
```sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sub TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
display_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX users_sub_idx ON users(sub);
```
Phase 3 layers `V2__households_memberships_invites.sql` on top. **ROADMAP.md Phase 3 description gets a one-line edit:** drop `users` from "users, households, memberships, invites" → "households, memberships, invites".
- **D-25:** **JIT-provisioning logic.** On every authenticated request, the auth phase's `PrincipalResolver` does:
```sql
INSERT INTO users (sub, email, display_name)
VALUES (:sub, :email, :name)
ON CONFLICT (sub) DO UPDATE
SET email = EXCLUDED.email,
display_name = EXCLUDED.display_name,
updated_at = now()
RETURNING *;
```
Updates email/display_name on every login so claim drift (user changed email in Authentik) is captured. Returns the row so the route handler can use it. Phase 3's `PrincipalResolver` extends this with a household lookup.
- **D-26:** **Exposed DSL only, `newSuspendedTransaction`.** Per CLAUDE.md #5 and PITFALLS.md #5/#6. Phase 2 establishes the pattern: `newSuspendedTransaction(Dispatchers.IO) { ... }` for every coroutine-touching DB call. No DAO.
- **D-27:** **`/api/v1/me` route.** Behind `authenticate("authentik")`. Returns the JIT-resolved user row as a `MeResponse` DTO (lives in `shared/commonMain/.../shared/dto/`). Shape: `{ id: UUID, sub: String, email: String, displayName: String }`.
### Client AuthSession state model
- **D-28:** **Sealed `AuthState` shape, forward-compatible with Phase 3:**
```kotlin
sealed class AuthState {
data object Loading : AuthState()
data object Unauthenticated : AuthState()
data class Authenticated(
val user: User,
val householdId: HouseholdId? = null, // Phase 2: always null. Phase 3 fills.
) : AuthState()
}
```
Phase 2 always emits `Authenticated(user, householdId = null)`. Phase 3 widens the meaning of `householdId` (resolved from `/api/v1/me` extended response). No sealed-class refactor needed at Phase 2/3 boundary.
- **D-29:** **`AuthSession` is a Koin singleton in `authModule`.** Exposes `state: StateFlow<AuthState>`, `login()`, `logout()`, `getAccessToken(): String?`. Owns the AppAuth `AuthState` blob and its persistence via `multiplatform-settings`. Hot at `App()` start: deserializes persisted blob, transitions to `Loading → (Authenticated | Unauthenticated)` based on whether the refresh token is still valid.
- **D-30:** **Auth gate composable.** `App()` reads `AuthSession.state.collectAsState()` and routes:
- `Loading` → splash placeholder
- `Unauthenticated` → `LoginScreen`
- `Authenticated` → `PostLoginPlaceholderScreen` (Phase 2) → `HouseholdGate` (Phase 3 replaces this)
### Login + post-login UI
- **D-31:** **Login screen: minimal.** App name + "Zaloguj się przez Authentik" button. Centered, plenty of breathing room (matches PROJECT.md "calmer typography" direction). No tagline, no marketing copy. Polish strings via Compose Resources scaffold (real i18n pass is Phase 11).
- **D-32:** **Login error states (inline below the button):**
- User cancels system browser → "Logowanie anulowane. Spróbuj ponownie." (Polish scaffold copy; refined in Phase 11)
- Network unreachable / Authentik down → "Nie można połączyć z Authentik. Sprawdź połączenie."
- Token exchange / validation failure → "Coś poszło nie tak. Spróbuj ponownie."
- Inline (snackbar-style) error message; button stays enabled for retry.
- **D-33:** **Post-login placeholder: `Witaj, {displayName}!` + "Wyloguj się" button.** Visually confirms login worked end-to-end and lets you exercise logout. Phase 3 replaces this entire screen with the household onboarding flow.
### Strings (Polish, scaffold)
- **D-34:** **All user-facing strings in Compose Resources from day 1** (CLAUDE.md #9). Keys: `auth_sign_in_button`, `auth_sign_out_button`, `auth_welcome_format`, `auth_error_cancelled`, `auth_error_network`, `auth_error_unknown`. Polish copy is scaffold-quality; Phase 11 does the polished pass with proper plural forms and tone.
### Claude's Discretion
- Exact `Koin` `authModule` Definition Style (`single<AuthSession> { ... }` vs `single { AuthSession(get(), get()) }`).
- Ktor Client `Auth { bearer { ... } }` configuration boilerplate — refresh-tokens block, token loader, `sendWithoutRequest` policy.
- Whether `MeResponse` DTO and `User` domain model are the same type in `shared/` or separate (DTO + domain mapper).
- Concrete `kotlinx.uuid` vs. `kotlin.uuid.Uuid` (Kotlin 2.0+) for the `User.id` type — pick whichever pairs cleanly with Exposed UUID columns and `kotlinx.serialization`.
- Whether the AppAuth-iOS CocoaPod is added via `cocoapods { pod("AppAuth") { ... } }` Gradle DSL or via a hand-written Podfile in `iosApp/`. Either is fine; Gradle DSL is the JetBrains-recommended pattern for KMP-managed pods.
- Splash placeholder visual (during `Loading` state) — solid color, app name, or progress indicator. Phase 11 polishes.
- Whether `OIDC_ISSUER` ends with a trailing slash (Authentik is sensitive here per PITFALLS.md #8). Pin and document either way.
- Logger tag/level for AppAuth events (debug/info on iOS — bridged via Kermit's iOS sink).
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Product + scope anchors
- `.planning/PROJECT.md` — Locked tech stack (§ Key Decisions), particularly the Authentication & identity, Mobile OIDC, and Token validation rows
- `.planning/REQUIREMENTS.md` — AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06 are the in-scope requirements for this phase
- `.planning/ROADMAP.md` § "Phase 2: Authentication Foundation" — phase goal + 5 success criteria. **NOTE:** Phase 3's description in ROADMAP gets a one-line edit per D-24 — `users` is removed from Phase 3's table list and lands in Phase 2 instead.
### Architecture + pitfalls (load-bearing)
- `.planning/research/ARCHITECTURE.md` — § Component Responsibilities (AuthSession, Ktor route, PrincipalResolver), § Pattern 3 (household-scope enforcement — Phase 2 only does the auth principal layer; household scope is Phase 3), § Build Order Implication ("auth + a working Ktor skeleton that echoes an authenticated principal" is the load-bearing first feature)
- `.planning/research/PITFALLS.md` — Phase 2 must prevent: **Pitfall #7** (Ktor JWT — audience, issuer, leeway, JWKS cache; D-21/D-22 directly mitigate); **Pitfall #8** (OIDC redirect URI + missing PKCE; D-05/D-09 mitigate). Tech-debt table row "Hardcoded OIDC issuer/client_id in shared/commonMain" is the explicit acceptance for D-11.
- `.planning/research/SUMMARY.md` § "Phase 2: Authentication foundation" — research-driven rationale for AppAuth + ASWebAuth + ktor-server-auth-jwt path; § "Gaps to Address" lists "Authentik-specific OIDC flow details" and "Mobile OIDC library choice for iOS" — both resolved by this CONTEXT.md.
### Project conventions
- `CLAUDE.md` — Non-negotiable conventions. Items #5 (Exposed DSL only), #6 (`newSuspendedTransaction`), #8 (`shared/commonMain` stays light — only `MeResponse` DTO crosses), #9 (strings externalized day 1) all touch Phase 2.
- `.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md` — D-14 (Koin `appModule` placeholder; Phase 2 adds `authModule`), D-15 (Kermit logger available for auth-flow debug), D-16 (server `application.conf` env-var pattern; Phase 2 extends with `OIDC_*` vars), D-19 (`shared/commonMain` purity rule).
### External docs to consult during research/planning
- AppAuth-Android: https://github.com/openid/AppAuth-Android — `OIDAuthState` lifecycle, `AuthorizationService.performTokenRequest`, `performEndSessionRequest`
- AppAuth-iOS: https://github.com/openid/AppAuth-iOS — `OIDAuthState`, `OIDExternalUserAgent`, CocoaPod integration with KMP
- Ktor `Auth { bearer { refreshTokens { ... } } }`: https://ktor.io/docs/client-bearer-auth.html
- Ktor `ktor-server-auth-jwt` + JwkProviderBuilder: https://ktor.io/docs/server-jwt.html
- Authentik OIDC provider docs: https://docs.goauthentik.io/docs/providers/oauth2/ (provider config, scopes, RP-initiated logout, `aud` shape)
No external ADRs or specs yet — project is greenfield; decisions flow from PROJECT.md + research/ files + this CONTEXT.md.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable assets (what Phase 1 left in place)
- **`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`** — comment literally reads `// Phase 2 adds authModule`. Ship `authModule = module { single { AuthSession(...) }; single { OidcClient }; ... }` and wire into the `appModule` `modules(...)` list.
- **`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt`** — `initKoin()` already callable. iOS-side bridge `KoinIosKt.doInitKoin()` already wired in `iOSApp.swift`. Phase 2 adds dependencies, not bootstrap code.
- **`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`** — current `App()` is a template button-and-greeting. Phase 2 replaces the body with the auth-gated routing (`Loading → LoginScreen → PostLoginPlaceholder`). Existing `MaterialTheme { ... }` wrapper stays.
- **`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/`** — Kermit bootstrap exists (Phase 1 D-15). Auth flow uses `Logger.withTag("auth")` for OIDC events.
- **`server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`** — `install(ContentNegotiation) { json() }` and `Database.migrate(this)` already wired. Phase 2 adds `install(Authentication) { jwt("authentik") { ... } }` between ContentNegotiation and `configureRouting()`. New routes go in a `configureAuth()` function alongside `configureRouting()`.
- **`server/src/main/kotlin/dev/ulfrx/recipe/Database.kt`** — Flyway already wired and runs on startup (Phase 1 D-16). Phase 2 drops `V1__users.sql` into `server/src/main/resources/db/migration/`. Database connection is fail-loud per Phase 1 — Phase 2 inherits this.
- **`shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/`** — empty package scaffold ready (Phase 1 D-19). Phase 2 lands `User` (or `MeResponse`) DTO + `Constants.kt` (with `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_REDIRECT_URI`).
- **`gradle/libs.versions.toml`** — Koin/Kermit/Flyway/Postgres/Ktor catalog entries exist. **Phase 2 ADDS:** `multiplatform-settings` + `multiplatform-settings-no-arg` (or coroutines extension), `ktor-server-auth`, `ktor-server-auth-jwt`, `appauth-android` (`net.openid:appauth`), AppAuth-iOS via CocoaPod. Plus a `kotlinx-uuid` (or stdlib `kotlin.uuid` if Kotlin 2.3 lands stable) library if not already covered for the `User.id` UUID type.
### Established patterns Phase 2 must respect
- **JetBrains template style** — plugin application via aliases inside `recipe.*` convention plugins (Phase 1 D-06D-09). Phase 2's `composeApp/build.gradle.kts` does NOT add direct alias references — adds to the convention plugins or to the module's existing dependency block.
- **JVM toolchain split** — JVM 21 for server/desktop/`shared/jvm`; JVM 11 for Android (Phase 1 D-08). Auth code in `composeApp/commonMain` compiles to both; ensure no JVM-21-only API leaks into commonMain.
- **`./gradlew check` is the local gate** (Phase 1 D-13). Phase 2's auth integration tests run under `:server:test`. Client unit tests under `:composeApp:commonTest`.
- **Server config: `application.conf` reading env vars with localhost defaults** (Phase 1 D-16). `OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_JWKS_URL` follow the same pattern.
### Integration points
- **iOS Info.plist** — `iosApp/iosApp/Info.plist` needs `CFBundleURLTypes` block registering `recipe://` scheme. AppAuth-iOS ATS exception NOT needed for the homelab (use a real cert per PITFALLS.md "Looks Done But Isn't" checklist).
- **Android manifest** — `composeApp/src/androidMain/AndroidManifest.xml` needs `<intent-filter>` on AppAuth's `RedirectUriReceiverActivity` (or your own activity declared per AppAuth-Android docs) for `android:scheme="recipe" android:host="callback"`.
- **iOSApp.swift** — current `KoinIosKt.doInitKoin()` runs in `init`. AppAuth-iOS's `currentAuthorizationFlow` global lives in the SwiftUI app and must receive callbacks from `application(_:open:options:)` or the SwiftUI `.onOpenURL { }` modifier. Add this wiring alongside the existing Koin init.
- **Phase 3 hand-off seam** — `AuthState.Authenticated` carries a nullable `householdId`. Phase 3's onboarding flow updates this via a yet-to-exist `AuthSession.onHouseholdEstablished(HouseholdId)` method. Phase 2 doesn't expose this method but the state model is ready.
### What must NOT change in Phase 2
- Package namespace `dev.ulfrx.recipe` (CLAUDE.md, Phase 1 D-20).
- Phase 1's iOS binary flags in `gradle.properties` (D-18).
- Phase 1's convention plugins (`recipe.*`) — they're applied as-is; Phase 2 adds module-level dependencies, not new conventions.
- `shared/commonMain` purity (D-19) — only DTOs cross. No Ktor Client, no AppAuth, no `multiplatform-settings` imports.
</code_context>
<specifics>
## Specific Ideas
- **"Wyloguj się" must mean it.** Local-only logout is a junk feature on a shared household device. RP-initiated end-session is the only logout that fulfills the user's expectation when they hand the phone to their partner.
- **AppAuth on both platforms is the symmetry win.** User is new to KMP/CMP idioms; symmetric `AuthState` shape across iOS and Android means one mental model. Hand-rolled was an option but the asymmetry tax (AppAuth on Android, custom on iOS) costs more than the dependency saves.
- **`AuthState.Authenticated(user, householdId: HouseholdId? = null)` is the explicit forward-compat decision.** Phase 3 is literally next; baking the field in now saves a sealed-class refactor across every call-site. This is the one allowed instance of "modeling something Phase 2 doesn't use" — justified by Phase 2/3 adjacency.
- **Phase 2 owns `users.sql`.** The auth phase owning the auth-principal table is the clean boundary; Phase 3 layers households+memberships+invites on top. ROADMAP edit is a single line (Phase 3 description: drop `users` from the list).
- **Token storage: full AppAuth `AuthState` blob, not hand-rolled.** AppAuth's serialized blob makes refresh "just work" across launches. The privacy concern (extra metadata stored in Keychain) is academic for a personal app. Hand-rolling token-only storage is the kind of "library handles this for you, don't reinvent it" trap to avoid as a KMP newcomer.
- **`docs/authentik-setup.md` is non-optional.** The provider config is the single most fragile piece of Phase 2 — if `aud` is wrong, JWKS URL is wrong, scopes are missing, or PKCE is forgotten, you get silent 401s with no useful error. Documenting it makes Phase 2 reproducible.
</specifics>
<deferred>
## Deferred Ideas
- **Universal Links / App Links** — rejected for v1; revisit only if (a) app gains broader distribution beyond the household, or (b) Apple/Google deprecate custom schemes for OIDC redirects (no signal of this in 2026).
- **BuildConfig-style Gradle injection of OIDC config** — Constants.kt is fine for v1 single-environment per PITFALLS.md tech-debt acceptance. Promote when a staging Authentik becomes a real need (estimated never for this app's lifetime).
- **Real Desktop OIDC** — JVM target gets a `DEV_AUTH_TOKEN` stub. If Desktop ever becomes a release surface (currently scoped to dev tool only per Phase 1 D-03), implement loopback-redirect OIDC: open system browser to Authentik, AppAuth-Java equivalent or hand-roll a tiny localhost:N HTTP listener to capture the code.
- **Wasm OIDC implementation** — `wasmJs` target gets `NotImplementedError` stub. If/when Wasm becomes a release surface, implement browser-redirect OIDC: `window.location.href = authUrl`, handle `code` param on return, store tokens in `sessionStorage`. Different code path from native AppAuth — won't reuse current `OidcClient` actuals.
- **"Wyloguj się i zapomnij sesję" two-tier logout** — current single "Wyloguj się" is RP-initiated. If a workflow emerges where users want fast re-login after intentional logout (testing, account switching), add a second menu item for local-only logout.
- **Background token refresh** — v1 has no background work. Refresh runs proactively on the next authenticated call. If/when background sync is added (PROJECT.md v2 SYNC2-01 SSE-based sync), Keychain accessibility may need re-evaluation.
- **Apple Sign-in as a first-class button** — explicitly out of scope per PROJECT.md / REQUIREMENTS.md. Authentik can federate Apple Sign-in upstream if ever wanted.
- **Per-user persisted `AuthState`** — D-15 keys the AuthState blob globally (not per-user). Multi-account on a single device is out of scope; one user per install is the v1 model.
- **Modal/toast for refresh-failure UX** — Phase 2 ships silent transition. If user complaints emerge ("why was I logged out without warning?"), add a toast on the login screen.
- **Authentik provisioning automation** — `docs/authentik-setup.md` is manual. A Terraform/Ansible playbook for the homelab Authentik is post-v1.
- **JWT validation tests at the Authentik-emit level** — Phase 2 ships unit tests with hand-crafted JWTs (using a test JWKS). Integration tests against a real Authentik instance are deferred to Phase 11 (deployment) where the homelab Authentik is the test target.
</deferred>
---
*Phase: 02-authentication-foundation*
*Context gathered: 2026-04-27*

View File

@@ -0,0 +1,185 @@
# Phase 2: Authentication Foundation - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in `02-CONTEXT.md` — this log preserves the alternatives considered.
**Date:** 2026-04-27
**Phase:** 02-authentication-foundation
**Areas discussed:** iOS OIDC wrapper approach, Redirect URI + Authentik provider config, Token lifecycle (storage/refresh/logout), Phase 2/3 boundary + login UX shape
---
## iOS OIDC wrapper approach
**Original question:** "How should iOS speak OIDC + PKCE to Authentik?"
| Option | Description | Selected |
|--------|-------------|----------|
| AppAuth on both platforms | AppAuth-Android + AppAuth-iOS via CocoaPod. Symmetric expect/actual seam. Battle-tested, library-managed refresh, PKCE built-in. | ✓ |
| Hand-rolled ASWebAuthenticationSession wrapper | Thin Swift wrapper + Kotlin-side PKCE/token-exchange. Smallest deps; ~250 LOC owned. | |
| Defer to researcher — evaluate community KMP libs | Have researcher survey 2026 KMP OIDC library landscape first. | |
| Hand-rolled iOS, AppAuth Android | Asymmetric. Avoids CocoaPods on iOS. | |
**User's clarification:** Asked whether AppAuth would block Desktop and Wasm support; questioned whether to abandon Wasm.
**Reframed answer:** Native OIDC is platform-specific regardless of choice — AppAuth doesn't make Desktop/Wasm worse. Decision: AppAuth on mobile + dev-mode env-var stub (`DEV_AUTH_TOKEN`) for Desktop + `NotImplementedError` stub for Wasm. Don't abandon Wasm yet — cost of preserving (~5-30 LOC stubs per platform-touching phase) is much smaller than cost of resurrecting it later. Revisit only if stubbing tax compounds painfully.
**User's choice:** AppAuth on both platforms.
**Notes:** User's pushback on cross-target implications was correct and surfaced a load-bearing decision (Desktop/Wasm stubs). Recorded as D-01 through D-04 in CONTEXT.md.
---
## Redirect URI + Authentik provider config
### Sub-question 1: Redirect URI scheme
| Option | Description | Selected |
|--------|-------------|----------|
| Custom URL scheme `recipe://callback` | iOS Info.plist + Android intent-filter. ~10 lines. AppAuth + PKCE state/nonce makes interception non-exploitable. | ✓ |
| Universal Links / App Links via HTTPS | Requires hosting apple-app-site-association + assetlinks.json on homelab. Cert SHA pinning. Cryptographically tied to domain. | |
| Custom now, Universal Links later if needed | Same as option 1 for v1; documented in deferred. | |
**User's choice:** Custom URL scheme `recipe://callback`.
### Sub-question 2: Client OIDC config location
| Option | Description | Selected |
|--------|-------------|----------|
| Hardcoded in shared/commonMain/Constants.kt | Single source of truth. Per PITFALLS.md "acceptable v1 single-environment only" tech-debt note. | ✓ |
| Gradle property → BuildConfig-style generated Kotlin | Build-time injection; supports dev/staging/prod variants. | |
| Hybrid: hardcoded defaults, Gradle override available | Constants with Gradle-property overrides. | |
**User's choice:** Hardcoded in `shared/commonMain/Constants.kt`.
### Sub-question 3: OIDC scopes
| Option | Description | Selected |
|--------|-------------|----------|
| openid profile email offline_access | Standard mobile-app OIDC scope set. JIT-provisioning gets sub + email + display_name + refresh token. | ✓ |
| openid email offline_access (no profile) | Drops display_name; UI shows email everywhere. | |
| openid offline_access (minimal) | Sub + refresh token only; no email or name. | |
**User's choice:** `openid profile email offline_access`.
**Notes:** No-fork recommendations also recorded: pin Authentik `aud` to single client_id string; ship `docs/authentik-setup.md` as a Phase 2 deliverable. RS256 signing alg confirmed (Authentik default, matches PITFALLS.md #7 expectation).
---
## Token lifecycle (storage, refresh, logout)
### Sub-question 1: Storage backend
| Option | Description | Selected |
|--------|-------------|----------|
| Full AppAuth AuthState blob, AfterFirstUnlockThisDeviceOnly | ~2KB JSON via multiplatform-settings. AppAuth's `.serialize()`/`.jsonDeserialize()`. iCloud-Keychain-excluded. | ✓ |
| Just access + refresh + expiry, AfterFirstUnlockThisDeviceOnly | ~200 bytes explicit fields. We own the deserialization. | |
| Full AppAuth AuthState blob, WhenUnlockedThisDeviceOnly | Stricter accessibility; blocks pre-unlock work (none in v1, but blocks future background sync). | |
**User's choice:** Full AppAuth AuthState blob, AfterFirstUnlockThisDeviceOnly.
### Sub-question 2: Refresh policy
| Option | Description | Selected |
|--------|-------------|----------|
| Proactive (AppAuth performActionWithFreshTokens) + reactive 401 fallback | Both layers. Library-provided single-flight on each. UX: refresh is invisible. | ✓ |
| Reactive only (Ktor bearer plugin) | Simpler. One wasted round-trip per expiry boundary. | |
| Proactive only (AppAuth, no Ktor bearer) | Skips Ktor's plugin. No clock-drift recovery. | |
**User's choice:** Proactive + reactive 401 fallback.
### Sub-question 3: Refresh-failure UX
| Option | Description | Selected |
|--------|-------------|----------|
| Silent — transition to Unauthenticated, return to login | No modal, no toast. Cleanest UX. | ✓ |
| Surfaced — modal "Twoja sesja wygasła, zaloguj się ponownie" | Explicit dialog before returning to login. | |
| Surfaced as toast on login screen | Silent transition + non-blocking snackbar. | |
**User's choice:** Silent.
### Sub-question 4: Logout semantics
| Option | Description | Selected |
|--------|-------------|----------|
| RP-initiated end-session | Wipe local tokens AND call Authentik's end_session_endpoint with id_token_hint. Forces fresh credentials on next login. | ✓ |
| Local-only token wipe | Authentik session persists; next login silently SSO's. | |
| Both — local default + "forget session" as long-press / settings option | Two-tier UX. Overkill for v1. | |
**User's choice:** RP-initiated end-session.
**Notes:** User accepted all four recommendations without challenge. Decisions recorded as D-13 through D-20 in CONTEXT.md.
---
## Phase 2/3 boundary + login UX shape
### Sub-question 1: Server schema split
| Option | Description | Selected |
|--------|-------------|----------|
| Phase 2 owns V1__users.sql; Phase 3 layers V2__households_memberships_invites.sql | Auth phase owns auth-principal table. JIT-provisioning writes a real row in Phase 2. ROADMAP Phase 3 description gets a one-line edit (drop `users`). | ✓ |
| Phase 3 ships V1__init.sql with everything; Phase 2 returns JWT-derived user | Single migration in Phase 3. Phase 2 doesn't persist; SC#5 gets rewritten. | |
| Phase 2 ships V1__users.sql with JIT-insert wired but table only used in Phase 3 | Schema lands but doesn't see traffic until Phase 3. Same complexity, no win. | |
**User's choice:** Phase 2 owns `V1__users.sql`; Phase 3 layers `V2`.
### Sub-question 2: AuthSession state shape
| Option | Description | Selected |
|--------|-------------|----------|
| Forward-compat: Authenticated(user, householdId: HouseholdId?) — null in Phase 2 | Carries Phase 3's needs from day 1. No sealed-class refactor at Phase 2/3 boundary. Mild forward-compat justified by adjacency. | ✓ |
| Phase 2 minimal: Authenticated(user) only | Strict YAGNI. Phase 3 widens the sealed shape; refactor cost is small. | |
**User's choice:** Forward-compat with nullable `householdId`.
### Sub-question 3: Login screen shape
| Option | Description | Selected |
|--------|-------------|----------|
| Minimal: app name + "Zaloguj się przez Authentik" button | Centered, no marketing copy. Inline error states for cancelled/network/exchange failures. | ✓ |
| Branded: app name + tagline + button + disclosure | Adds tagline + "Otworzy się w przeglądarce" disclosure. | |
| Stub: just a button labeled "login" | Bare-minimum; Phase 11 polishes. | |
**User's choice:** Minimal app name + button.
### Sub-question 4: Post-login UI in Phase 2
| Option | Description | Selected |
|--------|-------------|----------|
| Placeholder "Witaj, {displayName}!" + Wyloguj button | Confirms login worked end-to-end. Lets you exercise logout. Phase 3 replaces wholesale. | ✓ |
| Empty state "Brak gospodarstwa" + Wyloguj button | Forward-compat: this IS Phase 3's "no household yet" state. | |
| Just route back to login screen with token persisted | No post-login UI; verify via /api/v1/me curl. | |
**User's choice:** Placeholder welcome screen.
**Notes:** All four sub-questions accepted recommendations. Decisions recorded as D-24 through D-33 in CONTEXT.md.
---
## Claude's Discretion
The following implementation details were left to Claude's judgment during planning/execution:
- Exact Koin `authModule` definition style (`single<T> { ... }` vs `single { T(get(), ...) }`)
- Ktor Client `Auth { bearer { ... } }` plugin configuration boilerplate
- Whether `MeResponse` DTO and `User` domain model are unified or separate
- UUID library choice for `User.id` (`kotlinx.uuid` vs `kotlin.uuid.Uuid` if Kotlin 2.3 is stable)
- AppAuth-iOS CocoaPod integration via Gradle DSL (`cocoapods { pod("AppAuth") }`) vs hand-written Podfile
- Splash placeholder visual during `Loading` state
- `OIDC_ISSUER` trailing-slash convention (pin and document)
- Logger tag/level for AppAuth events
## Deferred Ideas
The following ideas surfaced during discussion and were noted for future phases or v2:
- Universal Links / App Links (deferred unless distribution broadens or custom schemes get deprecated)
- BuildConfig-style Gradle config injection (defer until staging Authentik is a real need)
- Real Desktop OIDC (deferred unless Desktop becomes a release surface)
- Wasm OIDC implementation (deferred to v2; native AppAuth path won't reuse)
- Two-tier logout ("forget session" long-press)
- Background token refresh
- Apple Sign-in first-class button (PROJECT.md says Authentik federates upstream)
- Per-user persisted AuthState (multi-account is post-v1)
- Modal/toast for refresh-failure UX (revisit if users complain about silent logout)
- Authentik provisioning automation (Terraform/Ansible — post-v1)
- Integration tests against real Authentik (deferred to Phase 11 deployment)

View File

@@ -0,0 +1,815 @@
# Phase 2: Authentication Foundation - Pattern Map
**Mapped:** 2026-04-27
**Files analyzed:** 56 new/modified files or file groups
**Analogs found:** 48 / 56
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `gradle/libs.versions.toml` | config | build-config | `gradle/libs.versions.toml` | exact |
| `shared/build.gradle.kts` | config | build-config | `server/build.gradle.kts` + `shared/build.gradle.kts` | role-match |
| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt` | config | transform | `shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt` | role-match |
| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt` | model | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` `Health` DTO | partial |
| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt` | model | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` `Health` DTO | partial |
| `shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt` | test | transform | `shared/src/commonTest/kotlin/dev/ulfrx/recipe/SharedCommonTest.kt` | role-match |
| `server/build.gradle.kts` | config | build-config | `server/build.gradle.kts` | exact |
| `server/src/main/resources/application.conf` | config | request-response | `server/src/main/resources/application.conf` | exact |
| `server/src/main/resources/db/migration/V1__users.sql` | migration | CRUD | `server/src/main/resources/db/migration/.gitkeep` + `Database.kt` Flyway path | partial |
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt` | config | request-response | `server/src/main/resources/application.conf` + `Database.kt` config reads | role-match |
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` | middleware | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` plugin install pattern | role-match |
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt` | service | CRUD | `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` fail-loud DB boundary | partial |
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt` | model | CRUD | `V1__users.sql` planned migration + Exposed DSL decision | no existing analog |
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt` | route | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` `configureRouting()` | exact |
| `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` | controller | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` | exact |
| `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` | service | file-I/O | `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` | exact |
| `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtAuthTest.kt` | test | request-response | `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` | exact |
| `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt` | test utility | transform | `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` | partial |
| `composeApp/build.gradle.kts` | config | build-config | `composeApp/build.gradle.kts` | exact |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt` | service | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt` expect-free common seam style | partial |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt` | model | event-driven | `shared` DTO style + `kotlin.test` examples | partial |
| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` | service | event-driven | `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt` | role-match |
| `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` | service | event-driven | `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt` | role-match |
| `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt` | service | transform | `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt` | role-match |
| `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt` | service | transform | `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt` | role-match |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt` | model | event-driven | `shared` model/test shape | partial |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt` | service | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` Koin singleton hook | role-match |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/TokenStore.kt` | service | file-I/O | `tools/verify-shared-pure.sh` persistence-boundary guard | partial |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt` | service | request-response | no Ktor client analog; use research Ktor bearer pattern | no existing analog |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt` | service | request-response | `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` client GET pattern | role-match |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt` | provider | dependency-injection | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | exact |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | provider | dependency-injection | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | exact |
| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SettingsFactory.android.kt` | service | file-I/O | `MainApplication.kt` Android context injection | role-match |
| `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SettingsFactory.ios.kt` | service | file-I/O | `KoinIos.kt` iOS actual bridge | partial |
| `composeApp/src/*Main/kotlin/dev/ulfrx/recipe/auth/HttpClientEngine.*.kt` | utility | request-response | platform `main.kt` target-specific files | role-match |
| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt` | controller | event-driven | `MainActivity.kt` | exact |
| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt` | provider | dependency-injection | `MainApplication.kt` | exact |
| `composeApp/src/androidMain/AndroidManifest.xml` | config | event-driven | `AndroidManifest.xml` | exact |
| `iosApp/iosApp/Info.plist` | config | event-driven | `Info.plist` | exact |
| `iosApp/iosApp/iOSApp.swift` | controller | event-driven | `iOSApp.swift` | exact |
| `iosApp/Podfile` | config | build-config | no existing Podfile; use `composeApp/build.gradle.kts` iOS framework block | no existing analog |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` | component | transform | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` `MaterialTheme` wrapper | exact |
| `composeApp/src/commonMain/composeResources/values/strings.xml` | config | transform | `composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml` | partial |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt` | component | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | role-match |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt` | component | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | role-match |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt` | controller | event-driven | no ViewModel analog; use Koin/viewmodel deps and UI-SPEC method-per-action contract | no existing analog |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt` | component | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | role-match |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt` | controller | event-driven | no ViewModel analog; use same shape as `LoginViewModel` | no existing analog |
| `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt` | test | event-driven | `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ComposeAppCommonTest.kt` | role-match |
| `docs/authentik-setup.md` | docs | manual-UAT | `README.md` local development pattern if present; otherwise phase docs style | partial |
## Pattern Assignments
### Shared DTO + Constants Files
Applies to:
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt`
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt`
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt`
- `shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt`
**Analog:** `shared/build.gradle.kts`, `shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt`, `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`, `shared/src/commonTest/kotlin/dev/ulfrx/recipe/SharedCommonTest.kt`
**Shared purity pattern** (`shared/build.gradle.kts` lines 16-20):
```kotlin
sourceSets {
commonMain.dependencies {
// Phase 1: intentionally empty. Domain models + DTOs land Phase 2+.
// D-19 / INFRA-06: No Ktor, Compose, SQLDelight, Koin, or Kermit here - EVER.
}
}
```
**Public constant pattern** (`shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt` lines 1-3):
```kotlin
package dev.ulfrx.recipe
public const val SERVER_PORT: Int = 8080
```
**Serializable DTO pattern** (`server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` lines 12, 19-22):
```kotlin
import kotlinx.serialization.Serializable
@Serializable
private data class Health(
val status: String,
)
```
**Test skeleton pattern** (`shared/src/commonTest/kotlin/dev/ulfrx/recipe/SharedCommonTest.kt` lines 1-10):
```kotlin
package dev.ulfrx.recipe
import kotlin.test.Test
import kotlin.test.assertEquals
class SharedCommonTest {
@Test
fun example() {
assertEquals(3, 1 + 2)
}
}
```
**Planner note:** New shared symbols must be `public` because `shared` has `explicitApi()` enabled at `shared/build.gradle.kts` lines 9-10. Keep only Kotlin stdlib and `kotlinx.serialization` imports in shared DTOs.
### Gradle Catalog + Module Build Files
Applies to:
- `gradle/libs.versions.toml`
- `shared/build.gradle.kts`
- `server/build.gradle.kts`
- `composeApp/build.gradle.kts`
**Analog:** existing build files
**Version catalog organization** (`gradle/libs.versions.toml` lines 1-24, 27-43, 68-79):
```toml
[versions]
kotlin = "2.3.20"
kotlinx-serialization = "1.7.3"
ktor = "3.4.1"
[libraries]
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
ktor-serverCore = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" }
[plugins]
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
```
**Server dependency pattern** (`server/build.gradle.kts` lines 1-8, 27-39):
```kotlin
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.ktor)
alias(libs.plugins.flywayPlugin)
application
id("recipe.quality")
}
dependencies {
implementation(libs.ktor.serverCore)
implementation(libs.ktor.serverNetty)
implementation(libs.ktor.serverContentNegotiation)
implementation(libs.ktor.serializationKotlinxJson)
implementation(projects.shared)
testImplementation(libs.ktor.serverTestHost)
testImplementation(libs.kotlin.testJunit)
}
```
**Compose dependency pattern** (`composeApp/build.gradle.kts` lines 48-69):
```kotlin
sourceSets {
commonMain.dependencies {
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.composeViewmodel)
implementation(libs.kermit)
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.components.resources)
implementation(projects.shared)
}
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
implementation(libs.koin.android)
}
}
```
**No version literal guard** (`tools/verify-no-version-literals.sh` lines 1-20):
```bash
VIOLATIONS=$(grep -rn -E 'version[[:space:]]*=[[:space:]]*"[0-9]' --include='*.gradle.kts' . 2>/dev/null \
| grep -v 'build-logic/build.gradle.kts' \
| grep -vE ':[0-9]+:version[[:space:]]*=[[:space:]]*"[0-9]' \
|| true)
```
**Planner note:** Put new dependency versions in `gradle/libs.versions.toml`; do not add library versions directly in `*.gradle.kts` except for explicitly justified test-only literals already called out by the plan.
### Client Koin + Logging + Auth Module
Applies to:
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt`
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`
- Auth singleton wiring in `AuthSession.kt`
**Analog:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`, `Koin.kt`, `Logging.kt`, platform bootstraps
**Koin module placeholder pattern** (`AppModule.kt` lines 1-9):
```kotlin
package dev.ulfrx.recipe.di
import org.koin.dsl.module
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
val appModule =
module {
// intentionally empty in Phase 1
}
```
**Koin startup pattern** (`Koin.kt` lines 1-10):
```kotlin
package dev.ulfrx.recipe.di
import org.koin.core.KoinApplication
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
fun initKoin(config: KoinAppDeclaration? = null): KoinApplication =
startKoin {
config?.invoke(this)
modules(appModule)
}
```
**Client logging bootstrap** (`Logging.kt` lines 1-8):
```kotlin
package dev.ulfrx.recipe.logging
import co.touchlab.kermit.Logger
fun configureLogging() {
Logger.setTag("recipe")
// Platform log writers (OSLog iOS, LogCat Android, System.out JVM/Wasm) install by default.
}
```
**Platform init order** (`MainApplication.kt` lines 8-15, `KoinIos.kt` lines 5-8):
```kotlin
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
configureLogging()
initKoin {
androidContext(this@MainApplication)
}
}
}
```
```kotlin
fun doInitKoin() {
configureLogging()
initKoin()
}
```
**Planner note:** Add `authModule` without starting Koin from composables. Wire modules from the existing `initKoin` path, preserving the one-start-per-platform rule.
### Compose App Shell + Auth Screens
Applies to:
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt`
- `SplashScreen.kt`
- `LoginScreen.kt`
- `LoginViewModel.kt`
- `PostLoginPlaceholderScreen.kt`
- `PostLoginViewModel.kt`
- `composeApp/src/commonMain/composeResources/values/strings.xml`
**Analog:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`, `composeResources/drawable/compose-multiplatform.xml`
**Current app wrapper to preserve/replace** (`App.kt` lines 25-52):
```kotlin
@Composable
@Preview
fun App() {
MaterialTheme {
var showContent by remember { mutableStateOf(false) }
Column(
modifier =
Modifier
.background(MaterialTheme.colorScheme.primaryContainer)
.safeContentPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Button(onClick = { showContent = !showContent }) {
Text("Click me!")
}
}
}
}
```
**Resource import pattern** (`App.kt` lines 21-23):
```kotlin
import org.jetbrains.compose.resources.painterResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.compose_multiplatform
```
**Compose resource file placement analog** (`composeResources/drawable/compose-multiplatform.xml` lines 1-7):
```xml
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="450dp"
android:height="450dp"
android:viewportWidth="64"
android:viewportHeight="64">
```
**UI contract to copy from `02-UI-SPEC.md` lines 149-161:**
```text
AuthState.Loading -> SplashScreen()
AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel())
AuthState.Authenticated(user, householdId) -> PostLoginPlaceholderScreen(user, viewModel = koinViewModel())
```
**Layout contract to copy from `02-UI-SPEC.md` lines 185-197:**
```kotlin
Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.safeContentPadding()
.padding(horizontal = 16.dp)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize(),
)
```
**Planner note:** Existing `App.kt` is template code. Keep the `@Composable`, `@Preview`, `MaterialTheme` shape, but replace the button/greeting body with the auth gate. All strings come from Compose Resources, not raw Polish literals.
### Server Application, Config, Flyway, and Routes
Applies to:
- `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`
- `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt`
- `server/src/main/resources/application.conf`
- `server/src/main/resources/db/migration/V1__users.sql`
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt`
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt`
**Analog:** existing server files
**Application install and routing pattern** (`Application.kt` lines 24-38):
```kotlin
fun Application.module() {
install(ContentNegotiation) {
json()
}
Database.migrate(this)
configureRouting()
}
fun Application.configureRouting() {
routing {
get("/health") {
call.respond(Health(status = "ok"))
}
}
}
```
**HOCON env override pattern** (`application.conf` lines 1-18):
```hocon
ktor {
deployment {
port = 8080
port = ${?PORT}
}
}
database {
url = "jdbc:postgresql://localhost:5432/recipe"
url = ${?DATABASE_URL}
user = "recipe"
user = ${?DATABASE_USER}
password = "recipe"
password = ${?DATABASE_PASSWORD}
}
```
**Flyway runtime pattern** (`Database.kt` lines 10-39):
```kotlin
fun migrate(app: Application) {
val url = app.environment.config.property("database.url").getString()
val user = app.environment.config.property("database.user").getString()
val password = app.environment.config.property("database.password").getString()
log.info("Connecting to {} as {} and running Flyway migrations", url, user)
runCatching {
Flyway
.configure()
.dataSource(url, user, password)
.locations("classpath:db/migration")
.baselineOnMigrate(true)
.validateOnMigrate(true)
.cleanDisabled(true)
.load()
.migrate()
}.onFailure { ex ->
log.error("Flyway migration failed", ex)
throw IllegalStateException("Database unreachable or migration failed", ex)
}
}
```
**Planner note:** Insert auth install in `Application.module()` after `ContentNegotiation`/`CallLogging` and before protected routing. Keep `configureRouting()` testable without invoking real Postgres where possible.
### Server JWT Auth + Principal Resolver
Applies to:
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt`
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt`
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt`
**Analog:** `Application.kt` plugin install, `Database.kt` DB boundary. No existing JWT or Exposed analog exists yet.
**Plugin install style to copy** (`Application.kt` lines 24-27):
```kotlin
install(ContentNegotiation) {
json()
}
```
**DB config/logging boundary to copy** (`Database.kt` lines 7-9, 24-39):
```kotlin
object Database {
private val log = LoggerFactory.getLogger(Database::class.java)
fun migrate(app: Application) {
log.info("Connecting to {} as {} and running Flyway migrations", url, user)
runCatching {
Flyway.configure().dataSource(url, user, password).load().migrate()
}.onFailure { ex ->
log.error("Flyway migration failed", ex)
throw IllegalStateException("Database unreachable or migration failed", ex)
}
}
}
```
**Required auth shape from `02-CONTEXT.md` lines 62-64:**
```kotlin
install(Authentication) {
jwt("authentik") {
// verifier(jwkProvider, issuer), withIssuer, withAudience, acceptLeeway(30)
// validate block must reject null or blank sub
}
}
```
**Required JIT upsert from `02-CONTEXT.md` lines 81-91:**
```sql
INSERT INTO users (sub, email, display_name)
VALUES (:sub, :email, :name)
ON CONFLICT (sub) DO UPDATE
SET email = EXCLUDED.email,
display_name = EXCLUDED.display_name,
updated_at = now()
RETURNING *;
```
**Planner note:** Use Exposed DSL only and suspend transaction APIs for request-handling DB work. There is no local Exposed analog yet; the plan must treat `PrincipalResolver` as the first canonical server CRUD service.
### Server Tests
Applies to:
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtAuthTest.kt`
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt`
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt` if added
**Analog:** `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt`
**Ktor test pattern** (`ApplicationTest.kt` lines 14-29):
```kotlin
class ApplicationTest {
@Test
fun `health endpoint returns 200 with status ok`() =
testApplication {
application {
install(ContentNegotiation) {
json()
}
configureRouting()
}
val response = client.get("/health")
assertEquals(HttpStatusCode.OK, response.status)
val body = response.bodyAsText()
assertTrue(body.contains("\"status\""), "expected body to contain status field, was: $body")
assertTrue(body.contains("\"ok\""), "expected body to contain ok value, was: $body")
}
}
```
**Planner note:** Compose test modules directly with `testApplication { application { ... } }`. Avoid calling `Application.module()` in tests that should not require a real Postgres unless the test sets up an in-memory DB first.
### OIDC Platform Bootstrap
Applies to:
- `OidcClient.kt`
- `OidcResult.kt`
- platform `OidcClient.*.kt`
- `composeApp/src/androidMain/AndroidManifest.xml`
- `iosApp/iosApp/Info.plist`
- `iosApp/iosApp/iOSApp.swift`
- `iosApp/Podfile`
**Analog:** platform entry points and manifests
**Android activity pattern** (`MainActivity.kt` lines 10-19):
```kotlin
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
App()
}
}
}
```
**Android manifest application/activity pattern** (`AndroidManifest.xml` lines 1-23):
```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".MainApplication"
android:label="@string/app_name"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:exported="true"
android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
```
**iOS SwiftUI bootstrap pattern** (`iOSApp.swift` lines 1-15):
```swift
import SwiftUI
import ComposeApp
@main
struct iOSApp: App {
init() {
KoinIosKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
```
**iOS plist pattern** (`Info.plist` lines 1-8):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>
```
**Target-specific main patterns** (`jvmMain/main.kt` lines 8-18, `webMain/main.kt` lines 8-14):
```kotlin
fun main() {
configureLogging()
initKoin()
application {
Window(
onCloseRequest = ::exitApplication,
title = "recipe",
) {
App()
}
}
}
```
```kotlin
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
configureLogging()
initKoin()
ComposeViewport {
App()
}
}
```
**Planner note:** Preserve existing Koin bootstrap while adding AppAuth callback wiring. For `recipe://callback`, register Android AppAuth receiver and iOS `CFBundleURLTypes`; do not replace unrelated app metadata.
### Client AuthSession, Token Store, HTTP Client, and Common Tests
Applies to:
- `AuthState.kt`
- `AuthSession.kt`
- `TokenStore.kt`
- `AuthHttpClient.kt`
- `MeClient.kt`
- `SettingsFactory.*.kt`
- `HttpClientEngine.*.kt`
- `AuthSessionTest.kt`
**Analog:** Koin module pattern, platform main files, `ComposeAppCommonTest.kt`. There is no existing Ktor client or settings storage analog.
**Common test skeleton** (`ComposeAppCommonTest.kt` lines 1-10):
```kotlin
package dev.ulfrx.recipe
import kotlin.test.Test
import kotlin.test.assertEquals
class ComposeAppCommonTest {
@Test
fun example() {
assertEquals(3, 1 + 2)
}
}
```
**Koin module registration analog** (`AppModule.kt` lines 5-9):
```kotlin
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
val appModule =
module {
// intentionally empty in Phase 1
}
```
**Required Ktor client bearer shape from `02-RESEARCH.md` lines 313-329:**
```kotlin
install(Auth) {
bearer {
loadTokens {
authSession.currentBearerTokens()
}
refreshTokens {
authSession.refreshBearerTokens()
}
sendWithoutRequest { request ->
request.url.host == apiHost
}
}
}
```
**Required auth state shape from `02-CONTEXT.md` lines 97-107:**
```kotlin
sealed class AuthState {
data object Loading : AuthState()
data object Unauthenticated : AuthState()
data class Authenticated(
val user: User,
val householdId: HouseholdId? = null,
) : AuthState()
}
```
**Planner note:** This group becomes the client auth canonical pattern for later phases. Keep collaborators injectable behind small interfaces so common tests can fake OIDC and `/me` without platform AppAuth.
## Shared Patterns
### Shared Module Purity
**Source:** `tools/verify-shared-pure.sh` lines 1-15
**Apply to:** all files under `shared/src/commonMain`
```bash
# Enforces INFRA-06 / D-19: shared/commonMain must not import Ktor, Compose, SQLDelight.
# Runs grep against shared/src/commonMain/ only. Allowed imports: kotlin.*, kotlinx.serialization, kotlinx.datetime.
VIOLATIONS=$(grep -rn -E '^import[[:space:]]+(io\.ktor|androidx\.compose|org\.jetbrains\.compose|app\.cash\.sqldelight)' shared/src/commonMain/ 2>/dev/null || true)
```
### Server Startup Order
**Source:** `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` lines 24-30
**Apply to:** `Application.kt`, auth plugin install, route wiring
```kotlin
fun Application.module() {
install(ContentNegotiation) {
json()
}
Database.migrate(this)
configureRouting()
}
```
Phase 2 should extend this to:
```text
ContentNegotiation -> CallLogging redaction -> Database.migrate -> Database.connect -> configureAuth -> configureRouting
```
### Server Logging
**Source:** `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` lines 7-8, 24, 36-39
**Apply to:** server DB/auth services
```kotlin
object Database {
private val log = LoggerFactory.getLogger(Database::class.java)
log.info("Connecting to {} as {} and running Flyway migrations", url, user)
log.error("Flyway migration failed", ex)
throw IllegalStateException("Database unreachable or migration failed", ex)
}
```
### Client Logging
**Source:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt` lines 1-8
**Apply to:** `AuthSession`, OIDC client wrappers
```kotlin
import co.touchlab.kermit.Logger
fun configureLogging() {
Logger.setTag("recipe")
// Platform log writers (OSLog iOS, LogCat Android, System.out JVM/Wasm) install by default.
}
```
Use `Logger.withTag("auth")` for auth flow diagnostics, but never log token bodies or `Authorization` headers.
### Koin Startup
**Source:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt` lines 7-10 and platform callers
**Apply to:** `authModule`, ViewModels, OIDC clients, settings factories
```kotlin
fun initKoin(config: KoinAppDeclaration? = null): KoinApplication =
startKoin {
config?.invoke(this)
modules(appModule)
}
```
### HOCON Env Vars
**Source:** `server/src/main/resources/application.conf` lines 11-18
**Apply to:** `oidc.issuer`, `oidc.audience`, `oidc.jwksUrl`, `oidc.leewaySeconds`
```hocon
database {
url = "jdbc:postgresql://localhost:5432/recipe"
url = ${?DATABASE_URL}
user = "recipe"
user = ${?DATABASE_USER}
password = "recipe"
password = ${?DATABASE_PASSWORD}
}
```
### Ktor Route Tests
**Source:** `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` lines 17-29
**Apply to:** `JwtAuthTest`, `MeRouteTest`
```kotlin
testApplication {
application {
install(ContentNegotiation) {
json()
}
configureRouting()
}
val response = client.get("/health")
assertEquals(HttpStatusCode.OK, response.status)
}
```
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt` | model | CRUD | No Exposed table exists yet. Phase 2 establishes first server table DSL pattern. |
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` JWT details | middleware | request-response | Ktor server exists, but no Authentication/JWT plugin exists yet. Use `02-CONTEXT.md` D-21/D-22 and Ktor docs from research. |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt` | service | request-response | No Ktor client code exists yet. Use `02-RESEARCH.md` Ktor bearer shape. |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/TokenStore.kt` | service | file-I/O | No multiplatform-settings usage exists yet. Keep explicit platform store seam. |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt` | controller | event-driven | Koin ViewModel deps exist, but no ViewModel classes exist yet. Use method-per-action `StateFlow` convention from project docs/UI spec. |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt` | controller | event-driven | Same as LoginViewModel. |
| `iosApp/Podfile` | config | build-config | No existing Podfile. Follow Plan 03 CocoaPods DSL and iOS deployment target from `composeApp/build.gradle.kts`. |
## Metadata
**Analog search scope:** `composeApp/`, `server/`, `shared/`, `iosApp/`, `build-logic/`, `gradle/`, `tools/`, Phase 1 summaries and Phase 2 plan drafts.
**Files scanned:** 75+ code/config/planning files via `rg --files`, `find`, and targeted `nl -ba` reads.
**Pattern extraction date:** 2026-04-27

View File

@@ -0,0 +1,496 @@
# Phase 2: Authentication Foundation - Research
**Researched:** 2026-04-27
**Domain:** KMP native OIDC, secure token storage, Ktor JWT validation, Exposed/Flyway JIT users
**Confidence:** MEDIUM-HIGH
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
The following locked decisions are copied from `.planning/phases/02-authentication-foundation/02-CONTEXT.md` and are authoritative for planning. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
- **D-01:** AppAuth on both mobile platforms. iOS uses AppAuth-iOS via CocoaPod added to `iosApp/Podfile`; Android uses AppAuth-Android (`net.openid:appauth`). Symmetric `expect class OidcClient` in `composeApp/commonMain/.../auth/`, with `actual` impls in `iosMain` and `androidMain` wrapping each platform's AppAuth. Uses AppAuth's `OIDAuthState` / `AuthState` as the in-memory session shape behind the seam.
- **D-02:** JVM Desktop actual is a dev-mode `DEV_AUTH_TOKEN` stub.
- **D-03:** Wasm actual is `NotImplementedError("Wasm OIDC: v2")`.
- **D-04:** `OidcClient.login()` and `.refresh()` are suspend functions bridged with `suspendCancellableCoroutine`.
- **D-05:** Authentik provider is Public + PKCE S256.
- **D-06:** Requested scopes are `openid profile email offline_access`.
- **D-07:** `aud` claim shape is pinned to a single string equal to `client_id`.
- **D-08:** Signing algorithm is RS256.
- **D-09:** Redirect URI is custom scheme `recipe://callback`.
- **D-10:** `docs/authentik-setup.md` is a Phase 2 deliverable.
- **D-11:** Client OIDC config is hardcoded in `shared/commonMain/Constants.kt`.
- **D-12:** Server OIDC config is via env vars in `application.conf`.
- **D-13:** Persist full AppAuth `AuthState` JSON blob via a secure settings abstraction.
- **D-14:** iOS Keychain accessibility target is `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`.
- **D-15:** One AuthState blob per app install.
- **D-16:** Proactive refresh uses AppAuth `performActionWithFreshTokens`.
- **D-17:** Reactive fallback uses Ktor client `Auth { bearer { refreshTokens { ... } } }`.
- **D-18:** Refresh failure silently transitions to unauthenticated.
- **D-19:** Logout calls Authentik end-session and deletes persisted AuthState.
- **D-20:** AppAuth end-session APIs drive logout on both mobile platforms.
- **D-21:** Ktor installs `jwt("authentik")` with issuer, audience, 30-second leeway, and `sub` validation.
- **D-22:** JWKS provider uses cache size 10, 15-minute TTL, and 10/minute rate limit.
- **D-23:** Never log tokens or `Authorization` headers.
- **D-24:** Phase 2 ships `V1__users.sql`.
- **D-25:** JIT provisioning upserts by OIDC `sub` and updates email/display name on each authenticated request.
- **D-26:** Exposed DSL only; every coroutine-touching DB call uses the suspend transaction API.
- **D-27:** Protected `GET /api/v1/me` returns `MeResponse`.
- **D-28:** Client auth state is `Loading | Unauthenticated | Authenticated(user, householdId = null)`.
- **D-29:** `AuthSession` is a Koin singleton in `authModule`.
- **D-30:** `App()` gates between loading, login, and post-login placeholder.
- **D-31:** Login screen is minimal.
- **D-32:** Login errors render inline below the button.
- **D-33:** Post-login placeholder says `Witaj, {displayName}!` and includes `Wyloguj się`.
- **D-34:** User-facing auth strings use Compose Resources from day 1.
### Claude's Discretion
Copied from CONTEXT.md; planner may choose within these boundaries. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
- Exact `Koin` `authModule` definition style.
- Ktor Client bearer auth boilerplate, including `refreshTokens`, token loader, and `sendWithoutRequest`.
- Whether `MeResponse` DTO and `User` domain model are the same type or separate.
- Concrete UUID type, choosing what pairs cleanly with Exposed UUID columns and `kotlinx.serialization`.
- Whether AppAuth-iOS is added via Gradle CocoaPods DSL or hand-written `iosApp/Podfile`.
- Splash placeholder visual.
- Whether `OIDC_ISSUER` ends with a trailing slash; pin and document the choice.
- Logger tag/level for AppAuth events.
### Deferred Ideas (OUT OF SCOPE)
Copied from CONTEXT.md; planner must not include these in Phase 2. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
- Universal Links / App Links.
- BuildConfig-style Gradle injection of OIDC config.
- Real Desktop OIDC.
- Wasm OIDC implementation.
- Two-tier logout.
- Background token refresh.
- Apple Sign-in as a first-class button.
- Per-user persisted `AuthState`.
- Modal/toast for refresh-failure UX.
- Authentik provisioning automation.
- JWT validation tests against a real Authentik instance.
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| AUTH-01 | User signs in through Authentik with authorization code + PKCE. [VERIFIED: `.planning/REQUIREMENTS.md`] | AppAuth native flows, redirect registration, Authentik public provider + PKCE S256. [CITED: https://cocoapods.org/pods/AppAuth] |
| AUTH-02 | Client stores access + refresh tokens securely. [VERIFIED: `.planning/REQUIREMENTS.md`] | Persist AppAuth state JSON, but use explicit secure platform storage; no-arg multiplatform-settings is not enough on Android. [CITED: https://github.com/russhwolf/multiplatform-settings] |
| AUTH-03 | Ktor validates access tokens via Authentik JWKS. [VERIFIED: `.planning/REQUIREMENTS.md`] | Use Ktor JWT provider with issuer, audience, signature/JWKS, leeway, and validate block. [CITED: https://ktor.io/docs/server-jwt.html] |
| AUTH-04 | Session persists across launches via refresh. [VERIFIED: `.planning/REQUIREMENTS.md`] | Restore AppAuth AuthState JSON and call `performActionWithFreshTokens`; request `offline_access`. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html] |
| AUTH-05 | User can sign out and return to login screen. [VERIFIED: `.planning/REQUIREMENTS.md`] | Use Authentik end-session endpoint and AppAuth end-session request, then wipe local state. [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/] |
| AUTH-06 | Users are JIT-provisioned by OIDC `sub`. [VERIFIED: `.planning/REQUIREMENTS.md`] | Flyway users table plus Exposed DSL upsert in suspend transaction. [CITED: https://www.jetbrains.com/help/exposed/dsl-crud-operations.html] |
</phase_requirements>
## Summary
Phase 2 should be planned as three cooperating auth boundaries: native client OIDC, server token validation, and server principal provisioning. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] AppAuth is the right non-hand-rolled primitive because both Android and iOS expose an auth-state object that refreshes tokens and serializes/restores state. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html] [CITED: https://openid.github.io/AppAuth-iOS/docs/latest/interface_o_i_d_auth_state.html]
The main planning correction is token storage. [VERIFIED: web docs] `multiplatform-settings` supports Apple `KeychainSettings`, but its no-arg/default Android path delegates to ordinary SharedPreferences and its README says no-arg does not provide encrypted implementations. [CITED: https://github.com/russhwolf/multiplatform-settings] Android `EncryptedSharedPreferences` still exists and encrypts preferences, but AndroidX Security Crypto 1.1.0 deprecated its crypto APIs in favor of platform APIs/direct Android Keystore use. [CITED: https://developer.android.com/jetpack/androidx/releases/security] Plan Phase 2 with an explicit Wave 0 decision/task for Android secure storage instead of assuming a secure adapter exists. [VERIFIED: docs comparison]
**Primary recommendation:** Use AppAuth AuthState as the session source of truth, store the serialized state behind an explicit `SecureAuthStateStore` expect/actual, protect `/api/v1/me` with Ktor `jwt("authentik")`, and JIT-upsert `users` by `sub` in a suspend Exposed transaction. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] [CITED: https://ktor.io/docs/server-jwt.html]
## Project Constraints (from CLAUDE.md)
- Use GSD planning artifacts as source of truth before implementation. [VERIFIED: `CLAUDE.md`]
- Keep Phase 2 within current roadmap order: Phase 1 complete, Phase 2 auth next. [VERIFIED: `.planning/STATE.md`]
- Client is KMP + Compose Multiplatform; server is Ktor 3.x + Postgres + Exposed DSL + Flyway. [VERIFIED: `CLAUDE.md`]
- `shared/commonMain` may contain domain models and API DTOs only; no UI, HTTP, DB, Koin, Kermit, AppAuth, or settings imports. [VERIFIED: `CLAUDE.md`]
- Exposed DAO is forbidden; use DSL only. [VERIFIED: `CLAUDE.md`]
- Coroutine-touching Ktor handlers must use Exposed suspend transactions, not blocking `transaction {}`. [VERIFIED: `CLAUDE.md`] [CITED: https://www.jetbrains.com/help/exposed/transactions.html]
- All user-facing strings must be externalized from day 1. [VERIFIED: `CLAUDE.md`]
- Never log bearer tokens or authorization headers. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|--------------|----------------|-----------|
| OIDC browser login + callback | Browser / Client | Authentik | Native app owns browser launch and redirect handling; Authentik owns identity UI and token issuance. [CITED: https://cocoapods.org/pods/AppAuth] |
| Token refresh | Browser / Client | Authentik | AppAuth owns fresh-token behavior; Authentik token endpoint issues replacements. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html] |
| Secure token persistence | Browser / Client | OS secure storage | Client owns persistence; storage must be Keychain/Android secure storage, not server. [VERIFIED: `.planning/REQUIREMENTS.md`] |
| Bearer attachment to API calls | Browser / Client | API / Backend | Ktor Client attaches bearer tokens; backend rejects invalid tokens. [CITED: https://ktor.io/docs/client-bearer-auth.html] |
| JWT signature/claim validation | API / Backend | Authentik JWKS | Ktor validates issuer/audience/signature/expiry; Authentik exposes JWKS. [CITED: https://ktor.io/docs/server-jwt.html] [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/] |
| JIT user provisioning | API / Backend | Database / Storage | Backend derives user from JWT claims and owns DB upsert. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] |
| `/api/v1/me` | API / Backend | shared DTO | Route returns authenticated user DTO after provisioning. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] |
| Logout | Browser / Client | Authentik | Client initiates end-session and deletes local state. [CITED: https://openid.github.io/AppAuth-iOS/docs/latest/interface_o_i_d_end_session_request.html] |
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `net.openid:appauth` | 0.11.1 | Android OIDC/OAuth native authorization flow and AuthState. [VERIFIED: Maven Central search] | AppAuth provides PKCE-compatible native browser flow, token refresh helpers, and JSON AuthState. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html] |
| CocoaPod `AppAuth` | 2.0.0 | iOS OIDC/OAuth native authorization flow. [VERIFIED: CocoaPods] | AppAuth maps OAuth/OIDC flows and supports fresh-token helpers and native browser/user-agent patterns. [CITED: https://cocoapods.org/pods/AppAuth] |
| Ktor auth client/server artifacts | Project catalog 3.4.1; current release observed 3.4.3 | Client bearer retry and server JWT validation. [VERIFIED: `gradle/libs.versions.toml`] [VERIFIED: Maven search] | Ktor docs expose `loadTokens`, `refreshTokens`, single-flight refresh, JWT verifier, JWKS provider, and validate/challenge hooks. [CITED: https://ktor.io/docs/client-bearer-auth.html] [CITED: https://ktor.io/docs/server-jwt.html] |
| `com.russhwolf:multiplatform-settings` | 1.3.0 | Common key-value API over platform delegates. [VERIFIED: Maven Central] | Useful interface for `SecureAuthStateStore`; must not use no-arg/default Android for secrets. [CITED: https://github.com/russhwolf/multiplatform-settings] |
| Exposed DSL | 1.2.0 current | Server SQL DSL and suspend transactions. [VERIFIED: Exposed docs] | Official docs show DSL CRUD/upsert and suspend transaction APIs. [CITED: https://www.jetbrains.com/help/exposed/dsl-crud-operations.html] |
| Flyway | Project catalog 12.4.0 | `V1__users.sql` migration. [VERIFIED: `gradle/libs.versions.toml`] | Existing Phase 1 server bootstrap already runs Flyway on startup. [VERIFIED: `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt`] |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `androidx.security:security-crypto` | 1.1.0 stable | Android encrypted SharedPreferences option. [CITED: https://developer.android.com/jetpack/androidx/releases/security] | Use only if accepting deprecation; otherwise implement Android Keystore-backed store and flag decision. [CITED: https://developer.android.com/reference/kotlin/androidx/security/crypto/EncryptedSharedPreferences] |
| `com.auth0:jwks-rsa` | Transitive/API used by Ktor examples | JWKS provider cache/rate limit builder. [CITED: https://ktor.io/docs/server-jwt.html] | Add explicit alias if Ktor JWT artifact does not expose the builder cleanly. [ASSUMED] |
| `kotlinx-serialization-json` | Already via Ktor serialization artifact | DTO JSON and AuthState wrapper metadata if needed. [VERIFIED: `gradle/libs.versions.toml`] | Keep DTOs in `shared`; keep AppAuth JSON as opaque string in client. [VERIFIED: `CLAUDE.md`] |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| AppAuth native clients | Hand-rolled authorization-code flow | Reject: PKCE, redirect state, token exchange, refresh, cancellation, and end-session are deceptively complex. [CITED: https://cocoapods.org/pods/AppAuth] |
| `multiplatform-settings` no-arg | Explicit expect/actual store | Use explicit store: no-arg defaults are not encrypted and are hard to substitute in tests. [CITED: https://github.com/russhwolf/multiplatform-settings] |
| Exposed DAO | Exposed DSL | Reject: project forbids DAO; DSL better matches JSON/upsert and transaction control. [VERIFIED: `CLAUDE.md`] |
**Installation:**
```bash
# Add aliases in gradle/libs.versions.toml; use the project's version catalog.
# Ktor artifacts should share the existing ktor version ref unless a deliberate full Ktor bump is planned.
```
**Version verification:** Ktor current docs show 3.4.3 and Maven search observed 3.4.3 on 2026-04-22; the repo currently pins 3.4.1. [CITED: https://ktor.io/docs/server-jwt.html] [VERIFIED: Maven search] Multiplatform Settings current is 1.3.0. [CITED: https://central.sonatype.com/artifact/com.russhwolf/multiplatform-settings] AppAuth-iOS current CocoaPod is 2.0.0. [CITED: https://cocoapods.org/pods/AppAuth] AppAuth-Android current Maven version remains 0.11.1. [VERIFIED: Maven Central search]
## Architecture Patterns
### System Architecture Diagram
```text
User taps "Zaloguj się"
-> Compose LoginScreen
-> AuthSession.login()
-> OidcClient actual (Android/iOS AppAuth)
-> Authentik authorization endpoint (system browser, PKCE, state)
-> recipe://callback
-> AppAuth token exchange
-> AuthState JSON persisted via SecureAuthStateStore
-> AuthSession calls GET /api/v1/me with fresh access token
-> Ktor jwt("authentik") verifier
-> Authentik JWKS cache/rate limit
-> validate issuer + audience + expiry + sub
-> PrincipalResolver upserts users by sub
-> /api/v1/me returns MeResponse
-> AuthSession emits Authenticated(user, householdId = null)
Logout:
User taps "Wyloguj się"
-> AppAuth EndSessionRequest / Authentik end-session endpoint
-> local AuthState blob removed
-> AuthSession emits Unauthenticated
```
### Recommended Project Structure
```text
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/
├── auth/ # AuthSession, AuthState, OidcClient expect, SecureAuthStateStore expect
├── data/remote/ # HttpClient factory, AuthApi for /api/v1/me
├── di/ # authModule added to appModule composition
└── ui/screens/auth/ # LoginScreen, PostLoginPlaceholder
composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/
└── OidcClient.android.kt # AppAuth-Android + redirect support + secure store actual
composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/
└── OidcClient.ios.kt # AppAuth-iOS CocoaPod bindings + secure store actual
server/src/main/kotlin/dev/ulfrx/recipe/
├── auth/ # AuthConfig, configureAuthentication, PrincipalResolver, AuthPrincipal
├── db/tables/ # Users table
└── routes/ # me route
shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/
└── MeResponse.kt # Serializable DTO only
```
### Pattern 1: AuthState Is Opaque Session Storage
**What:** Store the platform AppAuth AuthState JSON string as one opaque blob and keep DTO/domain mapping outside the blob. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html]
**When to use:** Always for mobile token persistence in Phase 2. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
**Example:**
```kotlin
interface SecureAuthStateStore {
fun readAuthStateJson(): String?
fun writeAuthStateJson(value: String)
fun clear()
}
```
### Pattern 2: Fresh Token Wrapper Before Ktor Calls
**What:** `AuthSession.getAccessToken()` calls AppAuth `performActionWithFreshTokens`, persists any updated state, and returns a token to Ktor. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html]
**When to use:** Before every authenticated API call, especially `/api/v1/me`. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
**Ktor fallback:** Configure `refreshTokens {}` for 401 retries and rely on Ktor's documented single-flight refresh behavior. [CITED: https://ktor.io/docs/client-bearer-auth.html]
### Pattern 3: JWT Validation Then Principal Resolution
**What:** Ktor JWT authenticates claims; a resolver maps JWT `sub` to a persisted `users` row. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
**When to use:** Every protected route, starting with `/api/v1/me`. [VERIFIED: `.planning/ROADMAP.md`]
**Example:**
```kotlin
install(Authentication) {
jwt("authentik") {
realm = "recipe"
verifier(jwkProvider, issuer) {
withIssuer(issuer)
withAudience(audience)
acceptLeeway(30)
}
validate { credential ->
credential.payload.subject?.takeIf { it.isNotBlank() }?.let { JWTPrincipal(credential.payload) }
}
}
}
```
Source: Ktor JWT docs show dependencies, JWKS verifier, `acceptLeeway`, and required `validate`. [CITED: https://ktor.io/docs/server-jwt.html]
### Anti-Patterns to Avoid
- **Using no-arg `Settings()` for refresh tokens:** It defaults to non-encrypted stores on some platforms; use explicit secure actuals. [CITED: https://github.com/russhwolf/multiplatform-settings]
- **Trusting access token alone for user creation:** Use `sub` as stable identity and update email/name as mutable claims. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
- **Blocking `transaction {}` inside Ktor suspend routes:** Current Exposed docs warn synchronous transactions block the current thread; use suspend transactions for coroutine paths. [CITED: https://www.jetbrains.com/help/exposed/transactions.html]
- **Logging token-bearing headers:** Bearer tokens grant API access; project explicitly forbids token logging. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Native OAuth/OIDC browser flow | Custom URL construction + manual token exchange | AppAuth Android/iOS | Handles PKCE/state/native user agents and token refresh helpers. [CITED: https://cocoapods.org/pods/AppAuth] |
| JWT parsing/verification | Manual JWT decode or static public key | Ktor `ktor-server-auth-jwt` + JWKS provider | Handles signature verification and Ktor principal integration. [CITED: https://ktor.io/docs/server-jwt.html] |
| Token retry machinery | Custom 401 retry queue | Ktor Client Auth bearer provider | Ktor documents automatic refresh and single-flight behavior. [CITED: https://ktor.io/docs/client-bearer-auth.html] |
| User provisioning race handling | Select-then-insert | Postgres/Exposed upsert | Atomic upsert avoids duplicate rows under concurrent first requests. [CITED: https://www.jetbrains.com/help/exposed/dsl-crud-operations.html] |
| Android crypto primitives | Custom encryption without review | Android Keystore-backed approach or accepted Security Crypto dependency | AndroidX deprecated Security Crypto in favor of platform APIs/direct Keystore. [CITED: https://developer.android.com/jetpack/androidx/releases/security] |
**Key insight:** The auth surface is mostly protocol glue; bugs are usually in edge handling (redirect byte-match, refresh races, JWKS rotation, secure persistence), so the plan should compose proven libraries and test edge cases. [VERIFIED: `.planning/research/PITFALLS.md`] [CITED: https://ktor.io/docs/client-bearer-auth.html]
## Common Pitfalls
### Pitfall 1: Assuming Multiplatform Settings Gives Secure Android Storage
**What goes wrong:** Refresh tokens land in ordinary SharedPreferences. [CITED: https://github.com/russhwolf/multiplatform-settings]
**Why it happens:** The no-arg module favors convenience and documents that it does not provide encrypted implementations. [CITED: https://github.com/russhwolf/multiplatform-settings]
**How to avoid:** Create `SecureAuthStateStore` with platform actuals; iOS uses Keychain, Android uses an explicitly chosen secure implementation. [VERIFIED: docs comparison]
**Warning signs:** `Settings()` appears in auth storage code or Android store is `SharedPreferencesSettings` over default prefs. [CITED: https://github.com/russhwolf/multiplatform-settings]
### Pitfall 2: Authentik Refresh Token Missing
**What goes wrong:** Login works but session does not survive access-token expiry or app relaunch. [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/create-oauth2-provider/]
**Why it happens:** Authentik requires `offline_access` request and provider scope mapping to issue refresh tokens. [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/create-oauth2-provider/]
**How to avoid:** Provider config doc must include `offline_access` scope mapping and app request must include `offline_access`. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
### Pitfall 3: JWKS / Audience / Issuer Drift
**What goes wrong:** Valid-looking tokens return 401, especially after config changes or key rotation. [VERIFIED: `.planning/research/PITFALLS.md`]
**Why it happens:** Ktor requires explicit verifier and validate block; Authentik provider config controls signing and JWKS endpoint. [CITED: https://ktor.io/docs/server-jwt.html] [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/]
**How to avoid:** Pin issuer, audience, RS256 signing key, JWKS URL, cache TTL, and wrong-audience tests. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
### Pitfall 4: Exposed API Drift
**What goes wrong:** Planner writes tasks using old `newSuspendedTransaction` imports but current Exposed docs show `suspendTransaction` in `org.jetbrains.exposed.v1.*`. [CITED: https://www.jetbrains.com/help/exposed/transactions.html]
**Why it happens:** Exposed 1.x introduced package/API changes. [CITED: https://www.jetbrains.com/help/exposed/breaking-changes.html]
**How to avoid:** Before implementation, pin Exposed version and verify the exact suspend transaction import via Gradle dependency sources. [VERIFIED: docs comparison]
## Code Examples
### Authentik Provider Checklist
```text
Provider type: OAuth2/OIDC Public client
Flow: authorization code with PKCE S256
Redirect URI: recipe://callback
Scopes: openid profile email offline_access
Audience: single string = client_id
Signing: asymmetric RS256 signing key, JWKS endpoint documented
Logout: end-session endpoint documented
```
Sources: Authentik OAuth docs and Phase 2 context. [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/] [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
### Ktor Client Bearer Shape
```kotlin
install(Auth) {
bearer {
loadTokens {
authSession.currentBearerTokens()
}
refreshTokens {
authSession.refreshBearerTokens()
}
sendWithoutRequest { request ->
request.url.host == apiHost
}
}
}
```
Source: Ktor client bearer docs. [CITED: https://ktor.io/docs/client-bearer-auth.html]
### Users Migration
```sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sub TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
display_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX users_sub_idx ON users(sub);
```
Source: Phase 2 context. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Hand-rolled mobile OAuth redirects | AppAuth native libraries | Stable native-app OAuth guidance; AppAuth-iOS 2.0.0 current | Use AppAuth on both mobile targets. [CITED: https://cocoapods.org/pods/AppAuth] |
| AppAuth-iOS 1.x | AppAuth-iOS 2.0.0 | Latest CocoaPod released Apr 2025 | Check iOS minimum support; release notes say 2.0.0 raises minimum iOS to 12. [CITED: https://github.com/openid/AppAuth-iOS/releases] |
| Ktor 3.4.1 in repo | Ktor 3.4.3 current docs/release | 2026-04-22 | Prefer same catalog ref for new auth artifacts; consider full Ktor patch bump as a separate task. [VERIFIED: `gradle/libs.versions.toml`] [VERIFIED: Maven search] |
| Exposed `newSuspendedTransaction` examples | Exposed 1.2 docs show `suspendTransaction` under `org.jetbrains.exposed.v1.*` | Exposed 1.x | Verify exact import during planning. [CITED: https://www.jetbrains.com/help/exposed/transactions.html] |
| AndroidX Security Crypto as preferred encrypted prefs | AndroidX deprecated all Security Crypto APIs in favor of platform APIs/direct Keystore | 1.1.0-alpha07 / 1.1.0 | Planner must explicitly choose Android storage path. [CITED: https://developer.android.com/jetpack/androidx/releases/security] |
**Deprecated/outdated:**
- Treating `multiplatform-settings-no-arg` as secure storage is wrong for Phase 2 secrets. [CITED: https://github.com/russhwolf/multiplatform-settings]
- Treating Android `EncryptedSharedPreferences` as unproblematic current best practice is outdated; it is available but deprecated. [CITED: https://developer.android.com/reference/kotlin/androidx/security/crypto/EncryptedSharedPreferences]
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `com.auth0:jwks-rsa` may need an explicit alias if Ktor's auth JWT dependency does not expose the builder cleanly. | Standard Stack | Minor Gradle dependency task may be missing. |
## Open Questions (RESOLVED)
1. **RESOLVED — Android secure token storage final choice**
- What we know: no-arg/default multiplatform-settings is not encrypted on Android; AndroidX Security Crypto encrypts preferences but is deprecated. [CITED: https://github.com/russhwolf/multiplatform-settings] [CITED: https://developer.android.com/jetpack/androidx/releases/security]
- Decision from PLAN.md: Phase 2 uses AndroidX Security Crypto `EncryptedSharedPreferences` behind an explicit `SecureAuthStateStore.android.kt` implementation because the project requirement/context explicitly calls out EncryptedSharedPreferences for Android token storage. The deprecation risk is contained behind the `SecureAuthStateStore` seam so a future Android Keystore-backed implementation can replace it without touching `AuthSession`.
- Guardrail: auth code must not use no-arg `Settings()` or ordinary `SharedPreferences` for tokens; Plan 03 includes grep-verifiable acceptance criteria for this.
2. **RESOLVED — Exposed version and suspend transaction import**
- What we know: current Exposed docs use `suspendTransaction`; project context says `newSuspendedTransaction`. [CITED: https://www.jetbrains.com/help/exposed/transactions.html] [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
- Decision from PLAN.md: Plan 02 verifies the exact suspend transaction API/import against the pinned Exposed dependency before implementing DB code. Expected for the current catalog path is `org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction`; if the pinned version requires `suspendTransaction`, execution must use that exact import and record the choice in `02-02-SUMMARY.md`.
- Guardrail: no blocking `transaction {}` inside suspend route code.
3. **RESOLVED — Ktor patch bump**
- What we know: repo pins 3.4.1 and current docs/release search show 3.4.3. [VERIFIED: `gradle/libs.versions.toml`] [VERIFIED: Maven search]
- Decision from PLAN.md: Phase 2 keeps Ktor pinned to the existing catalog version (`3.4.1`) and adds auth artifacts against that same version ref. A patch bump is deferred unless implementation reveals a concrete incompatibility.
- Guardrail: do not opportunistically bump Ktor during auth implementation without an explicit failing command or compatibility reason.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|-------------|-----------|---------|----------|
| Java | Gradle/Kotlin build | yes | OpenJDK 25.0.2 | Gradle toolchains may download/use configured JDKs. [VERIFIED: `java -version`] |
| Gradle wrapper | Build/test | yes | 9.4.1 | None needed. [VERIFIED: `./gradlew --version`] |
| Xcode | iOS build/callback wiring | yes | Xcode 26.2 | None for iOS UAT. [VERIFIED: `xcodebuild -version`] |
| CocoaPods | AppAuth-iOS integration | yes | 1.16.2 | Swift Package/manual Podfile possible but not preferred for KMP CocoaPods DSL. [VERIFIED: `pod --version`] |
| Docker | Postgres/test services | yes | 27.3.1 | Use local Postgres if Docker unavailable. [VERIFIED: `docker --version`] |
| psql | Manual DB inspection | no | — | Use Docker exec or server tests. [VERIFIED: `command -v psql`] |
| Android Debug Bridge | Android manual UAT | no | — | Android manual UAT may need Android Studio/SDK install; iOS remains primary. [VERIFIED: `command -v adb`] |
| OpenSSL | JWT/test key generation support | yes | 3.4.1 | JVM crypto APIs can generate test keys. [VERIFIED: `openssl version`] |
**Missing dependencies with no fallback:** none for research/planning. [VERIFIED: environment audit]
**Missing dependencies with fallback:** `psql` and `adb` are missing; planner should not depend on them for automated Phase 2 gates. [VERIFIED: environment audit]
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | `kotlin.test` + JUnit for server; KMP common tests for auth state/store seams. [VERIFIED: `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt`] |
| Config file | Existing Gradle/KMP test setup; no standalone test config. [VERIFIED: repo scan] |
| Quick run command | `./gradlew :server:test :composeApp:jvmTest :shared:jvmTest` [VERIFIED: Phase 1 validation pattern] |
| Full suite command | `./gradlew check` [VERIFIED: Phase 1 validation pattern] |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|--------------|
| AUTH-01 | OIDC request config includes issuer/client/redirect/scopes and mobile actuals compile | unit/build + manual iOS UAT | `./gradlew :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileDebugKotlinAndroid` | no, Wave 0 |
| AUTH-02 | AuthState JSON store writes/reads/clears and avoids no-arg insecure store for auth | common unit + grep invariant | `./gradlew :composeApp:jvmTest` plus grep for `Settings()` in auth store | no, Wave 0 |
| AUTH-03 | `/api/v1/me` rejects missing, expired, wrong-audience tokens and accepts valid test JWT | server integration | `./gradlew :server:test --tests "*Auth*"` | no, Wave 0 |
| AUTH-04 | Restored persisted AuthState refreshes token before `/me` | common/platform-stub unit | `./gradlew :composeApp:jvmTest` | no, Wave 0 |
| AUTH-05 | Logout calls end-session path when possible and clears local AuthState | unit + manual iOS UAT | `./gradlew :composeApp:jvmTest` | no, Wave 0 |
| AUTH-06 | First authenticated `/me` creates/updates user by `sub` | server integration with test DB or mocked transaction seam | `./gradlew :server:test --tests "*Me*"` | no, Wave 0 |
### Sampling Rate
- **Per task commit:** `./gradlew :server:test :composeApp:jvmTest :shared:jvmTest` [VERIFIED: Phase 1 validation pattern]
- **Per wave merge:** `./gradlew check` [VERIFIED: Phase 1 validation pattern]
- **Phase gate:** full suite green plus manual iOS Authentik login/logout UAT. [VERIFIED: `.planning/ROADMAP.md`]
### Wave 0 Gaps
- [ ] `server/src/test/kotlin/dev/ulfrx/recipe/AuthJwtTest.kt` — covers valid/missing/expired/wrong-audience JWT cases. [VERIFIED: repo scan]
- [ ] `server/src/test/kotlin/dev/ulfrx/recipe/MeRouteTest.kt` — covers JIT provisioning and `/api/v1/me`. [VERIFIED: repo scan]
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt` — covers state transitions and refresh failure behavior. [VERIFIED: repo scan]
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreTest.kt` — covers read/write/clear contract with fake store. [VERIFIED: repo scan]
- [ ] Android/iOS manual UAT checklist in `docs/authentik-setup.md`. [VERIFIED: repo scan]
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|------------------|
| V2 Authentication | yes | Authentik OIDC authorization code + PKCE through AppAuth. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] |
| V3 Session Management | yes | Secure AuthState persistence, AppAuth refresh, logout clears local state and calls end-session. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html] |
| V4 Access Control | yes | JWT-protected `/api/v1/me`; household access control waits for Phase 3. [VERIFIED: `.planning/ROADMAP.md`] |
| V5 Input Validation | yes | Validate JWT claims (`sub`, issuer, audience, expiry); validate route authentication before response. [CITED: https://ktor.io/docs/server-jwt.html] |
| V6 Cryptography | yes | Use AppAuth/JWKS/OS secure storage; do not hand-roll protocol crypto. [CITED: https://cocoapods.org/pods/AppAuth] |
### Known Threat Patterns for KMP/Ktor OIDC
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Authorization-code interception via custom scheme | Spoofing / Elevation | Public client + PKCE S256 + AppAuth state handling. [CITED: https://cocoapods.org/pods/AppAuth] |
| Token leakage in logs | Information Disclosure | Redact Authorization header and never log token bodies. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] |
| Wrong-audience token accepted | Elevation | `.withAudience(clientId)` and wrong-audience test. [CITED: https://ktor.io/docs/server-jwt.html] |
| JWKS key rotation denial | Denial of Service | JWKS cache with bounded TTL and rate limiting. [CITED: https://ktor.io/docs/server-jwt.html] |
| Refresh token stored in plaintext | Information Disclosure | Explicit secure platform actuals; reject no-arg settings for auth secrets. [CITED: https://github.com/russhwolf/multiplatform-settings] |
## Sources
### Primary (HIGH confidence)
- `.planning/phases/02-authentication-foundation/02-CONTEXT.md` — phase decisions and boundaries.
- `.planning/PROJECT.md`, `.planning/REQUIREMENTS.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` — product and phase scope.
- `CLAUDE.md` / `AGENTS.md` — project constraints.
- Ktor JWT docs: https://ktor.io/docs/server-jwt.html
- Ktor client bearer docs: https://ktor.io/docs/client-bearer-auth.html
- AppAuth Android AuthState docs: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html
- AppAuth iOS AuthState docs: https://openid.github.io/AppAuth-iOS/docs/latest/interface_o_i_d_auth_state.html
- Authentik OAuth2 provider docs: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/
- Authentik create provider docs: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/create-oauth2-provider/
- Multiplatform Settings README: https://github.com/russhwolf/multiplatform-settings
- AndroidX Security Crypto docs: https://developer.android.com/jetpack/androidx/releases/security
- Exposed transactions/docs: https://www.jetbrains.com/help/exposed/transactions.html
### Secondary (MEDIUM confidence)
- Maven/CocoaPods registry search results for latest versions.
- Existing Phase 1 summaries and validation artifacts under `.planning/phases/01-project-infrastructure-module-wiring/`.
### Tertiary (LOW confidence)
- A1 about needing an explicit `jwks-rsa` alias; verify in Gradle during planning.
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH for locked choices; MEDIUM for Android secure storage because current docs conflict with the original assumption. [VERIFIED: docs comparison]
- Architecture: HIGH for tier ownership and route/session shape. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
- Pitfalls: HIGH for Ktor/AppAuth/AuthentiK pitfalls; MEDIUM for exact Exposed API import until version is pinned. [CITED: https://www.jetbrains.com/help/exposed/transactions.html]
**Research date:** 2026-04-27
**Valid until:** 2026-05-04 for auth library/version details; 2026-05-27 for architecture patterns.

View File

@@ -0,0 +1,302 @@
---
phase: 2
slug: authentication-foundation
status: approved
shadcn_initialized: false
preset: none
created: 2026-04-27
reviewed_at: 2026-04-27
---
# Phase 2 — UI Design Contract
> Visual and interaction contract for the Authentication Foundation phase. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
>
> **Stack note:** This is a Kotlin Multiplatform / Compose Multiplatform app, not shadcn/web. The template's "shadcn" framing has been adapted for Material 3 on CMP. All values below are expressed in `dp` (Compose) and Material 3 `Type` / `ColorScheme` roles.
>
> **Phase boundary reminder:** Phase 2 ships SCAFFOLD UI quality — three composables (`SplashScreen`, `LoginScreen`, `PostLoginPlaceholderScreen`) plus the auth gate in `App()`. The Liquid-Glass visual language and Haze blur land in **Phase 10**. The polished Polish copy + display font live in **Phase 11**. Tokens locked here are the SEED that later phases extend, not retroactively rewrite.
---
## Design System
| Property | Value |
|----------|-------|
| Tool | Compose Multiplatform Material 3 |
| Preset | not applicable |
| Component library | `androidx.compose.material3` (CMP port via `compose-multiplatform` 1.7+) |
| Icon library | none used in Phase 2 (no icons on Splash / Login / PostLoginPlaceholder); `androidx.compose.material.icons` available but deferred to Phase 5+ |
| Font | system default — `FontFamily.Default` (Compose Multiplatform resolves to SF on iOS, Roboto on Android, system default on JVM/Wasm). **Reserved for Phase 11:** display font selection + custom `FontFamily` in Compose Resources. |
**Component sourcing:** Material 3 stdlib only (`Surface`, `Text`, `Button`, `OutlinedButton`, `CircularProgressIndicator`, `Column`, `Spacer`, `Box`). No third-party UI components, no Haze, no custom blur. **Haze blur is explicitly deferred to Phase 10** per CLAUDE.md non-negotiable #10 ("Haze on chrome only, never over fast-scrolling content").
---
## Spacing Scale
Declared values (all multiples of 4, expressed in Compose `dp`):
| Token | Value | Phase 2 Usage |
|-------|-------|---------------|
| xs | 4.dp | Reserved for later phases (icon gaps, fine adjustments) |
| sm | 8.dp | Vertical gap between welcome text and "Wyloguj się" button on PostLogin; vertical gap between "Recipe" wordmark and progress indicator on Splash |
| md | 16.dp | Default vertical gap between button and inline error text on LoginScreen; horizontal screen-edge padding on all three screens |
| lg | 24.dp | Vertical gap between app-name display and primary button on LoginScreen; vertical gap between welcome text block and logout button on PostLoginPlaceholder |
| xl | 32.dp | Reserved for later phases (planner/calendar layouts) |
| 2xl | 48.dp | Vertical breathing room between top-most centered content cluster and its surrounding empty space (visual centering target on LoginScreen and Splash) |
| 3xl | 64.dp | Reserved for later phases (page-level section breaks in catalog / planner) |
**Touch target floor:** All interactive controls (buttons) honor Material 3 minimum touch target of `48.dp` height. Material 3's `Button` defaults satisfy this; do not shrink.
**Safe-area insets:** All three screens wrap their root in `Modifier.safeContentPadding()` (already established by Phase 1's `App.kt` pattern). This keeps content clear of the iOS notch/home indicator and Android system bars without introducing platform-specific code in `commonMain`.
**Exceptions:** none. The full `xs..3xl` scale is declared for forward-compat with Phases 3+; tokens marked "Reserved for later phases" are spec'd here so the planner/executor draws from one canonical scale instead of inventing per-phase increments.
---
## Typography
Material 3 `Typography` roles. Phase 2 uses four roles; the rest of the M3 scale is implicitly available for later phases. Phase 11 may swap `FontFamily` but the **role-to-element mapping below is locked**.
| Role | M3 Token | Size | Weight | Line Height | Phase 2 Element |
|------|----------|------|--------|-------------|-----------------|
| Display | `displaySmall` | 36.sp | Regular (W400) | 44.sp (≈1.22) | "Recipe" wordmark on `SplashScreen` and `LoginScreen` |
| Heading | `headlineSmall` | 24.sp | Regular (W400) | 32.sp (≈1.33) | `Witaj, {displayName}!` welcome text on `PostLoginPlaceholderScreen` |
| Body | `bodyLarge` | 16.sp | Regular (W400) | 24.sp (1.5) | Inline error text below the sign-in button on `LoginScreen` |
| Label | `labelLarge` | 14.sp | Medium (W500) | 20.sp (≈1.43) | Button label text — "Zaloguj się przez Authentik", "Wyloguj się" (Material 3 `Button` slot uses this role by default) |
**Weights declared:** exactly 2 — Regular (W400) for body / heading / display, Medium (W500) for button labels (Material 3 default for `labelLarge`). No Bold, no Light, no SemiBold in Phase 2.
**Sizes declared:** exactly 4 — 14, 16, 24, 36. This satisfies the "34 sizes" cap.
**Line-height policy:**
- Body (16.sp body): 1.5 ratio → 24.sp line height. Matches the brand recommendation; Material 3 `bodyLarge` default is 24.sp.
- Heading (24.sp `headlineSmall`): ~1.33 ratio. Tighter than body per Material 3 baseline; aligns with the "calmer typography" direction in PROJECT.md.
- Display (36.sp `displaySmall`): ~1.22 ratio. Material 3 default.
**Implementation:** use `MaterialTheme.typography.displaySmall` / `.headlineSmall` / `.bodyLarge` / `.labelLarge` directly. Do **not** override `style.copy(fontWeight = ...)` ad-hoc in Phase 2 composables — if a deviation is needed, add it to the `Typography` config in `ui/theme/Typography.kt` so Phase 11 has one place to retune.
---
## Color
Material 3 `ColorScheme` derived from a **single seed color** via `dynamicLightColorScheme` / `dynamicDarkColorScheme` is **not** used (dynamic color is Android 12+ only and would diverge between iOS and Android). Instead Phase 2 ships **explicit baseline schemes** seeded once:
- **Seed color:** `#3B6939` (mid-saturation green, warm-leaning — chosen as a placeholder that reads well in a cooking/meal-planning context without committing to a brand identity).
- **Generation:** `lightColorScheme()` / `darkColorScheme()` Material 3 defaults overridden with the seed-derived `primary` only. All other roles use Material 3 baseline values for their respective scheme.
- **Phase 11 hand-off:** the seed value is open to revision in Phase 11 (final brand-color pass). Tokens listed below are CONTRACT for Phase 2; Phase 11 may rebase the entire palette around a different seed without breaking the role-to-element mapping locked here.
The 60 / 30 / 10 split, mapped to Material 3 roles:
| Role | Light scheme | Dark scheme | Usage |
|------|--------------|-------------|-------|
| Dominant (60%) — `surface` | `#FEF7FF` (M3 default) | `#141218` (M3 default) | Root background of all three Phase 2 screens |
| Secondary (30%) — `surfaceContainer` | `#F3EDF7` (M3 default) | `#211F26` (M3 default) | **Reserved for Phase 5+** (cards, sheets, nav containers). Phase 2 has no card surfaces; this token is declared for forward-compat. |
| Accent (10%) — `primary` | `#3B6939` (seed) | `#A2D597` (seed-derived dark variant) | The single primary CTA on each screen — **only**: "Zaloguj się przez Authentik" button (`LoginScreen`) and **only** that button. Logout uses a different role (see below). |
| Destructive — `error` | `#BA1A1A` (M3 default) | `#FFB4AB` (M3 default) | Inline error text color on `LoginScreen` (`auth_error_*` strings). Reserved for actual error states only — not used for the "Wyloguj się" button. |
**Accent reserved for:** the `LoginScreen` primary CTA button (`Button` composable using `colors = ButtonDefaults.buttonColors()` which resolves to `containerColor = primary`). Nothing else in Phase 2.
**"Wyloguj się" button styling:** uses Material 3 `OutlinedButton` (not `Button`) → `borderColor` = `outline`, `contentColor` = `primary`. This is a deliberate hierarchy choice: logout is a less-frequent, more-deliberate action than login, and reserving the filled-accent variant for the login CTA preserves the "10% accent" ratio. **Not** styled as destructive (red `error`) because logout is not destructive in this app — it ends the session but does not delete user data.
**Dark mode is required.** Per orchestrator note (homelab user's primary environment is dark mode), both `lightColorScheme()` and `darkColorScheme()` MUST be wired. App respects system theme via `isSystemInDarkTheme()` (already standard in Compose). No in-app theme toggle in Phase 2.
**Translucency / blur:** none in Phase 2. All surfaces are opaque. The Liquid-Glass aesthetic begins in Phase 10.
---
## Copywriting Contract
All user-facing strings live in **Compose Resources** (`composeApp/src/commonMain/composeResources/values/strings.xml` per Compose Multiplatform conventions) per CLAUDE.md non-negotiable #9 + CONTEXT D-34. Polish copy below is **scaffold quality**; Phase 11 polishes for plural forms, tone, and proofs the full locale.
| Element | Resource Key | Polish Copy (scaffold) | Screen | Notes |
|---------|--------------|------------------------|--------|-------|
| App wordmark | `auth_app_name` | `Recipe` | Splash, Login | English working title per PROJECT.md; final brand name is a Phase 11 decision. Not localizable in Phase 2. |
| Primary CTA | `auth_sign_in_button` | `Zaloguj się przez Authentik` | LoginScreen | Verb + noun; explicit IdP name to set expectation that the system browser will open. |
| Secondary CTA (logout) | `auth_sign_out_button` | `Wyloguj się` | PostLoginPlaceholderScreen | Single Polish reflexive verb; matches user's expected idiom. |
| Welcome / authenticated state | `auth_welcome_format` | `Witaj, %1$s!` | PostLoginPlaceholderScreen | `%1$s` substituted with `User.displayName` from the JIT-provisioned server response. Use Compose Resources `stringResource(Res.string.auth_welcome_format, user.displayName)`. |
| Error: user cancelled | `auth_error_cancelled` | `Logowanie anulowane. Spróbuj ponownie.` | LoginScreen (inline below button) | Triggered when AppAuth surfaces `OIDAuthError.userCancelled` (iOS) / `AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW` (Android). |
| Error: network unreachable | `auth_error_network` | `Nie można połączyć z Authentik. Sprawdź połączenie.` | LoginScreen (inline below button) | Triggered on `IOException` / network errors during authorization OR token exchange. |
| Error: token exchange / validation failure | `auth_error_unknown` | `Coś poszło nie tak. Spróbuj ponownie.` | LoginScreen (inline below button) | Catch-all for token-exchange failures, JWT validation errors, JIT-provisioning 5xx. |
**Empty states:** Phase 2 has no empty-state surfaces. The "no user yet" condition routes to `LoginScreen`, which is itself the empty state. No "you're not signed in yet" placeholder text is needed.
**Destructive confirmation:** none in Phase 2. Logout is silent (CONTEXT D-19): "Wyloguj się" tap immediately initiates RP-initiated end-session without a confirmation modal. **Rationale:** the user can re-authenticate trivially; a confirmation modal here would be cargo-culted from destructive-delete patterns where re-creation is impossible. The post-login screen is a placeholder anyway and gets replaced by household onboarding in Phase 3.
**Refresh-failure UX:** silent transition (CONTEXT D-18). When `AuthSession` detects an `invalid_grant` from a background token refresh, it emits `AuthState.Unauthenticated` and the auth gate routes to `LoginScreen`. No toast, no modal, no error message on the LoginScreen itself (the user landed there silently — there is no "previous attempt" to error about). Logged at `Logger.withTag("auth").w(...)` for diagnostics.
**Inline-error display rules (LoginScreen):**
- Error text is rendered **below** the primary button with `md` (16.dp) vertical gap.
- Button **stays enabled** during the error state — the user can retry by tapping again.
- Tapping the button again **clears** the previous error message before initiating a new login flow (so the user does not see stale error text during the next attempt).
- Error text uses `bodyLarge` typography role, `error` color (see Color section).
- Errors are NOT surfaced as Snackbars in Phase 2. Inline-below-button is the contract; Snackbars require a `Scaffold` host that Phase 2 does not need.
**Loading / pending UX (LoginScreen):**
- While AppAuth's authorization request is in flight (system browser is open), the LoginScreen does NOT need a separate loading state — the system browser is full-screen and obscures the app.
- After the system browser dismisses but before token exchange + JIT-provisioning completes, the button shows a `CircularProgressIndicator` (16.dp) inside its content slot, replacing the label, with the button **disabled**. Total expected duration: <500ms in practice.
- Implementation hint: a `Boolean` `isLoading` flag in `LoginScreenState` controls this.
**Splash UX:**
- Visible during `AuthState.Loading` (deserializing persisted `AuthState` blob, possibly running a refresh).
- Centered "Recipe" wordmark using `displaySmall`.
- `sm` (8.dp) below: a `CircularProgressIndicator` at default size (40.dp), `color = primary`.
- No "Loading..." text. No marketing copy. No tagline.
- Background = `surface` (matches Login + PostLogin to avoid a color flash when the auth gate transitions).
---
## Auth Gate Routing Contract
The `App()` composable observes `AuthSession.state: StateFlow<AuthState>` and renders exactly one of:
| `AuthState` value | Rendered composable |
|-------------------|---------------------|
| `AuthState.Loading` | `SplashScreen()` |
| `AuthState.Unauthenticated` | `LoginScreen(viewModel = koinViewModel())` |
| `AuthState.Authenticated(user, householdId)` | `PostLoginPlaceholderScreen(user, viewModel = koinViewModel())` (Phase 2). Phase 3 replaces with `HouseholdGate`. |
**Transition behavior:** state changes drive recomposition; no manual navigation calls. Material 3 default cross-fade (the implicit `Crossfade` recommended pattern, NOT explicit — keep Phase 2 minimal) is acceptable but not required. **Required:** no white flash between transitions — both screens use the same `surface` background.
Implementation note for executor: replace the existing `App.kt` body (currently the JetBrains template's button-and-greeting demo) with a `when` over `authSession.state.collectAsState().value`. Keep the existing `MaterialTheme { ... }` wrapper.
---
## Component Inventory (Phase 2)
Composables the planner / executor must produce in `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/`:
| Composable | File | Responsibility |
|------------|------|----------------|
| `SplashScreen()` | `SplashScreen.kt` | Stateless. Renders wordmark + progress indicator. No ViewModel — the auth gate above it owns state. |
| `LoginScreen(viewModel: LoginViewModel)` | `LoginScreen.kt` | Stateless wrt auth tokens (those live in `AuthSession`). Owns local UI state for `isLoading` + `errorKind`. Triggers `viewModel.onSignInClick()` which delegates to `AuthSession.login()`. |
| `LoginViewModel` | `LoginViewModel.kt` | Wraps `AuthSession`. Maps `AuthSession.LoginResult``LoginScreenState(isLoading, errorKey: StringResource?)`. Method-per-action: `onSignInClick()`. |
| `LoginScreenState` | (data class in `LoginViewModel.kt`) | `(val isLoading: Boolean, val errorKey: StringResource?)`. Immutable. |
| `PostLoginPlaceholderScreen(user: User, viewModel: PostLoginViewModel)` | `PostLoginPlaceholderScreen.kt` | Renders welcome text + logout button. Triggers `viewModel.onSignOutClick()`. |
| `PostLoginViewModel` | `PostLoginViewModel.kt` | Wraps `AuthSession.logout()`. Trivial in Phase 2 — exists so Phase 3's `HouseholdGate` follows the same VM pattern. |
| `RecipeTheme(content)` | `ui/theme/RecipeTheme.kt` | Top-level theme wrapper applying `lightColorScheme()` / `darkColorScheme()` based on `isSystemInDarkTheme()`. Wraps `MaterialTheme(colorScheme, typography, shapes)`. **Phase 2 ships this seed;** later phases extend with custom typography + shape tokens here. |
**No `Scaffold` in Phase 2.** Each of the three auth screens uses `Surface(modifier = Modifier.fillMaxSize().safeContentPadding())` as the root. `Scaffold` (with its `topBar` / `bottomBar` slots and Snackbar host) lands in Phase 5 (`RecipeListScreen`) or Phase 10 (`MainScaffold` chrome).
---
## Layout Contract
All three screens use a **vertically-centered single-column layout**:
```
Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.safeContentPadding()
.padding(horizontal = 16.dp) // md token
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize(),
)
```
**Per-screen content order (top → bottom):**
| Screen | Content |
|--------|---------|
| `SplashScreen` | Wordmark `displaySmall``Spacer(8.dp)``CircularProgressIndicator(color = primary)` |
| `LoginScreen` | Wordmark `displaySmall``Spacer(24.dp)``Button(onClick = onSignInClick) { Text(R.string.auth_sign_in_button) }` (or `CircularProgressIndicator(16.dp)` when `isLoading`) → `Spacer(16.dp)``Text(error, style = bodyLarge, color = error)` if `errorKey != null` |
| `PostLoginPlaceholderScreen` | `Text(stringResource(Res.string.auth_welcome_format, user.displayName), style = headlineSmall)``Spacer(24.dp)``OutlinedButton(onClick = onSignOutClick) { Text(R.string.auth_sign_out_button) }` |
**Width constraint:** content column natural-fits its children. No `widthIn(max = 480.dp)` tablet-narrowing in Phase 2 — the app targets phone-sized iOS first; tablet polish is post-v1.
---
## Component Sourcing & Safety
| Source | Components Used (Phase 2) | Safety Gate |
|--------|---------------------------|-------------|
| Material 3 stdlib (`androidx.compose.material3`) | `Surface`, `MaterialTheme`, `Text`, `Button`, `OutlinedButton`, `CircularProgressIndicator` | not required (first-party Compose Multiplatform stdlib, applied by `recipe.compose.multiplatform` convention plugin per Phase 1 D-07) |
| Compose Foundation (`androidx.compose.foundation`) | `Column`, `Spacer`, `Box`, `Modifier.background`, `Modifier.safeContentPadding`, `Modifier.fillMaxSize`, `Modifier.padding` | not required (first-party) |
| Compose Resources (`org.jetbrains.compose.components:components-resources`) | `stringResource`, generated `Res.string.*` accessors | not required (first-party Compose Multiplatform; Phase 1 generated `Res` accessors already wired) |
| Third-party UI registry | none in Phase 2 | not applicable |
**No Haze, no third-party UI components in Phase 2.** Haze is gated to Phase 10 per CLAUDE.md non-negotiable #10. Adding third-party UI components to the auth scaffold is explicitly out-of-scope.
---
## Accessibility
| Requirement | Implementation |
|-------------|----------------|
| Touch target ≥48dp | Material 3 `Button` / `OutlinedButton` defaults satisfy this; do not shrink |
| Color contrast (WCAG AA) | Material 3 baseline `lightColorScheme()` / `darkColorScheme()` ship WCAG AA-compliant role pairings (e.g., `onPrimary` on `primary`); seed override only changes `primary` so the contrast pairing holds |
| Dynamic type / font scaling | Material 3 `Typography` roles use `sp` (already scale-respecting); no override forcing fixed sizes |
| Screen reader semantics | `Button` carries its label as accessibility text by default; `Text` for the welcome line is announced by VoiceOver / TalkBack as plain content. No custom `Modifier.semantics` overrides required in Phase 2 |
| RTL | not applicable in Phase 2 (Polish is LTR) |
**Phase 11 will revisit:** `contentDescription` on any decorative imagery, semantic grouping of multi-element clusters, full VoiceOver pass on iOS device.
---
## Out-of-Scope (Reserved for Later Phases)
The following intentionally have NO contract in Phase 2:
| Concern | Owning Phase |
|---------|--------------|
| Tab bar / bottom navigation | Phase 10 (`UI Chrome & Haze`) |
| Top app bar / nav bar with Haze blur | Phase 10 |
| Glass / translucent surface tokens | Phase 10 |
| Display font selection + custom `FontFamily` | Phase 11 |
| Polished Polish copy with plural forms (1 / 2 / 5 / 22) | Phase 11 |
| Brand color final pass (re-seeding `primary`) | Phase 11 |
| In-app theme toggle (override system dark/light) | not in v1 (out of scope per PROJECT.md) |
| Animated transitions between auth states | Phase 10 |
| Logo / wordmark image asset | not in v1 — text wordmark only until Phase 11 brand pass |
---
## Checker Sign-Off
- [ ] Dimension 1 Copywriting: PASS — 6 string keys declared with Polish scaffold copy + Compose Resources delivery contract; inline-error UX rules locked; logout silent UX justified
- [ ] Dimension 2 Visuals: PASS — 3 composables specified with file paths, layout column structure, and no-Scaffold-in-Phase-2 boundary
- [ ] Dimension 3 Color: PASS — Material 3 `lightColorScheme()` / `darkColorScheme()` seeded with `#3B6939`; 60/30/10 mapped to `surface` / `surfaceContainer` / `primary`; accent reserved for the single LoginScreen CTA; dark mode required
- [ ] Dimension 4 Typography: PASS — exactly 4 sizes (14/16/24/36), exactly 2 weights (W400/W500), Material 3 role-to-element mapping locked
- [ ] Dimension 5 Spacing: PASS — full xs..3xl scale declared, all multiples of 4, Phase 2 uses sm/md/lg/2xl, others reserved
- [ ] Dimension 6 Component Sourcing: PASS — Material 3 stdlib only, no third-party UI, no Haze in Phase 2 (gated to Phase 10), no registry safety gate needed
**Approval:** pending
---
## UI-SPEC COMPLETE
**Phase:** 2 — Authentication Foundation
**Design System:** Compose Multiplatform Material 3 (no shadcn — KMP project)
### Contract Summary
- **Spacing:** 8-point scale `xs..3xl` (4 / 8 / 16 / 24 / 32 / 48 / 64 dp); Phase 2 actively uses sm / md / lg / 2xl
- **Typography:** 4 sizes (14, 16, 24, 36 sp), 2 weights (W400, W500); Material 3 roles `displaySmall` / `headlineSmall` / `bodyLarge` / `labelLarge`
- **Color:** Material 3 `light` + `dark` schemes seeded with `#3B6939`; 60% `surface` / 30% `surfaceContainer` / 10% `primary`; accent reserved for single LoginScreen CTA; logout uses `OutlinedButton` (not destructive `error`)
- **Copywriting:** 6 Compose Resources keys + Polish scaffold copy locked (`auth_app_name`, `auth_sign_in_button`, `auth_sign_out_button`, `auth_welcome_format`, `auth_error_cancelled`, `auth_error_network`, `auth_error_unknown`); inline-error UX + silent-logout UX defined
- **Component Sourcing:** Material 3 stdlib only — no Haze, no third-party UI registries (Phase 2 has no registry-safety gate to clear)
### File Created
`.planning/phases/02-authentication-foundation/02-UI-SPEC.md`
### Pre-Populated From
| Source | Decisions Used |
|--------|----------------|
| `02-CONTEXT.md` (D-30..D-34) | 5 (auth gate routing, login minimal, login error states, post-login placeholder, Compose Resources) |
| `02-CONTEXT.md` (Claude's Discretion) | 1 resolved here (splash visual = wordmark + circular progress indicator) |
| `PROJECT.md` (locked stack) | 4 (Material 3, system font, Polish-only v1, Liquid-Glass deferred to polish phase) |
| `CLAUDE.md` (non-negotiables) | 2 (#9 strings externalized day 1, #10 Haze on chrome only — gates Phase 2 to no-blur) |
| `ROADMAP.md` (phase boundaries) | 2 (Phase 10 owns UI chrome / Haze, Phase 11 owns localization + final polish) |
| `REQUIREMENTS.md` (AUTH-01..AUTH-06) | 1 (AUTH-05 logout returns to login screen) |
| `ARCHITECTURE.md` (component responsibilities) | 1 (`AuthSession` Koin singleton owning `StateFlow<AuthState>`) |
| `App.kt` (Phase 1 scaffold) | 1 (existing `MaterialTheme { ... }` + `safeContentPadding()` pattern preserved) |
### Awaiting / Notes for Downstream
- **Planner (`gsd-planner`):** the Component Inventory + Layout Contract sections give you concrete file paths and composable shapes; tokens in Spacing / Typography / Color sections are referenced via Material 3 theme accessors (`MaterialTheme.colorScheme.primary`, `MaterialTheme.typography.displaySmall`, etc.). The seed color `#3B6939` is the only manual override needed in `RecipeTheme.kt`.
- **Executor (`gsd-executor`):** replace `App.kt` body with the auth-gate `when`-block; do NOT keep the JetBrains template's button-and-greeting code. Wire `Res.string.*` keys via Compose Resources (`composeApp/src/commonMain/composeResources/values/strings.xml`).
- **Phase 10 / 11 hand-off seam:** every "Reserved for Phase 10/11" annotation in this doc is an explicit hand-off point; do not retroactively rewrite Phase 2's seed tokens during those phases unless the tradeoff is documented.
### Ready for Verification
UI-SPEC complete. Checker can now validate against the 6 design quality dimensions.

View File

@@ -0,0 +1,94 @@
---
phase: 02
slug: authentication-foundation
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-04-27
---
# Phase 02 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | kotlin.test + JUnit for server; KMP common tests for auth state/store seams |
| **Config file** | Existing Gradle/KMP test setup; no standalone test config |
| **Quick run command** | `./gradlew :server:test :composeApp:jvmTest :shared:jvmTest` |
| **Full suite command** | `./gradlew check` |
| **Estimated runtime** | ~120 seconds |
---
## Sampling Rate
- **After every task commit:** Run `./gradlew :server:test :composeApp:jvmTest :shared:jvmTest`
- **After every plan wave:** Run `./gradlew check`
- **Before `$gsd-verify-work`:** Full suite must be green and manual iOS Authentik login/logout UAT must be recorded
- **Max feedback latency:** 120 seconds for quick checks
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 02-01-01 | 01 | 1 | AUTH-01 | T-02-01 | OIDC config pins issuer, client ID, redirect URI, scopes, PKCE-compatible public-client flow | build/unit | `./gradlew :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileDebugKotlinAndroid` | ❌ W0 | ⬜ pending |
| 02-01-02 | 01 | 1 | AUTH-02 | T-02-02 | AuthState JSON store reads/writes/clears without using no-arg insecure Settings for secrets | common unit + grep | `./gradlew :composeApp:jvmTest` | ❌ W0 | ⬜ pending |
| 02-02-01 | 02 | 1 | AUTH-03 | T-02-03 | `/api/v1/me` rejects missing, expired, wrong-audience, and wrong-issuer tokens | server integration | `./gradlew :server:test --tests "*Auth*"` | ❌ W0 | ⬜ pending |
| 02-02-02 | 02 | 1 | AUTH-06 | T-02-04 | First authenticated `/api/v1/me` creates or updates a `users` row keyed by OIDC `sub` | server integration | `./gradlew :server:test --tests "*Me*"` | ❌ W0 | ⬜ pending |
| 02-03-01 | 03 | 1 | AUTH-04 | T-02-05 | Restored AuthState refreshes before `/api/v1/me` and transitions to authenticated without UI prompt | common/platform-stub unit | `./gradlew :composeApp:jvmTest` | ❌ W0 | ⬜ pending |
| 02-03-02 | 03 | 1 | AUTH-05 | T-02-06 | Logout calls end-session when possible and clears local AuthState even if network logout fails | unit + manual iOS UAT | `./gradlew :composeApp:jvmTest` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt` — covers valid, missing, expired, wrong-audience, and wrong-issuer JWT cases for AUTH-03
- [ ] `server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt` — covers JIT provisioning and `/api/v1/me` response shape for AUTH-06
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt` — covers login, restored session refresh, invalid-grant transition, and logout state transitions for AUTH-01, AUTH-04, AUTH-05
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreTest.kt` — covers read/write/clear store contract and guards against insecure no-arg `Settings()` use for AUTH-02
- [ ] `docs/authentik-setup.md` — includes manual iOS UAT checklist for fresh login, reopen-with-refresh, logout, and `/api/v1/me`
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Fresh iOS install opens Authentik, completes hosted login, and returns through `recipe://callback` | AUTH-01 | Requires real Authentik provider, iOS browser handoff, and custom URL callback | Install on iOS simulator/device, tap `Zaloguj się przez Authentik`, authenticate, verify app shows `Witaj, {displayName}!` |
| Reopen after access token expiry remains signed in via refresh token | AUTH-04 | Depends on Authentik-issued refresh token and persisted OS secure storage | Sign in, close app, wait or force short token lifetime in Authentik, reopen, verify app returns to authenticated screen without credential entry |
| `Wyloguj się` clears local tokens and invokes Authentik end-session | AUTH-05 | Requires browser/end-session behavior that unit tests can only stub | Tap `Wyloguj się`, verify login screen appears, then relaunch and confirm no silent local session restore |
---
## Security Threat References
| Threat Ref | Threat | Required Mitigation |
|------------|--------|---------------------|
| T-02-01 | Authorization-code interception through custom URL scheme | Public client, PKCE S256, AppAuth state/nonce handling, redirect URI byte-match |
| T-02-02 | Refresh token persisted in plaintext | Explicit secure platform store; iOS Keychain and Android secure storage; no no-arg `Settings()` for auth secrets |
| T-02-03 | Wrong-audience or wrong-issuer token accepted by server | `withIssuer`, `withAudience`, 30-second leeway only, non-empty `sub`, negative JWT tests |
| T-02-04 | Duplicate or stale user rows on concurrent first login | Atomic upsert by unique `sub`; update email/display name on conflict |
| T-02-05 | Token expiry breaks reopened sessions | AppAuth `performActionWithFreshTokens` before authenticated calls plus Ktor bearer 401 refresh fallback |
| T-02-06 | Logout leaves recoverable local refresh token | Always clear persisted AuthState after logout attempt, even if end-session fails |
---
## Validation Sign-Off
- [x] All tasks have `<automated>` verify or Wave 0 dependencies represented in Phase 2 plans
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 missing references are mapped into Phase 2 plan tasks
- [x] No watch-mode flags
- [x] Feedback latency target < 120s for quick checks is documented
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** plan-ready 2026-04-27; execution must keep `nyquist_compliant: false` and `wave_0_complete: false` until Wave 0 tests/manual-UAT artifacts actually exist.

View File

@@ -0,0 +1,99 @@
# Decision: Drop CocoaPods, switch to embedAndSign + SwiftPM bridge
**Date:** 2026-04-28
**Status:** Decided, not yet executed
**Trigger:** Xcode build fails with *"Incompatible 'embedAndSign' Task with CocoaPods Dependencies."* The Xcode run script calls `:composeApp:embedAndSignAppleFrameworkForXcode` while the Kotlin CocoaPods plugin is also active — these two iOS framework integration modes are mutually exclusive.
## Decision
Remove the Kotlin CocoaPods plugin. Deliver the shared framework via `embedAndSign` (current Xcode run script stays). Deliver AppAuth-iOS via Swift Package Manager in `iosApp.xcodeproj`. Move all AppAuth calls out of `iosMain` Kotlin and behind a Swift bridge injected via Koin.
## Why
- One integration mode, fewer moving parts (no Podfile, Pods/, .xcworkspace, no Ruby/CocoaPods gem prerequisite).
- Aligns with where Apple tooling is going (SwiftPM is the strategic direction; CocoaPods is in maintenance).
- AppAuth surface in `iosMain` is small and contained — migration is local.
- Eliminates the entire class of "cocoapods vs embedAndSign" Xcode build errors.
## Cost (what we accept)
- `OidcClient.ios.kt` (~231 lines) is rewritten to call a Swift bridge instead of `cocoapods.AppAuth.*` cinterop bindings.
- `iosApp/` gains a small Swift class implementing the bridge using AppAuth-iOS APIs directly.
- D-01 (PROJECT.md) remains AppAuth-iOS — only the *delivery channel* changes (CocoaPods → SwiftPM).
## Surface area in this repo (scanned)
AppAuth-iOS is used in exactly one place:
- `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` (231 lines, imports `cocoapods.AppAuth.*``OIDAuthState`, `OIDAuthorizationRequest`, `OIDAuthorizationService`, `OIDEndSessionRequest`, `OIDExternalUserAgentIOS`, `OIDResponseTypeCode`, error codes).
`SecureAuthStateStore.ios.kt` does NOT depend on AppAuth — it serializes `OIDAuthState` via `NSKeyedArchiver`. After migration, the serialized blob crosses the bridge as `NSData`/`ByteArray` and the Swift side does the archiving. Or we change the on-disk format to JSON of our own AuthState (cleaner; recommended).
## Work plan (execute in a fresh session)
### 1 — Gradle / build config
- `composeApp/build.gradle.kts`:
- Remove `id("org.jetbrains.kotlin.native.cocoapods")` from the `plugins { }` block.
- Remove the entire `cocoapods { ... }` block inside `kotlin { }`.
- Keep `group = "dev.ulfrx.recipe"` and `version = "1.0.0"` (the comment explaining "required by cocoapods plugin" can be deleted; group is also referenced by Compose Resources package naming — do NOT change `group`).
- The framework declaration is already provided by the `recipe.kotlin.multiplatform` convention plugin via `iosTarget.binaries.framework`. Verify it sets `baseName = "ComposeApp"` and `isStatic = true`. If not, add the `binaries.framework { baseName = "ComposeApp"; isStatic = true }` block to the iOS targets in the convention plugin (or inline in composeApp).
- `gradle/libs.versions.toml`: leave `appauth-ios` version entry — repurpose it as the documented SwiftPM pin in `docs/authentik-setup.md`. Or delete it and put the version only in the iOS project's Package.resolved.
### 2 — Delete CocoaPods artifacts
- Delete: `iosApp/Podfile`, `iosApp/Podfile.lock`, `iosApp/Pods/`, `iosApp/iosApp.xcworkspace/`.
- From now on open `iosApp/iosApp.xcodeproj` directly (not `.xcworkspace`).
- The Xcode run script stays — it already invokes `./gradlew :composeApp:embedAndSignAppleFrameworkForXcode`.
### 3 — Add AppAuth-iOS via SwiftPM in Xcode
- Open `iosApp.xcodeproj` → File → Add Package Dependencies → `https://github.com/openid/AppAuth-iOS` → choose "Up to Next Major" from the same major version currently in `libs.versions.toml`.
- Add `AppAuth` product to the `iosApp` target.
### 4 — Swift bridge (in `iosApp/iosApp/`)
- Define a Kotlin interface in `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/IosAuthBridge.kt`:
```kotlin
interface IosAuthBridge {
suspend fun authorize(presentingVc: UIViewController): AuthBridgeResult
suspend fun endSession(presentingVc: UIViewController, idToken: String): AuthBridgeResult
// refresh, plus serialize/deserialize hooks if you keep OIDAuthState as the persisted blob
}
sealed class AuthBridgeResult { data class Success(...) : AuthBridgeResult(); data class Error(val kind: ErrorKind, val message: String?) : AuthBridgeResult(); object Cancelled : AuthBridgeResult() }
```
Mark with `@OptIn(ExperimentalObjCName::class)` and `@ObjCName` so Swift sees stable names.
- Implement in Swift: `iosApp/iosApp/Auth/AuthBridge.swift` — uses `OIDAuthState`, `OIDAuthorizationService`, etc. Maps AppAuth callbacks → suspending Kotlin via `kotlinx.coroutines` continuation helpers (or callback-style if simpler — pick one and stay consistent).
- Decide AuthState persistence format:
- **Option A (recommended):** Define a Kotlin `AuthTokens` data class (access token, refresh token, id token, expiresAt, scopes). Bridge returns this. `SecureAuthStateStore.ios.kt` persists it as JSON via kotlinx.serialization. Removes the last AppAuth dependency from Kotlin and lets you delete `NSKeyedArchiver`/`NSKeyedUnarchiver` plumbing.
- **Option B:** Keep persisting opaque `NSData` blob produced by Swift via `NSKeyedArchiver(rootObject: OIDAuthState)`. Less rewrite of `SecureAuthStateStore`, but Kotlin is now blind to token contents (can't compute expiry locally).
- Wire in Koin from `iosApp` entry point (`MainViewController.kt` or wherever Koin's iOS module starts): `single<IosAuthBridge> { IosAuthBridgeImpl() }` where `IosAuthBridgeImpl` is an `@ObjCName`-annotated Kotlin shim that holds a reference to a Swift-side instance handed over from `iosApp` Swift code at startup.
### 5 — Rewrite `OidcClient.ios.kt`
- Drop all `cocoapods.AppAuth.*` imports.
- Inject `IosAuthBridge` via constructor (Koin).
- Each `OidcClient` method becomes a thin call into the bridge + result mapping to the existing common `OidcClient` contract (Cancelled / NetworkError / Failed / Success).
- Error code mapping (`OIDErrorCodeUserCanceledAuthorizationFlow`, `OIDErrorCodeProgramCanceledAuthorizationFlow`, `OIDErrorCodeNetworkError`) now lives in Swift, surfaced as `AuthBridgeResult.ErrorKind` enum.
### 6 — Verification
- `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64` — Kotlin compiles without cocoapods imports.
- Open Xcode, build & run on simulator — no "incompatible task" error.
- Re-run the Phase 02-07 manual UAT (login → welcome → logout → token refresh).
- `./gradlew check` — all existing tests still green; `LoginViewModelTest` / `AuthSessionTest` are unaffected (they test common code, not iOS actuals).
### 7 — Docs / planning updates
- `.planning/PROJECT.md` § Key Decisions: amend D-01 — "AppAuth-iOS via SwiftPM, called through a Swift bridge from `iosMain`. CocoaPods plugin removed 2026-04-28."
- `.planning/research/PITFALLS.md`: replace the cocoapods-specific pitfall (if any) with a SwiftPM-bridge pitfall ("Swift bridge instances must be handed in from `iosApp` at startup; do not try to instantiate AppAuth from pure Kotlin").
- `docs/authentik-setup.md` (or create it): document SwiftPM step for new contributors, AppAuth-iOS version pin, and how to open the project (`.xcodeproj` directly, not `.xcworkspace`).
- `CLAUDE.md` "Tech stack" line: change "Mobile OIDC: AppAuth on Android; ASWebAuthenticationSession wrapper on iOS (KMP interface)" — your current code uses AppAuth-iOS, not ASWebAuthenticationSession; keep AppAuth-iOS but note the SwiftPM + Swift-bridge delivery.
## Out of scope for this change
- Phase 02-07 manual UAT — must be re-run after the migration, but on the same auth flow.
- Pre-existing failures already logged in `.planning/phases/02-authentication-foundation/deferred-items.md` (Android Robolectric test, iOS ktlint warning).
## Rollback
If the SwiftPM bridge proves harder than expected:
- `git revert` the migration commit(s).
- Restore `Podfile`, run `pod install`, reopen `.xcworkspace`.
- The original cocoapods setup is recoverable from git history.
## Resume signal
Start a fresh Claude Code session in `/Users/rwilk/dev/repo/recipe`. Open this file as the briefing. Plan 02-07 stays at the human-verify checkpoint until the migration lands and UAT passes.

View File

@@ -0,0 +1,24 @@
# Deferred Items — Phase 02 (auth foundation)
## Pre-existing failures discovered during 02-07 `./gradlew check`
### `SecureAuthStateStoreContractTest` (Android JVM unit test) — pre-existing
- **Tests:** `clearRemovesStoredValue`, `writeOverwritesPreviousValueAndReadReturnsLatest`
- **File:** `composeApp/src/androidUnitTest/.../SecureAuthStateStoreContractTest.kt`
- **Failure:** `java.lang.IllegalStateException` at construction (Android Keystore not available in
plain JVM unit tests under Robolectric-less harness).
- **Provenance:** Reproduced on `master` HEAD before any 02-07 change (verified via `git stash`
+ run of `./gradlew :composeApp:testDebugUnitTest`).
- **Not caused by 02-07.** Source plan was 02-04 (Android secure-store actuals). Likely
needs Robolectric or an instrumented (`androidTest`) target. Out of scope for 02-07's
UI gate plan.
- **Action:** Track for a follow-up Android-test infra task; do not block Phase 02 on it.
### Spotless `property-naming` lint in `SecureAuthStateStore.ios.kt:L31` — pre-existing
- Reproduced on `master` HEAD before any 02-07 change.
- Source plan: 02-05 (iOS auth actuals).
- ktlint expects SCREAMING_SNAKE_CASE for an immutable property; the iOS implementation
uses camelCase. Fix is a one-line rename or `suppressLintsFor` annotation.
- Out of scope for 02-07; track for follow-up.

View File

@@ -116,7 +116,9 @@
**Warning signs:** Works on Android, fails on iOS (or vice versa); Authentik logs show `invalid_grant`; no `code_challenge` in auth request; fails on release build only.
**How to avoid:** Authentik provider = "Public" + PKCE S256. Register both `recipe://callback` and `recipe://callback/`. AppAuth (Android) + ASWebAuthenticationSession (iOS) with `usePKCE = true`. Keep the redirect URI in one constant in `shared/commonMain`.
**How to avoid:** Authentik provider = "Public" + PKCE S256. Register both `recipe://callback` and `recipe://callback/`. AppAuth on both platforms — Kotlin actual on Android, Swift `AuthBridge` (over AppAuth-iOS via SwiftPM) called from `iosMain` on iOS with `usePKCE = true`. Keep the redirect URI in one constant in `shared/commonMain`.
**iOS bridge gotcha:** the Swift `AuthBridge` instance must be set on `IosAuthBridgeRegistry.shared.instance` from `iOSApp.init` *before* `KoinIosKt.doInitKoin()` runs — otherwise Koin's `single<IosAuthBridge>` fails on first auth call. Do not try to instantiate AppAuth from pure Kotlin: there is no `cocoapods.AppAuth.*` available since 2026-04-28.
**Phase:** Auth.

117
AGENTS.md Normal file
View File

@@ -0,0 +1,117 @@
# AGENTS.md
Guidance for Codex when working in this repository.
## Project
**Recipe** (working title) — a household meal planning + pantry + shopping list app built with Kotlin Multiplatform (iOS-primary) and a self-hosted Ktor server. Offline-first with last-write-wins sync; household sharing (me + partner); auth via self-hosted Authentik (OIDC).
**Core value:** "My week is planned." I pick recipes, the calendar fills up, and I know what we're eating.
## Planning workflow — always start here
This project uses GSD (Get Shit Done). All product scope, tech decisions, requirements, and phase structure live in `.planning/`. **Read these files before doing any implementation work.**
| File | What it is | When to read |
|------|-----------|--------------|
| `.planning/PROJECT.md` | Product scope, locked tech decisions, constraints, out-of-scope | Every session — source of truth |
| `.planning/REQUIREMENTS.md` | 72 v1 requirements with REQ-IDs grouped by category, plus v2 / out-of-scope | When touching any feature area |
| `.planning/ROADMAP.md` | 11 phases with goals, mapped requirements, success criteria | To know which phase we're in |
| `.planning/STATE.md` | Current phase + high-level pointer | Fast orientation |
| `.planning/config.json` | Workflow settings (YOLO mode, fine granularity, quality models) | Rarely — set once |
| `.planning/research/SUMMARY.md` | Executive synthesis of architecture + pitfalls research | When planning a phase |
| `.planning/research/ARCHITECTURE.md` | Component structure, data flow, build-order reasoning | When structuring code |
| `.planning/research/PITFALLS.md` | 14 critical pitfalls specific to this stack | Before touching auth, sync, or iOS specifics |
## Tech stack (locked — see PROJECT.md § Key Decisions for full rationale)
**Client (`composeApp/`):**
- Kotlin Multiplatform + Compose Multiplatform (iOS-primary; Android, Desktop, Wasm secondary)
- Navigation: `org.jetbrains.androidx.navigation:navigation-compose` (JetBrains-official CMP port of Jetpack Navigation)
- State: ViewModel + StateFlow, method-per-action; `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose`
- DI: Koin (`koin-core`, `koin-compose`, `koin-compose-viewmodel`)
- Local DB: SQLDelight 2.x (raw `.sq` files, generated type-safe Kotlin)
- HTTP: Ktor Client
- Serialization: kotlinx.serialization
- Date/time: kotlinx.datetime
- Logging: Kermit (Touchlab)
- Images: Coil 3 (`io.coil-kt.coil3:coil-compose`)
- Settings/KV: `com.russhwolf:multiplatform-settings`
- Glass/blur: Haze (`dev.chrisbanes.haze:haze`)
- Mobile OIDC: AppAuth on Android; ASWebAuthenticationSession wrapper on iOS (KMP interface)
**Server (`server/`):**
- Ktor Server 3.x on the user's homelab (alongside Authentik)
- Postgres
- Exposed (DSL only — never the DAO / active-record API)
- Flyway for migrations
- Auth: `io.ktor:ktor-server-auth-jwt` validating Authentik tokens via JWKS
**Shared (`shared/commonMain`):**
- Domain models + API DTOs only
- No UI, no HTTP, no DB code — keep dependency graph minimal
**Sync:** Last-write-wins with server-assigned `updated_at`; HTTP polling (2030s foreground) + pull-to-refresh + debounced push after writes. `POST /api/v1/sync/push`, `GET /api/v1/sync/pull?since=...`.
## Module structure
```
recipe/
├── composeApp/ # KMP: commonMain + androidMain + iosMain + jvmMain (desktop)
├── iosApp/ # iOS bootstrap (Swift/SwiftUI thin shell)
├── server/ # Ktor + Exposed + Postgres + Flyway
├── shared/ # commonMain: domain + DTOs, no UI/HTTP/DB
├── build-logic/ # Convention plugins (Kotlin/Compose/test config)
├── gradle/libs.versions.toml # Single source of truth for versions
└── .planning/ # GSD planning artifacts (see above)
```
**Package layout inside `composeApp/commonMain`:**
```
dev.ulfrx.recipe/
├── app/ # App entry, Koin init, theme
├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab)
├── ui/
│ ├── theme/ # Colors, typography, Haze glass styles
│ ├── components/ # Shared composables
│ └── screens/{recipes,planner,pantry,shopping}/ # Each with screen + ViewModel
├── data/{local,remote,repository}/
└── domain/ # Client-only logic; shared/ handles cross-cutting
```
**Rule:** No feature modules in v1. Flat `composeApp/commonMain` with the package layout above.
## Non-negotiable conventions
1. **Sync timestamps come from the server, never the device.** `updated_at` is assigned server-side; pulling uses lexicographic `(updated_at, id)` cursor.
2. **Row identity is always UUIDs, never composite natural keys.** `(date, slot)` is not a primary key. See ARCHITECTURE.md § Anti-Patterns.
3. **Household scope is enforced in 3 layers:** client query filter + server `PrincipalResolver` deriving `householdId` from JWT `sub` + DB `household_id` column. Never accept `household_id` from request body.
4. **All sync I/O goes through the `SyncEngine` Koin singleton.** Features write to SQLDelight + outbox, never to HTTP directly. See ARCHITECTURE.md § Pattern 2.
5. **Exposed DSL only, never DAO.** Active-record pattern has footguns with JSONB and coroutines.
6. **`newSuspendedTransaction` for every coroutine-touching handler.** Plain `transaction {}` inside a `suspend` block exhausts the connection pool.
7. **iOS binary flags on day 1:** `kotlin.native.binary.objcDisposeOnMain=false`, `kotlin.native.binary.gc=cms`.
8. **`shared/commonMain` stays light.** No Ktor, Compose, or SQLDelight imports.
9. **Strings externalized from day 1** — Polish-only content, but resources are multi-locale-ready.
10. **Haze blur on chrome only** (tab bar, nav bar), never over fast-scrolling content.
## Current phase
See `.planning/STATE.md`. The roadmap has 11 phases; you must work within the currently active one. Don't jump ahead.
**Build order (load-bearing — do not reorder):**
Phase 1 Infra → Phase 2 Auth → Phase 3 Households → Phase 4 SyncEngine skeleton → Phase 5 Recipe catalog → Phase 6 Planner core → Phase 7 Planner customization/nutrition → Phase 8 Pantry → Phase 9 Shopping → Phase 10 UI chrome (Haze) → Phase 11 Localization + deployment.
## GSD commands you'll use
- `/gsd-progress` — show current state and suggest next action
- `/gsd-discuss-phase N` — socratic phase clarification before planning
- `/gsd-plan-phase N` — produce detailed PLAN.md for phase N
- `/gsd-execute-phase N` — execute the plans in phase N
- `/gsd-next` — automatically advance to the next logical step
## Functional reference
The legacy PWA mockup at `~/dev/repo/recipe-mockup/` is the **functional** reference (logic, data shapes, user flows). It is **not** a visual reference — UI is being rebuilt around a Liquid-Glass-inspired language. Mine it for planner customization logic (substitutions, amount overrides, product selections), shortfall computation, and shopping aggregation. Do not port its vanilla-JS data or Tailwind styling.
---
*Initialized: 2026-04-24. Update when `.planning/PROJECT.md` § Key Decisions gains load-bearing new entries.*

View File

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

View File

@@ -4,14 +4,7 @@ plugins {
dependencies {
compileOnly(libs.plugins.kotlinMultiplatform.asDependency())
compileOnly(libs.plugins.androidApplication.asDependency())
compileOnly(libs.plugins.composeMultiplatform.asDependency())
compileOnly(libs.plugins.composeCompiler.asDependency())
compileOnly(libs.plugins.composeHotReload.asDependency())
compileOnly(libs.plugins.kotlinJvm.asDependency())
compileOnly(libs.plugins.ktor.asDependency())
compileOnly(libs.plugins.spotless.asDependency())
compileOnly(libs.plugins.flywayPlugin.asDependency())
}
fun Provider<PluginDependency>.asDependency(): Provider<String> =

View File

@@ -1,35 +0,0 @@
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
plugins {
id("com.android.application")
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
android {
namespace = "dev.ulfrx.recipe"
compileSdk = libs.findVersion("android-compileSdk").get().toString().toInt()
defaultConfig {
applicationId = "dev.ulfrx.recipe"
minSdk = libs.findVersion("android-minSdk").get().toString().toInt()
targetSdk = libs.findVersion("android-targetSdk").get().toString().toInt()
versionCode = 1
versionName = "1.0"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}

View File

@@ -1,27 +0,0 @@
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
plugins {
id("recipe.kotlin.multiplatform")
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.compose.hot-reload")
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.findLibrary("compose-runtime").get())
implementation(libs.findLibrary("compose-foundation").get())
implementation(libs.findLibrary("compose-material3").get())
implementation(libs.findLibrary("compose-ui").get())
implementation(libs.findLibrary("compose-components-resources").get())
implementation(libs.findLibrary("androidx-lifecycle-viewmodelCompose").get())
implementation(libs.findLibrary("androidx-lifecycle-runtimeCompose").get())
implementation(libs.findLibrary("koin-compose").get())
implementation(libs.findLibrary("koin-composeViewmodel").get())
}
}
}

View File

@@ -1,41 +0,0 @@
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
plugins {
id("org.jetbrains.kotlin.jvm")
id("io.ktor.plugin")
id("org.flywaydb.flyway")
application
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
kotlin {
jvmToolchain(21)
compilerOptions {
allWarningsAsErrors.set(true)
}
}
dependencies {
"implementation"(libs.findLibrary("ktor-serverCore").get())
"implementation"(libs.findLibrary("ktor-serverNetty").get())
"implementation"(libs.findLibrary("ktor-serverContentNegotiation").get())
"implementation"(libs.findLibrary("ktor-serializationKotlinxJson").get())
"implementation"(libs.findLibrary("logback").get())
"implementation"(libs.findLibrary("flyway-core").get())
"implementation"(libs.findLibrary("flyway-database-postgresql").get())
"implementation"(libs.findLibrary("postgresql").get())
"testImplementation"(libs.findLibrary("ktor-serverTestHost").get())
"testImplementation"(libs.findLibrary("kotlin-testJunit").get())
}
flyway {
url = System.getenv("DATABASE_URL") ?: "jdbc:postgresql://localhost:5432/recipe"
user = System.getenv("DATABASE_USER") ?: "recipe"
password = System.getenv("DATABASE_PASSWORD") ?: "recipe"
locations = arrayOf("classpath:db/migration")
cleanDisabled = true
baselineOnMigrate = true
validateOnMigrate = true
}

View File

@@ -1,6 +1,8 @@
// build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts
// Establishes the D-05 target matrix + JVM toolchain + common deps.
// Establishes the D-05 target matrix + JVM toolchain + warning policy.
// Android bytecode is JVM 11 (D-08); server + desktop + shared/jvm are JVM 21.
//
// This plugin is intentionally dependency-free: shared/ must stay light
// (no Koin, no Kermit), and composeApp adds those in its own build file.
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
@@ -22,10 +24,24 @@ kotlin {
}
}
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
// Framework declaration moved here from composeApp/build.gradle.kts when the
// CocoaPods plugin was dropped (2026-04-28). The Xcode run script invokes
// :composeApp:embedAndSignAppleFrameworkForXcode, which needs `baseName` to
// resolve `import ComposeApp` from Swift. `isStatic = true` keeps the link
// shape unchanged from the previous CocoaPods setup. The `:shared` module is
// re-exported so the Swift `AuthBridge` can read `Constants` (single source
// of truth for OIDC issuer / client id / redirect URI).
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "ComposeApp"
isStatic = true
// `composeApp` only applies the multiplatform plugin; project deps
// live in its own build file. Skip the export when this convention
// plugin is applied to a module that doesn't depend on `:shared`
// (e.g., shared itself).
project.findProject(":shared")?.let { sharedProject ->
if (project != sharedProject) export(sharedProject)
}
}
}
@@ -43,11 +59,6 @@ kotlin {
}
sourceSets {
commonMain.dependencies {
implementation(project.dependencies.platform(libs.findLibrary("koin-bom").get()))
implementation(libs.findLibrary("koin-core").get())
implementation(libs.findLibrary("kermit").get())
}
commonTest.dependencies {
implementation(libs.findLibrary("kotlin-test").get())
}
@@ -67,3 +78,11 @@ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompileCommon>().configur
allWarningsAsErrors.set(false)
}
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinNativeCompile>().configureEach {
if (name.endsWith("KotlinMetadata")) {
compilerOptions {
allWarningsAsErrors.set(false)
}
}
}

View File

@@ -27,7 +27,7 @@ spotless {
plugins.withId("org.jetbrains.kotlin.multiplatform") {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
compilerOptions {
allWarningsAsErrors.set(true)
allWarningsAsErrors.set(!name.endsWith("KotlinMetadata"))
}
}
}

View File

@@ -7,8 +7,9 @@ plugins {
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinJvm) apply false
alias(libs.plugins.kotlinSerialization) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.ktor) apply false
alias(libs.plugins.spotless) apply false
alias(libs.plugins.flywayPlugin) apply false
}
}

View File

@@ -1,27 +1,130 @@
plugins {
// AGP must apply BEFORE recipe.kotlin.multiplatform — the latter calls androidTarget(),
// which requires the Android Gradle Plugin to already be on the project. Gradle applies
// plugin IDs in declaration order, so recipe.android.application is listed first.
id("recipe.android.application")
// AGP must apply before recipe.kotlin.multiplatform — the latter calls androidTarget(),
// which requires the Android Gradle Plugin to already be on the project.
alias(libs.plugins.androidApplication)
id("recipe.kotlin.multiplatform")
id("recipe.compose.multiplatform")
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeHotReload)
alias(libs.plugins.kotlinSerialization)
id("recipe.quality")
}
// `group` is referenced by Compose Resources package naming — the
// `compose.resources { packageOfResClass }` block below pins the historical package
// regardless, but keep `group` set explicitly. Gradle artifact metadata only.
group = "dev.ulfrx.recipe"
version = "1.0.0"
android {
namespace = "dev.ulfrx.recipe"
compileSdk =
libs.versions.android.compileSdk
.get()
.toInt()
defaultConfig {
applicationId = "dev.ulfrx.recipe"
minSdk =
libs.versions.android.minSdk
.get()
.toInt()
targetSdk =
libs.versions.android.targetSdk
.get()
.toInt()
versionCode = 1
versionName = "1.0"
// AppAuth-Android (D-01) bundles a manifest entry for its
// `RedirectUriReceiverActivity` that requires `${appAuthRedirectScheme}` to be
// resolved at merge time. Pin it to the Phase 2 redirect scheme so simply
// pulling AppAuth into the classpath (Plan 02-01) doesn't break AGP's manifest
// merger before Plan 02-04 lands the full `<intent-filter>` registration.
// Must match `dev.ulfrx.recipe.shared.Constants.OIDC_REDIRECT_URI` byte-for-byte.
manifestPlaceholders["appAuthRedirectScheme"] = "recipe"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.composeViewmodel)
implementation(libs.kermit)
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.compose.components.resources)
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
// `api` so `:shared` types (notably `Constants`) flow through to the
// exported ObjC framework headers — the iOS Swift bridge needs them.
api(projects.shared)
// Phase 2: Ktor client + serialization + secure settings (D-13, D-16, D-17).
// The MPP variant of `ktor-serialization-kotlinx-json` is required here; the
// server module keeps the `-jvm` variant via `libs.ktor.serializationKotlinxJson`.
implementation(libs.ktor.clientCore)
implementation(libs.ktor.clientAuth)
implementation(libs.ktor.clientContentNegotiation)
implementation(libs.ktor.clientLogging)
implementation(libs.ktor.serializationKotlinxJsonMpp)
implementation(libs.kotlinx.serializationJson)
implementation(libs.multiplatform.settings)
implementation(libs.multiplatform.settings.coroutines)
}
commonTest.dependencies {
// 02-07: kotlinx.coroutines.test.runTest is the multiplatform-safe
// alternative to runBlocking (which is JVM/Native-only and breaks the
// wasmJs test target). All commonTest coroutine tests use it.
implementation(libs.kotlinx.coroutinesTest)
}
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
implementation(libs.koin.android)
// Phase 2 Android: AppAuth-Android + AndroidX Security Crypto for the
// SecureAuthStateStore actual (D-01, D-13). EncryptedSharedPreferences is
// accepted technical debt per Open Question #1; the Keystore-backed
// implementation can replace it without touching AuthSession.
implementation(libs.appauth)
implementation(libs.androidx.security.crypto)
implementation(libs.ktor.clientOkhttp)
}
commonMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(projects.shared)
iosMain.dependencies {
// Phase 2 iOS: Darwin engine for Ktor. AppAuth-iOS is delivered via
// SwiftPM in iosApp.xcodeproj and consumed through a Swift bridge —
// no Kotlin-side AppAuth dependency (DECISION-drop-cocoapods, 2026-04-28).
implementation(libs.ktor.clientDarwin)
}
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
// Phase 2 Desktop: CIO is the JVM Ktor engine for the dev-mode auth stub
// (D-02). The full stub lives in Plan 02-04; this just makes the engine
// available so `composeApp:run` still compiles in Phase 2.
implementation(libs.ktor.clientCio)
}
}
}
@@ -29,3 +132,11 @@ kotlin {
dependencies {
debugImplementation(libs.compose.uiTooling)
}
// `group = "dev.ulfrx.recipe"` shifts the Compose Resources `Res` class package from
// `recipe.composeapp.generated.resources` to `dev.ulfrx.recipe.composeapp.generated.resources`,
// breaking the Phase 1 `App.kt` import. Lock the historical package so module-naming
// changes don't cascade into UI code.
compose.resources {
packageOfResClass = "recipe.composeapp.generated.resources"
}

View File

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

View File

@@ -0,0 +1,331 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import dev.ulfrx.recipe.shared.Constants
import kotlinx.coroutines.suspendCancellableCoroutine
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.EndSessionRequest
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenResponse
import org.koin.core.context.GlobalContext
import kotlin.coroutines.resume
actual class OidcClient {
private val context: Context
get() = GlobalContext.get().get<Context>().applicationContext
actual suspend fun login(): OidcResult {
val configuration =
when (val outcome = fetchConfiguration()) {
is ConfigurationOutcome.Success -> outcome.configuration
is ConfigurationOutcome.Error -> return outcome.exception.toOidcError()
}
val request =
AuthorizationRequest
.Builder(
configuration,
Constants.OIDC_CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse(Constants.OIDC_REDIRECT_URI),
).setScopes("openid", "profile", "email", "offline_access")
.build()
val service = AuthorizationService(context)
return try {
when (val authorization = service.performAuthorization(request)) {
is AuthorizationOutcome.Success -> exchangeCode(service, authorization.response)
is AuthorizationOutcome.Cancelled -> OidcResult.Cancelled
is AuthorizationOutcome.Error -> authorization.exception.toOidcError()
}
} finally {
service.dispose()
}
}
actual suspend fun refresh(authStateJson: String): OidcResult {
val authState =
runCatching { AuthState.jsonDeserialize(authStateJson) }
.getOrElse { return OidcResult.AuthError("Invalid AuthState JSON", it) }
val service = AuthorizationService(context)
return try {
service.freshTokens(authState)
} finally {
service.dispose()
}
}
actual suspend fun logout(authStateJson: String) {
val authState = runCatching { AuthState.jsonDeserialize(authStateJson) }.getOrNull() ?: return
val configuration = authState.authorizationServiceConfiguration ?: return
if (configuration.endSessionEndpoint == null) return
val request =
EndSessionRequest
.Builder(configuration)
.setIdTokenHint(authState.idToken)
.setPostLogoutRedirectUri(Uri.parse(Constants.OIDC_REDIRECT_URI))
.build()
val service = AuthorizationService(context)
try {
service.performEndSession(request)
} finally {
service.dispose()
}
}
private suspend fun fetchConfiguration(): ConfigurationOutcome =
suspendCancellableCoroutine { continuation ->
AuthorizationServiceConfiguration.fetchFromIssuer(Uri.parse(Constants.OIDC_ISSUER)) { configuration, exception ->
if (!continuation.isActive) return@fetchFromIssuer
when {
configuration != null -> {
continuation.resume(ConfigurationOutcome.Success(configuration))
}
exception != null -> {
continuation.resume(ConfigurationOutcome.Error(exception))
}
else -> {
continuation.resume(
ConfigurationOutcome.Error(
AuthorizationException.GeneralErrors.INVALID_DISCOVERY_DOCUMENT,
),
)
}
}
}
}
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
}
}

View File

@@ -0,0 +1,50 @@
@file:Suppress("DEPRECATION", "EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.koin.core.context.GlobalContext
actual class SecureAuthStateStore {
private val preferences by lazy {
val appContext = GlobalContext.get().get<Context>().applicationContext
val masterKey =
MasterKey
.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
// AndroidX Security Crypto is deprecated, but AUTH-02 explicitly requires
// EncryptedSharedPreferences in v1; this abstraction contains that debt.
EncryptedSharedPreferences.create(
appContext,
FILE_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
actual fun read(): String? = preferences.getString(KEY_AUTH_STATE_JSON, null)
actual fun write(authStateJson: String) {
preferences
.edit()
.putString(KEY_AUTH_STATE_JSON, authStateJson)
.apply()
}
actual fun clear() {
preferences
.edit()
.remove(KEY_AUTH_STATE_JSON)
.apply()
}
private companion object {
const val FILE_NAME = "recipe_auth_state"
const val KEY_AUTH_STATE_JSON = "auth_state_json"
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Phase 2 auth scaffold copy. Polish-only v1; resources are multi-locale-ready per
CLAUDE.md non-negotiable #9. Phase 11 polishes copy + plurals; do not edit
these keys without coordinating with the auth UI in `ui/screens/auth/*`.
-->
<resources>
<string name="auth_app_name">Recipe</string>
<string name="auth_sign_in_button">Zaloguj się przez Authentik</string>
<string name="auth_sign_out_button">Wyloguj się</string>
<string name="auth_welcome_format">Witaj, %1$s!</string>
<string name="auth_error_cancelled">Logowanie anulowane. Spróbuj ponownie.</string>
<string name="auth_error_network">Nie można połączyć z Authentik. Sprawdź połączenie.</string>
<string name="auth_error_unknown">Coś poszło nie tak. Spróbuj ponownie.</string>
</resources>

View File

@@ -1,52 +1,54 @@
package dev.ulfrx.recipe
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import org.jetbrains.compose.resources.painterResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.compose_multiplatform
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthState
import dev.ulfrx.recipe.ui.screens.auth.LoginScreen
import dev.ulfrx.recipe.ui.screens.auth.LoginViewModel
import dev.ulfrx.recipe.ui.screens.auth.PostLoginPlaceholderScreen
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
import dev.ulfrx.recipe.ui.screens.auth.SplashScreen
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
/**
* Auth gate. Observes [AuthSession.state] and renders Splash / Login / PostLogin per
* `02-UI-SPEC.md` § Auth Gate Routing Contract. State changes drive recomposition;
* no manual navigation. Phase 3 replaces the `Authenticated` branch with `HouseholdGate`.
*/
@Composable
@Preview
fun App() {
MaterialTheme {
var showContent by remember { mutableStateOf(false) }
Column(
modifier =
Modifier
.background(MaterialTheme.colorScheme.primaryContainer)
.safeContentPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Button(onClick = { showContent = !showContent }) {
Text("Click me!")
RecipeTheme {
val authSession = koinInject<AuthSession>()
val authState by authSession.state.collectAsStateWithLifecycle()
// Kick off the persisted-session restore once. AuthSession.initialize()
// refreshes the stored AuthState (or transitions to Unauthenticated on
// empty store / refresh failure) and the gate below recomposes accordingly.
LaunchedEffect(authSession) {
authSession.initialize()
}
when (val current = authState) {
AuthState.Loading -> {
SplashScreen()
}
AnimatedVisibility(showContent) {
val greeting = remember { Greeting().greet() }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(painterResource(Res.drawable.compose_multiplatform), null)
Text("Compose: $greeting")
}
AuthState.Unauthenticated -> {
LoginScreen(viewModel = koinViewModel<LoginViewModel>())
}
is AuthState.Authenticated -> {
PostLoginPlaceholderScreen(
user = current.user,
viewModel = koinViewModel<PostLoginViewModel>(),
)
}
}
}

View File

@@ -0,0 +1,60 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.shared.Constants
import io.ktor.client.HttpClient
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.http.HttpHeaders
import io.ktor.http.Url
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
object AuthHttpClient {
fun create(authSession: AuthSession): HttpClient =
HttpClient {
install(ContentNegotiation) {
json(authJson)
}
install(Auth) {
bearer {
loadTokens {
authSession.currentBearerTokens()
}
refreshTokens {
authSession.refreshBearerTokens()
}
sendWithoutRequest { request ->
request.url.host == Url(Constants.API_BASE_URL).host
}
}
}
install(Logging) {
level = LogLevel.HEADERS
sanitizeHeader { header -> header.equals(HttpHeaders.Authorization, ignoreCase = true) }
logger =
object : Logger {
override fun log(message: String) {
co.touchlab.kermit.Logger
.withTag("auth-http")
.i(redact(message))
}
}
}
}
private fun redact(message: String): String =
message
.replace(Regex("Bearer\\s+[^\\s,;]+"), "Bearer <redacted>")
.replace(Regex("(?i)(Authorization:\\s*)[^\\n\\r]+")) { match ->
match.groupValues[1] + "<redacted>"
}
private val authJson =
Json {
ignoreUnknownKeys = true
}
}

View File

@@ -0,0 +1,27 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.ui.screens.auth.LoginViewModel
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
import io.ktor.client.HttpClient
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
val authModule =
module {
single<SecureAuthStateStore> { SecureAuthStateStore() }
single<OidcClient> { OidcClient() }
single<MeClient> { MeClient() }
single<AuthSession> {
AuthSession(
oidcClient = get<OidcClient>(),
store = get<SecureAuthStateStore>(),
meClient = get<MeClient>(),
)
}
single<HttpClient> { AuthHttpClient.create(get()) }
// Phase 2 auth-gate ViewModels (02-07). Registered here so the same module that
// owns AuthSession also owns its UI consumers; Koin scopes them per Composable.
viewModel { LoginViewModel(authSession = get()) }
viewModel { PostLoginViewModel(authSession = get()) }
}

View File

@@ -0,0 +1,176 @@
package dev.ulfrx.recipe.auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
interface OidcClientGateway {
suspend fun login(): OidcResult
suspend fun refresh(authStateJson: String): OidcResult
suspend fun logout(authStateJson: String)
}
interface AuthStateStore {
fun read(): String?
fun write(authStateJson: String)
fun clear()
}
sealed interface AuthLoginResult {
data object Success : AuthLoginResult
data object Cancelled : AuthLoginResult
data object NetworkError : AuthLoginResult
data class Failed(
val message: String,
) : AuthLoginResult
}
class AuthSession(
private val oidcClient: OidcClientGateway,
private val store: AuthStateStore,
private val meClient: MeGateway,
) {
constructor(
oidcClient: OidcClient,
store: SecureAuthStateStore,
meClient: MeClient,
) : this(
oidcClient =
object : OidcClientGateway {
override suspend fun login(): OidcResult = oidcClient.login()
override suspend fun refresh(authStateJson: String): OidcResult = oidcClient.refresh(authStateJson)
override suspend fun logout(authStateJson: String) {
oidcClient.logout(authStateJson)
}
},
store =
object : AuthStateStore {
override fun read(): String? = store.read()
override fun write(authStateJson: String) {
store.write(authStateJson)
}
override fun clear() {
store.clear()
}
},
meClient = meClient,
)
private val _state = MutableStateFlow<AuthState>(AuthState.Loading)
val state: StateFlow<AuthState> = _state.asStateFlow()
private var currentTokens: BearerTokens? = null
suspend fun initialize() {
_state.value = AuthState.Loading
val storedJson = store.read()
if (storedJson.isNullOrBlank()) {
clearSession()
return
}
when (val refreshResult = oidcClient.refresh(storedJson)) {
is OidcResult.Success -> authenticate(refreshResult)
OidcResult.Cancelled,
OidcResult.NetworkError,
is OidcResult.AuthError,
-> clearSession()
}
}
suspend fun login(): AuthLoginResult =
when (val loginResult = oidcClient.login()) {
is OidcResult.Success -> {
authenticate(loginResult)
AuthLoginResult.Success
}
OidcResult.Cancelled -> {
_state.value = AuthState.Unauthenticated
AuthLoginResult.Cancelled
}
OidcResult.NetworkError -> {
_state.value = AuthState.Unauthenticated
AuthLoginResult.NetworkError
}
is OidcResult.AuthError -> {
_state.value = AuthState.Unauthenticated
AuthLoginResult.Failed(loginResult.message)
}
}
suspend fun logout() {
val storedJson = store.read()
if (!storedJson.isNullOrBlank()) {
runCatching {
oidcClient.logout(storedJson)
}
}
clearSession()
}
suspend fun getAccessToken(): String? = refreshBearerTokens()?.accessToken
fun currentBearerTokens(): BearerTokens? = currentTokens
suspend fun refreshBearerTokens(): BearerTokens? {
val storedJson =
store.read() ?: return null.also {
clearSession()
}
return when (val refreshResult = oidcClient.refresh(storedJson)) {
is OidcResult.Success -> {
persistTokens(refreshResult)
currentTokens
}
OidcResult.Cancelled,
OidcResult.NetworkError,
is OidcResult.AuthError,
-> {
null.also {
clearSession()
}
}
}
}
private suspend fun authenticate(result: OidcResult.Success) {
persistTokens(result)
val user = meClient.getMe(result.accessToken)
_state.value = AuthState.Authenticated(user = user, householdId = null)
}
private fun persistTokens(result: OidcResult.Success) {
store.write(result.authStateJson)
currentTokens =
BearerTokens(
accessToken = result.accessToken,
refreshToken = result.authStateJson,
)
}
private fun clearSession() {
currentTokens = null
store.clear()
_state.value = AuthState.Unauthenticated
}
}

View File

@@ -0,0 +1,16 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.shared.dto.User
typealias HouseholdId = String
sealed class AuthState {
data object Loading : AuthState()
data object Unauthenticated : AuthState()
data class Authenticated(
val user: User,
val householdId: HouseholdId? = null,
) : AuthState()
}

View File

@@ -0,0 +1,41 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.shared.Constants
import dev.ulfrx.recipe.shared.dto.User
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
interface MeGateway {
suspend fun getMe(accessToken: String? = null): User
}
class MeClient(
private val httpClient: HttpClient =
HttpClient {
install(ContentNegotiation) {
json(authJson)
}
},
) : MeGateway {
override suspend fun getMe(accessToken: String?): User =
httpClient
.get("${Constants.API_BASE_URL}api/v1/me") {
if (!accessToken.isNullOrBlank()) {
header(HttpHeaders.Authorization, "Bearer ".plus(accessToken))
}
}.body<dev.ulfrx.recipe.shared.dto.MeResponse>()
.toUser()
private companion object {
val authJson =
Json {
ignoreUnknownKeys = true
}
}
}

View File

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

View File

@@ -0,0 +1,25 @@
package dev.ulfrx.recipe.auth
/**
* Result returned by platform OIDC clients.
*
* `authStateJson` is the opaque AppAuth AuthState JSON blob persisted by
* [SecureAuthStateStore]. Callers must not parse token values out of it directly.
*/
sealed interface OidcResult {
data class Success(
val authStateJson: String,
val accessToken: String,
val idToken: String?,
val expiresAtEpochMillis: Long,
) : OidcResult
data object Cancelled : OidcResult
data object NetworkError : OidcResult
data class AuthError(
val message: String,
val cause: Throwable? = null,
) : OidcResult
}

View File

@@ -0,0 +1,19 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
/**
* Persists the full AppAuth AuthState JSON blob for the current app install.
*
* Mobile actuals must use explicit secure platform storage for token material
* (D-13): iOS Keychain and Android encrypted/Keystore-backed storage. Do not use
* no-arg or default insecure settings implementations for tokens. The stored
* blob is global to the install and must be deleted on logout (D-15).
*/
expect class SecureAuthStateStore() {
fun read(): String?
fun write(authStateJson: String)
fun clear()
}

View File

@@ -1,9 +1,10 @@
package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.auth.authModule
import org.koin.dsl.module
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
val appModule =
module {
// intentionally empty in Phase 1
includes(authModule)
}

View File

@@ -0,0 +1,88 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_app_name
import recipe.composeapp.generated.resources.auth_sign_in_button
/**
* Visible during [dev.ulfrx.recipe.auth.AuthState.Unauthenticated]. Wordmark + sign-in
* button + inline error text (when present). Inline-error UX rules and loading rules
* locked in `02-UI-SPEC.md` § Copywriting Contract.
*/
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.safeContentPadding()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(Res.string.auth_app_name),
style = MaterialTheme.typography.displaySmall,
)
Spacer(Modifier.height(24.dp))
Button(
onClick = { viewModel.onSignInClick() },
enabled = !state.isLoading,
) {
if (state.isLoading) {
Box(
modifier = Modifier.size(16.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = LocalContentColor.current,
)
}
} else {
Text(text = stringResource(Res.string.auth_sign_in_button))
}
}
val errorKey = state.errorKey
if (errorKey != null) {
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(errorKey),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
}
}
}

View File

@@ -0,0 +1,63 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.ulfrx.recipe.auth.AuthLoginResult
import dev.ulfrx.recipe.auth.AuthSession
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_error_cancelled
import recipe.composeapp.generated.resources.auth_error_network
import recipe.composeapp.generated.resources.auth_error_unknown
/**
* Immutable UI state for [LoginScreen]. The [errorKey] is a Compose Resources
* [StringResource] handle, not a translated string — the screen resolves it via
* `stringResource(...)` so the ViewModel stays platform/locale agnostic.
*/
data class LoginScreenState(
val isLoading: Boolean = false,
val errorKey: StringResource? = null,
)
/**
* Wraps [AuthSession] to drive the LoginScreen. Method-per-action: [onSignInClick] is the
* single entry point. Cancellation/network/unknown failures map to user-facing string
* resources per `02-UI-SPEC.md` § Copywriting Contract.
*
* Returns the launched [Job] from [onSignInClick] so tests can deterministically await
* completion without dragging a TestDispatcher into commonTest.
*/
class LoginViewModel(
private val authSession: AuthSession,
) : ViewModel() {
private val _state = MutableStateFlow(LoginScreenState())
val state: StateFlow<LoginScreenState> = _state.asStateFlow()
fun onSignInClick(): Job {
// Clear any previous inline error and enter the loading state before suspending —
// contract from UI-SPEC: tapping the button again clears stale error text.
_state.value = LoginScreenState(isLoading = true, errorKey = null)
return viewModelScope.launch {
val result = authSession.login()
_state.value =
LoginScreenState(
isLoading = false,
errorKey = result.toErrorKeyOrNull(),
)
}
}
private fun AuthLoginResult.toErrorKeyOrNull(): StringResource? =
when (this) {
AuthLoginResult.Success -> null
AuthLoginResult.Cancelled -> Res.string.auth_error_cancelled
AuthLoginResult.NetworkError -> Res.string.auth_error_network
is AuthLoginResult.Failed -> Res.string.auth_error_unknown
}
}

View File

@@ -0,0 +1,57 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.shared.dto.User
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_sign_out_button
import recipe.composeapp.generated.resources.auth_welcome_format
/**
* Phase 2 placeholder: welcome message + logout. Phase 3 replaces this with `HouseholdGate`.
*/
@Composable
fun PostLoginPlaceholderScreen(
user: User,
viewModel: PostLoginViewModel,
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.safeContentPadding()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(Res.string.auth_welcome_format, user.displayName),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = { viewModel.onSignOutClick() }) {
Text(text = stringResource(Res.string.auth_sign_out_button))
}
}
}
}

View File

@@ -0,0 +1,21 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.ulfrx.recipe.auth.AuthSession
import kotlinx.coroutines.launch
/**
* Trivial in Phase 2 — exists so Phase 3's `HouseholdGate` follows the same VM pattern.
* Logout is silent (CONTEXT D-19): no confirmation modal; tap immediately initiates
* RP-initiated end-session via [AuthSession.logout].
*/
class PostLoginViewModel(
private val authSession: AuthSession,
) : ViewModel() {
fun onSignOutClick() {
viewModelScope.launch {
authSession.logout()
}
}
}

View File

@@ -0,0 +1,52 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_app_name
/**
* Visible during [dev.ulfrx.recipe.auth.AuthState.Loading]. Wordmark + circular progress.
* No marketing copy, no tagline. Background is `surface` so the Login transition has no
* color flash.
*/
@Composable
fun SplashScreen() {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.safeContentPadding()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(Res.string.auth_app_name),
style = MaterialTheme.typography.displaySmall,
)
Spacer(Modifier.height(8.dp))
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
)
}
}
}

View File

@@ -0,0 +1,35 @@
package dev.ulfrx.recipe.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Phase 2 seed theme. Material 3 light/dark schemes with a single seed override on `primary`
* (`#3B6939` light / `#A2D597` dark — see `02-UI-SPEC.md` § Color). All other roles use
* Material 3 baseline values. Phase 11 may rebase the palette around a different seed.
*
* Intentionally minimal: no Haze, no custom typography, no shapes. Per UI-SPEC, Material 3
* defaults satisfy Phase 2's spacing/typography/accessibility contract.
*/
private val LightColors =
lightColorScheme(
primary = Color(0xFF3B6939),
)
private val DarkColors =
darkColorScheme(
primary = Color(0xFFA2D597),
)
@Composable
fun RecipeTheme(content: @Composable () -> Unit) {
val colors = if (isSystemInDarkTheme()) DarkColors else LightColors
MaterialTheme(
colorScheme = colors,
content = content,
)
}

View File

@@ -0,0 +1,240 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
class AuthSessionTest {
@Test
fun emptyStoreInitializesLoadingToUnauthenticated() {
runTest {
val session = newSession(store = FakeAuthStateStore())
assertIs<AuthState.Loading>(session.state.value)
session.initialize()
assertIs<AuthState.Unauthenticated>(session.state.value)
}
}
@Test
fun successfulLoginWritesAuthStateJsonFetchesMeAndEmitsAuthenticatedWithNullHouseholdId() {
runTest {
val store = FakeAuthStateStore()
val oidcClient =
FakeOidcClient(
loginResult =
OidcResult.Success(
authStateJson = AUTH_STATE_JSON,
accessToken = ACCESS_TOKEN,
idToken = "id-token",
expiresAtEpochMillis = 123_456L,
),
)
val meClient = FakeMeClient(user = USER)
val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient)
val result = session.login()
assertEquals(AuthLoginResult.Success, result)
assertEquals(AUTH_STATE_JSON, store.value)
assertEquals(listOf<String?>(ACCESS_TOKEN), meClient.accessTokens)
val authenticated = assertIs<AuthState.Authenticated>(session.state.value)
assertEquals(USER, authenticated.user)
assertNull(authenticated.householdId)
}
}
@Test
fun existingStoreRefreshesBeforeMeAndEmitsAuthenticatedWithoutLogin() {
runTest {
val store = FakeAuthStateStore(value = "stored-auth-state-json")
val oidcClient =
FakeOidcClient(
refreshResult =
OidcResult.Success(
authStateJson = REFRESHED_AUTH_STATE_JSON,
accessToken = REFRESHED_ACCESS_TOKEN,
idToken = null,
expiresAtEpochMillis = 789_000L,
),
)
val meClient = FakeMeClient(user = USER)
val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient)
session.initialize()
assertEquals(emptyList(), oidcClient.loginCalls)
assertEquals(listOf("stored-auth-state-json"), oidcClient.refreshCalls)
assertEquals(REFRESHED_AUTH_STATE_JSON, store.value)
assertEquals(listOf<String?>(REFRESHED_ACCESS_TOKEN), meClient.accessTokens)
val authenticated = assertIs<AuthState.Authenticated>(session.state.value)
assertEquals(USER, authenticated.user)
assertNull(authenticated.householdId)
}
}
@Test
fun refreshInvalidGrantClearsStoreAndEmitsUnauthenticatedWithoutUiError() {
runTest {
val store = FakeAuthStateStore(value = "stored-auth-state-json")
val oidcClient =
FakeOidcClient(
refreshResult = OidcResult.AuthError("invalid_grant"),
)
val session = newSession(store = store, oidcClient = oidcClient)
session.initialize()
assertNull(store.value)
assertIs<AuthState.Unauthenticated>(session.state.value)
}
}
@Test
fun refreshAuthErrorClearsStoreAndEmitsUnauthenticatedWithoutUiError() {
runTest {
val store = FakeAuthStateStore(value = "stored-auth-state-json")
val oidcClient =
FakeOidcClient(
refreshResult = OidcResult.AuthError("token endpoint rejected refresh"),
)
val session = newSession(store = store, oidcClient = oidcClient)
session.initialize()
assertNull(store.value)
assertIs<AuthState.Unauthenticated>(session.state.value)
}
}
@Test
fun logoutCallsEndSessionThenClearsStoreAndEmitsUnauthenticatedWhenLogoutSucceeds() {
runTest {
val store = FakeAuthStateStore(value = AUTH_STATE_JSON)
val oidcClient = FakeOidcClient()
val session = newSession(store = store, oidcClient = oidcClient)
session.logout()
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
assertNull(store.value)
assertIs<AuthState.Unauthenticated>(session.state.value)
}
}
@Test
fun logoutClearsStoreAndEmitsUnauthenticatedEvenWhenEndSessionThrows() {
runTest {
val store = FakeAuthStateStore(value = AUTH_STATE_JSON)
val oidcClient = FakeOidcClient(logoutThrows = true)
val session = newSession(store = store, oidcClient = oidcClient)
session.logout()
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
assertNull(store.value)
assertIs<AuthState.Unauthenticated>(session.state.value)
}
}
@Test
fun loginCancelledMapsToUiRenderableCancelledResult() {
runTest {
val store = FakeAuthStateStore()
val session =
newSession(
store = store,
oidcClient = FakeOidcClient(loginResult = OidcResult.Cancelled),
)
val result = session.login()
assertEquals(AuthLoginResult.Cancelled, result)
assertNull(store.value)
assertIs<AuthState.Unauthenticated>(session.state.value)
}
}
private fun newSession(
store: AuthStateStore = FakeAuthStateStore(),
oidcClient: OidcClientGateway = FakeOidcClient(),
meClient: MeGateway = FakeMeClient(user = USER),
): AuthSession =
AuthSession(
oidcClient = oidcClient,
store = store,
meClient = meClient,
)
private class FakeAuthStateStore(
var value: String? = null,
) : AuthStateStore {
override fun read(): String? = value
override fun write(authStateJson: String) {
value = authStateJson
}
override fun clear() {
value = null
}
}
private class FakeOidcClient(
private val loginResult: OidcResult = OidcResult.AuthError("login not configured"),
private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"),
private val logoutThrows: Boolean = false,
) : OidcClientGateway {
val loginCalls = mutableListOf<Unit>()
val refreshCalls = mutableListOf<String>()
val logoutCalls = mutableListOf<String>()
override suspend fun login(): OidcResult {
loginCalls += Unit
return loginResult
}
override suspend fun refresh(authStateJson: String): OidcResult {
refreshCalls += authStateJson
return refreshResult
}
override suspend fun logout(authStateJson: String) {
logoutCalls += authStateJson
if (logoutThrows) {
error("end-session failed")
}
}
}
private class FakeMeClient(
private val user: User,
) : MeGateway {
val accessTokens = mutableListOf<String?>()
override suspend fun getMe(accessToken: String?): User {
accessTokens += accessToken
return user
}
}
private companion object {
const val AUTH_STATE_JSON = """{"refresh_token":"initial"}"""
const val REFRESHED_AUTH_STATE_JSON = """{"refresh_token":"refreshed"}"""
const val ACCESS_TOKEN = "access-token"
const val REFRESHED_ACCESS_TOKEN = "refreshed-access-token"
val USER =
User(
id = "00000000-0000-0000-0000-000000000001",
sub = "authentik-sub",
email = "user@example.invalid",
displayName = "Recipe User",
)
}
}

View File

@@ -0,0 +1,27 @@
package dev.ulfrx.recipe.auth
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class SecureAuthStateStoreContractTest {
@Test
fun writeOverwritesPreviousValueAndReadReturnsLatest() {
val store = SecureAuthStateStore()
store.write("""{"refresh_token":"first"}""")
store.write("""{"refresh_token":"second"}""")
assertEquals("""{"refresh_token":"second"}""", store.read())
}
@Test
fun clearRemovesStoredValue() {
val store = SecureAuthStateStore()
store.write("""{"refresh_token":"stored"}""")
store.clear()
assertNull(store.read())
}
}

View File

@@ -0,0 +1,167 @@
package dev.ulfrx.recipe.ui.screens.auth
import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthStateStore
import dev.ulfrx.recipe.auth.MeGateway
import dev.ulfrx.recipe.auth.OidcClientGateway
import dev.ulfrx.recipe.auth.OidcResult
import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_error_cancelled
import recipe.composeapp.generated.resources.auth_error_network
import recipe.composeapp.generated.resources.auth_error_unknown
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class LoginViewModelTest {
@Test
fun cancelledAuthFailureMapsToCancelledStringResource() =
runTest {
val session = newSession(loginResult = OidcResult.Cancelled)
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
}
@Test
fun networkAuthFailureMapsToNetworkStringResource() =
runTest {
val session = newSession(loginResult = OidcResult.NetworkError)
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
}
@Test
fun unknownAuthFailureMapsToUnknownStringResource() =
runTest {
val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed"))
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
}
@Test
fun successClearsErrorAndStopsLoading() =
runTest {
val session =
newSession(
loginResult =
OidcResult.Success(
authStateJson = "{}",
accessToken = "access",
idToken = null,
expiresAtEpochMillis = 0L,
),
)
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
assertNull(viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
}
@Test
fun startingNewSignInClearsPreviousErrorAndSetsLoading() =
runTest {
// Queue: first login resolves Cancelled to seed an inline error.
// Second login awaits a gate so we can synchronously observe the
// "loading=true, error=null" intermediate state contract from UI-SPEC.
val gate = CompletableDeferred<OidcResult>()
val queue = mutableListOf<OidcResult>(OidcResult.Cancelled)
val oidc =
object : OidcClientGateway {
override suspend fun login(): OidcResult = if (queue.isNotEmpty()) queue.removeAt(0) else gate.await()
override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used")
override suspend fun logout(authStateJson: String) {}
}
val session = AuthSession(oidc, FakeAuthStateStore(), FakeMeClient(USER))
val viewModel = LoginViewModel(session)
// First attempt: error seeded.
viewModel.onSignInClick().join()
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
// Second attempt: launching the job sets loading=true + clears error
// BEFORE suspending. onSignInClick() does that synchronously before
// returning the launched Job, so we can assert immediately.
val job = viewModel.onSignInClick()
assertTrue(viewModel.state.value.isLoading)
assertNull(viewModel.state.value.errorKey)
// Release the gate; the second login also returns Cancelled.
gate.complete(OidcResult.Cancelled)
job.join()
assertEquals(false, viewModel.state.value.isLoading)
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
}
private fun newSession(
loginResult: OidcResult,
store: AuthStateStore = FakeAuthStateStore(),
meClient: MeGateway = FakeMeClient(USER),
): AuthSession =
AuthSession(
oidcClient = FakeOidcClient(loginResult = loginResult),
store = store,
meClient = meClient,
)
private class FakeAuthStateStore(
var value: String? = null,
) : AuthStateStore {
override fun read(): String? = value
override fun write(authStateJson: String) {
value = authStateJson
}
override fun clear() {
value = null
}
}
private class FakeOidcClient(
private val loginResult: OidcResult = OidcResult.AuthError("login not configured"),
private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"),
) : OidcClientGateway {
override suspend fun login(): OidcResult = loginResult
override suspend fun refresh(authStateJson: String): OidcResult = refreshResult
override suspend fun logout(authStateJson: String) {}
}
private class FakeMeClient(
private val user: User,
) : MeGateway {
override suspend fun getMe(accessToken: String?): User = user
}
private companion object {
val USER =
User(
id = "00000000-0000-0000-0000-000000000001",
sub = "authentik-sub",
email = "user@example.invalid",
displayName = "Recipe User",
)
}
}

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

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

View File

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

View File

@@ -0,0 +1,33 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import com.russhwolf.settings.ExperimentalSettingsApi
import com.russhwolf.settings.ExperimentalSettingsImplementation
import com.russhwolf.settings.KeychainSettings
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Security.kSecAttrAccessible
import platform.Security.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
@OptIn(ExperimentalSettingsApi::class, ExperimentalSettingsImplementation::class, ExperimentalForeignApi::class)
actual class SecureAuthStateStore {
private val settings =
KeychainSettings(
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
)
actual fun read(): String? =
settings.getStringOrNull(authStateKey)
actual fun write(authStateJson: String) {
settings.putString(authStateKey, authStateJson)
}
actual fun clear() {
settings.remove(authStateKey)
}
private companion object {
const val authStateKey = "dev.ulfrx.recipe.auth.appauth-state"
}
}

View File

@@ -1,8 +1,11 @@
package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.auth.iosAuthModule
import dev.ulfrx.recipe.logging.configureLogging
fun doInitKoin() {
configureLogging()
initKoin()
initKoin {
modules(iosAuthModule)
}
}

View File

@@ -0,0 +1,38 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
actual class OidcClient {
actual suspend fun login(): OidcResult {
val token =
System.getenv(DEV_AUTH_TOKEN)
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
return OidcResult.Success(
authStateJson = "dev:$token",
accessToken = token,
idToken = null,
expiresAtEpochMillis = Long.MAX_VALUE,
)
}
actual suspend fun refresh(authStateJson: String): OidcResult {
val token =
authStateJson.removePrefix("dev:").takeIf { it.isNotBlank() }
?: System.getenv(DEV_AUTH_TOKEN)
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
return OidcResult.Success(
authStateJson = "dev:$token",
accessToken = token,
idToken = null,
expiresAtEpochMillis = Long.MAX_VALUE,
)
}
actual suspend fun logout(authStateJson: String) = Unit
private companion object {
const val DEV_AUTH_TOKEN = "DEV_AUTH_TOKEN"
}
}

View File

@@ -0,0 +1,17 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
actual class SecureAuthStateStore {
private var authStateJson: String? = null
actual fun read(): String? = authStateJson
actual fun write(authStateJson: String) {
this.authStateJson = authStateJson
}
actual fun clear() {
authStateJson = null
}
}

View File

@@ -0,0 +1,11 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
actual class OidcClient {
actual suspend fun login(): OidcResult = throw NotImplementedError("Wasm OIDC: v2")
actual suspend fun refresh(authStateJson: String): OidcResult = throw NotImplementedError("Wasm OIDC: v2")
actual suspend fun logout(authStateJson: String): Unit = throw NotImplementedError("Wasm OIDC: v2")
}

View File

@@ -0,0 +1,17 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
actual class SecureAuthStateStore {
private var authStateJson: String? = null
actual fun read(): String? = authStateJson
actual fun write(authStateJson: String) {
this.authStateJson = authStateJson
}
actual fun clear() {
authStateJson = null
}
}

242
docs/authentik-setup.md Normal file
View File

@@ -0,0 +1,242 @@
# Authentik Provider Setup — Recipe (Phase 2)
> Reproducible Authentik configuration for the Recipe app. Anyone with admin access
> to the homelab Authentik should be able to recreate the OAuth2/OIDC provider in
> under five minutes by following this document end to end (D-10).
>
> Phase: `02-authentication-foundation`. Locked decisions referenced here live in
> `.planning/phases/02-authentication-foundation/02-CONTEXT.md` (D-05 .. D-10,
> D-19, D-21 .. D-23) and the version-controlled requirements in
> `.planning/REQUIREMENTS.md` (AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06).
## Provider
Configure a **single OAuth2/OIDC provider** in Authentik with the following pinned settings.
Authentik admin path: **Admin → Applications → Providers → Create → OAuth2/OpenID Provider**.
| Setting | Value | Why this exact value |
|---------|-------|----------------------|
| Provider type | **OAuth2/OIDC, Public client** | Mobile apps cannot ship a secret per RFC 8252 (D-05). No `client_secret` is set on the provider, sent on the wire, or stored anywhere in this repo. |
| Authorization flow | **Authorization code with PKCE S256** | PKCE S256 is the only safe pattern for native + custom-scheme redirect URIs (D-05, D-09). `plain` is forbidden. |
| Client ID | **`recipe-app`** | Mirrors `dev.ulfrx.recipe.shared.Constants.OIDC_CLIENT_ID`. The same value is the JWT `aud` claim per D-07. |
| Client secret | **(leave empty)** | Public client — D-05. Any non-empty value is a bug. |
| Redirect URIs | **`recipe://callback`** (exactly, no trailing slash, no spaces) | Custom URL scheme — see [`## Redirect URI`](#redirect-uri). Byte-for-byte match with `Constants.OIDC_REDIRECT_URI` (D-09). |
| Signing algorithm | **RS256** | Authentik default; matches `JwkProviderBuilder` expectations on the server (D-08, D-21, D-22). |
| Signing key | RS256 asymmetric key from Authentik (auto-managed) | Public key reaches the server through the JWKS endpoint, never copied or pinned in code (D-22). |
| Audience | **single-string** value `aud = recipe-app` (NOT array) | Authentik can emit `aud` as either an array or a single string per provider config; pin to single string and let `JWTAuth.withAudience("recipe-app")` validate against it (D-07, PITFALLS.md #7). A negative test in Plan 02-02 asserts wrong-`aud` → 401. |
| Issuer URL | `https://auth.<your-homelab>.tld/application/o/recipe/` (trailing slash required) | Trailing slash is byte-sensitive in Authentik's OpenID metadata responses (PITFALLS.md #8). The placeholder host `auth.example.invalid` in `Constants.kt` is replaced at deploy time via env-var override on the server (`OIDC_ISSUER`) — do not commit your real homelab URL here. |
| JWKS URI | Pulled from `<issuer>/.well-known/openid-configuration` `jwks_uri` (typically `<issuer>jwks/`) | Cached and rate-limited on the server with `JwkProviderBuilder(issuer).cached(10, 15, MINUTES).rateLimited(10, 1, MINUTES)` (D-22). |
| End-session endpoint | Pulled from `<issuer>/.well-known/openid-configuration` `end_session_endpoint` | Required for RP-initiated logout (D-19, D-20). See [`## Logout`](#logout). |
| Token validity | Access token ~5 min, refresh token long-lived (default Authentik) | Short access lifetime exercises the `performActionWithFreshTokens` + Ktor bearer 401 fallback paths (D-16, D-17). |
After saving, bind the provider to a new **Application** (Admin → Applications → Applications → Create) called `Recipe`. The application is what Authentik users see in their My Apps / consent screens.
## Scopes
The app requests **exactly** these four scopes from Authentik:
```
openid profile email offline_access
```
| Scope | Purpose | Where it lands |
|-------|---------|----------------|
| `openid` | Marks the request as OIDC; Authentik issues an ID token | Mandatory; without it Authentik issues OAuth2-only tokens that are unusable here (D-06) |
| `profile` | Populates the `name` / `preferred_username` claim | Maps to `users.display_name` via JIT provisioning (D-25) |
| `email` | Populates the `email` claim | Maps to `users.email` via JIT provisioning (D-25) |
| `offline_access` | Asks Authentik to issue a **refresh** token | AUTH-04 (session persists across launches via refresh) is impossible without it. Authentik issues no refresh token unless `offline_access` is BOTH requested by the client AND mapped/allowed in the provider's scope mapping (D-06, PITFALLS.md Phase 2 Pitfall 2). |
**Authentik provider configuration must explicitly map the `offline_access` scope** so the
provider returns a refresh token. Newer Authentik versions add it by default; older ones
require explicit creation under **Customization → Property Mappings → OAuth2 / OpenID Scope
Mapping**.
## Redirect URI
The app uses a **custom URL scheme**:
```
recipe://callback
```
This single URI must be registered three times — byte-for-byte identical, no trailing
slash, no query parameters. Drift between any of these three places produces silent OAuth
redirect failures (D-09).
| Where | Mechanism | Phase 2 plan that lands it |
|-------|-----------|----------------------------|
| Authentik provider | Redirect URIs textbox (one line) | This document (Plan 02-01) |
| iOS | `iosApp/iosApp/Info.plist` `CFBundleURLTypes``CFBundleURLSchemes` array containing exactly `recipe` | Plan 02-05 (iOS AppAuth actual) |
| Android | `composeApp/src/androidMain/AndroidManifest.xml` `<intent-filter>` on AppAuth's `RedirectUriReceiverActivity` with `android:scheme="recipe"` and `android:host="callback"` | Plan 02-04 (Android AppAuth actual). Plan 02-01 already supplies the `appAuthRedirectScheme=recipe` manifest placeholder so the AppAuth dependency merges cleanly without yet wiring the receiver. |
PKCE S256 + AppAuth's state/nonce handling makes the well-known custom-scheme
interception attack non-exploitable in practice. **Universal Links / App Links are
explicitly excluded** from v1 — see [`## Source Audit`](#source-audit).
## Server Env Vars
The Ktor server reads OIDC configuration from `application.conf` with env-var overrides
(D-12, mirrors Phase 1 D-16's `DATABASE_URL` pattern). Set these on the homelab deploy
target before booting the server:
| Variable | Required | Example value | Notes |
|----------|----------|---------------|-------|
| `OIDC_ISSUER` | yes | `https://auth.example.invalid/application/o/recipe/` | Trailing slash required (PITFALLS.md #8). Must equal Authentik's issuer URL byte-for-byte. |
| `OIDC_AUDIENCE` | yes | `recipe-app` | Equal to the provider's Client ID; validated as a **single string** (D-07). |
| `OIDC_JWKS_URL` | optional | `https://auth.example.invalid/application/o/recipe/jwks/` | Optional — derived from `<OIDC_ISSUER>/.well-known/openid-configuration` if unset. Set explicitly only when Authentik puts JWKS at a non-standard path. |
Plan 02-02 wires these into `application.conf` and the `JwkProviderBuilder`. They are NOT
read by the client — the client OIDC config is hardcoded in
`shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt` per D-11 (single-
environment v1 acceptance from PITFALLS.md tech-debt table).
## Logout
Logout is **RP-initiated end-session** (D-19, D-20). Tapping "Wyloguj się" performs both
of these atomically, in this order:
1. Call Authentik's `end_session_endpoint` (advertised by `<issuer>/.well-known/openid-configuration`) with the user's `id_token_hint`. AppAuth's `EndSessionRequest` API drives this on both mobile platforms (D-20).
2. Delete the persisted `AuthState` blob from secure storage (Keychain on iOS, EncryptedSharedPreferences on Android per D-13).
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
device. Local-only logout would fail AUTH-05 because the next "Zaloguj się" tap would
silently SSO instead of forcing fresh credentials.
To support this, the Authentik provider's **end-session endpoint must be reachable** from
the device — confirm via `curl -I "<issuer>/.well-known/openid-configuration"` and
checking that the JSON response contains `end_session_endpoint`.
## Manual UAT
These checks must pass on a real iOS device or simulator before Phase 2 is signed off
(per `02-VALIDATION.md`'s Manual-Only Verifications). They cover the surface unit tests
cannot reach: real Authentik, real browser handoff, real Keychain.
### UAT-01 — Fresh iOS login (AUTH-01)
1. Wipe app data: delete the app from the simulator/device.
2. Reinstall via `./gradlew :composeApp:iosDeployIPhone…` or Xcode.
3. Tap **"Zaloguj się przez Authentik"**.
4. Confirm the system browser (`ASWebAuthenticationSession`) opens at Authentik's hosted login page.
5. Authenticate. Confirm Authentik consent screen lists `openid profile email offline_access`.
6. Confirm the app returns to foreground via `recipe://callback` and renders `Witaj, {displayName}!` with the partner's actual display name.
7. **Failure modes to verify visually:** cancelling the system browser shows "Logowanie anulowane. Spróbuj ponownie." inline.
### UAT-02 — Reopen with refresh (AUTH-04)
1. Sign in via UAT-01.
2. In Authentik, set the provider's access-token lifetime to ~60 seconds (or wait the default).
3. Backgroud the app for ~2 minutes; relaunch.
4. Confirm the app returns directly to `Witaj, {displayName}!` with no login prompt — the AppAuth `performActionWithFreshTokens` path silently exchanged the refresh token (D-16, D-17).
### UAT-03 — Logout returns to login (AUTH-05)
1. Sign in via UAT-01.
2. Tap **"Wyloguj się"**.
3. Confirm the app returns to the LoginScreen.
4. Tap "Zaloguj się przez Authentik" again. Confirm Authentik **prompts for credentials** (no silent SSO) — proves the end-session call succeeded.
5. **Network-failure variant:** disconnect from network, tap "Wyloguj się", confirm app still returns to LoginScreen and the local AuthState is gone (relaunching does not auto-sign-in).
### UAT-04 — `/api/v1/me` validation (AUTH-03)
This is the only UAT step performed via terminal, not the app. It validates the server
side of the boundary independently of mobile UI bugs.
1. Run the server locally or hit the homelab. Capture a valid access token (e.g., copy from `AuthState` JSON via the iOS debugger immediately after UAT-01, or use `curl` directly against Authentik's token endpoint with the same client_id).
2. Confirm:
```bash
curl -i -H "Authorization: Bearer $TOKEN" https://api.<homelab>/api/v1/me
# expect: HTTP/1.1 200 OK; body matches MeResponse {"id":..., "sub":..., "email":..., "displayName":...}
```
3. Confirm:
```bash
curl -i https://api.<homelab>/api/v1/me
# expect: HTTP/1.1 401 Unauthorized
```
4. Confirm wrong-audience rejection by minting a JWT with `aud != recipe-app` (use the test JWKS Plan 02-02 ships):
```bash
curl -i -H "Authorization: Bearer $WRONG_AUD_TOKEN" https://api.<homelab>/api/v1/me
# expect: HTTP/1.1 401 Unauthorized
```
5. Confirm logs do **not** contain the token body. The custom `CallLogging` filter must redact the `Authorization` header (D-23).
## Source Audit
This document is the Phase 2 anchor for "every locked source is honored". The table below
asserts that every Phase 2 input — goal, requirement, research finding, decision, UI
spec, validation gate, and pattern map — is either covered here or in a downstream Phase
2 plan. Markers: ✅ covered in this document, ⤳ covered in a downstream plan (with
plan number), ✂ explicitly deferred (see end of section).
| Source | Item | Coverage |
|--------|------|----------|
| GOAL | Phase 2 goal: end-to-end OIDC+PKCE login with server JWT validation and JIT users | ✅ Provider + Scopes + Redirect URI + Server Env Vars + Manual UAT |
| REQ | **AUTH-01** sign in via Authentik OIDC + PKCE | ✅ Provider; ⤳ 02-04 (Android AppAuth) + 02-05 (iOS AppAuth) |
| REQ | **AUTH-02** secure token storage | ⤳ 02-03 (common contract) + 02-04 (Android EncryptedSharedPreferences) + 02-05 (iOS Keychain) |
| REQ | **AUTH-03** server JWT validation via JWKS | ✅ Provider (RS256, single-string aud, JWKS); ⤳ 02-02 (Ktor JWT install + tests) |
| REQ | **AUTH-04** session persists across launches via refresh | ✅ Scopes (`offline_access`); ⤳ 02-03 (AuthSession refresh wiring) + Manual UAT-02 |
| REQ | **AUTH-05** logout returns to login | ✅ Logout section; ⤳ 02-04/02-05 (AppAuth end-session per platform) + Manual UAT-03 |
| REQ | **AUTH-06** JIT user provisioning by `sub` | ⤳ 02-02 (`V1__users.sql` + upsert by sub + `/api/v1/me`) |
| RESEARCH | Standard stack: AppAuth, Ktor JWT, multiplatform-settings, Exposed DSL, Flyway | ✅ Server Env Vars (Ktor); ⤳ 02-01 catalog wiring (this plan, task 2) + per-platform plans |
| RESEARCH | Open Questions resolved: Android secure storage = EncryptedSharedPreferences behind `SecureAuthStateStore` seam | ⤳ 02-03 (seam) + 02-04 (Android impl) |
| RESEARCH | Open Question resolved: Exposed `newSuspendedTransaction` import verified at impl time | ⤳ 02-02 |
| RESEARCH | Open Question resolved: Ktor stays at 3.4.1 (no patch bump) | ✅ Task 2 catalog keeps `ktor = "3.4.1"` |
| CONTEXT | **D-01** AppAuth on both mobile platforms via expect/actual `OidcClient` | ⤳ 02-03 (expect) + 02-04 (Android actual) + 02-05 (iOS actual) |
| CONTEXT | **D-02** JVM `actual` is `DEV_AUTH_TOKEN` env-var stub | ⤳ 02-03 |
| CONTEXT | **D-03** Wasm `actual` is `NotImplementedError("Wasm OIDC: v2")` | ⤳ 02-03 |
| CONTEXT | **D-04** `OidcClient.login()` / `.refresh()` are `suspend` | ⤳ 02-03 |
| CONTEXT | **D-05** Public + PKCE S256 | ✅ Provider |
| CONTEXT | **D-06** scopes `openid profile email offline_access` | ✅ Scopes |
| CONTEXT | **D-07** single-string `aud` = `client_id` | ✅ Provider |
| CONTEXT | **D-08** RS256 signing | ✅ Provider |
| CONTEXT | **D-09** redirect URI `recipe://callback` | ✅ Redirect URI |
| CONTEXT | **D-10** this document is a Phase 2 deliverable | ✅ this document |
| CONTEXT | **D-11** client OIDC config in `shared/commonMain/Constants.kt` | ✅ Server Env Vars (relationship spelled out); ⤳ 02-01 task 1 (Constants.kt landed) |
| CONTEXT | **D-12** server OIDC config via env vars | ✅ Server Env Vars |
| CONTEXT | **D-13** persist full AppAuth `AuthState` JSON via `multiplatform-settings` | ⤳ 02-03 + 02-04 + 02-05 |
| CONTEXT | **D-14** iOS Keychain `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | ⤳ 02-05 |
| CONTEXT | **D-15** one AuthState blob per app install | ⤳ 02-03 |
| CONTEXT | **D-16** proactive refresh via `performActionWithFreshTokens` | ⤳ 02-04 + 02-05 |
| CONTEXT | **D-17** Ktor bearer `refreshTokens` 401 fallback | ⤳ 02-03 |
| CONTEXT | **D-18** silent refresh-failure transition | ⤳ 02-03 |
| CONTEXT | **D-19** RP-initiated end-session | ✅ Logout |
| CONTEXT | **D-20** AppAuth `EndSessionRequest` drives logout | ✅ Logout; ⤳ 02-04 + 02-05 |
| CONTEXT | **D-21** Ktor `jwt("authentik")` install with leeway 30s and `sub` validation | ⤳ 02-02 |
| CONTEXT | **D-22** JWKS provider cache 10 / 15min, rate limit 10/min | ⤳ 02-02 |
| CONTEXT | **D-23** never log Authorization header / token bodies | ✅ Manual UAT-04 step 5; ⤳ 02-02 (server filter) + 02-03 (client logger discipline) |
| CONTEXT | **D-24** ship `V1__users.sql` migration | ⤳ 02-02 |
| CONTEXT | **D-25** JIT upsert by `sub`, update email/display_name | ⤳ 02-02 |
| CONTEXT | **D-26** Exposed DSL only, `newSuspendedTransaction` | ⤳ 02-02 |
| CONTEXT | **D-27** `/api/v1/me` returns `MeResponse` | ✅ Manual UAT-04; ⤳ 02-01 task 1 (DTO) + 02-02 (route) |
| CONTEXT | **D-28** `AuthState` sealed shape with `householdId: HouseholdId? = null` | ⤳ 02-03 |
| CONTEXT | **D-29** `AuthSession` Koin singleton in `authModule` | ⤳ 02-03 + 02-06 |
| CONTEXT | **D-30** auth gate in `App()` | ⤳ 02-06 (UI) |
| CONTEXT | **D-31** minimal login screen | ⤳ 02-06 |
| CONTEXT | **D-32** inline login error states | ⤳ 02-06 |
| CONTEXT | **D-33** post-login placeholder `Witaj, {displayName}!` | ⤳ 02-06 |
| CONTEXT | **D-34** Compose Resources strings from day 1 | ⤳ 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 | Manual UAT checklist in `docs/authentik-setup.md` | ✅ Manual UAT |
| PATTERNS | File map: shared DTO/Constants location, Koin authModule, Ktor JWT install, Exposed table, AppAuth platform actuals | ⤳ 02-01 task 1 (shared) + 02-02 (server) + 02-03 (client common) + 02-04 (Android) + 02-05 (iOS) + 02-06 (UI) |
### Deferred (excluded from Phase 2)
These are explicitly out of scope for v1 per `.planning/phases/02-authentication-foundation/02-CONTEXT.md` § Deferred Ideas. Listed here so the audit makes the exclusions traceable.
- **Universal Links / App Links** — excluded; rely on `recipe://callback` custom scheme. Revisit only if app gains broader distribution beyond the household or if Apple/Google deprecate custom-scheme OIDC redirects.
- **Real Desktop OIDC** — JVM target ships a `DEV_AUTH_TOKEN` env-var stub (D-02). Loopback-redirect implementation deferred until Desktop becomes a release surface.
- **Wasm OIDC implementation** — `wasmJs` actual throws `NotImplementedError`. Browser-redirect flow deferred until Wasm becomes a release surface.
- **Apple Sign-in as a first-class button** — Authentik can federate Apple upstream if ever desired.
- **Authentik provisioning automation (Terraform/Ansible)** — this document is the manual reproduction playbook; automation deferred post-v1.
- **JWT validation tests against a real Authentik instance** — Phase 2 ships unit/integration tests with hand-crafted JWTs. Real-Authentik integration tests deferred to Phase 11 (deployment).
- **BuildConfig-style Gradle injection of OIDC config** — `Constants.kt` is the v1 single-environment acceptance per PITFALLS.md tech-debt table.
- **Per-user persisted `AuthState`** — one user per install is the v1 model.
- **Modal/toast for refresh-failure UX** — silent transition ships in v1 (D-18).
- **Background token refresh** — v1 has no background work.
- **"Wyloguj się i zapomnij sesję" two-tier logout** — single RP-initiated logout only.
---
*Phase: `02-authentication-foundation` · Plan: `02-01` · Last updated: 2026-04-28*

View File

@@ -8,10 +8,16 @@ androidx-appcompat = "1.7.1"
androidx-core = "1.18.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0"
androidx-security-crypto = "1.1.0"
androidx-testExt = "1.3.0"
appauth = "0.11.1"
# AppAuth-iOS version is pinned in iosApp.xcodeproj's Package.resolved (SwiftPM)
# since 2026-04-28 — see .planning/phases/02-authentication-foundation/DECISION-drop-cocoapods.md.
composeHotReload = "1.0.0"
composeMultiplatform = "1.10.3"
exposed = "0.55.0"
flyway = "12.4.0"
hikari = "6.2.1"
junit = "4.13.2"
kermit = "2.1.0"
koin = "4.2.1"
@@ -21,13 +27,18 @@ kotlinx-serialization = "1.7.3"
ktor = "3.4.1"
logback = "1.5.32"
material3 = "1.10.0-alpha05"
multiplatformSettings = "1.3.0"
postgresql = "42.7.10"
spotless = "8.4.0"
testcontainers = "1.21.4"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
# kotlinx.serialization (shared DTOs — D-27)
kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
@@ -43,6 +54,7 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMul
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
ktor-serverCore = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" }
ktor-serverNetty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" }
@@ -65,6 +77,40 @@ flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" }
flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" }
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
# Phase 2 — Server: Ktor auth + JWT + call logging + status pages (D-21..D-23)
ktor-serverAuth = { module = "io.ktor:ktor-server-auth-jvm", version.ref = "ktor" }
ktor-serverAuthJwt = { module = "io.ktor:ktor-server-auth-jwt-jvm", version.ref = "ktor" }
ktor-serverCallLogging = { module = "io.ktor:ktor-server-call-logging-jvm", version.ref = "ktor" }
ktor-serverStatusPages = { module = "io.ktor:ktor-server-status-pages-jvm", version.ref = "ktor" }
# Phase 2 — Client: Ktor client core + auth + content-negotiation + logging + engines (D-16..D-18)
# `ktor-serializationKotlinxJsonMpp` is the multiplatform variant (no `-jvm` classifier) for
# commonMain consumption; the `-jvm` variant above stays available to the server module.
ktor-clientCore = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-clientAuth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
ktor-clientContentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-clientLogging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-clientOkhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-clientDarwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-clientCio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-serializationKotlinxJsonMpp = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
# Phase 2 — Client: AppAuth + Android secure storage + multiplatform-settings (D-01, D-13, AUTH-02)
appauth = { module = "net.openid:appauth", version.ref = "appauth" }
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security-crypto" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformSettings" }
# Phase 2 — Server: Exposed DSL + Hikari (D-26)
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
exposed-java-time = { module = "org.jetbrains.exposed:exposed-java-time", version.ref = "exposed" }
hikari = { module = "com.zaxxer:HikariCP", version.ref = "hikari" }
# Phase 2 — Server tests: Testcontainers (D-21..D-25)
testcontainers-postgresql = { module = "org.testcontainers:postgresql", version.ref = "testcontainers" }
testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
@@ -72,6 +118,7 @@ composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "com
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }

View File

@@ -6,6 +6,10 @@
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
D6D5D1FA2FA11AF8008BF8AF /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = D6D5D1F92FA11AF8008BF8AF /* AppAuth */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
4B3C797CB7B3655AAA3375CB /* recipe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = recipe.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@@ -21,6 +25,11 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
7AAC38CA2AF1E20DE13538FB /* Configuration */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Configuration;
sourceTree = "<group>";
};
EF7D69B0746377ACEB868F32 /* iosApp */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@@ -29,11 +38,6 @@
path = iosApp;
sourceTree = "<group>";
};
7AAC38CA2AF1E20DE13538FB /* Configuration */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Configuration;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -41,6 +45,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D6D5D1FA2FA11AF8008BF8AF /* AppAuth in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -85,6 +90,7 @@
);
name = iosApp;
packageProductDependencies = (
D6D5D1F92FA11AF8008BF8AF /* AppAuth */,
);
productName = iosApp;
productReference = 4B3C797CB7B3655AAA3375CB /* recipe.app */;
@@ -114,6 +120,9 @@
);
mainGroup = 9AD793E4EFD47C3FC2FBCEBD;
minimizedProjectReferenceProxies = 1;
packageReferences = (
D6D5D1F82FA11AF8008BF8AF /* XCRemoteSwiftPackageReference "AppAuth-iOS" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = DFB8271353F280D44A8EF684 /* Products */;
projectDirPath = "";
@@ -167,6 +176,92 @@
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
37796B69615CDCCEFF016651 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = QA9JTAZXDL;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
5CAAA9206DE2876B88C1F201 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 7AAC38CA2AF1E20DE13538FB /* Configuration */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
948490899002FCF04A2150FF /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 7AAC38CA2AF1E20DE13538FB /* Configuration */;
@@ -232,92 +327,6 @@
};
name = Debug;
};
5CAAA9206DE2876B88C1F201 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 7AAC38CA2AF1E20DE13538FB /* Configuration */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
37796B69615CDCCEFF016651 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AFEF708D67BCEE78AA2502AA /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -327,7 +336,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
DEVELOPMENT_TEAM = QA9JTAZXDL;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
@@ -368,6 +377,25 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
D6D5D1F82FA11AF8008BF8AF /* XCRemoteSwiftPackageReference "AppAuth-iOS" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/openid/AppAuth-iOS";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.0.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
D6D5D1F92FA11AF8008BF8AF /* AppAuth */ = {
isa = XCSwiftPackageProductDependency;
package = D6D5D1F82FA11AF8008BF8AF /* XCRemoteSwiftPackageReference "AppAuth-iOS" */;
productName = AppAuth;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 64D626B4C2477EC6512D1B55 /* Project object */;
}
}

View File

@@ -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,5 +4,16 @@
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>dev.ulfrx.recipe.auth</string>
<key>CFBundleURLSchemes</key>
<array>
<string>recipe</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

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

View File

@@ -6,3 +6,8 @@
version "3.2.0"
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
ws@8.18.3:
version "8.18.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472"
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==

View File

@@ -1,11 +1,22 @@
plugins {
id("recipe.jvm.server")
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.ktor)
alias(libs.plugins.flywayPlugin)
application
id("recipe.quality")
}
group = "dev.ulfrx.recipe"
version = "1.0.0"
kotlin {
jvmToolchain(21)
compilerOptions {
allWarningsAsErrors.set(true)
}
}
application {
mainClass.set("dev.ulfrx.recipe.ApplicationKt")
@@ -14,5 +25,44 @@ application {
}
dependencies {
implementation(libs.ktor.serverCore)
implementation(libs.ktor.serverNetty)
implementation(libs.ktor.serverContentNegotiation)
implementation(libs.ktor.serializationKotlinxJson)
implementation(libs.kotlinx.serializationJson)
implementation(libs.logback)
implementation(libs.flyway.core)
implementation(libs.flyway.database.postgresql)
implementation(libs.postgresql)
implementation(projects.shared)
// Phase 2: Ktor auth + JWT validation + observability (D-21..D-23).
implementation(libs.ktor.serverAuth)
implementation(libs.ktor.serverAuthJwt)
implementation(libs.ktor.serverCallLogging)
implementation(libs.ktor.serverStatusPages)
// Phase 2: Exposed DSL + Hikari connection pool (D-26).
implementation(libs.exposed.core)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.java.time)
implementation(libs.hikari)
testImplementation(libs.ktor.serverTestHost)
testImplementation(libs.kotlin.testJunit)
// Phase 2: Testcontainers for JIT user provisioning + JWT auth integration tests
// (AUTH-03, AUTH-06). Wired here so Plan 02-02 only needs to write tests.
testImplementation(libs.testcontainers.postgresql)
testImplementation(libs.testcontainers.junit.jupiter)
}
flyway {
url = System.getenv("DATABASE_URL") ?: "jdbc:postgresql://localhost:5432/recipe"
user = System.getenv("DATABASE_USER") ?: "recipe"
password = System.getenv("DATABASE_PASSWORD") ?: "recipe"
locations = arrayOf("classpath:db/migration")
cleanDisabled = true
baselineOnMigrate = true
validateOnMigrate = true
}

View File

@@ -1,11 +1,17 @@
package dev.ulfrx.recipe
import dev.ulfrx.recipe.auth.PrincipalResolver
import dev.ulfrx.recipe.auth.configureAuthentication
import dev.ulfrx.recipe.auth.meRoute
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.plugins.calllogging.CallLogging
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.request.httpMethod
import io.ktor.server.request.path
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
@@ -22,17 +28,28 @@ private data class Health(
)
fun Application.module() {
install(CallLogging) {
// Method + path + status only — never headers (D-23). Ktor 3.4.1's CallLoggingConfig
// doesn't expose a header-redaction API, so the only safe approach is to omit header
// data from the format entirely. Bearer tokens never reach the log.
format { call ->
"${call.request.httpMethod.value} ${call.request.path()} -> ${call.response.status()?.value ?: "-"}"
}
}
install(ContentNegotiation) {
json()
}
configureAuthentication()
Database.migrate(this)
configureRouting()
Database.connect(this)
configureRouting(PrincipalResolver())
}
fun Application.configureRouting() {
fun Application.configureRouting(principalResolver: PrincipalResolver = PrincipalResolver()) {
routing {
get("/health") {
call.respond(Health(status = "ok"))
}
meRoute(principalResolver)
}
}

View File

@@ -1,28 +1,18 @@
package dev.ulfrx.recipe
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.application.Application
import org.flywaydb.core.Flyway
import org.jetbrains.exposed.sql.Database as ExposedDatabase
import org.slf4j.LoggerFactory
object Database {
private val log = LoggerFactory.getLogger(Database::class.java)
fun migrate(app: Application) {
val url =
app.environment.config
.property("database.url")
.getString()
val user =
app.environment.config
.property("database.user")
.getString()
val password =
app.environment.config
.property("database.password")
.getString()
val (url, user, password) = readConfig(app)
log.info("Connecting to {} as {} and running Flyway migrations", url, user)
runCatching {
Flyway
.configure()
@@ -38,4 +28,35 @@ object Database {
throw IllegalStateException("Database unreachable or migration failed", ex)
}
}
/**
* Wire Exposed to a Hikari pool against the same JDBC URL Flyway used.
* Must be called AFTER [migrate] so the schema is in place before the first
* Exposed transaction runs (Phase 2 D-26).
*/
fun connect(app: Application) {
val (url, user, password) = readConfig(app)
val ds =
HikariDataSource(
HikariConfig().apply {
jdbcUrl = url
username = user
setPassword(password)
maximumPoolSize = 10
minimumIdle = 2
poolName = "recipe-pool"
},
)
ExposedDatabase.connect(ds)
log.info("Exposed connected via Hikari pool '{}'", ds.poolName)
}
private data class Conf(val url: String, val user: String, val password: String)
private fun readConfig(app: Application): Conf =
Conf(
url = app.environment.config.property("database.url").getString(),
user = app.environment.config.property("database.user").getString(),
password = app.environment.config.property("database.password").getString(),
)
}

View File

@@ -0,0 +1,42 @@
package dev.ulfrx.recipe.auth
import io.ktor.server.config.ApplicationConfig
/**
* Server OIDC configuration (D-12, D-21..D-22).
*
* Values come from `application.conf` (`oidc { ... }`) overridden by
* `OIDC_ISSUER` / `OIDC_AUDIENCE` / `OIDC_JWKS_URL` env vars at deploy time. The
* pinned defaults match `Constants.OIDC_ISSUER` / `Constants.OIDC_CLIENT_ID` so
* dev runs against the documented Authentik provider playbook
* (`docs/authentik-setup.md`) without extra wiring.
*
* `jwksUrl` is required: D-22 mandates a `JwkProvider` with explicit cache + rate
* limits. If the deployer omits it, `fromApplicationConfig` derives the standard
* Authentik path (`{issuer}jwks/`). The trailing-slash invariant on `issuer` is
* enforced by [Constants.OIDC_ISSUER] / `application.conf`.
*/
public data class AuthConfig(
val issuer: String,
val audience: String,
val jwksUrl: String,
val leewaySeconds: Long = 30L,
) {
public companion object {
public fun fromApplicationConfig(config: ApplicationConfig): AuthConfig {
val issuer = config.property("oidc.issuer").getString()
val audience = config.property("oidc.audience").getString()
val jwksUrl =
config.propertyOrNull("oidc.jwksUrl")?.getString()?.takeIf { it.isNotBlank() }
?: "${issuer.trimEnd('/')}/jwks/"
val leewaySeconds =
config.propertyOrNull("oidc.leewaySeconds")?.getString()?.toLongOrNull() ?: 30L
return AuthConfig(
issuer = issuer,
audience = audience,
jwksUrl = jwksUrl,
leewaySeconds = leewaySeconds,
)
}
}
}

View File

@@ -0,0 +1,52 @@
package dev.ulfrx.recipe.auth
import com.auth0.jwk.JwkProvider
import com.auth0.jwk.JwkProviderBuilder
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.jwt.jwt
import java.net.URI
import java.util.concurrent.TimeUnit
/**
* Installs the `jwt("authentik")` Ktor authentication provider per D-21..D-23.
*
* Production callers use the no-arg form, which reads [AuthConfig] from
* `application.conf` and builds a cached, rate-limited [JwkProvider] from the
* issuer's JWKS endpoint (D-22: `cached(10, 15, MINUTES)` + `rateLimited(10, 1, MINUTES)`).
*
* Tests override [jwkProvider] with [JwtTestSupport.provider] to keep verification
* in-process — `testApplication` has no usable network port, so the URL-based
* builder isn't viable.
*
* Validate block enforces D-21's non-empty `sub` rule. Returning `null` from
* `validate` produces 401 — Ktor's default challenge handler is sufficient; we
* deliberately avoid logging Authorization headers or token bodies (D-23).
*/
public fun Application.configureAuthentication(
authConfig: AuthConfig = AuthConfig.fromApplicationConfig(environment.config),
jwkProvider: JwkProvider =
JwkProviderBuilder(URI(authConfig.jwksUrl).toURL())
.cached(10, 15, TimeUnit.MINUTES)
.rateLimited(10, 1, TimeUnit.MINUTES)
.build(),
) {
install(Authentication) {
jwt("authentik") {
realm = "recipe"
verifier(jwkProvider, authConfig.issuer) {
withIssuer(authConfig.issuer)
withAudience(authConfig.audience)
// D-21: clock-skew tolerance pinned at 30 seconds (acceptLeeway(30 seconds)).
// Honor the AuthConfig override if set lower for tests, but never exceed 30.
acceptLeeway(authConfig.leewaySeconds.coerceAtMost(30L))
}
validate { credential ->
val sub = credential.payload.subject
if (sub.isNullOrBlank()) null else JWTPrincipal(credential.payload)
}
}
}
}

View File

@@ -0,0 +1,28 @@
package dev.ulfrx.recipe.auth
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
/**
* Protected `GET /api/v1/me` route per D-27.
*
* The `authenticate("authentik")` block must be installed by [configureAuthentication]
* before this route is registered. JIT user provisioning happens inside [PrincipalResolver]
* on every authenticated call so claim drift propagates without an explicit migration.
*/
public fun Route.meRoute(principalResolver: PrincipalResolver) {
authenticate("authentik") {
get("/api/v1/me") {
val jwt =
call.principal<JWTPrincipal>()
?: return@get call.respond(HttpStatusCode.Unauthorized)
val me = principalResolver.resolve(jwt)
call.respond(me)
}
}
}

View File

@@ -0,0 +1,74 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.shared.dto.MeResponse
import io.ktor.server.auth.jwt.JWTPrincipal
import kotlinx.coroutines.Dispatchers
import org.jetbrains.exposed.sql.TextColumnType
import org.jetbrains.exposed.sql.statements.StatementType
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
/**
* Resolves a Ktor [JWTPrincipal] to a persisted [MeResponse] via JIT upsert (D-25).
*
* One row per OIDC `sub`; email + display_name refresh on every authenticated
* request so claim drift in Authentik propagates without an explicit migration.
*
* Implementation detail: Postgres `INSERT ... ON CONFLICT (sub) DO UPDATE ...
* RETURNING *` is the atomic upsert per D-25. We issue raw SQL via Exposed's
* `exec()` so we can use `RETURNING` (Exposed 0.55.0 DSL `upsert` returns the
* insert count, not the row, and we need the generated id and updated_at). DAO
* is forbidden by CLAUDE.md #5; we use `exec` (DSL) not the DAO API.
*
* `newSuspendedTransaction(Dispatchers.IO)` is mandatory: a plain blocking
* Exposed transaction inside a suspend route exhausts the connection pool
* (CLAUDE.md #6, PITFALLS.md #5/#6).
*/
public class PrincipalResolver {
public suspend fun resolve(principal: JWTPrincipal): MeResponse {
val sub =
principal.payload.subject?.takeIf { it.isNotBlank() }
?: error("PrincipalResolver invoked with blank sub — should be blocked by AuthPlugin.validate")
val email = principal.payload.getClaim("email")?.asString().orEmpty()
val nameClaim = principal.payload.getClaim("name")?.asString()
val preferredUsername = principal.payload.getClaim("preferred_username")?.asString()
val displayName = nameClaim ?: preferredUsername ?: email.ifBlank { sub }
return newSuspendedTransaction(Dispatchers.IO) {
var row: MeResponse? = null
// INSERT...RETURNING returns a ResultSet, so we must mark this as a SELECT
// statement type — Exposed's `exec` defaults to executeUpdate() for INSERT
// statements which Postgres rejects with "A result was returned when none
// was expected."
exec(
stmt =
"""
INSERT INTO users (sub, email, display_name)
VALUES (?, ?, ?)
ON CONFLICT (sub) DO UPDATE
SET email = EXCLUDED.email,
display_name = EXCLUDED.display_name,
updated_at = now()
RETURNING id, sub, email, display_name
""".trimIndent(),
args =
listOf(
TextColumnType() to sub,
TextColumnType() to email,
TextColumnType() to displayName,
),
explicitStatementType = StatementType.SELECT,
) { rs ->
if (rs.next()) {
row =
MeResponse(
id = rs.getObject("id").toString(),
sub = rs.getString("sub"),
email = rs.getString("email"),
displayName = rs.getString("display_name"),
)
}
}
row ?: error("Upsert RETURNING produced no row for sub=$sub")
}
}
}

View File

@@ -0,0 +1,21 @@
package dev.ulfrx.recipe.auth
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.javatime.timestamp
/**
* Exposed DSL mapping for the Phase 2 `users` migration (D-24, D-26).
*
* DSL only — DAO is forbidden by CLAUDE.md #5. Phase 3 will add `households`,
* `memberships`, and `invites` tables; this table stays untouched at that point
* because identity is anchored on the OIDC `sub`.
*/
public object UsersTable : Table("users") {
public val id = uuid("id")
public val sub = text("sub")
public val email = text("email")
public val displayName = text("display_name")
public val createdAt = timestamp("created_at")
public val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}

View File

@@ -16,3 +16,16 @@ database {
password = "recipe"
password = ${?DATABASE_PASSWORD}
}
oidc {
# Authentik OIDC issuer (trailing slash required — see Constants.OIDC_ISSUER / D-11).
issuer = "https://auth.ulfrx.dev/application/o/recipe-app/"
issuer = ${?OIDC_ISSUER}
# Audience pinned to client_id per D-07.
audience = "recipe-app"
audience = ${?OIDC_AUDIENCE}
# Optional override; if blank, AuthConfig.fromApplicationConfig derives `${issuer}jwks/`.
jwksUrl = ""
jwksUrl = ${?OIDC_JWKS_URL}
leewaySeconds = "30"
}

View File

@@ -0,0 +1,14 @@
-- Phase 2 D-24: principal table for OIDC-resolved users.
-- Identity is the OIDC `sub` claim (UNIQUE); email/display_name are mutable claims
-- refreshed by the JIT upsert in PrincipalResolver. Phase 3 layers
-- households/memberships/invites on top of this; do NOT add household_id here.
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sub TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
display_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX users_sub_idx ON users(sub);

View File

@@ -1,5 +1,8 @@
package dev.ulfrx.recipe
import dev.ulfrx.recipe.auth.AuthConfig
import dev.ulfrx.recipe.auth.JwtTestSupport
import dev.ulfrx.recipe.auth.configureAuthentication
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
@@ -15,10 +18,24 @@ class ApplicationTest {
@Test
fun `health endpoint returns 200 with status ok`() =
testApplication {
// Phase 2: configureRouting now wires `meRoute` under `authenticate("authentik")`,
// so the Authentication plugin must be installed before routing — otherwise the
// route DSL throws MissingApplicationPluginException at registration time.
// The verifier doesn't need to validate any tokens for this `/health` test, but
// the plugin must exist; reuse the test JWT support to keep the wiring honest.
val support = JwtTestSupport()
application {
install(ContentNegotiation) {
json()
}
configureAuthentication(
AuthConfig(
issuer = support.issuer,
audience = support.audience,
jwksUrl = "https://test-authentik.invalid/application/o/jwks/",
),
support.provider,
)
configureRouting()
}
val response = client.get("/health")

View File

@@ -0,0 +1,113 @@
package dev.ulfrx.recipe.auth
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.auth.authenticate
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Negative + positive JWT validation coverage for the `/api/v1/me` boundary
* (AUTH-03, D-21..D-23). Tests run against a hand-mounted protected route under
* `authenticate("authentik")` so they exercise the same Ktor verifier path as
* production without depending on Postgres or Flyway (those land in
* `MeRouteTest` per Plan 02-02 Task 3).
*/
class AuthJwtTest {
private fun authConfigFor(support: JwtTestSupport): AuthConfig =
AuthConfig(
issuer = support.issuer,
audience = support.audience,
// jwksUrl is unused when a JwkProvider override is supplied — the value
// just needs to be a syntactically-valid URL so AuthConfig invariants hold.
jwksUrl = "https://test-authentik.invalid/application/o/jwks/",
leewaySeconds = 30,
)
private fun Application.installProtectedRoute(support: JwtTestSupport) {
install(ContentNegotiation) { json() }
configureAuthentication(authConfigFor(support), support.provider)
routing {
authenticate("authentik") {
get("/protected") { call.respondText("ok") }
}
}
}
@Test
fun `no Authorization header returns 401`() =
testApplication {
val support = JwtTestSupport()
application { installProtectedRoute(support) }
val response = client.get("/protected")
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `expired token returns 401`() =
testApplication {
val support = JwtTestSupport()
application { installProtectedRoute(support) }
val token = support.mint(expiresInSeconds = -60)
val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `wrong issuer returns 401`() =
testApplication {
val support = JwtTestSupport()
application { installProtectedRoute(support) }
val token = support.mint(iss = "https://attacker.invalid/")
val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `wrong audience returns 401`() =
testApplication {
val support = JwtTestSupport()
application { installProtectedRoute(support) }
val token = support.mint(aud = "some-other-app")
val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `blank sub returns 401`() =
testApplication {
val support = JwtTestSupport()
application { installProtectedRoute(support) }
// JWT spec disallows an empty `sub` claim (auth0/java-jwt rejects ""),
// so we omit the claim entirely — the validate block must reject both
// null and blank as required by D-21.
val token = support.mint(sub = null)
val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `valid RS256 token returns 200 from a protected test route`() =
testApplication {
val support = JwtTestSupport()
application { installProtectedRoute(support) }
val token = support.mint()
val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
assertEquals(HttpStatusCode.OK, response.status)
}
}

View File

@@ -0,0 +1,98 @@
package dev.ulfrx.recipe.auth
import com.auth0.jwk.Jwk
import com.auth0.jwk.JwkProvider
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import java.security.KeyPairGenerator
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.util.Base64
import java.util.Date
/**
* Test helper for the Phase 2 server JWT validation surface (D-21..D-23).
*
* Generates an RS256 RSA-2048 keypair in-process, exposes a [JwkProvider] backed
* by the public key (so Ktor's `verifier(jwkProvider, issuer)` can resolve the
* `kid` without HTTP), and mints RS256 tokens with configurable claims so tests
* can hand-craft missing/expired/wrong-issuer/wrong-audience/blank-sub variants.
*
* Tests must NOT spin up a real JWKS endpoint — `JwkProviderBuilder` requires a
* URL, and using a network port from `testApplication` is fragile. The [provider]
* surface lets `configureAuthentication(authConfig, jwkProvider)` bypass the URL
* fetch entirely.
*/
internal class JwtTestSupport(
val issuer: String = "https://test-authentik.invalid/application/o/recipe/",
val audience: String = "recipe-app",
val keyId: String = "test-key-1",
) {
private val keyPair =
KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.genKeyPair()
private val publicKey = keyPair.public as RSAPublicKey
private val privateKey = keyPair.private as RSAPrivateKey
private val algorithm = Algorithm.RSA256(publicKey, privateKey)
val provider: JwkProvider =
JwkProvider {
// Use the documented Jwk.fromValues factory — the 9-arg constructors are
// both deprecated in jwks-rsa, and `allWarningsAsErrors` forbids them.
// RS256 = RSA + SHA-256; jwks-rsa needs n + e modulus/exponent (base64url).
Jwk.fromValues(
mapOf(
"kid" to keyId,
"kty" to "RSA",
"alg" to "RS256",
"use" to "sig",
"n" to publicKey.modulus.toBase64UrlNoPad(),
"e" to publicKey.publicExponent.toBase64UrlNoPad(),
),
)
}
/**
* Mint a signed JWT.
*
* @param iss override issuer (defaults to the verifier's expected [issuer]).
* @param aud override audience (defaults to the verifier's expected [audience]).
* @param sub subject claim; pass `""` or `null` to test the blank-sub gate.
* @param email optional email claim.
* @param name optional name claim (preferred_username uses the same input).
* @param expiresInSeconds expiry offset from now; pass a negative value for
* the expired-token negative case (default 600s = 10 min).
*/
fun mint(
iss: String = issuer,
aud: String = audience,
sub: String? = "auth0|test-user",
email: String? = "test@example.invalid",
name: String? = "Test User",
expiresInSeconds: Long = 600,
): String {
val now = System.currentTimeMillis()
val builder =
JWT.create()
.withKeyId(keyId)
.withIssuer(iss)
.withAudience(aud)
.withIssuedAt(Date(now))
.withExpiresAt(Date(now + expiresInSeconds * 1000L))
if (sub != null) builder.withSubject(sub)
if (email != null) builder.withClaim("email", email)
if (name != null) {
builder.withClaim("name", name)
builder.withClaim("preferred_username", name)
}
return builder.sign(algorithm)
}
}
private fun java.math.BigInteger.toBase64UrlNoPad(): String {
// Strip the leading sign byte that BigInteger.toByteArray() prepends when the
// high bit is set (jwks-rsa expects unsigned big-endian per RFC 7518).
val raw = this.toByteArray()
val unsigned =
if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw
return Base64.getUrlEncoder().withoutPadding().encodeToString(unsigned)
}

View File

@@ -0,0 +1,145 @@
package dev.ulfrx.recipe.auth
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import dev.ulfrx.recipe.shared.dto.MeResponse
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
import org.flywaydb.core.Flyway
import org.jetbrains.exposed.sql.Database as ExposedDatabase
import org.junit.AfterClass
import org.junit.BeforeClass
import org.testcontainers.containers.PostgreSQLContainer
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* Integration test for `GET /api/v1/me` — exercises the full
* Auth → PrincipalResolver → Postgres path against a real database (AUTH-03,
* AUTH-06, D-25, D-27).
*
* Uses Testcontainers `postgres:16` rather than ambient localhost Postgres so
* the test is hermetic across dev machines / CI. Flyway runs once per JVM
* process before any test executes; Exposed is connected through Hikari to
* the container.
*/
class MeRouteTest {
companion object {
private val postgres = PostgreSQLContainer("postgres:16")
private lateinit var dataSource: HikariDataSource
private val json = Json { ignoreUnknownKeys = true }
@JvmStatic
@BeforeClass
fun setUpClass() {
postgres.start()
Flyway
.configure()
.dataSource(postgres.jdbcUrl, postgres.username, postgres.password)
.locations("classpath:db/migration")
.baselineOnMigrate(true)
.load()
.migrate()
dataSource =
HikariDataSource(
HikariConfig().apply {
jdbcUrl = postgres.jdbcUrl
username = postgres.username
setPassword(postgres.password)
maximumPoolSize = 4
poolName = "me-route-test-pool"
},
)
ExposedDatabase.connect(dataSource)
}
@JvmStatic
@AfterClass
fun tearDownClass() {
dataSource.close()
postgres.stop()
}
}
private fun authConfigFor(support: JwtTestSupport): AuthConfig =
AuthConfig(
issuer = support.issuer,
audience = support.audience,
jwksUrl = "https://test-authentik.invalid/application/o/jwks/",
leewaySeconds = 30,
)
@Test
fun `valid token creates user row and returns MeResponse`() =
testApplication {
val support = JwtTestSupport()
val resolver = PrincipalResolver()
application {
install(ContentNegotiation) { json() }
configureAuthentication(authConfigFor(support), support.provider)
routing { meRoute(resolver) }
}
val token = support.mint(sub = "auth0|user-create-123")
val response =
client.get("/api/v1/me") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, response.status)
val body = json.decodeFromString(MeResponse.serializer(), response.bodyAsText())
assertEquals("auth0|user-create-123", body.sub)
assertEquals("test@example.invalid", body.email)
assertEquals("Test User", body.displayName)
assertTrue(body.id.isNotBlank(), "id should be a server-generated UUID, was: ${body.id}")
}
@Test
fun `second token with same sub updates email and display name without duplicating`() =
testApplication {
val support = JwtTestSupport()
val resolver = PrincipalResolver()
application {
install(ContentNegotiation) { json() }
configureAuthentication(authConfigFor(support), support.provider)
routing { meRoute(resolver) }
}
val first =
client.get("/api/v1/me") {
header(
HttpHeaders.Authorization,
"Bearer ${support.mint(sub = "auth0|user-update-1", email = "old@example.invalid", name = "Old Name")}",
)
}
assertEquals(HttpStatusCode.OK, first.status)
val firstBody = json.decodeFromString(MeResponse.serializer(), first.bodyAsText())
val second =
client.get("/api/v1/me") {
header(
HttpHeaders.Authorization,
"Bearer ${support.mint(sub = "auth0|user-update-1", email = "new@example.invalid", name = "New Name")}",
)
}
assertEquals(HttpStatusCode.OK, second.status)
val secondBody = json.decodeFromString(MeResponse.serializer(), second.bodyAsText())
assertEquals(firstBody.id, secondBody.id) // same row
assertEquals(firstBody.sub, secondBody.sub)
assertEquals("new@example.invalid", secondBody.email)
assertEquals("New Name", secondBody.displayName)
assertNotEquals(firstBody.email, secondBody.email)
}
}

View File

@@ -1,45 +1,39 @@
plugins {
// AGP must apply BEFORE recipe.kotlin.multiplatform — the latter calls androidTarget(),
// which requires the Android Gradle Plugin to already be on the project. Gradle applies
// plugin IDs in declaration order, so com.android.library is listed first.
// which requires the Android Gradle Plugin to already be on the project.
alias(libs.plugins.androidLibrary)
id("recipe.kotlin.multiplatform")
alias(libs.plugins.kotlinSerialization)
id("recipe.quality")
}
kotlin {
explicitApi()
// Override framework baseName: shared exposes "Shared.framework" to Swift, while
// composeApp's convention-plugin default is "ComposeApp.framework". (D-07 / PITFALL #10)
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>().configureEach {
binaries.withType<org.jetbrains.kotlin.gradle.plugin.mpp.Framework>().configureEach {
baseName = "Shared"
}
}
// No iOS framework here — composeApp's umbrella `ComposeApp.framework`
// transitively exports shared. Producing a second framework would double-bundle
// the Kotlin stdlib at link time (PITFALL: duplicate-framework collision).
sourceSets {
commonMain.dependencies {
// Phase 1: intentionally empty. Domain models + DTOs land Phase 2+.
// D-19 / INFRA-06: Do NOT add Ktor, Compose, or SQLDelight deps here — EVER.
// Phase 2: DTOs land here (MeResponse/User per D-27). kotlinx.serialization
// is the only allowed runtime dependency in shared/commonMain — D-19 / INFRA-06
// forbids Ktor, Compose, SQLDelight, Koin, Kermit. `api(...)` so consumers
// (composeApp, server) inherit the @Serializable runtime without each
// re-declaring it.
api(libs.kotlinx.serializationJson)
}
}
}
android {
namespace = "dev.ulfrx.recipe.shared"
compileSdk =
libs.versions.android.compileSdk
.get()
.toInt()
compileSdk = libs.versions.android.compileSdk.get().toInt()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
defaultConfig {
minSdk =
libs.versions.android.minSdk
.get()
.toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
}
}

View File

@@ -0,0 +1,52 @@
package dev.ulfrx.recipe.shared
/**
* Phase 2 OIDC + API configuration shared by client and server (D-11, D-12).
*
* Hardcoded for the v1 single-environment per `PITFALLS.md` tech-debt acceptance:
* the homelab Authentik is the only target, so `BuildConfig`-style Gradle injection
* is deliberately deferred. The placeholder issuer host (`auth.example.invalid`) is
* substituted at deploy time by overriding the build at the call site, never by
* mutating this file in user installs.
*
* **Invariants the rest of Phase 2 depends on:**
* - `OIDC_ISSUER` ends with `/` so JWKS / authorization endpoints can append paths
* without double-slash bugs (PITFALLS.md #8 — Authentik is byte-sensitive here).
* - `OIDC_REDIRECT_URI` is exactly `recipe://callback` — both `iosApp/Info.plist`
* `CFBundleURLTypes` and the Android `<intent-filter>` registration must match
* byte-for-byte (D-09).
* - `OIDC_CLIENT_ID` doubles as the JWT `aud` claim value (D-07: single string,
* not array). Server-side `withAudience(OIDC_CLIENT_ID)` validates against it.
*/
public object Constants {
/** Reserved by `composeApp/server` for the local Ktor port (Phase 1). */
public const val SERVER_PORT: Int = 8080
/**
* Base URL the client uses for `/api/v1/...` calls. v1 single environment;
* staging support is deferred per PITFALLS.md tech-debt acceptance.
*/
public const val API_BASE_URL: String = "http://localhost:8080/"
/**
* Authentik OIDC issuer. Trailing slash is required (D-11, PITFALLS.md #8).
* Replace `auth.example.invalid` with the homelab Authentik hostname before
* shipping a build; the placeholder keeps tests/CI deterministic without
* leaking real infrastructure into the repo.
*/
public const val OIDC_ISSUER: String = "https://auth.ulfrx.dev/application/o/recipe-app/"
/**
* OAuth2 client_id registered with Authentik. The same value MUST appear as
* the single-string `aud` claim per D-07.
*/
public const val OIDC_CLIENT_ID: String = "recipe-app"
/**
* Custom URL scheme for the OAuth redirect (D-09). Must match `Info.plist`
* `CFBundleURLTypes` on iOS and the Android manifest `<intent-filter>` byte
* for byte. PKCE S256 + AppAuth state (D-05) make custom-scheme interception
* non-exploitable; Universal Links / App Links are explicitly deferred.
*/
public const val OIDC_REDIRECT_URI: String = "recipe://callback"
}

View File

@@ -0,0 +1,33 @@
package dev.ulfrx.recipe.shared.dto
import kotlinx.serialization.Serializable
/**
* Wire-format DTO for `GET /api/v1/me` (D-27).
*
* Mirrors [User] verbatim today; kept as a separate type so Phase 3 can extend the
* response with `householdId` (and any future fields) without changing the client
* domain model. Decoders MUST be configured with `ignoreUnknownKeys = true` so a
* Phase 2 client keeps parsing Phase 3+ responses (see
* `MeResponseSerializationTest.decoder ignores future fields like householdId without failing`).
*
* Wire keys are camelCase to stay consistent with kotlinx.serialization defaults;
* `sub` is left raw because it's an opaque OIDC claim and renaming it on the wire
* is a recipe for drift.
*/
@Serializable
public data class MeResponse(
public val id: String,
public val sub: String,
public val email: String,
public val displayName: String,
) {
/** One-to-one mapping to the client domain [User]; no fallback / coercion. */
public fun toUser(): User =
User(
id = id,
sub = sub,
email = email,
displayName = displayName,
)
}

View File

@@ -0,0 +1,26 @@
package dev.ulfrx.recipe.shared.dto
import kotlinx.serialization.Serializable
/**
* Authenticated user identity shared by client and server (D-25, D-27).
*
* Phase 2 always emits `User` in the [dev.ulfrx.recipe.shared.dto.MeResponse] payload;
* Phase 3 will extend the response with a household lookup but `User` itself stays
* stable. `id` is a server-issued UUID serialized as a string so the shared module
* stays free of Exposed / SQLDelight / kotlin.uuid dependencies (D-19 / INFRA-06).
*
* Field semantics:
* - [id] — server primary key (`users.id` UUID per D-24).
* - [sub] — opaque OIDC subject claim from Authentik; the only stable identity key
* for JIT provisioning per D-25.
* - [email] — most recent `email` claim. May change between logins (D-25 upserts).
* - [displayName] — most recent `name` / `preferred_username` claim (D-25).
*/
@Serializable
public data class User(
public val id: String,
public val sub: String,
public val email: String,
public val displayName: String,
)

View File

@@ -0,0 +1,90 @@
package dev.ulfrx.recipe.shared.dto
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Wire-format contract for [MeResponse] (D-27).
*
* Phase 2 pins the DTO shape used by both the Ktor server's `/api/v1/me` route and the
* KMP client. The fields, JSON keys, and forward-compatibility behavior asserted here are
* the load-bearing contract — downstream Phase 2 plans implement against it.
*/
class MeResponseSerializationTest {
private val strictJson = Json { encodeDefaults = true }
private val lenientJson =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
@Test
fun `round trips id sub email and displayName via camelCase wire keys`() {
val original =
MeResponse(
id = "11111111-2222-3333-4444-555555555555",
sub = "authentik|abc",
email = "anna@example.test",
displayName = "Anna",
)
val encoded = strictJson.encodeToString(MeResponse.serializer(), original)
// Wire keys must match the server contract exactly. Authentik's `sub` is opaque,
// so we never rename it on the wire.
assertEquals(
"{\"id\":\"11111111-2222-3333-4444-555555555555\"," +
"\"sub\":\"authentik|abc\"," +
"\"email\":\"anna@example.test\"," +
"\"displayName\":\"Anna\"}",
encoded,
)
val decoded = strictJson.decodeFromString(MeResponse.serializer(), encoded)
assertEquals(original, decoded)
}
@Test
fun `decoder ignores future fields like householdId without failing`() {
// Phase 3 will extend the response with `householdId` (D-28). The Phase 2 client
// must keep parsing the response after that change, so the decoder must skip
// unknown keys.
val withFutureField =
"{\"id\":\"11111111-2222-3333-4444-555555555555\"," +
"\"sub\":\"authentik|abc\"," +
"\"email\":\"anna@example.test\"," +
"\"displayName\":\"Anna\"," +
"\"householdId\":\"99999999-0000-0000-0000-000000000000\"}"
val decoded = lenientJson.decodeFromString(MeResponse.serializer(), withFutureField)
assertEquals("11111111-2222-3333-4444-555555555555", decoded.id)
assertEquals("authentik|abc", decoded.sub)
assertEquals("anna@example.test", decoded.email)
assertEquals("Anna", decoded.displayName)
}
@Test
fun `toUser maps every field one-to-one without dropping data`() {
val response =
MeResponse(
id = "11111111-2222-3333-4444-555555555555",
sub = "authentik|abc",
email = "anna@example.test",
displayName = "Anna",
)
val user = response.toUser()
assertEquals(
User(
id = response.id,
sub = response.sub,
email = response.email,
displayName = response.displayName,
),
user,
)
}
}