Files
2026-04-29 21:07:49 +02:00

26 KiB
Raw Permalink Blame History

Phase 2: Authentication Foundation - Context

Gathered: 2026-04-27 Status: Ready for planning

## Phase Boundary

End-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 Decisions

Client 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). 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: dev-mode env-var stub. Reads DEV_AUTH_TOKEN env 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:run working without standing up the full Authentik flow on dev machines.
  • D-03: Wasm actual: NotImplementedError("Wasm OIDC: v2") stub. Preserves wasmJs as a build target without committing to a browser-redirect implementation. If/when Wasm becomes a release surface, this gets replaced with a window.location.href-based browser-redirect flow (different code path from native AppAuth).
  • D-04: Coroutine bridge. OidcClient.login() and .refresh() are suspend functions. iOS/Android actual impls use suspendCancellableCoroutine to 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_access is required for AUTH-04 (token refresh across launches); without it, Authentik may not issue a refresh token. profile + email populate display_name and email for JIT-provisioning.
  • D-07: aud claim 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; Ktor JWTAuth.withAudience(clientId) validates against it. Document the pin in docs/authentik-setup.md and add an integration test that asserts wrong-aud → 401.
  • D-08: Signing alg: RS256. Default for Authentik. Verify kid resolves via JWKS cache. Document in setup guide.
  • D-09: Redirect URI: custom URL scheme recipe://callback. iOS: CFBundleURLTypes in iosApp/iosApp/Info.plist. Android: <intent-filter> with android:scheme="recipe" android:host="callback" in composeApp/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.md is 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's DATABASE_URL pattern. Localhost defaults match Authentik in user's homelab.

Token storage

  • D-13: Persistence: full AppAuth AuthState JSON blob via multiplatform-settings. AppAuth's AuthState.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 by multiplatform-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 persisted AuthState.
  • 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's performActionWithFreshTokens).
  • D-18: Refresh-failure UX: silent. When refresh returns invalid_grant (revoked / expired / Authentik forgot us), AuthSession.state transitions Authenticated → Unauthenticated. App routes back to the login screen. No modal, no toast. Logged at Kermit.w for diagnostics.

Logout

  • D-19: RP-initiated end-session. "Wyloguj się" does two things atomically: (a) call Authentik's end_session_endpoint (per OIDC spec) with id_token_hint; (b) delete the persisted AuthState blob 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 EndSessionRequest API drives this on both platforms. Android: AuthorizationService.performEndSessionRequest(...). iOS: OIDExternalUserAgent with the end-session endpoint.

Server-side validation (carries forward from PITFALLS.md #7)

  • D-21: install(Authentication) { jwt("authentik") { ... } } with explicit verifier(jwkProvider, issuer), .withIssuer(issuer), .withAudience(clientId), acceptLeeway(30) (seconds), and validate-by-claims block that asserts sub is 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 Authorization header. Custom Ktor CallLogging filter redacts it. Kermit on the client never logs token bodies. Token-related debug uses Authorization: Bearer <token>Authorization: Bearer <redacted>.

Server data model + JIT provisioning

  • D-24: Phase 2 ships V1__users.sql (Flyway migration). Schema:
    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);
    
    Phase 3 layers V2__households_memberships_invites.sql on top. ROADMAP.md Phase 3 description gets a one-line edit: drop users from "users, households, memberships, invites" → "households, memberships, invites".
  • D-25: JIT-provisioning logic. On every authenticated request, the auth phase's PrincipalResolver does:
    INSERT 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 *;
    
    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's PrincipalResolver extends 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/me route. Behind authenticate("authentik"). Returns the JIT-resolved user row as a MeResponse DTO (lives in shared/commonMain/.../shared/dto/). Shape: { id: UUID, sub: String, email: String, displayName: String }.

Client AuthSession state model

  • D-28: Sealed AuthState shape, forward-compatible with Phase 3:
    sealed 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()
    }
    
    Phase 2 always emits Authenticated(user, householdId = null). Phase 3 widens the meaning of householdId (resolved from /api/v1/me extended response). No sealed-class refactor needed at Phase 2/3 boundary.
  • D-29: AuthSession is a Koin singleton in authModule. Exposes state: StateFlow<AuthState>, login(), logout(), getAccessToken(): String?. Owns the AppAuth AuthState blob and its persistence via multiplatform-settings. Hot at App() start: deserializes persisted blob, transitions to Loading → (Authenticated | Unauthenticated) based on whether the refresh token is still valid.
  • D-30: Auth gate composable. App() reads AuthSession.state.collectAsState() and routes:
    • Loading → splash placeholder
    • UnauthenticatedLoginScreen
    • AuthenticatedPostLoginPlaceholderScreen (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 Koin authModule Definition Style (single<AuthSession> { ... } vs single { AuthSession(get(), get()) }).
  • Ktor Client Auth { bearer { ... } } configuration boilerplate — refresh-tokens block, token loader, sendWithoutRequest policy.
  • Whether MeResponse DTO and User domain model are the same type in shared/ or separate (DTO + domain mapper).
  • Concrete kotlinx.uuid vs. kotlin.uuid.Uuid (Kotlin 2.0+) for the User.id type — pick whichever pairs cleanly with Exposed UUID columns and kotlinx.serialization.
  • Whether the AppAuth-iOS CocoaPod is added via cocoapods { pod("AppAuth") { ... } } Gradle DSL or via a hand-written Podfile in iosApp/. Either is fine; Gradle DSL is the JetBrains-recommended pattern for KMP-managed pods.
  • Splash placeholder visual (during Loading state) — solid color, app name, or progress indicator. Phase 11 polishes.
  • Whether OIDC_ISSUER ends 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 — users is 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/commonMain stays light — only MeResponse DTO crosses), #9 (strings externalized day 1) all touch Phase 2.
  • .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md — D-14 (Koin appModule placeholder; Phase 2 adds authModule), D-15 (Kermit logger available for auth-flow debug), D-16 (server application.conf env-var pattern; Phase 2 extends with OIDC_* vars), D-19 (shared/commonMain purity rule).

External docs to consult during research/planning

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. Ship authModule = module { single { AuthSession(...) }; single { OidcClient }; ... } and wire into the appModule modules(...) list.
  • composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.ktinitKoin() already callable. iOS-side bridge KoinIosKt.doInitKoin() already wired in iOSApp.swift. Phase 2 adds dependencies, not bootstrap code.
  • composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt — current App() is a template button-and-greeting. Phase 2 replaces the body with the auth-gated routing (Loading → LoginScreen → PostLoginPlaceholder). Existing MaterialTheme { ... } wrapper stays.
  • composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/ — Kermit bootstrap exists (Phase 1 D-15). Auth flow uses Logger.withTag("auth") for OIDC events.
  • server/src/main/kotlin/dev/ulfrx/recipe/Application.ktinstall(ContentNegotiation) { json() } and Database.migrate(this) already wired. Phase 2 adds install(Authentication) { jwt("authentik") { ... } } between ContentNegotiation and configureRouting(). New routes go in a configureAuth() function alongside configureRouting().
  • server/src/main/kotlin/dev/ulfrx/recipe/Database.kt — Flyway already wired and runs on startup (Phase 1 D-16). Phase 2 drops V1__users.sql into server/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 lands User (or MeResponse) DTO + Constants.kt (with OIDC_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 a kotlinx-uuid (or stdlib kotlin.uuid if Kotlin 2.3 lands stable) library if not already covered for the User.id UUID type.

Established patterns Phase 2 must respect

  • JetBrains template style — plugin application via aliases inside recipe.* convention plugins (Phase 1 D-06D-09). Phase 2's composeApp/build.gradle.kts does 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 in composeApp/commonMain compiles to both; ensure no JVM-21-only API leaks into commonMain.
  • ./gradlew check is 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.conf reading env vars with localhost defaults (Phase 1 D-16). OIDC_ISSUER, OIDC_AUDIENCE, OIDC_JWKS_URL follow the same pattern.

Integration points

  • iOS Info.plistiosApp/iosApp/Info.plist needs CFBundleURLTypes block registering recipe:// scheme. AppAuth-iOS ATS exception NOT needed for the homelab (use a real cert per PITFALLS.md "Looks Done But Isn't" checklist).
  • Android manifestcomposeApp/src/androidMain/AndroidManifest.xml needs <intent-filter> on AppAuth's RedirectUriReceiverActivity (or your own activity declared per AppAuth-Android docs) for android:scheme="recipe" android:host="callback".
  • iOSApp.swift — current KoinIosKt.doInitKoin() runs in init. AppAuth-iOS's currentAuthorizationFlow global lives in the SwiftUI app and must receive callbacks from application(_:open:options:) or the SwiftUI .onOpenURL { } modifier. Add this wiring alongside the existing Koin init.
  • Phase 3 hand-off seamAuthState.Authenticated carries a nullable householdId. Phase 3's onboarding flow updates this via a yet-to-exist AuthSession.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/commonMain purity (D-19) — only DTOs cross. No Ktor Client, no AppAuth, no multiplatform-settings imports.

</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 AuthState shape 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: drop users from the list).
  • Token storage: full AppAuth AuthState blob, 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.md is non-optional. The provider config is the single most fragile piece of Phase 2 — if aud is 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.
## Deferred Ideas
  • 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_TOKEN stub. 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 implementationwasmJs target gets NotImplementedError stub. If/when Wasm becomes a release surface, implement browser-redirect OIDC: window.location.href = authUrl, handle code param on return, store tokens in sessionStorage. Different code path from native AppAuth — won't reuse current OidcClient actuals.
  • "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 automationdocs/authentik-setup.md is 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