docs(02-01): add Authentik provider setup and Phase 2 source audit

Task 02-01-03. Creates docs/authentik-setup.md as the load-bearing
Phase 2 deliverable (D-10): a reproducible playbook for the
homelab Authentik provider plus the multi-source audit that ties
every Phase 2 input to a covering plan.

Sections (in mandated order):
- Provider — Public + PKCE S256, recipe-app client_id, RS256, single-
  string aud, JWKS URI, end-session endpoint, Issuer trailing slash.
- Scopes — exactly `openid profile email offline_access`; explains
  why offline_access must be both requested AND mapped on the
  provider for refresh tokens (PITFALLS.md Phase 2 Pitfall 2).
- Redirect URI — recipe://callback, registered byte-for-byte in
  Authentik + iOS Info.plist + Android <intent-filter>.
- Server Env Vars — OIDC_ISSUER / OIDC_AUDIENCE / OIDC_JWKS_URL with
  override semantics matching Phase 1's DATABASE_URL pattern.
- Logout — RP-initiated end-session sequence (D-19, D-20).
- Manual UAT — UAT-01 fresh login, UAT-02 reopen with refresh,
  UAT-03 logout returns to login, UAT-04 curl/HTTP verification of
  GET /api/v1/me 200/401 cases including wrong-aud and never-log-
  Authorization assertion.
- Source Audit — exhaustive table mapping GOAL Phase 2, REQ
  AUTH-01..AUTH-06, RESEARCH constraints, CONTEXT D-01..D-34,
  UI-SPEC, VALIDATION Wave 0, and PATTERNS file map to either this
  doc () or a downstream Phase 2 plan (⤳). All deferred ideas
  listed as ✂ excluded: Universal Links/App Links, real Desktop
  OIDC, Wasm OIDC, Apple Sign-in, Authentik provisioning automation,
  per-user AuthState, modal refresh-failure UX, background refresh,
  two-tier logout, BuildConfig OIDC injection, real-Authentik
  integration tests.

Verification:
- grep -E 'openid profile email offline_access|PKCE S256|single-string
  |recipe://callback|/api/v1/me|Source Audit' docs/authentik-setup.md:
  hits all six tokens.
- All Task 3 grep acceptance criteria PASS, including
  AUTH-01.*AUTH-02.*AUTH-03.*AUTH-04.*AUTH-05.*AUTH-06 on a single
  audit-table line and "Universal Links / App Links.*excluded".
This commit is contained in:
2026-04-28 10:55:38 +02:00
parent c1cc713bbb
commit 62040d461a

242
docs/authentik-setup.md Normal file
View File

@@ -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.<your-homelab>.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 `<issuer>/.well-known/openid-configuration` `jwks_uri` (typically `<issuer>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 `<issuer>/.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` `<intent-filter>` 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 `<OIDC_ISSUER>/.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 `<issuer>/.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 "<issuer>/.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.<homelab>/api/v1/me
# expect: HTTP/1.1 200 OK; body matches MeResponse {"id":..., "sub":..., "email":..., "displayName":...}
```
3. Confirm:
```bash
curl -i https://api.<homelab>/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.<homelab>/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*