docs(02): capture phase context

This commit is contained in:
2026-04-27 19:28:57 +02:00
parent 04b3d9b1d5
commit f3569b41d6
2 changed files with 422 additions and 0 deletions

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)