Add generic OIDC provider support (Authentik, Keycloak, Zitadel, Google as OIDC, …) #85
Labels
No labels
area:auth
area:ci
area:db
area:infra
area:native
area:pwa
area:service
epic
feature
foundation
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
james/carol#85
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Follow-up to #12 / ADR-0015. The current OAuth implementation only handles plain OAuth2 with hand-written
fetchProfileper provider (GitHub today). Many self-hosters consolidate their auth behind an OIDC IdP — Authentik is the most common in Carol's target audience, but Keycloak, Zitadel, and "Google as OIDC instead of OAuth2 userinfo" all benefit from the same code path.ADR-0015 explicitly deferred OIDC:
This is that follow-up.
Scope
A single
generic-oidcprovider that's configured dynamically from env, not a per-IdP entry in the staticPROVIDERSregistry. The self-hoster declares one or more OIDC instances:The naming pattern (
OIDC_<NAME>_*) lets multiple OIDC IdPs coexist (e.g. a homelab Authentik for the admin + a Google-via-OIDC for invitees).LABELcontrols button copy;<NAME>is the URL slug used in/api/auth/oauth/callback/<name>.OIDC discovery via
<issuer>/.well-known/openid-configuration— fetched once at startup (or on first use), cached. The doc suppliesauthorization_endpoint,token_endpoint,userinfo_endpoint, andjwks_uri. Discovery is the default path; the four endpoints below come from the doc unless an override env says otherwise.Per-endpoint overrides. Each of the four endpoints can be overridden individually via
OIDC_<NAME>_<ENDPOINT>env vars (see example above). Resolution order per endpoint:OIDC_<NAME>_<ENDPOINT>env var → discovery-doc value → startup error if neither is present.Why we ship this on day one:
token_endpointthat included a trailing slash but the actual endpoint rejected it (and vice-versa). The override unblocks the self-hoster without waiting for an IdP upgrade.Overrides are validated the same way discovered values are (must be
https://, must be same-origin as the issuer for the auth + token + userinfo endpoints; JWKS may be a different host since some IdPs publish JWKS from a CDN).id_token validation (the load-bearing security piece, and why it was deferred):
jwks_uri(discovery or override), cached with TTL, refreshed onkidmiss.kid.issmatches the configured issuer (no override —issis the IdP's self-identification and must match the trust anchor we configured).audincludes the configuredclient_id.exp(not expired) andiat/nbfwithin tolerance.noncematches a value Carol set at /start (new cookie in addition to state + PKCE).subasproviderUserId,email+email_verifiedfor the email policy.All existing security invariants from ADR-0015 hold.
email_in_use; #72 unblocks the recovery flow).email_verified === truerequired. The id_token carries the flag; refuse if false.noncecookie alongside state + PKCE.Routes work without code changes. The existing
/api/auth/oauth/startand/api/auth/oauth/callback/[provider]accept the dynamic provider id; the registry / resolution layer surfaces all enabled providers (static + each configured OIDC instance) viagetEnabledProviders().UI works without code changes.
<OAuthButtons>already renders one button per enabled provider; OIDC instances appear automatically. Account /account page lists per-instance linked identities the same way.ADR-0016 records the load-bearing pieces:
kid-miss refresh.email/email_verifiedin the id_token's standard claims, it's not v1-supported.Acceptance criteria
OIDC_AUTHENTIK_*env vars + register the callback URL<APP_URL>/api/auth/oauth/callback/authentikin Authentik, and "Sign in with Authentik" appears on/login,/register, and/account"Connect another".OIDC_AUTHENTIK_*+OIDC_KEYCLOAK_*) all surface as separate buttons and work independently. A user can link both to one Carol account.npm run build/ container start.iss, missingaud, expiredexp, mismatchednonce. Each refusal is a test case.email_verified !== truein the id_token results in the existingno_verified_emailflow.kidrotation, every id_token rejection path, the full sign-in flow against a mocked OIDC IdP, the override-precedence path, and the override + cross-origin JWKS host case (JWKS is allowed to live on a different host; auth/token/userinfo are not).docs/oidc-self-hoster-guide.md).docs/adr/README.md.docs/oidc-self-hoster-guide.md(or a section indocs/ci.md) documents the Authentik + Keycloak setup recipes (one paragraph each — what to register where, which env vars to set, when to reach for an endpoint override).Out of scope
is_admin, mapping OIDC group claims onto Carol roles is its own ticket.issstays anchored toOIDC_<NAME>_ISSUERand is checked against what the IdP signs into the id_token. A mismatch is fatal — adding an "expected_iss" override would let an attacker who controls a domain claim to be any other IdP.Composes with
email_in_uselockout. OIDC providers all exposeemail_verified, so the recovery flow becomes more reliably usable once both ship.Part of epic #1.