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
authModuledefinition style (single<T> { ... }vssingle { T(get(), ...) }) - Ktor Client
Auth { bearer { ... } }plugin configuration boilerplate - Whether
MeResponseDTO andUserdomain model are unified or separate - UUID library choice for
User.id(kotlinx.uuidvskotlin.uuid.Uuidif Kotlin 2.3 is stable) - AppAuth-iOS CocoaPod integration via Gradle DSL (
cocoapods { pod("AppAuth") }) vs hand-written Podfile - Splash placeholder visual during
Loadingstate OIDC_ISSUERtrailing-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)