Files
recipe/docs/authentik-setup.md
2026-04-29 21:07:49 +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 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.

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:
    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 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 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 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 — 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