# 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 OIDC actual) |
| Android | Lokksmith's manifest callback using `lokksmithRedirectScheme=recipe` and the `callback` host | Plan 02-04 (Android OIDC actual). |

PKCE S256 + Lokksmith'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`. Lokksmith's end-session flow drives this on both mobile platforms (D-20).
2. Delete the persisted auth-state marker 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 — Lokksmith 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 OIDC) + 02-05 (iOS OIDC) |
| 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 (Lokksmith 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: Lokksmith, 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 patch version follows the selected auth client | ✅ Lokksmith requires Ktor 3.4.2 |
| CONTEXT | **D-01** Lokksmith on both mobile platforms via expect/actual `OidcClient` | ⤳ 02-03 (expect) + 02-04 (Android actual) + 02-05 (iOS actual) |
| CONTEXT | **D-02** Desktop/JVM app auth stub | Superseded: `composeApp` no longer has a JVM/Desktop target; `shared.jvm()` remains only for the server dependency |
| CONTEXT | **D-03** Wasm app auth stub | Superseded: `composeApp` no longer has a Wasm target in v1 |
| 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 opaque auth-state marker 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 Lokksmith token refresh | ⤳ 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** Lokksmith end-session flow 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, Lokksmith 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** — no longer applicable in v1; the `composeApp` JVM/Desktop target was removed.
- **Wasm OIDC implementation** — no longer applicable in v1; the `composeApp` Wasm target was removed.
- **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*
