# 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 { ... }` 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)