# 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 (RESOLVED) 1. **RESOLVED — 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] - Decision from PLAN.md: Phase 2 uses AndroidX Security Crypto `EncryptedSharedPreferences` behind an explicit `SecureAuthStateStore.android.kt` implementation because the project requirement/context explicitly calls out EncryptedSharedPreferences for Android token storage. The deprecation risk is contained behind the `SecureAuthStateStore` seam so a future Android Keystore-backed implementation can replace it without touching `AuthSession`. - Guardrail: auth code must not use no-arg `Settings()` or ordinary `SharedPreferences` for tokens; Plan 03 includes grep-verifiable acceptance criteria for this. 2. **RESOLVED — 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`] - Decision from PLAN.md: Plan 02 verifies the exact suspend transaction API/import against the pinned Exposed dependency before implementing DB code. Expected for the current catalog path is `org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction`; if the pinned version requires `suspendTransaction`, execution must use that exact import and record the choice in `02-02-SUMMARY.md`. - Guardrail: no blocking `transaction {}` inside suspend route code. 3. **RESOLVED — 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] - Decision from PLAN.md: Phase 2 keeps Ktor pinned to the existing catalog version (`3.4.1`) and adds auth artifacts against that same version ref. A patch bump is deferred unless implementation reveals a concrete incompatibility. - Guardrail: do not opportunistically bump Ktor during auth implementation without an explicit failing command or compatibility reason. ## 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.