Add authentication

This commit is contained in:
2026-04-27 19:28:57 +02:00
parent 6684b7179d
commit e0af5f4053
92 changed files with 8140 additions and 208 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*