13 KiB
Unified Authentik JWT/OIDC Auth — Rollout Status & Plan
Goal: every service's API (not just browser logins) authenticates against
Authentik as the single OIDC provider, with RBAC driven by Authentik group
membership (groups claim) and/or the synthesized permissions claim
(GROUP_PERMISSIONS dict in k8s/infra/iam/scripts/authentik-provision.py).
Same shape as what's already working for Vault: register an Authentik OAuth2
provider/application, the service validates JWTs against Authentik's JWKS,
claims map to policy/role.
Verified live 2026-08-27.
JWT validation & authorization contract (what a service must implement)
This is the checklist any service in any repo needs to satisfy to actually
consume Authentik JWTs, not just have a placeholder issuer URL. Confirmed
live against the vault app's discovery doc (2026-08-27); same shape for
every other Authentik app/slug.
1. Discovery & key fetch
- Per-app issuer:
https://authentik.riotpiao.com/application/o/<app-slug>/ - Discovery doc:
<issuer>.well-known/openid-configuration - JWKS:
<issuer>jwks/(also given asjwks_uriin the discovery doc) - Gotcha: the token endpoint is shared across every app, not per-slug:
https://authentik.riotpiao.com/application/o/token/. Don't construct it from the issuer the way jwks_uri is constructed. - Fetch the JWKS through the discovery doc (or
jwks_uridirectly) and cache it with a TTL + refresh-on-kid-miss, not a hardcoded key blob — Authentik's signing key (SIGNING_KEY_PKinauthentik-provision.py) can rotate, and a hardcoded key silently breaks every token validation the moment it does.
2. Token validation checklist
- Verify the signature against the fetched JWKS.
- Pin the algorithm allow-list to
RS256only (confirmedid_token_signing_alg_values_supported: ["RS256"]— nothing else is issued). Reject any token whose header claims a differentalg, including"none"— this is the standard alg-confusion defense; don't trust the token's own header to pick the verification algorithm. - Verify
issequals the exact expected issuer string for that app's slug (not just "some Authentik issuer" — a token minted for a different app should not validate here). - Verify
aud(orazp) equals the service's ownclient_id. - Verify
exp/nbf/iatwith a small clock-skew tolerance (30-60s).
3. Claims to request and how to use them
groupsandpermissionsare non-default scopes — Authentik only includes them in the token if explicitly requested, both at provider registration (property_mappings— every app inauthentik-provision.py'sSERVICESloop already gets both viaSCOPE_PKS) and at token-request time (e.g. Vault's OIDC role setsoidc_scopes: ["permissions"]; a service doing its own token requests needsscope=openid permissionsorscope=openid groupsin the auth/token request).- Permissions-claim pattern (what kmsvc/temporal/etc. should do): read
the
permissionsclaim (list of strings like"kmsvc:read","kmsvc:write"), check it contains the string the attempted action needs.homelab-adminsmembers get the literal string"*"in that list — treat that as wildcard-allow, not as a literal permission string to match against. - Groups-claim pattern (what Vault does, what Temporal's
claimMapperor MinIO'spolicyclaim expect): read thegroupsclaim directly (raw Authentik group names) and map group name -> internal role/policy inside the service's own config, the way Vault's Identity Group aliases do. Prefer this pattern when the service already has its own native role/policy system to map into, instead of parsing the synthesizedpermissionsstrings.
4. Gap: machine-to-machine (no human/browser step) calls aren't wired anywhere yet
Authentik's server supports client_credentials — confirmed live in
grant_types_supported on the discovery doc. But every provider currently
registered in authentik-provision.py only declares
grant_types: ["authorization_code", "refresh_token"] (see the SERVICES
loop and the kubernetes public-client block) — none of them can issue a
token via client_credentials today. If a service needs to call another
service's API with no human/browser step at all (true service-to-service,
not "a human logged in via browser, then the resulting token gets reused"),
it needs its own confidential-client Application with client_credentials
added to grant_types, and requests a token via
POST /application/o/token/ with grant_type=client_credentials. Decide
per-service whether this is actually needed before assuming a JWT is always
available to attach to an outbound call — right now, none are set up for it.
5. Device code flow (CLI / headless API callers with no local browser)
For a caller that can't do a browser redirect itself (a CLI tool, a script
on a headless box, an API client embedded in another service) but still
needs a human to approve the login somewhere. Confirmed live 2026-08-27
against the vault provider (device-authorization leg only — the
token-polling leg below is standard RFC 8628 + Authentik's declared support,
not independently re-verified since completing it needs a real human
approval step).
Gap, same shape as client_credentials above: every provider currently
registered only has grant_types: ["authorization_code", "refresh_token"].
Requesting a device code against one of them fails with a generic
invalid_client error — misleading, since it looks like a bad secret but is
actually just the missing grant type. Confirmed by adding
urn:ietf:params:oauth:grant-type:device_code to the vault provider's
grant_types (temporarily, via the API, reverted after testing) — the exact
same request then succeeded. Any service that wants this flow needs that
grant type added to its own provider's grant_types list in
authentik-provision.py.
Flow, what the customer/caller must specify:
POST https://authentik.riotpiao.com/application/o/device/, form-encoded, withclient_id=<app's client id>andscope=openid ...(same non-default-scope rule as above — addpermissions/groupsif the resulting token needs to drive RBAC). Live response shape, confirmed:No client_secret was required on this leg even for a confidential client — Authentik didn't enforce it here in testing. Don't rely on that as a security boundary; verify token-endpoint behavior below before assuming secrets are optional throughout the flow.{ "device_code": "<opaque, client polls with this, never shown to the human>", "user_code": "022228491", "verification_uri": "https://authentik.riotpiao.com/device", "verification_uri_complete": "https://authentik.riotpiao.com/device?code=022228491", "expires_in": 60, "interval": 5 }- The caller shows
user_code+verification_uri(or just theverification_uri_completelink) to the human — this can be on a completely different device. The human opens it, logs into Authentik, approves. - The caller polls
POST https://authentik.riotpiao.com/application/o/token/withgrant_type=urn:ietf:params:oauth:grant-type:device_code,device_code=<from step 1>,client_id=<same client id>(+client_secretif confidential — not independently confirmed on this leg), no faster than everyintervalseconds. Per RFC 8628/Authentik's declared support:authorization_pendingwhile waiting,slow_downif polling too fast,expired_tokenpastexpires_in,access_deniedif the human rejects it, or the normal token bundle on approval.
Done
| Service | Authentik app | Validates JWT itself | Notes |
|---|---|---|---|
| grafana, forgejo, argocd, homarr, paperless, immich | yes | yes (native OIDC login) | browser session auth |
| vault | yes | yes, tested | vault login -method=oidc; policies attached via Vault Identity Groups aliased to Authentik group names (groups claim) — adding a new Vault policy needs zero Authentik-side change, just a new identity/group+identity/group-alias pair in Vault |
| minio | yes | likely yes, not yet load-tested | MINIO_IDENTITY_OPENID_SCOPES includes non-default scopes, which also enables AssumeRoleWithWebIdentity (real S3 API access via STS), not just console login. Need to actually mint a token and call AssumeRoleWithWebIdentity + an S3 op to confirm before calling this done. |
| kubectl | yes (public PKCE client kubernetes) |
yes | kube-apiserver --oidc-* flags confirmed live (2026-08-27); per-service Role/RoleBinding in k8s/infra/rbac/*.yaml bind oidc:<service>-admins groups — should now actually be enforced, not inert as an earlier pass of this doc set assumed. Worth a real login test to confirm group->Role resolution end-to-end. |
Not done — this repo can finish it (config only, no new app code)
-
portainer — has a built-in OAuth login feature. Register an Authentik app (confidential client, redirect URI to Portainer's OAuth callback) the same way
vault/immich/etc. are registered inauthentik-provision.py, then flip on OAuth in Portainer's own settings (or via its API). No application code involved. -
kmsvc / management-service —
KMSVC_AUTHENTIK_ISSUER_URLandKMSVC_AUTHENTIK_AUDIENCEenv vars already exist in k8s/apps/messaging/management-service/values.yaml but are empty strings — placeholders, never wired. Registering the Authentik app and populating them is real progress, BUT: whether the deployed image (ghcr.io/riotpiaole/kmsvc-management-service) actually validates a JWT against those values is unverified — that's a claim about code in a separate repo this checkout doesn't have. Confirm there before assuming this is load-bearing (this repo already has one precedent of a README claiming JWT support that the code didn't actually have — the LLM gateway, see "Not done" below). -
temporal — Temporal's own Helm chart (k8s/apps/temporal/temporal-values.yaml, no
authorization/jwtKeyProviderblock currently set) has native JWT authorization support (server.config.authorization.jwtKeyProvider+claimMapper) — this is Helm values, not custom plugin code. The real target is thetemporal-frontendgRPC service (port 7233 — what SDKs and workers actually connect to), not the Web UI. Verified live 2026-08-27: onlytemporal.riotpiao.comis ingressed, and it points attemporal-web(the UI);temporal-frontend/temporal-frontend-headlessare ClusterIP-only, no public hostname, notemporal-frontend.riotpiao.comexists yet. Temporal'sjwtKeyProvider/claimMapperauth gates the frontend service itself at the RPC level regardless of exposure — it would apply the same to in-cluster workers as to any external caller, so this is worth doing even with no public ingress. Needs:- Point
jwtKeyProvider.keySourceURIsat Authentik's JWKS endpoint for a dedicatedtemporalAuthentik app (none exists yet — only thetemporal-adminspermissions-claim group, confirmed live in Authentik, no OAuth2 provider/application). - Confirm whether Temporal's default
claimMapperexpects a claim shape compatible with this repo'spermissionsclaim (["temporal:read","temporal:write"]) or needs a custom claim-mapper config to translate it into Temporal's own namespace-permission format. - Open question, unresolved: is external (outside-cluster) worker/client
access to
temporal-frontendeven needed? If yes, that's a separate decision on top of the JWT work — a gRPC-capable ingress would need to be added (same shape as kmsvc'sgrpcPathPrefixpattern in k8s/apps/messaging/management-service/values.yaml), since nothing currently exposes 7233 outside the cluster.
- Point
Not done — needs application code in a different repo
-
poimen-memory (
forgejo.riotpiao.com/rock/poimen.git) — confirmed current auth is a static API key (poimen-memory-api-keyk8s Secret, 1 key, Opaque), not JWT/OIDC at all. No Authentik app exists. Two paths:- Add real JWT validation to poimen-memory's own code (that repo).
- Bridge without touching poimen-memory: front it behind the Go API gateway, gateway validates the Authentik JWT once and forwards with the existing static API key internally. Doesn't fix poimen-memory itself, but unblocks unified auth for callers without waiting on that repo.
-
LLM gateway (
rock/homelab-frontend, deployed asapi-gateway) —internal/authis an empty directory per that repo's own task board (phase 3, tasks 2.9-2.15 also unbuilt — Anthropic dialect, streaming tool calls).LLM_TOOL_CALLS.mdin that repo overclaims what's implemented; don't trust it without checkinginternal/authdirectly.
Open design question, unresolved
Should each service validate JWTs itself (JWKS-fetch + claim-check logic duplicated per service), or should the Go gateway become the one chokepoint doing JWT validation for everything behind it (kmsvc, llm-serving, eventually poimen-memory), so that logic exists once instead of N times? This changes where phase-3 code actually goes. Not decided as of this doc.