Files
recipe/.planning/phases/02-authentication-foundation/02-DISCUSSION-LOG.md
2026-04-29 20:54:13 +02:00

10 KiB

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)