Add authentication

This commit is contained in:
2026-04-27 19:28:57 +02:00
parent 015d8d51d0
commit 995bdd5ae6
92 changed files with 8140 additions and 208 deletions

View File

@@ -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>
## 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.
</user_constraints>
<phase_requirements>
## 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] |
</phase_requirements>
## 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.