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.