Files
recipe/docs/authentik-setup.md
ulfrxdev 62040d461a 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".
2026-04-28 10:55:38 +02:00

18 KiB

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. 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.
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 CFBundleURLTypesCFBundleURLSchemes 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.

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:
    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:
    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):
    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 implementationwasmJs 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 configConstants.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