26 KiB
Phase 2: Authentication Foundation - Context
Gathered: 2026-04-27 Status: Ready for planning
## Phase BoundaryEnd-to-end OIDC + PKCE login to Authentik. App opens Authentik in the system browser via AppAuth, returns with tokens stored securely (Keychain on iOS, EncryptedSharedPreferences on Android), Ktor server validates JWTs via JWKS, JIT-provisions a user row by sub, and GET /api/v1/me returns the user. "Wyloguj się" wipes local tokens AND calls Authentik's RP-initiated end_session_endpoint. Token refresh runs transparently across launches.
In scope: OIDC client (AppAuth on iOS+Android, stubs on JVM/Wasm), token storage, token refresh, server JWT validation, JIT user provisioning, users table migration, /api/v1/me route, login + post-login screens with error handling, docs/authentik-setup.md.
Out of scope (Phase 3): Households, memberships, invites, household-scoped principal, household onboarding screen. Phase 2's post-login UI is a placeholder; AuthSession.householdId is always null until Phase 3 lands.
Out of scope (Phase 4+): Sync engine, outbox, household-scoped data tables. Phase 2 has no offline write path because there is no household-scoped data yet.
## Implementation DecisionsClient OIDC implementation
- 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: dev-mode env-var stub. ReadsDEV_AUTH_TOKENenv var (or hardcoded dev user fallback). Bypasses real OIDC. Desktop is a hot-reload dev tool per Phase 1 D-03, not a release surface — this stub exists to keep./gradlew :composeApp:runworking without standing up the full Authentik flow on dev machines. - D-03: Wasm
actual:NotImplementedError("Wasm OIDC: v2")stub. PreserveswasmJsas a build target without committing to a browser-redirect implementation. If/when Wasm becomes a release surface, this gets replaced with awindow.location.href-based browser-redirect flow (different code path from native AppAuth). - D-04: Coroutine bridge.
OidcClient.login()and.refresh()aresuspendfunctions. iOS/Androidactualimpls usesuspendCancellableCoroutineto bridge AppAuth's callback API. Cancellation cancels the underlying AppAuth request.
Authentik provider configuration
- D-05: Provider type: Public + PKCE S256. Mobile apps are public clients per OAuth 2 RFC 8252 — no shippable secret. PITFALLS.md #8 enforces this.
- D-06: Scopes requested:
openid profile email offline_access.offline_accessis required for AUTH-04 (token refresh across launches); without it, Authentik may not issue a refresh token.profile+emailpopulatedisplay_nameandemailfor JIT-provisioning. - D-07:
audclaim shape pinned to single string equal to client_id. Authentik can emit array OR string per provider config (PITFALLS.md #7). Pin to string in the provider config; KtorJWTAuth.withAudience(clientId)validates against it. Document the pin indocs/authentik-setup.mdand add an integration test that asserts wrong-aud→ 401. - D-08: Signing alg: RS256. Default for Authentik. Verify
kidresolves via JWKS cache. Document in setup guide. - D-09: Redirect URI: custom URL scheme
recipe://callback. iOS:CFBundleURLTypesiniosApp/iosApp/Info.plist. Android:<intent-filter>withandroid:scheme="recipe" android:host="callback"incomposeApp/src/androidMain/AndroidManifest.xml. AppAuth + PKCE state/nonce makes the theoretical interception attack non-exploitable. Universal Links / App Links explicitly deferred (see Deferred Ideas). - D-10:
docs/authentik-setup.mdis a Phase 2 deliverable. Documents the exact provider config: Public + PKCE S256, redirect URIs registered (recipe://callback), scopes, audience pinned to single string, RS256 signing, JWKS endpoint URL. Goal: anyone (or future-you on a new homelab) can recreate the Authentik provider from scratch in ~5 minutes by following the doc.
Configuration plumbing
- D-11: Client OIDC config hardcoded in
shared/commonMain/Constants.kt. Constants:OIDC_ISSUER(e.g.,https://auth.<homelab>.tld/application/o/recipe/),OIDC_CLIENT_ID,OIDC_REDIRECT_URI(recipe://callback). PITFALLS.md tech-debt table marks this "Acceptable: v1 single-environment only." Promote to BuildConfig-style Gradle injection only if a staging Authentik appears. - D-12: Server OIDC config via env vars in
application.conf. Variables:OIDC_ISSUER,OIDC_AUDIENCE,OIDC_JWKS_URL(optional — derive from issuer if absent). Matches Phase 1 D-16'sDATABASE_URLpattern. Localhost defaults match Authentik in user's homelab.
Token storage
- D-13: Persistence: full AppAuth
AuthStateJSON blob viamultiplatform-settings. AppAuth'sAuthState.serialize()returns a ~2KB JSON containing tokens + provider config + last error + registration response. Restoring across launches is one-line:AuthState.jsonDeserialize(serialized). Settings backend: Keychain on iOS, EncryptedSharedPreferences on Android — both handled bymultiplatform-settings's platform-secure adapters. - D-14: iOS Keychain accessibility:
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. Standard for OAuth refresh tokens. Excluded from iCloud Keychain backup. Background refresh would work pre-unlock if v2 ever adds it; v1 has no background work but this doesn't hurt. - D-15: One AuthState blob per app install. No per-user keying — the user is whoever last logged in. Logout deletes the blob entirely.
Token refresh
- D-16: Proactive refresh via AppAuth
performActionWithFreshTokens. Wrap every authenticated Ktor call in this. AppAuth refreshes if access token expiry is within its threshold (~60s). Returns a fresh access token to the caller; updates the persistedAuthState. - D-17: Reactive 401 fallback via Ktor
Auth { bearer { refreshTokens { ... } } }. Catches the rare case where proactive refresh missed (clock drift, mid-call expiry). Coalesces concurrent refreshes (single-flight is library-provided on both Ktor's plugin and AppAuth'sperformActionWithFreshTokens). - D-18: Refresh-failure UX: silent. When refresh returns
invalid_grant(revoked / expired / Authentik forgot us),AuthSession.statetransitionsAuthenticated → Unauthenticated. App routes back to the login screen. No modal, no toast. Logged atKermit.wfor diagnostics.
Logout
- D-19: RP-initiated end-session. "Wyloguj się" does two things atomically: (a) call Authentik's
end_session_endpoint(per OIDC spec) withid_token_hint; (b) delete the persistedAuthStateblob from secure storage. Order: end-session first, then local wipe — if end-session fails (network), still wipe locally so the user isn't stuck. Correct semantics for shared household devices: next "Zaloguj się" forces fresh credentials, doesn't silently SSO. - D-20: AppAuth's
EndSessionRequestAPI drives this on both platforms. Android:AuthorizationService.performEndSessionRequest(...). iOS:OIDExternalUserAgentwith the end-session endpoint.
Server-side validation (carries forward from PITFALLS.md #7)
- D-21:
install(Authentication) { jwt("authentik") { ... } }with explicitverifier(jwkProvider, issuer),.withIssuer(issuer),.withAudience(clientId),acceptLeeway(30)(seconds), and validate-by-claims block that assertssubis non-null. Provider name"authentik"is the route auth scope. - D-22: JWKS provider configuration.
JwkProviderBuilder(issuerUrl).cached(10, 15, MINUTES).rateLimited(10, 1, MINUTES).build(). Cache size 10 (one issuer × ~3 active keys with rotation headroom). Rate limit defends against pathological JWKS-thrashing during key rotation. - D-23: Audit-grade logging discipline. Never log the
Authorizationheader. Custom KtorCallLoggingfilter redacts it.Kermiton the client never logs token bodies. Token-related debug usesAuthorization: Bearer <token>→Authorization: Bearer <redacted>.
Server data model + JIT provisioning
- D-24: Phase 2 ships
V1__users.sql(Flyway migration). Schema:Phase 3 layersCREATE 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);V2__households_memberships_invites.sqlon top. ROADMAP.md Phase 3 description gets a one-line edit: dropusersfrom "users, households, memberships, invites" → "households, memberships, invites". - D-25: JIT-provisioning logic. On every authenticated request, the auth phase's
PrincipalResolverdoes:Updates email/display_name on every login so claim drift (user changed email in Authentik) is captured. Returns the row so the route handler can use it. Phase 3'sINSERT INTO users (sub, email, display_name) VALUES (:sub, :email, :name) ON CONFLICT (sub) DO UPDATE SET email = EXCLUDED.email, display_name = EXCLUDED.display_name, updated_at = now() RETURNING *;PrincipalResolverextends this with a household lookup. - D-26: Exposed DSL only,
newSuspendedTransaction. Per CLAUDE.md #5 and PITFALLS.md #5/#6. Phase 2 establishes the pattern:newSuspendedTransaction(Dispatchers.IO) { ... }for every coroutine-touching DB call. No DAO. - D-27:
/api/v1/meroute. Behindauthenticate("authentik"). Returns the JIT-resolved user row as aMeResponseDTO (lives inshared/commonMain/.../shared/dto/). Shape:{ id: UUID, sub: String, email: String, displayName: String }.
Client AuthSession state model
- D-28: Sealed
AuthStateshape, forward-compatible with Phase 3:Phase 2 always emitssealed class AuthState { data object Loading : AuthState() data object Unauthenticated : AuthState() data class Authenticated( val user: User, val householdId: HouseholdId? = null, // Phase 2: always null. Phase 3 fills. ) : AuthState() }Authenticated(user, householdId = null). Phase 3 widens the meaning ofhouseholdId(resolved from/api/v1/meextended response). No sealed-class refactor needed at Phase 2/3 boundary. - D-29:
AuthSessionis a Koin singleton inauthModule. Exposesstate: StateFlow<AuthState>,login(),logout(),getAccessToken(): String?. Owns the AppAuthAuthStateblob and its persistence viamultiplatform-settings. Hot atApp()start: deserializes persisted blob, transitions toLoading → (Authenticated | Unauthenticated)based on whether the refresh token is still valid. - D-30: Auth gate composable.
App()readsAuthSession.state.collectAsState()and routes:Loading→ splash placeholderUnauthenticated→LoginScreenAuthenticated→PostLoginPlaceholderScreen(Phase 2) →HouseholdGate(Phase 3 replaces this)
Login + post-login UI
- D-31: Login screen: minimal. App name + "Zaloguj się przez Authentik" button. Centered, plenty of breathing room (matches PROJECT.md "calmer typography" direction). No tagline, no marketing copy. Polish strings via Compose Resources scaffold (real i18n pass is Phase 11).
- D-32: Login error states (inline below the button):
- User cancels system browser → "Logowanie anulowane. Spróbuj ponownie." (Polish scaffold copy; refined in Phase 11)
- Network unreachable / Authentik down → "Nie można połączyć z Authentik. Sprawdź połączenie."
- Token exchange / validation failure → "Coś poszło nie tak. Spróbuj ponownie."
- Inline (snackbar-style) error message; button stays enabled for retry.
- D-33: Post-login placeholder:
Witaj, {displayName}!+ "Wyloguj się" button. Visually confirms login worked end-to-end and lets you exercise logout. Phase 3 replaces this entire screen with the household onboarding flow.
Strings (Polish, scaffold)
- D-34: All user-facing strings in Compose Resources from day 1 (CLAUDE.md #9). Keys:
auth_sign_in_button,auth_sign_out_button,auth_welcome_format,auth_error_cancelled,auth_error_network,auth_error_unknown. Polish copy is scaffold-quality; Phase 11 does the polished pass with proper plural forms and tone.
Claude's Discretion
- Exact
KoinauthModuleDefinition Style (single<AuthSession> { ... }vssingle { AuthSession(get(), get()) }). - Ktor Client
Auth { bearer { ... } }configuration boilerplate — refresh-tokens block, token loader,sendWithoutRequestpolicy. - Whether
MeResponseDTO andUserdomain model are the same type inshared/or separate (DTO + domain mapper). - Concrete
kotlinx.uuidvs.kotlin.uuid.Uuid(Kotlin 2.0+) for theUser.idtype — pick whichever pairs cleanly with Exposed UUID columns andkotlinx.serialization. - Whether the AppAuth-iOS CocoaPod is added via
cocoapods { pod("AppAuth") { ... } }Gradle DSL or via a hand-written Podfile iniosApp/. Either is fine; Gradle DSL is the JetBrains-recommended pattern for KMP-managed pods. - Splash placeholder visual (during
Loadingstate) — solid color, app name, or progress indicator. Phase 11 polishes. - Whether
OIDC_ISSUERends with a trailing slash (Authentik is sensitive here per PITFALLS.md #8). Pin and document either way. - Logger tag/level for AppAuth events (debug/info on iOS — bridged via Kermit's iOS sink).
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
Product + scope anchors
.planning/PROJECT.md— Locked tech stack (§ Key Decisions), particularly the Authentication & identity, Mobile OIDC, and Token validation rows.planning/REQUIREMENTS.md— AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06 are the in-scope requirements for this phase.planning/ROADMAP.md§ "Phase 2: Authentication Foundation" — phase goal + 5 success criteria. NOTE: Phase 3's description in ROADMAP gets a one-line edit per D-24 —usersis removed from Phase 3's table list and lands in Phase 2 instead.
Architecture + pitfalls (load-bearing)
.planning/research/ARCHITECTURE.md— § Component Responsibilities (AuthSession, Ktor route, PrincipalResolver), § Pattern 3 (household-scope enforcement — Phase 2 only does the auth principal layer; household scope is Phase 3), § Build Order Implication ("auth + a working Ktor skeleton that echoes an authenticated principal" is the load-bearing first feature).planning/research/PITFALLS.md— Phase 2 must prevent: Pitfall #7 (Ktor JWT — audience, issuer, leeway, JWKS cache; D-21/D-22 directly mitigate); Pitfall #8 (OIDC redirect URI + missing PKCE; D-05/D-09 mitigate). Tech-debt table row "Hardcoded OIDC issuer/client_id in shared/commonMain" is the explicit acceptance for D-11..planning/research/SUMMARY.md§ "Phase 2: Authentication foundation" — research-driven rationale for AppAuth + ASWebAuth + ktor-server-auth-jwt path; § "Gaps to Address" lists "Authentik-specific OIDC flow details" and "Mobile OIDC library choice for iOS" — both resolved by this CONTEXT.md.
Project conventions
CLAUDE.md— Non-negotiable conventions. Items #5 (Exposed DSL only), #6 (newSuspendedTransaction), #8 (shared/commonMainstays light — onlyMeResponseDTO crosses), #9 (strings externalized day 1) all touch Phase 2..planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md— D-14 (KoinappModuleplaceholder; Phase 2 addsauthModule), D-15 (Kermit logger available for auth-flow debug), D-16 (serverapplication.confenv-var pattern; Phase 2 extends withOIDC_*vars), D-19 (shared/commonMainpurity rule).
External docs to consult during research/planning
- AppAuth-Android: https://github.com/openid/AppAuth-Android —
OIDAuthStatelifecycle,AuthorizationService.performTokenRequest,performEndSessionRequest - AppAuth-iOS: https://github.com/openid/AppAuth-iOS —
OIDAuthState,OIDExternalUserAgent, CocoaPod integration with KMP - Ktor
Auth { bearer { refreshTokens { ... } } }: https://ktor.io/docs/client-bearer-auth.html - Ktor
ktor-server-auth-jwt+ JwkProviderBuilder: https://ktor.io/docs/server-jwt.html - Authentik OIDC provider docs: https://docs.goauthentik.io/docs/providers/oauth2/ (provider config, scopes, RP-initiated logout,
audshape)
No external ADRs or specs yet — project is greenfield; decisions flow from PROJECT.md + research/ files + this CONTEXT.md.
</canonical_refs>
<code_context>
Existing Code Insights
Reusable assets (what Phase 1 left in place)
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt— comment literally reads// Phase 2 adds authModule. ShipauthModule = module { single { AuthSession(...) }; single { OidcClient }; ... }and wire into theappModulemodules(...)list.composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt—initKoin()already callable. iOS-side bridgeKoinIosKt.doInitKoin()already wired iniOSApp.swift. Phase 2 adds dependencies, not bootstrap code.composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt— currentApp()is a template button-and-greeting. Phase 2 replaces the body with the auth-gated routing (Loading → LoginScreen → PostLoginPlaceholder). ExistingMaterialTheme { ... }wrapper stays.composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/— Kermit bootstrap exists (Phase 1 D-15). Auth flow usesLogger.withTag("auth")for OIDC events.server/src/main/kotlin/dev/ulfrx/recipe/Application.kt—install(ContentNegotiation) { json() }andDatabase.migrate(this)already wired. Phase 2 addsinstall(Authentication) { jwt("authentik") { ... } }between ContentNegotiation andconfigureRouting(). New routes go in aconfigureAuth()function alongsideconfigureRouting().server/src/main/kotlin/dev/ulfrx/recipe/Database.kt— Flyway already wired and runs on startup (Phase 1 D-16). Phase 2 dropsV1__users.sqlintoserver/src/main/resources/db/migration/. Database connection is fail-loud per Phase 1 — Phase 2 inherits this.shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/— empty package scaffold ready (Phase 1 D-19). Phase 2 landsUser(orMeResponse) DTO +Constants.kt(withOIDC_ISSUER,OIDC_CLIENT_ID,OIDC_REDIRECT_URI).gradle/libs.versions.toml— Koin/Kermit/Flyway/Postgres/Ktor catalog entries exist. Phase 2 ADDS:multiplatform-settings+multiplatform-settings-no-arg(or coroutines extension),ktor-server-auth,ktor-server-auth-jwt,appauth-android(net.openid:appauth), AppAuth-iOS via CocoaPod. Plus akotlinx-uuid(or stdlibkotlin.uuidif Kotlin 2.3 lands stable) library if not already covered for theUser.idUUID type.
Established patterns Phase 2 must respect
- JetBrains template style — plugin application via aliases inside
recipe.*convention plugins (Phase 1 D-06–D-09). Phase 2'scomposeApp/build.gradle.ktsdoes NOT add direct alias references — adds to the convention plugins or to the module's existing dependency block. - JVM toolchain split — JVM 21 for server/desktop/
shared/jvm; JVM 11 for Android (Phase 1 D-08). Auth code incomposeApp/commonMaincompiles to both; ensure no JVM-21-only API leaks into commonMain. ./gradlew checkis the local gate (Phase 1 D-13). Phase 2's auth integration tests run under:server:test. Client unit tests under:composeApp:commonTest.- Server config:
application.confreading env vars with localhost defaults (Phase 1 D-16).OIDC_ISSUER,OIDC_AUDIENCE,OIDC_JWKS_URLfollow the same pattern.
Integration points
- iOS Info.plist —
iosApp/iosApp/Info.plistneedsCFBundleURLTypesblock registeringrecipe://scheme. AppAuth-iOS ATS exception NOT needed for the homelab (use a real cert per PITFALLS.md "Looks Done But Isn't" checklist). - Android manifest —
composeApp/src/androidMain/AndroidManifest.xmlneeds<intent-filter>on AppAuth'sRedirectUriReceiverActivity(or your own activity declared per AppAuth-Android docs) forandroid:scheme="recipe" android:host="callback". - iOSApp.swift — current
KoinIosKt.doInitKoin()runs ininit. AppAuth-iOS'scurrentAuthorizationFlowglobal lives in the SwiftUI app and must receive callbacks fromapplication(_:open:options:)or the SwiftUI.onOpenURL { }modifier. Add this wiring alongside the existing Koin init. - Phase 3 hand-off seam —
AuthState.Authenticatedcarries a nullablehouseholdId. Phase 3's onboarding flow updates this via a yet-to-existAuthSession.onHouseholdEstablished(HouseholdId)method. Phase 2 doesn't expose this method but the state model is ready.
What must NOT change in Phase 2
- Package namespace
dev.ulfrx.recipe(CLAUDE.md, Phase 1 D-20). - Phase 1's iOS binary flags in
gradle.properties(D-18). - Phase 1's convention plugins (
recipe.*) — they're applied as-is; Phase 2 adds module-level dependencies, not new conventions. shared/commonMainpurity (D-19) — only DTOs cross. No Ktor Client, no AppAuth, nomultiplatform-settingsimports.
</code_context>
## Specific Ideas- "Wyloguj się" must mean it. Local-only logout is a junk feature on a shared household device. RP-initiated end-session is the only logout that fulfills the user's expectation when they hand the phone to their partner.
- AppAuth on both platforms is the symmetry win. User is new to KMP/CMP idioms; symmetric
AuthStateshape across iOS and Android means one mental model. Hand-rolled was an option but the asymmetry tax (AppAuth on Android, custom on iOS) costs more than the dependency saves. AuthState.Authenticated(user, householdId: HouseholdId? = null)is the explicit forward-compat decision. Phase 3 is literally next; baking the field in now saves a sealed-class refactor across every call-site. This is the one allowed instance of "modeling something Phase 2 doesn't use" — justified by Phase 2/3 adjacency.- Phase 2 owns
users.sql. The auth phase owning the auth-principal table is the clean boundary; Phase 3 layers households+memberships+invites on top. ROADMAP edit is a single line (Phase 3 description: dropusersfrom the list). - Token storage: full AppAuth
AuthStateblob, not hand-rolled. AppAuth's serialized blob makes refresh "just work" across launches. The privacy concern (extra metadata stored in Keychain) is academic for a personal app. Hand-rolling token-only storage is the kind of "library handles this for you, don't reinvent it" trap to avoid as a KMP newcomer. docs/authentik-setup.mdis non-optional. The provider config is the single most fragile piece of Phase 2 — ifaudis wrong, JWKS URL is wrong, scopes are missing, or PKCE is forgotten, you get silent 401s with no useful error. Documenting it makes Phase 2 reproducible.
- Universal Links / App Links — rejected for v1; revisit only if (a) app gains broader distribution beyond the household, or (b) Apple/Google deprecate custom schemes for OIDC redirects (no signal of this in 2026).
- BuildConfig-style Gradle injection of OIDC config — Constants.kt is fine for v1 single-environment per PITFALLS.md tech-debt acceptance. Promote when a staging Authentik becomes a real need (estimated never for this app's lifetime).
- Real Desktop OIDC — JVM target gets a
DEV_AUTH_TOKENstub. If Desktop ever becomes a release surface (currently scoped to dev tool only per Phase 1 D-03), implement loopback-redirect OIDC: open system browser to Authentik, AppAuth-Java equivalent or hand-roll a tiny localhost:N HTTP listener to capture the code. - Wasm OIDC implementation —
wasmJstarget getsNotImplementedErrorstub. If/when Wasm becomes a release surface, implement browser-redirect OIDC:window.location.href = authUrl, handlecodeparam on return, store tokens insessionStorage. Different code path from native AppAuth — won't reuse currentOidcClientactuals. - "Wyloguj się i zapomnij sesję" two-tier logout — current single "Wyloguj się" is RP-initiated. If a workflow emerges where users want fast re-login after intentional logout (testing, account switching), add a second menu item for local-only logout.
- Background token refresh — v1 has no background work. Refresh runs proactively on the next authenticated call. If/when background sync is added (PROJECT.md v2 SYNC2-01 SSE-based sync), Keychain accessibility may need re-evaluation.
- Apple Sign-in as a first-class button — explicitly out of scope per PROJECT.md / REQUIREMENTS.md. Authentik can federate Apple Sign-in upstream if ever wanted.
- Per-user persisted
AuthState— D-15 keys the AuthState blob globally (not per-user). Multi-account on a single device is out of scope; one user per install is the v1 model. - Modal/toast for refresh-failure UX — Phase 2 ships silent transition. If user complaints emerge ("why was I logged out without warning?"), add a toast on the login screen.
- Authentik provisioning automation —
docs/authentik-setup.mdis manual. A Terraform/Ansible playbook for the homelab Authentik is post-v1. - JWT validation tests at the Authentik-emit level — Phase 2 ships unit tests with hand-crafted JWTs (using a test JWKS). Integration tests against a real Authentik instance are deferred to Phase 11 (deployment) where the homelab Authentik is the test target.
Phase: 02-authentication-foundation Context gathered: 2026-04-27