Files
recipe/.planning/phases/02-authentication-foundation/02-RESEARCH.md

38 KiB

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:

# 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

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
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:

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:

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

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

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

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:

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)

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.