diff --git a/.planning/phases/02-authentication-foundation/02-RESEARCH.md b/.planning/phases/02-authentication-foundation/02-RESEARCH.md
new file mode 100644
index 0000000..f5bb183
--- /dev/null
+++ b/.planning/phases/02-authentication-foundation/02-RESEARCH.md
@@ -0,0 +1,496 @@
+# Phase 2: Authentication Foundation - Research
+
+**Researched:** 2026-04-27
+**Domain:** KMP native OIDC, secure token storage, Ktor JWT validation, Exposed/Flyway JIT users
+**Confidence:** MEDIUM-HIGH
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+The following locked decisions are copied from `.planning/phases/02-authentication-foundation/02-CONTEXT.md` and are authoritative for planning. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
+
+- **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 is a dev-mode `DEV_AUTH_TOKEN` stub.
+- **D-03:** Wasm actual is `NotImplementedError("Wasm OIDC: v2")`.
+- **D-04:** `OidcClient.login()` and `.refresh()` are suspend functions bridged with `suspendCancellableCoroutine`.
+- **D-05:** Authentik provider is Public + PKCE S256.
+- **D-06:** Requested scopes are `openid profile email offline_access`.
+- **D-07:** `aud` claim shape is pinned to a single string equal to `client_id`.
+- **D-08:** Signing algorithm is RS256.
+- **D-09:** Redirect URI is custom scheme `recipe://callback`.
+- **D-10:** `docs/authentik-setup.md` is a Phase 2 deliverable.
+- **D-11:** Client OIDC config is hardcoded in `shared/commonMain/Constants.kt`.
+- **D-12:** Server OIDC config is via env vars in `application.conf`.
+- **D-13:** Persist full AppAuth `AuthState` JSON blob via a secure settings abstraction.
+- **D-14:** iOS Keychain accessibility target is `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`.
+- **D-15:** One AuthState blob per app install.
+- **D-16:** Proactive refresh uses AppAuth `performActionWithFreshTokens`.
+- **D-17:** Reactive fallback uses Ktor client `Auth { bearer { refreshTokens { ... } } }`.
+- **D-18:** Refresh failure silently transitions to unauthenticated.
+- **D-19:** Logout calls Authentik end-session and deletes persisted AuthState.
+- **D-20:** AppAuth end-session APIs drive logout on both mobile platforms.
+- **D-21:** Ktor installs `jwt("authentik")` with issuer, audience, 30-second leeway, and `sub` validation.
+- **D-22:** JWKS provider uses cache size 10, 15-minute TTL, and 10/minute rate limit.
+- **D-23:** Never log tokens or `Authorization` headers.
+- **D-24:** Phase 2 ships `V1__users.sql`.
+- **D-25:** JIT provisioning upserts by OIDC `sub` and updates email/display name on each authenticated request.
+- **D-26:** Exposed DSL only; every coroutine-touching DB call uses the suspend transaction API.
+- **D-27:** Protected `GET /api/v1/me` returns `MeResponse`.
+- **D-28:** Client auth state is `Loading | Unauthenticated | Authenticated(user, householdId = null)`.
+- **D-29:** `AuthSession` is a Koin singleton in `authModule`.
+- **D-30:** `App()` gates between loading, login, and post-login placeholder.
+- **D-31:** Login screen is minimal.
+- **D-32:** Login errors render inline below the button.
+- **D-33:** Post-login placeholder says `Witaj, {displayName}!` and includes `Wyloguj się`.
+- **D-34:** User-facing auth strings use Compose Resources from day 1.
+
+### Claude's Discretion
+Copied from CONTEXT.md; planner may choose within these boundaries. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
+
+- Exact `Koin` `authModule` definition style.
+- Ktor Client bearer auth boilerplate, including `refreshTokens`, token loader, and `sendWithoutRequest`.
+- Whether `MeResponse` DTO and `User` domain model are the same type or separate.
+- Concrete UUID type, choosing what pairs cleanly with Exposed UUID columns and `kotlinx.serialization`.
+- Whether AppAuth-iOS is added via Gradle CocoaPods DSL or hand-written `iosApp/Podfile`.
+- Splash placeholder visual.
+- Whether `OIDC_ISSUER` ends with a trailing slash; pin and document the choice.
+- Logger tag/level for AppAuth events.
+
+### Deferred Ideas (OUT OF SCOPE)
+Copied from CONTEXT.md; planner must not include these in Phase 2. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
+
+- Universal Links / App Links.
+- BuildConfig-style Gradle injection of OIDC config.
+- Real Desktop OIDC.
+- Wasm OIDC implementation.
+- Two-tier logout.
+- Background token refresh.
+- Apple Sign-in as a first-class button.
+- Per-user persisted `AuthState`.
+- Modal/toast for refresh-failure UX.
+- Authentik provisioning automation.
+- JWT validation tests against a real Authentik instance.
+
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| AUTH-01 | User signs in through Authentik with authorization code + PKCE. [VERIFIED: `.planning/REQUIREMENTS.md`] | AppAuth native flows, redirect registration, Authentik public provider + PKCE S256. [CITED: https://cocoapods.org/pods/AppAuth] |
+| AUTH-02 | Client stores access + refresh tokens securely. [VERIFIED: `.planning/REQUIREMENTS.md`] | Persist AppAuth state JSON, but use explicit secure platform storage; no-arg multiplatform-settings is not enough on Android. [CITED: https://github.com/russhwolf/multiplatform-settings] |
+| AUTH-03 | Ktor validates access tokens via Authentik JWKS. [VERIFIED: `.planning/REQUIREMENTS.md`] | Use Ktor JWT provider with issuer, audience, signature/JWKS, leeway, and validate block. [CITED: https://ktor.io/docs/server-jwt.html] |
+| AUTH-04 | Session persists across launches via refresh. [VERIFIED: `.planning/REQUIREMENTS.md`] | Restore AppAuth AuthState JSON and call `performActionWithFreshTokens`; request `offline_access`. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html] |
+| AUTH-05 | User can sign out and return to login screen. [VERIFIED: `.planning/REQUIREMENTS.md`] | Use Authentik end-session endpoint and AppAuth end-session request, then wipe local state. [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/] |
+| AUTH-06 | Users are JIT-provisioned by OIDC `sub`. [VERIFIED: `.planning/REQUIREMENTS.md`] | Flyway users table plus Exposed DSL upsert in suspend transaction. [CITED: https://www.jetbrains.com/help/exposed/dsl-crud-operations.html] |
+
+
+## Summary
+
+Phase 2 should be planned as three cooperating auth boundaries: native client OIDC, server token validation, and server principal provisioning. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] AppAuth is the right non-hand-rolled primitive because both Android and iOS expose an auth-state object that refreshes tokens and serializes/restores state. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html] [CITED: https://openid.github.io/AppAuth-iOS/docs/latest/interface_o_i_d_auth_state.html]
+
+The main planning correction is token storage. [VERIFIED: web docs] `multiplatform-settings` supports Apple `KeychainSettings`, but its no-arg/default Android path delegates to ordinary SharedPreferences and its README says no-arg does not provide encrypted implementations. [CITED: https://github.com/russhwolf/multiplatform-settings] Android `EncryptedSharedPreferences` still exists and encrypts preferences, but AndroidX Security Crypto 1.1.0 deprecated its crypto APIs in favor of platform APIs/direct Android Keystore use. [CITED: https://developer.android.com/jetpack/androidx/releases/security] Plan Phase 2 with an explicit Wave 0 decision/task for Android secure storage instead of assuming a secure adapter exists. [VERIFIED: docs comparison]
+
+**Primary recommendation:** Use AppAuth AuthState as the session source of truth, store the serialized state behind an explicit `SecureAuthStateStore` expect/actual, protect `/api/v1/me` with Ktor `jwt("authentik")`, and JIT-upsert `users` by `sub` in a suspend Exposed transaction. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] [CITED: https://ktor.io/docs/server-jwt.html]
+
+## Project Constraints (from CLAUDE.md)
+
+- Use GSD planning artifacts as source of truth before implementation. [VERIFIED: `CLAUDE.md`]
+- Keep Phase 2 within current roadmap order: Phase 1 complete, Phase 2 auth next. [VERIFIED: `.planning/STATE.md`]
+- Client is KMP + Compose Multiplatform; server is Ktor 3.x + Postgres + Exposed DSL + Flyway. [VERIFIED: `CLAUDE.md`]
+- `shared/commonMain` may contain domain models and API DTOs only; no UI, HTTP, DB, Koin, Kermit, AppAuth, or settings imports. [VERIFIED: `CLAUDE.md`]
+- Exposed DAO is forbidden; use DSL only. [VERIFIED: `CLAUDE.md`]
+- Coroutine-touching Ktor handlers must use Exposed suspend transactions, not blocking `transaction {}`. [VERIFIED: `CLAUDE.md`] [CITED: https://www.jetbrains.com/help/exposed/transactions.html]
+- All user-facing strings must be externalized from day 1. [VERIFIED: `CLAUDE.md`]
+- Never log bearer tokens or authorization headers. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|--------------|----------------|-----------|
+| OIDC browser login + callback | Browser / Client | Authentik | Native app owns browser launch and redirect handling; Authentik owns identity UI and token issuance. [CITED: https://cocoapods.org/pods/AppAuth] |
+| Token refresh | Browser / Client | Authentik | AppAuth owns fresh-token behavior; Authentik token endpoint issues replacements. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html] |
+| Secure token persistence | Browser / Client | OS secure storage | Client owns persistence; storage must be Keychain/Android secure storage, not server. [VERIFIED: `.planning/REQUIREMENTS.md`] |
+| Bearer attachment to API calls | Browser / Client | API / Backend | Ktor Client attaches bearer tokens; backend rejects invalid tokens. [CITED: https://ktor.io/docs/client-bearer-auth.html] |
+| JWT signature/claim validation | API / Backend | Authentik JWKS | Ktor validates issuer/audience/signature/expiry; Authentik exposes JWKS. [CITED: https://ktor.io/docs/server-jwt.html] [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/] |
+| JIT user provisioning | API / Backend | Database / Storage | Backend derives user from JWT claims and owns DB upsert. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] |
+| `/api/v1/me` | API / Backend | shared DTO | Route returns authenticated user DTO after provisioning. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] |
+| Logout | Browser / Client | Authentik | Client initiates end-session and deletes local state. [CITED: https://openid.github.io/AppAuth-iOS/docs/latest/interface_o_i_d_end_session_request.html] |
+
+## Standard Stack
+
+### Core
+
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `net.openid:appauth` | 0.11.1 | Android OIDC/OAuth native authorization flow and AuthState. [VERIFIED: Maven Central search] | AppAuth provides PKCE-compatible native browser flow, token refresh helpers, and JSON AuthState. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html] |
+| CocoaPod `AppAuth` | 2.0.0 | iOS OIDC/OAuth native authorization flow. [VERIFIED: CocoaPods] | AppAuth maps OAuth/OIDC flows and supports fresh-token helpers and native browser/user-agent patterns. [CITED: https://cocoapods.org/pods/AppAuth] |
+| Ktor auth client/server artifacts | Project catalog 3.4.1; current release observed 3.4.3 | Client bearer retry and server JWT validation. [VERIFIED: `gradle/libs.versions.toml`] [VERIFIED: Maven search] | Ktor docs expose `loadTokens`, `refreshTokens`, single-flight refresh, JWT verifier, JWKS provider, and validate/challenge hooks. [CITED: https://ktor.io/docs/client-bearer-auth.html] [CITED: https://ktor.io/docs/server-jwt.html] |
+| `com.russhwolf:multiplatform-settings` | 1.3.0 | Common key-value API over platform delegates. [VERIFIED: Maven Central] | Useful interface for `SecureAuthStateStore`; must not use no-arg/default Android for secrets. [CITED: https://github.com/russhwolf/multiplatform-settings] |
+| Exposed DSL | 1.2.0 current | Server SQL DSL and suspend transactions. [VERIFIED: Exposed docs] | Official docs show DSL CRUD/upsert and suspend transaction APIs. [CITED: https://www.jetbrains.com/help/exposed/dsl-crud-operations.html] |
+| Flyway | Project catalog 12.4.0 | `V1__users.sql` migration. [VERIFIED: `gradle/libs.versions.toml`] | Existing Phase 1 server bootstrap already runs Flyway on startup. [VERIFIED: `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt`] |
+
+### Supporting
+
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `androidx.security:security-crypto` | 1.1.0 stable | Android encrypted SharedPreferences option. [CITED: https://developer.android.com/jetpack/androidx/releases/security] | Use only if accepting deprecation; otherwise implement Android Keystore-backed store and flag decision. [CITED: https://developer.android.com/reference/kotlin/androidx/security/crypto/EncryptedSharedPreferences] |
+| `com.auth0:jwks-rsa` | Transitive/API used by Ktor examples | JWKS provider cache/rate limit builder. [CITED: https://ktor.io/docs/server-jwt.html] | Add explicit alias if Ktor JWT artifact does not expose the builder cleanly. [ASSUMED] |
+| `kotlinx-serialization-json` | Already via Ktor serialization artifact | DTO JSON and AuthState wrapper metadata if needed. [VERIFIED: `gradle/libs.versions.toml`] | Keep DTOs in `shared`; keep AppAuth JSON as opaque string in client. [VERIFIED: `CLAUDE.md`] |
+
+### Alternatives Considered
+
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| AppAuth native clients | Hand-rolled authorization-code flow | Reject: PKCE, redirect state, token exchange, refresh, cancellation, and end-session are deceptively complex. [CITED: https://cocoapods.org/pods/AppAuth] |
+| `multiplatform-settings` no-arg | Explicit expect/actual store | Use explicit store: no-arg defaults are not encrypted and are hard to substitute in tests. [CITED: https://github.com/russhwolf/multiplatform-settings] |
+| Exposed DAO | Exposed DSL | Reject: project forbids DAO; DSL better matches JSON/upsert and transaction control. [VERIFIED: `CLAUDE.md`] |
+
+**Installation:**
+
+```bash
+# Add aliases in gradle/libs.versions.toml; use the project's version catalog.
+# Ktor artifacts should share the existing ktor version ref unless a deliberate full Ktor bump is planned.
+```
+
+**Version verification:** Ktor current docs show 3.4.3 and Maven search observed 3.4.3 on 2026-04-22; the repo currently pins 3.4.1. [CITED: https://ktor.io/docs/server-jwt.html] [VERIFIED: Maven search] Multiplatform Settings current is 1.3.0. [CITED: https://central.sonatype.com/artifact/com.russhwolf/multiplatform-settings] AppAuth-iOS current CocoaPod is 2.0.0. [CITED: https://cocoapods.org/pods/AppAuth] AppAuth-Android current Maven version remains 0.11.1. [VERIFIED: Maven Central search]
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```text
+User taps "Zaloguj się"
+ -> Compose LoginScreen
+ -> AuthSession.login()
+ -> OidcClient actual (Android/iOS AppAuth)
+ -> Authentik authorization endpoint (system browser, PKCE, state)
+ -> recipe://callback
+ -> AppAuth token exchange
+ -> AuthState JSON persisted via SecureAuthStateStore
+ -> AuthSession calls GET /api/v1/me with fresh access token
+ -> Ktor jwt("authentik") verifier
+ -> Authentik JWKS cache/rate limit
+ -> validate issuer + audience + expiry + sub
+ -> PrincipalResolver upserts users by sub
+ -> /api/v1/me returns MeResponse
+ -> AuthSession emits Authenticated(user, householdId = null)
+
+Logout:
+User taps "Wyloguj się"
+ -> AppAuth EndSessionRequest / Authentik end-session endpoint
+ -> local AuthState blob removed
+ -> AuthSession emits Unauthenticated
+```
+
+### Recommended Project Structure
+
+```text
+composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/
+├── auth/ # AuthSession, AuthState, OidcClient expect, SecureAuthStateStore expect
+├── data/remote/ # HttpClient factory, AuthApi for /api/v1/me
+├── di/ # authModule added to appModule composition
+└── ui/screens/auth/ # LoginScreen, PostLoginPlaceholder
+
+composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/
+└── OidcClient.android.kt # AppAuth-Android + redirect support + secure store actual
+
+composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/
+└── OidcClient.ios.kt # AppAuth-iOS CocoaPod bindings + secure store actual
+
+server/src/main/kotlin/dev/ulfrx/recipe/
+├── auth/ # AuthConfig, configureAuthentication, PrincipalResolver, AuthPrincipal
+├── db/tables/ # Users table
+└── routes/ # me route
+
+shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/
+└── MeResponse.kt # Serializable DTO only
+```
+
+### Pattern 1: AuthState Is Opaque Session Storage
+
+**What:** Store the platform AppAuth AuthState JSON string as one opaque blob and keep DTO/domain mapping outside the blob. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html]
+**When to use:** Always for mobile token persistence in Phase 2. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
+**Example:**
+
+```kotlin
+interface SecureAuthStateStore {
+ fun readAuthStateJson(): String?
+ fun writeAuthStateJson(value: String)
+ fun clear()
+}
+```
+
+### Pattern 2: Fresh Token Wrapper Before Ktor Calls
+
+**What:** `AuthSession.getAccessToken()` calls AppAuth `performActionWithFreshTokens`, persists any updated state, and returns a token to Ktor. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html]
+**When to use:** Before every authenticated API call, especially `/api/v1/me`. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
+**Ktor fallback:** Configure `refreshTokens {}` for 401 retries and rely on Ktor's documented single-flight refresh behavior. [CITED: https://ktor.io/docs/client-bearer-auth.html]
+
+### Pattern 3: JWT Validation Then Principal Resolution
+
+**What:** Ktor JWT authenticates claims; a resolver maps JWT `sub` to a persisted `users` row. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
+**When to use:** Every protected route, starting with `/api/v1/me`. [VERIFIED: `.planning/ROADMAP.md`]
+**Example:**
+
+```kotlin
+install(Authentication) {
+ jwt("authentik") {
+ realm = "recipe"
+ verifier(jwkProvider, issuer) {
+ withIssuer(issuer)
+ withAudience(audience)
+ acceptLeeway(30)
+ }
+ validate { credential ->
+ credential.payload.subject?.takeIf { it.isNotBlank() }?.let { JWTPrincipal(credential.payload) }
+ }
+ }
+}
+```
+
+Source: Ktor JWT docs show dependencies, JWKS verifier, `acceptLeeway`, and required `validate`. [CITED: https://ktor.io/docs/server-jwt.html]
+
+### Anti-Patterns to Avoid
+
+- **Using no-arg `Settings()` for refresh tokens:** It defaults to non-encrypted stores on some platforms; use explicit secure actuals. [CITED: https://github.com/russhwolf/multiplatform-settings]
+- **Trusting access token alone for user creation:** Use `sub` as stable identity and update email/name as mutable claims. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
+- **Blocking `transaction {}` inside Ktor suspend routes:** Current Exposed docs warn synchronous transactions block the current thread; use suspend transactions for coroutine paths. [CITED: https://www.jetbrains.com/help/exposed/transactions.html]
+- **Logging token-bearing headers:** Bearer tokens grant API access; project explicitly forbids token logging. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Native OAuth/OIDC browser flow | Custom URL construction + manual token exchange | AppAuth Android/iOS | Handles PKCE/state/native user agents and token refresh helpers. [CITED: https://cocoapods.org/pods/AppAuth] |
+| JWT parsing/verification | Manual JWT decode or static public key | Ktor `ktor-server-auth-jwt` + JWKS provider | Handles signature verification and Ktor principal integration. [CITED: https://ktor.io/docs/server-jwt.html] |
+| Token retry machinery | Custom 401 retry queue | Ktor Client Auth bearer provider | Ktor documents automatic refresh and single-flight behavior. [CITED: https://ktor.io/docs/client-bearer-auth.html] |
+| User provisioning race handling | Select-then-insert | Postgres/Exposed upsert | Atomic upsert avoids duplicate rows under concurrent first requests. [CITED: https://www.jetbrains.com/help/exposed/dsl-crud-operations.html] |
+| Android crypto primitives | Custom encryption without review | Android Keystore-backed approach or accepted Security Crypto dependency | AndroidX deprecated Security Crypto in favor of platform APIs/direct Keystore. [CITED: https://developer.android.com/jetpack/androidx/releases/security] |
+
+**Key insight:** The auth surface is mostly protocol glue; bugs are usually in edge handling (redirect byte-match, refresh races, JWKS rotation, secure persistence), so the plan should compose proven libraries and test edge cases. [VERIFIED: `.planning/research/PITFALLS.md`] [CITED: https://ktor.io/docs/client-bearer-auth.html]
+
+## Common Pitfalls
+
+### Pitfall 1: Assuming Multiplatform Settings Gives Secure Android Storage
+**What goes wrong:** Refresh tokens land in ordinary SharedPreferences. [CITED: https://github.com/russhwolf/multiplatform-settings]
+**Why it happens:** The no-arg module favors convenience and documents that it does not provide encrypted implementations. [CITED: https://github.com/russhwolf/multiplatform-settings]
+**How to avoid:** Create `SecureAuthStateStore` with platform actuals; iOS uses Keychain, Android uses an explicitly chosen secure implementation. [VERIFIED: docs comparison]
+**Warning signs:** `Settings()` appears in auth storage code or Android store is `SharedPreferencesSettings` over default prefs. [CITED: https://github.com/russhwolf/multiplatform-settings]
+
+### Pitfall 2: Authentik Refresh Token Missing
+**What goes wrong:** Login works but session does not survive access-token expiry or app relaunch. [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/create-oauth2-provider/]
+**Why it happens:** Authentik requires `offline_access` request and provider scope mapping to issue refresh tokens. [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/create-oauth2-provider/]
+**How to avoid:** Provider config doc must include `offline_access` scope mapping and app request must include `offline_access`. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
+
+### Pitfall 3: JWKS / Audience / Issuer Drift
+**What goes wrong:** Valid-looking tokens return 401, especially after config changes or key rotation. [VERIFIED: `.planning/research/PITFALLS.md`]
+**Why it happens:** Ktor requires explicit verifier and validate block; Authentik provider config controls signing and JWKS endpoint. [CITED: https://ktor.io/docs/server-jwt.html] [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/]
+**How to avoid:** Pin issuer, audience, RS256 signing key, JWKS URL, cache TTL, and wrong-audience tests. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
+
+### Pitfall 4: Exposed API Drift
+**What goes wrong:** Planner writes tasks using old `newSuspendedTransaction` imports but current Exposed docs show `suspendTransaction` in `org.jetbrains.exposed.v1.*`. [CITED: https://www.jetbrains.com/help/exposed/transactions.html]
+**Why it happens:** Exposed 1.x introduced package/API changes. [CITED: https://www.jetbrains.com/help/exposed/breaking-changes.html]
+**How to avoid:** Before implementation, pin Exposed version and verify the exact suspend transaction import via Gradle dependency sources. [VERIFIED: docs comparison]
+
+## Code Examples
+
+### Authentik Provider Checklist
+
+```text
+Provider type: OAuth2/OIDC Public client
+Flow: authorization code with PKCE S256
+Redirect URI: recipe://callback
+Scopes: openid profile email offline_access
+Audience: single string = client_id
+Signing: asymmetric RS256 signing key, JWKS endpoint documented
+Logout: end-session endpoint documented
+```
+
+Sources: Authentik OAuth docs and Phase 2 context. [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/] [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
+
+### Ktor Client Bearer Shape
+
+```kotlin
+install(Auth) {
+ bearer {
+ loadTokens {
+ authSession.currentBearerTokens()
+ }
+ refreshTokens {
+ authSession.refreshBearerTokens()
+ }
+ sendWithoutRequest { request ->
+ request.url.host == apiHost
+ }
+ }
+}
+```
+
+Source: Ktor client bearer docs. [CITED: https://ktor.io/docs/client-bearer-auth.html]
+
+### Users Migration
+
+```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);
+```
+
+Source: Phase 2 context. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| Hand-rolled mobile OAuth redirects | AppAuth native libraries | Stable native-app OAuth guidance; AppAuth-iOS 2.0.0 current | Use AppAuth on both mobile targets. [CITED: https://cocoapods.org/pods/AppAuth] |
+| AppAuth-iOS 1.x | AppAuth-iOS 2.0.0 | Latest CocoaPod released Apr 2025 | Check iOS minimum support; release notes say 2.0.0 raises minimum iOS to 12. [CITED: https://github.com/openid/AppAuth-iOS/releases] |
+| Ktor 3.4.1 in repo | Ktor 3.4.3 current docs/release | 2026-04-22 | Prefer same catalog ref for new auth artifacts; consider full Ktor patch bump as a separate task. [VERIFIED: `gradle/libs.versions.toml`] [VERIFIED: Maven search] |
+| Exposed `newSuspendedTransaction` examples | Exposed 1.2 docs show `suspendTransaction` under `org.jetbrains.exposed.v1.*` | Exposed 1.x | Verify exact import during planning. [CITED: https://www.jetbrains.com/help/exposed/transactions.html] |
+| AndroidX Security Crypto as preferred encrypted prefs | AndroidX deprecated all Security Crypto APIs in favor of platform APIs/direct Keystore | 1.1.0-alpha07 / 1.1.0 | Planner must explicitly choose Android storage path. [CITED: https://developer.android.com/jetpack/androidx/releases/security] |
+
+**Deprecated/outdated:**
+- Treating `multiplatform-settings-no-arg` as secure storage is wrong for Phase 2 secrets. [CITED: https://github.com/russhwolf/multiplatform-settings]
+- Treating Android `EncryptedSharedPreferences` as unproblematic current best practice is outdated; it is available but deprecated. [CITED: https://developer.android.com/reference/kotlin/androidx/security/crypto/EncryptedSharedPreferences]
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | `com.auth0:jwks-rsa` may need an explicit alias if Ktor's auth JWT dependency does not expose the builder cleanly. | Standard Stack | Minor Gradle dependency task may be missing. |
+
+## Open Questions
+
+1. **Android secure token storage final choice**
+ - What we know: no-arg/default multiplatform-settings is not encrypted on Android; AndroidX Security Crypto encrypts preferences but is deprecated. [CITED: https://github.com/russhwolf/multiplatform-settings] [CITED: https://developer.android.com/jetpack/androidx/releases/security]
+ - What's unclear: whether the user accepts deprecated `EncryptedSharedPreferences` for v1 or wants direct Android Keystore-backed storage. [VERIFIED: docs comparison]
+ - Recommendation: planner should add Wave 0 decision task; default to direct platform-specific `SecureAuthStateStore` with an implementation that can be swapped without touching `AuthSession`. [VERIFIED: docs comparison]
+
+2. **Exposed version and suspend transaction import**
+ - What we know: current Exposed docs use `suspendTransaction`; project context says `newSuspendedTransaction`. [CITED: https://www.jetbrains.com/help/exposed/transactions.html] [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
+ - What's unclear: which Exposed version will be pinned in `libs.versions.toml`.
+ - Recommendation: pin Exposed first, then write imports from that version's docs/source. [VERIFIED: docs comparison]
+
+3. **Ktor patch bump**
+ - What we know: repo pins 3.4.1 and current docs/release search show 3.4.3. [VERIFIED: `gradle/libs.versions.toml`] [VERIFIED: Maven search]
+ - What's unclear: whether Phase 2 should include a dependency patch bump.
+ - Recommendation: if bumping, bump the single `ktor` version ref once and run full `./gradlew build`; otherwise add auth artifacts at 3.4.1 for catalog consistency. [VERIFIED: project version catalog]
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|-------------|-----------|---------|----------|
+| Java | Gradle/Kotlin build | yes | OpenJDK 25.0.2 | Gradle toolchains may download/use configured JDKs. [VERIFIED: `java -version`] |
+| Gradle wrapper | Build/test | yes | 9.4.1 | None needed. [VERIFIED: `./gradlew --version`] |
+| Xcode | iOS build/callback wiring | yes | Xcode 26.2 | None for iOS UAT. [VERIFIED: `xcodebuild -version`] |
+| CocoaPods | AppAuth-iOS integration | yes | 1.16.2 | Swift Package/manual Podfile possible but not preferred for KMP CocoaPods DSL. [VERIFIED: `pod --version`] |
+| Docker | Postgres/test services | yes | 27.3.1 | Use local Postgres if Docker unavailable. [VERIFIED: `docker --version`] |
+| psql | Manual DB inspection | no | — | Use Docker exec or server tests. [VERIFIED: `command -v psql`] |
+| Android Debug Bridge | Android manual UAT | no | — | Android manual UAT may need Android Studio/SDK install; iOS remains primary. [VERIFIED: `command -v adb`] |
+| OpenSSL | JWT/test key generation support | yes | 3.4.1 | JVM crypto APIs can generate test keys. [VERIFIED: `openssl version`] |
+
+**Missing dependencies with no fallback:** none for research/planning. [VERIFIED: environment audit]
+
+**Missing dependencies with fallback:** `psql` and `adb` are missing; planner should not depend on them for automated Phase 2 gates. [VERIFIED: environment audit]
+
+## Validation Architecture
+
+### Test Framework
+
+| Property | Value |
+|----------|-------|
+| Framework | `kotlin.test` + JUnit for server; KMP common tests for auth state/store seams. [VERIFIED: `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt`] |
+| Config file | Existing Gradle/KMP test setup; no standalone test config. [VERIFIED: repo scan] |
+| Quick run command | `./gradlew :server:test :composeApp:jvmTest :shared:jvmTest` [VERIFIED: Phase 1 validation pattern] |
+| Full suite command | `./gradlew check` [VERIFIED: Phase 1 validation pattern] |
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|--------------|
+| AUTH-01 | OIDC request config includes issuer/client/redirect/scopes and mobile actuals compile | unit/build + manual iOS UAT | `./gradlew :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileDebugKotlinAndroid` | no, Wave 0 |
+| AUTH-02 | AuthState JSON store writes/reads/clears and avoids no-arg insecure store for auth | common unit + grep invariant | `./gradlew :composeApp:jvmTest` plus grep for `Settings()` in auth store | no, Wave 0 |
+| AUTH-03 | `/api/v1/me` rejects missing, expired, wrong-audience tokens and accepts valid test JWT | server integration | `./gradlew :server:test --tests "*Auth*"` | no, Wave 0 |
+| AUTH-04 | Restored persisted AuthState refreshes token before `/me` | common/platform-stub unit | `./gradlew :composeApp:jvmTest` | no, Wave 0 |
+| AUTH-05 | Logout calls end-session path when possible and clears local AuthState | unit + manual iOS UAT | `./gradlew :composeApp:jvmTest` | no, Wave 0 |
+| AUTH-06 | First authenticated `/me` creates/updates user by `sub` | server integration with test DB or mocked transaction seam | `./gradlew :server:test --tests "*Me*"` | no, Wave 0 |
+
+### Sampling Rate
+
+- **Per task commit:** `./gradlew :server:test :composeApp:jvmTest :shared:jvmTest` [VERIFIED: Phase 1 validation pattern]
+- **Per wave merge:** `./gradlew check` [VERIFIED: Phase 1 validation pattern]
+- **Phase gate:** full suite green plus manual iOS Authentik login/logout UAT. [VERIFIED: `.planning/ROADMAP.md`]
+
+### Wave 0 Gaps
+
+- [ ] `server/src/test/kotlin/dev/ulfrx/recipe/AuthJwtTest.kt` — covers valid/missing/expired/wrong-audience JWT cases. [VERIFIED: repo scan]
+- [ ] `server/src/test/kotlin/dev/ulfrx/recipe/MeRouteTest.kt` — covers JIT provisioning and `/api/v1/me`. [VERIFIED: repo scan]
+- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt` — covers state transitions and refresh failure behavior. [VERIFIED: repo scan]
+- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreTest.kt` — covers read/write/clear contract with fake store. [VERIFIED: repo scan]
+- [ ] Android/iOS manual UAT checklist in `docs/authentik-setup.md`. [VERIFIED: repo scan]
+
+## Security Domain
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|------------------|
+| V2 Authentication | yes | Authentik OIDC authorization code + PKCE through AppAuth. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] |
+| V3 Session Management | yes | Secure AuthState persistence, AppAuth refresh, logout clears local state and calls end-session. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html] |
+| V4 Access Control | yes | JWT-protected `/api/v1/me`; household access control waits for Phase 3. [VERIFIED: `.planning/ROADMAP.md`] |
+| V5 Input Validation | yes | Validate JWT claims (`sub`, issuer, audience, expiry); validate route authentication before response. [CITED: https://ktor.io/docs/server-jwt.html] |
+| V6 Cryptography | yes | Use AppAuth/JWKS/OS secure storage; do not hand-roll protocol crypto. [CITED: https://cocoapods.org/pods/AppAuth] |
+
+### Known Threat Patterns for KMP/Ktor OIDC
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Authorization-code interception via custom scheme | Spoofing / Elevation | Public client + PKCE S256 + AppAuth state handling. [CITED: https://cocoapods.org/pods/AppAuth] |
+| Token leakage in logs | Information Disclosure | Redact Authorization header and never log token bodies. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] |
+| Wrong-audience token accepted | Elevation | `.withAudience(clientId)` and wrong-audience test. [CITED: https://ktor.io/docs/server-jwt.html] |
+| JWKS key rotation denial | Denial of Service | JWKS cache with bounded TTL and rate limiting. [CITED: https://ktor.io/docs/server-jwt.html] |
+| Refresh token stored in plaintext | Information Disclosure | Explicit secure platform actuals; reject no-arg settings for auth secrets. [CITED: https://github.com/russhwolf/multiplatform-settings] |
+
+## Sources
+
+### Primary (HIGH confidence)
+
+- `.planning/phases/02-authentication-foundation/02-CONTEXT.md` — phase decisions and boundaries.
+- `.planning/PROJECT.md`, `.planning/REQUIREMENTS.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` — product and phase scope.
+- `CLAUDE.md` / `AGENTS.md` — project constraints.
+- Ktor JWT docs: https://ktor.io/docs/server-jwt.html
+- Ktor client bearer docs: https://ktor.io/docs/client-bearer-auth.html
+- AppAuth Android AuthState docs: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html
+- AppAuth iOS AuthState docs: https://openid.github.io/AppAuth-iOS/docs/latest/interface_o_i_d_auth_state.html
+- Authentik OAuth2 provider docs: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/
+- Authentik create provider docs: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/create-oauth2-provider/
+- Multiplatform Settings README: https://github.com/russhwolf/multiplatform-settings
+- AndroidX Security Crypto docs: https://developer.android.com/jetpack/androidx/releases/security
+- Exposed transactions/docs: https://www.jetbrains.com/help/exposed/transactions.html
+
+### Secondary (MEDIUM confidence)
+
+- Maven/CocoaPods registry search results for latest versions.
+- Existing Phase 1 summaries and validation artifacts under `.planning/phases/01-project-infrastructure-module-wiring/`.
+
+### Tertiary (LOW confidence)
+
+- A1 about needing an explicit `jwks-rsa` alias; verify in Gradle during planning.
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH for locked choices; MEDIUM for Android secure storage because current docs conflict with the original assumption. [VERIFIED: docs comparison]
+- Architecture: HIGH for tier ownership and route/session shape. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
+- Pitfalls: HIGH for Ktor/AppAuth/AuthentiK pitfalls; MEDIUM for exact Exposed API import until version is pinned. [CITED: https://www.jetbrains.com/help/exposed/transactions.html]
+
+**Research date:** 2026-04-27
+**Valid until:** 2026-05-04 for auth library/version details; 2026-05-27 for architecture patterns.