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 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.
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:
- Call Authentik's
end_session_endpoint(advertised by<issuer>/.well-known/openid-configuration) with the user'sid_token_hint. Lokksmith's end-session flow drives this on both mobile platforms (D-20). - 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)
- Wipe app data: delete the app from the simulator/device.
- Reinstall via
./gradlew :composeApp:iosDeployIPhone…or Xcode. - Tap "Zaloguj się przez Authentik".
- Confirm the system browser (
ASWebAuthenticationSession) opens at Authentik's hosted login page. - Authenticate. Confirm Authentik consent screen lists
openid profile email offline_access. - Confirm the app returns to foreground via
recipe://callbackand rendersWitaj, {displayName}!with the partner's actual display name. - Failure modes to verify visually: cancelling the system browser shows "Logowanie anulowane. Spróbuj ponownie." inline.
UAT-02 — Reopen with refresh (AUTH-04)
- Sign in via UAT-01.
- In Authentik, set the provider's access-token lifetime to ~60 seconds (or wait the default).
- Backgroud the app for ~2 minutes; relaunch.
- 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)
- Sign in via UAT-01.
- Tap "Wyloguj się".
- Confirm the app returns to the LoginScreen.
- Tap "Zaloguj się przez Authentik" again. Confirm Authentik prompts for credentials (no silent SSO) — proves the end-session call succeeded.
- 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.
- Run the server locally or hit the homelab. Capture a valid access token (e.g., copy from
AuthStateJSON via the iOS debugger immediately after UAT-01, or usecurldirectly against Authentik's token endpoint with the same client_id). - 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":...} - Confirm:
curl -i https://api.<homelab>/api/v1/me # expect: HTTP/1.1 401 Unauthorized - 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 - Confirm logs do not contain the token body. The custom
CallLoggingfilter must redact theAuthorizationheader (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://callbackcustom 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
composeAppJVM/Desktop target was removed. - Wasm OIDC implementation — no longer applicable in v1; the
composeAppWasm 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.ktis 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