diff --git a/docs/authentik-setup.md b/docs/authentik-setup.md new file mode 100644 index 0000000..22633fa --- /dev/null +++ b/docs/authentik-setup.md @@ -0,0 +1,242 @@ +# Authentik Provider Setup — Recipe (Phase 2) + +> Reproducible Authentik configuration for the Recipe app. Anyone with admin access +> to the homelab Authentik should be able to recreate the OAuth2/OIDC provider in +> under five minutes by following this document end to end (D-10). +> +> Phase: `02-authentication-foundation`. Locked decisions referenced here live in +> `.planning/phases/02-authentication-foundation/02-CONTEXT.md` (D-05 .. D-10, +> D-19, D-21 .. D-23) and the version-controlled requirements in +> `.planning/REQUIREMENTS.md` (AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06). + +## Provider + +Configure a **single OAuth2/OIDC provider** in Authentik with the following pinned settings. +Authentik admin path: **Admin → Applications → Providers → Create → OAuth2/OpenID Provider**. + +| Setting | Value | Why this exact value | +|---------|-------|----------------------| +| Provider type | **OAuth2/OIDC, Public client** | Mobile apps cannot ship a secret per RFC 8252 (D-05). No `client_secret` is set on the provider, sent on the wire, or stored anywhere in this repo. | +| Authorization flow | **Authorization code with PKCE S256** | PKCE S256 is the only safe pattern for native + custom-scheme redirect URIs (D-05, D-09). `plain` is forbidden. | +| Client ID | **`recipe-app`** | Mirrors `dev.ulfrx.recipe.shared.Constants.OIDC_CLIENT_ID`. The same value is the JWT `aud` claim per D-07. | +| Client secret | **(leave empty)** | Public client — D-05. Any non-empty value is a bug. | +| Redirect URIs | **`recipe://callback`** (exactly, no trailing slash, no spaces) | Custom URL scheme — see [`## Redirect URI`](#redirect-uri). Byte-for-byte match with `Constants.OIDC_REDIRECT_URI` (D-09). | +| Signing algorithm | **RS256** | Authentik default; matches `JwkProviderBuilder` expectations on the server (D-08, D-21, D-22). | +| Signing key | RS256 asymmetric key from Authentik (auto-managed) | Public key reaches the server through the JWKS endpoint, never copied or pinned in code (D-22). | +| Audience | **single-string** value `aud = recipe-app` (NOT array) | Authentik can emit `aud` as either an array or a single string per provider config; pin to single string and let `JWTAuth.withAudience("recipe-app")` validate against it (D-07, PITFALLS.md #7). A negative test in Plan 02-02 asserts wrong-`aud` → 401. | +| Issuer URL | `https://auth..tld/application/o/recipe/` (trailing slash required) | Trailing slash is byte-sensitive in Authentik's OpenID metadata responses (PITFALLS.md #8). The placeholder host `auth.example.invalid` in `Constants.kt` is replaced at deploy time via env-var override on the server (`OIDC_ISSUER`) — do not commit your real homelab URL here. | +| JWKS URI | Pulled from `/.well-known/openid-configuration` `jwks_uri` (typically `jwks/`) | Cached and rate-limited on the server with `JwkProviderBuilder(issuer).cached(10, 15, MINUTES).rateLimited(10, 1, MINUTES)` (D-22). | +| End-session endpoint | Pulled from `/.well-known/openid-configuration` `end_session_endpoint` | Required for RP-initiated logout (D-19, D-20). See [`## Logout`](#logout). | +| Token validity | Access token ~5 min, refresh token long-lived (default Authentik) | Short access lifetime exercises the `performActionWithFreshTokens` + Ktor bearer 401 fallback paths (D-16, D-17). | + +After saving, bind the provider to a new **Application** (Admin → Applications → Applications → Create) called `Recipe`. The application is what Authentik users see in their My Apps / consent screens. + +## Scopes + +The app requests **exactly** these four scopes from Authentik: + +``` +openid profile email offline_access +``` + +| Scope | Purpose | Where it lands | +|-------|---------|----------------| +| `openid` | Marks the request as OIDC; Authentik issues an ID token | Mandatory; without it Authentik issues OAuth2-only tokens that are unusable here (D-06) | +| `profile` | Populates the `name` / `preferred_username` claim | Maps to `users.display_name` via JIT provisioning (D-25) | +| `email` | Populates the `email` claim | Maps to `users.email` via JIT provisioning (D-25) | +| `offline_access` | Asks Authentik to issue a **refresh** token | AUTH-04 (session persists across launches via refresh) is impossible without it. Authentik issues no refresh token unless `offline_access` is BOTH requested by the client AND mapped/allowed in the provider's scope mapping (D-06, PITFALLS.md Phase 2 Pitfall 2). | + +**Authentik provider configuration must explicitly map the `offline_access` scope** so the +provider returns a refresh token. Newer Authentik versions add it by default; older ones +require explicit creation under **Customization → Property Mappings → OAuth2 / OpenID Scope +Mapping**. + +## Redirect URI + +The app uses a **custom URL scheme**: + +``` +recipe://callback +``` + +This single URI must be registered three times — byte-for-byte identical, no trailing +slash, no query parameters. Drift between any of these three places produces silent OAuth +redirect failures (D-09). + +| Where | Mechanism | Phase 2 plan that lands it | +|-------|-----------|----------------------------| +| Authentik provider | Redirect URIs textbox (one line) | This document (Plan 02-01) | +| iOS | `iosApp/iosApp/Info.plist` `CFBundleURLTypes` → `CFBundleURLSchemes` array containing exactly `recipe` | Plan 02-05 (iOS AppAuth actual) | +| Android | `composeApp/src/androidMain/AndroidManifest.xml` `` on AppAuth's `RedirectUriReceiverActivity` with `android:scheme="recipe"` and `android:host="callback"` | Plan 02-04 (Android AppAuth actual). Plan 02-01 already supplies the `appAuthRedirectScheme=recipe` manifest placeholder so the AppAuth dependency merges cleanly without yet wiring the receiver. | + +PKCE S256 + AppAuth's state/nonce handling makes the well-known custom-scheme +interception attack non-exploitable in practice. **Universal Links / App Links are +explicitly excluded** from v1 — see [`## Source Audit`](#source-audit). + +## Server Env Vars + +The Ktor server reads OIDC configuration from `application.conf` with env-var overrides +(D-12, mirrors Phase 1 D-16's `DATABASE_URL` pattern). Set these on the homelab deploy +target before booting the server: + +| Variable | Required | Example value | Notes | +|----------|----------|---------------|-------| +| `OIDC_ISSUER` | yes | `https://auth.example.invalid/application/o/recipe/` | Trailing slash required (PITFALLS.md #8). Must equal Authentik's issuer URL byte-for-byte. | +| `OIDC_AUDIENCE` | yes | `recipe-app` | Equal to the provider's Client ID; validated as a **single string** (D-07). | +| `OIDC_JWKS_URL` | optional | `https://auth.example.invalid/application/o/recipe/jwks/` | Optional — derived from `/.well-known/openid-configuration` if unset. Set explicitly only when Authentik puts JWKS at a non-standard path. | + +Plan 02-02 wires these into `application.conf` and the `JwkProviderBuilder`. They are NOT +read by the client — the client OIDC config is hardcoded in +`shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt` per D-11 (single- +environment v1 acceptance from PITFALLS.md tech-debt table). + +## Logout + +Logout is **RP-initiated end-session** (D-19, D-20). Tapping "Wyloguj się" performs both +of these atomically, in this order: + +1. Call Authentik's `end_session_endpoint` (advertised by `/.well-known/openid-configuration`) with the user's `id_token_hint`. AppAuth's `EndSessionRequest` API drives this on both mobile platforms (D-20). +2. Delete the persisted `AuthState` blob from secure storage (Keychain on iOS, EncryptedSharedPreferences on Android per D-13). + +If step 1 fails (network unreachable, Authentik down), step 2 still runs so the user +isn't trapped in a half-logged-out state — correct semantics for a shared household +device. Local-only logout would fail AUTH-05 because the next "Zaloguj się" tap would +silently SSO instead of forcing fresh credentials. + +To support this, the Authentik provider's **end-session endpoint must be reachable** from +the device — confirm via `curl -I "/.well-known/openid-configuration"` and +checking that the JSON response contains `end_session_endpoint`. + +## Manual UAT + +These checks must pass on a real iOS device or simulator before Phase 2 is signed off +(per `02-VALIDATION.md`'s Manual-Only Verifications). They cover the surface unit tests +cannot reach: real Authentik, real browser handoff, real Keychain. + +### UAT-01 — Fresh iOS login (AUTH-01) + +1. Wipe app data: delete the app from the simulator/device. +2. Reinstall via `./gradlew :composeApp:iosDeployIPhone…` or Xcode. +3. Tap **"Zaloguj się przez Authentik"**. +4. Confirm the system browser (`ASWebAuthenticationSession`) opens at Authentik's hosted login page. +5. Authenticate. Confirm Authentik consent screen lists `openid profile email offline_access`. +6. Confirm the app returns to foreground via `recipe://callback` and renders `Witaj, {displayName}!` with the partner's actual display name. +7. **Failure modes to verify visually:** cancelling the system browser shows "Logowanie anulowane. Spróbuj ponownie." inline. + +### UAT-02 — Reopen with refresh (AUTH-04) + +1. Sign in via UAT-01. +2. In Authentik, set the provider's access-token lifetime to ~60 seconds (or wait the default). +3. Backgroud the app for ~2 minutes; relaunch. +4. Confirm the app returns directly to `Witaj, {displayName}!` with no login prompt — the AppAuth `performActionWithFreshTokens` path silently exchanged the refresh token (D-16, D-17). + +### UAT-03 — Logout returns to login (AUTH-05) + +1. Sign in via UAT-01. +2. Tap **"Wyloguj się"**. +3. Confirm the app returns to the LoginScreen. +4. Tap "Zaloguj się przez Authentik" again. Confirm Authentik **prompts for credentials** (no silent SSO) — proves the end-session call succeeded. +5. **Network-failure variant:** disconnect from network, tap "Wyloguj się", confirm app still returns to LoginScreen and the local AuthState is gone (relaunching does not auto-sign-in). + +### UAT-04 — `/api/v1/me` validation (AUTH-03) + +This is the only UAT step performed via terminal, not the app. It validates the server +side of the boundary independently of mobile UI bugs. + +1. Run the server locally or hit the homelab. Capture a valid access token (e.g., copy from `AuthState` JSON via the iOS debugger immediately after UAT-01, or use `curl` directly against Authentik's token endpoint with the same client_id). +2. Confirm: + ```bash + curl -i -H "Authorization: Bearer $TOKEN" https://api./api/v1/me + # expect: HTTP/1.1 200 OK; body matches MeResponse {"id":..., "sub":..., "email":..., "displayName":...} + ``` +3. Confirm: + ```bash + curl -i https://api./api/v1/me + # expect: HTTP/1.1 401 Unauthorized + ``` +4. Confirm wrong-audience rejection by minting a JWT with `aud != recipe-app` (use the test JWKS Plan 02-02 ships): + ```bash + curl -i -H "Authorization: Bearer $WRONG_AUD_TOKEN" https://api./api/v1/me + # expect: HTTP/1.1 401 Unauthorized + ``` +5. Confirm logs do **not** contain the token body. The custom `CallLogging` filter must redact the `Authorization` header (D-23). + +## Source Audit + +This document is the Phase 2 anchor for "every locked source is honored". The table below +asserts that every Phase 2 input — goal, requirement, research finding, decision, UI +spec, validation gate, and pattern map — is either covered here or in a downstream Phase +2 plan. Markers: ✅ covered in this document, ⤳ covered in a downstream plan (with +plan number), ✂ explicitly deferred (see end of section). + +| Source | Item | Coverage | +|--------|------|----------| +| GOAL | Phase 2 goal: end-to-end OIDC+PKCE login with server JWT validation and JIT users | ✅ Provider + Scopes + Redirect URI + Server Env Vars + Manual UAT | +| REQ | **AUTH-01** sign in via Authentik OIDC + PKCE | ✅ Provider; ⤳ 02-04 (Android AppAuth) + 02-05 (iOS AppAuth) | +| REQ | **AUTH-02** secure token storage | ⤳ 02-03 (common contract) + 02-04 (Android EncryptedSharedPreferences) + 02-05 (iOS Keychain) | +| REQ | **AUTH-03** server JWT validation via JWKS | ✅ Provider (RS256, single-string aud, JWKS); ⤳ 02-02 (Ktor JWT install + tests) | +| REQ | **AUTH-04** session persists across launches via refresh | ✅ Scopes (`offline_access`); ⤳ 02-03 (AuthSession refresh wiring) + Manual UAT-02 | +| REQ | **AUTH-05** logout returns to login | ✅ Logout section; ⤳ 02-04/02-05 (AppAuth end-session per platform) + Manual UAT-03 | +| REQ | **AUTH-06** JIT user provisioning by `sub` | ⤳ 02-02 (`V1__users.sql` + upsert by sub + `/api/v1/me`) | +| RESEARCH | Standard stack: AppAuth, Ktor JWT, multiplatform-settings, Exposed DSL, Flyway | ✅ Server Env Vars (Ktor); ⤳ 02-01 catalog wiring (this plan, task 2) + per-platform plans | +| RESEARCH | Open Questions resolved: Android secure storage = EncryptedSharedPreferences behind `SecureAuthStateStore` seam | ⤳ 02-03 (seam) + 02-04 (Android impl) | +| RESEARCH | Open Question resolved: Exposed `newSuspendedTransaction` import verified at impl time | ⤳ 02-02 | +| RESEARCH | Open Question resolved: Ktor stays at 3.4.1 (no patch bump) | ✅ Task 2 catalog keeps `ktor = "3.4.1"` | +| CONTEXT | **D-01** AppAuth on both mobile platforms via expect/actual `OidcClient` | ⤳ 02-03 (expect) + 02-04 (Android actual) + 02-05 (iOS actual) | +| CONTEXT | **D-02** JVM `actual` is `DEV_AUTH_TOKEN` env-var stub | ⤳ 02-03 | +| CONTEXT | **D-03** Wasm `actual` is `NotImplementedError("Wasm OIDC: v2")` | ⤳ 02-03 | +| CONTEXT | **D-04** `OidcClient.login()` / `.refresh()` are `suspend` | ⤳ 02-03 | +| CONTEXT | **D-05** Public + PKCE S256 | ✅ Provider | +| CONTEXT | **D-06** scopes `openid profile email offline_access` | ✅ Scopes | +| CONTEXT | **D-07** single-string `aud` = `client_id` | ✅ Provider | +| CONTEXT | **D-08** RS256 signing | ✅ Provider | +| CONTEXT | **D-09** redirect URI `recipe://callback` | ✅ Redirect URI | +| CONTEXT | **D-10** this document is a Phase 2 deliverable | ✅ this document | +| CONTEXT | **D-11** client OIDC config in `shared/commonMain/Constants.kt` | ✅ Server Env Vars (relationship spelled out); ⤳ 02-01 task 1 (Constants.kt landed) | +| CONTEXT | **D-12** server OIDC config via env vars | ✅ Server Env Vars | +| CONTEXT | **D-13** persist full AppAuth `AuthState` JSON via `multiplatform-settings` | ⤳ 02-03 + 02-04 + 02-05 | +| CONTEXT | **D-14** iOS Keychain `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | ⤳ 02-05 | +| CONTEXT | **D-15** one AuthState blob per app install | ⤳ 02-03 | +| CONTEXT | **D-16** proactive refresh via `performActionWithFreshTokens` | ⤳ 02-04 + 02-05 | +| CONTEXT | **D-17** Ktor bearer `refreshTokens` 401 fallback | ⤳ 02-03 | +| CONTEXT | **D-18** silent refresh-failure transition | ⤳ 02-03 | +| CONTEXT | **D-19** RP-initiated end-session | ✅ Logout | +| CONTEXT | **D-20** AppAuth `EndSessionRequest` drives logout | ✅ Logout; ⤳ 02-04 + 02-05 | +| CONTEXT | **D-21** Ktor `jwt("authentik")` install with leeway 30s and `sub` validation | ⤳ 02-02 | +| CONTEXT | **D-22** JWKS provider cache 10 / 15min, rate limit 10/min | ⤳ 02-02 | +| CONTEXT | **D-23** never log Authorization header / token bodies | ✅ Manual UAT-04 step 5; ⤳ 02-02 (server filter) + 02-03 (client logger discipline) | +| CONTEXT | **D-24** ship `V1__users.sql` migration | ⤳ 02-02 | +| CONTEXT | **D-25** JIT upsert by `sub`, update email/display_name | ⤳ 02-02 | +| CONTEXT | **D-26** Exposed DSL only, `newSuspendedTransaction` | ⤳ 02-02 | +| CONTEXT | **D-27** `/api/v1/me` returns `MeResponse` | ✅ Manual UAT-04; ⤳ 02-01 task 1 (DTO) + 02-02 (route) | +| CONTEXT | **D-28** `AuthState` sealed shape with `householdId: HouseholdId? = null` | ⤳ 02-03 | +| CONTEXT | **D-29** `AuthSession` Koin singleton in `authModule` | ⤳ 02-03 + 02-06 | +| CONTEXT | **D-30** auth gate in `App()` | ⤳ 02-06 (UI) | +| CONTEXT | **D-31** minimal login screen | ⤳ 02-06 | +| CONTEXT | **D-32** inline login error states | ⤳ 02-06 | +| CONTEXT | **D-33** post-login placeholder `Witaj, {displayName}!` | ⤳ 02-06 | +| CONTEXT | **D-34** Compose Resources strings from day 1 | ⤳ 02-06 | +| UI-SPEC | Auth screen contract: SplashScreen / LoginScreen / PostLoginPlaceholderScreen | ⤳ 02-06 | +| VALIDATION | Wave 0 tests: AuthJwtTest, MeRouteTest, AuthSessionTest, SecureAuthStateStoreTest | ⤳ 02-02 (server tests) + 02-03 (client tests) | +| VALIDATION | Manual UAT checklist in `docs/authentik-setup.md` | ✅ Manual UAT | +| PATTERNS | File map: shared DTO/Constants location, Koin authModule, Ktor JWT install, Exposed table, AppAuth platform actuals | ⤳ 02-01 task 1 (shared) + 02-02 (server) + 02-03 (client common) + 02-04 (Android) + 02-05 (iOS) + 02-06 (UI) | + +### Deferred (excluded from Phase 2) + +These are explicitly out of scope for v1 per `.planning/phases/02-authentication-foundation/02-CONTEXT.md` § Deferred Ideas. Listed here so the audit makes the exclusions traceable. + +- **Universal Links / App Links** — excluded; rely on `recipe://callback` custom scheme. Revisit only if app gains broader distribution beyond the household or if Apple/Google deprecate custom-scheme OIDC redirects. +- **Real Desktop OIDC** — JVM target ships a `DEV_AUTH_TOKEN` env-var stub (D-02). Loopback-redirect implementation deferred until Desktop becomes a release surface. +- **Wasm OIDC implementation** — `wasmJs` actual throws `NotImplementedError`. Browser-redirect flow deferred until Wasm becomes a release surface. +- **Apple Sign-in as a first-class button** — Authentik can federate Apple upstream if ever desired. +- **Authentik provisioning automation (Terraform/Ansible)** — this document is the manual reproduction playbook; automation deferred post-v1. +- **JWT validation tests against a real Authentik instance** — Phase 2 ships unit/integration tests with hand-crafted JWTs. Real-Authentik integration tests deferred to Phase 11 (deployment). +- **BuildConfig-style Gradle injection of OIDC config** — `Constants.kt` is the v1 single-environment acceptance per PITFALLS.md tech-debt table. +- **Per-user persisted `AuthState`** — one user per install is the v1 model. +- **Modal/toast for refresh-failure UX** — silent transition ships in v1 (D-18). +- **Background token refresh** — v1 has no background work. +- **"Wyloguj się i zapomnij sesję" two-tier logout** — single RP-initiated logout only. + +--- + +*Phase: `02-authentication-foundation` · Plan: `02-01` · Last updated: 2026-04-28*