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). Symmetricexpect class OidcClientincomposeApp/commonMain/.../auth/, withactualimpls iniosMainandandroidMainwrapping each platform's AppAuth. Uses AppAuth'sOIDAuthState/AuthStateas the in-memory session shape behind the seam. - D-02: JVM Desktop actual is a dev-mode
DEV_AUTH_TOKENstub. - D-03: Wasm actual is
NotImplementedError("Wasm OIDC: v2"). - D-04:
OidcClient.login()and.refresh()are suspend functions bridged withsuspendCancellableCoroutine. - D-05: Authentik provider is Public + PKCE S256.
- D-06: Requested scopes are
openid profile email offline_access. - D-07:
audclaim shape is pinned to a single string equal toclient_id. - D-08: Signing algorithm is RS256.
- D-09: Redirect URI is custom scheme
recipe://callback. - D-10:
docs/authentik-setup.mdis 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
AuthStateJSON 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, andsubvalidation. - D-22: JWKS provider uses cache size 10, 15-minute TTL, and 10/minute rate limit.
- D-23: Never log tokens or
Authorizationheaders. - D-24: Phase 2 ships
V1__users.sql. - D-25: JIT provisioning upserts by OIDC
suband 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/mereturnsMeResponse. - D-28: Client auth state is
Loading | Unauthenticated | Authenticated(user, householdId = null). - D-29:
AuthSessionis a Koin singleton inauthModule. - 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 includesWyloguj 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
KoinauthModuledefinition style. - Ktor Client bearer auth boilerplate, including
refreshTokens, token loader, andsendWithoutRequest. - Whether
MeResponseDTO andUserdomain 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_ISSUERends 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/commonMainmay 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
Recommended Project Structure
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
subas 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:
- Treating
multiplatform-settings-no-argas secure storage is wrong for Phase 2 secrets. [CITED: https://github.com/russhwolf/multiplatform-settings] - Treating Android
EncryptedSharedPreferencesas 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)
-
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
EncryptedSharedPreferencesbehind an explicitSecureAuthStateStore.android.ktimplementation because the project requirement/context explicitly calls out EncryptedSharedPreferences for Android token storage. The deprecation risk is contained behind theSecureAuthStateStoreseam so a future Android Keystore-backed implementation can replace it without touchingAuthSession. - Guardrail: auth code must not use no-arg
Settings()or ordinarySharedPreferencesfor tokens; Plan 03 includes grep-verifiable acceptance criteria for this.
-
RESOLVED — Exposed version and suspend transaction import
- What we know: current Exposed docs use
suspendTransaction; project context saysnewSuspendedTransaction. [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 requiressuspendTransaction, execution must use that exact import and record the choice in02-02-SUMMARY.md. - Guardrail: no blocking
transaction {}inside suspend route code.
- What we know: current Exposed docs use
-
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.
- What we know: repo pins 3.4.1 and current docs/release search show 3.4.3. [VERIFIED:
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-rsaalias; 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.