Single Sign-On (OIDC)¶
When your company runs Keycloak (or Auth0, Okta, Entra…), you don't want to store passwords at all —
you want the identity provider to do the proving and just tell you who showed up. With OpenID
Connect the IdP hands the user a signed JWT; your app's only job is to validate that token and
trust its claims. arvel's oidc guard does exactly that, and — because it reads the token's claims —
it feeds both halves of this section at once: the sub claim becomes the user's
identity, and the groups/roles claim drives authorization.
This page covers configuring the guard for Keycloak, what it validates, and how a token becomes a logged-in, authorized user.
Needs the [jwt] extra
The OIDC guard validates JWTs with pyjwt — uv add 'arvel[jwt]'.
Two modes: obtaining a token vs validating one¶
SSO has two distinct jobs, and arvel gives you a piece for each — don't confuse them:
| You need to… | Use | Extra |
|---|---|---|
| Obtain a token by logging the user in through the provider (the "Login with Keycloak" browser redirect → callback → code exchange) | OAuthProvider — the OAuth2 authorization-code client |
[oauth] |
| Validate a token a client already presents (an API/resource server checking a bearer JWT) | OidcGuard — the oidc guard below |
[jwt] |
OIDC is OAuth2 plus an identity layer, so the two work together: OAuthProvider runs the redirect
dance and hands you an id_token; the oidc guard's verifier validates it. The rest of this page
is the validation side. For the interactive redirect/callback login flow, see
OAuth2 Social Login.
Configure the guard¶
Point auth.oidc at your realm. The guard fetches the IdP's public keys from jwks_uri and checks
every token's signature, issuer, and audience against these values:
# config/auth.py
auth = {
"oidc": {
"jwks_uri": "https://keycloak.acme.com/realms/acme/protocol/openid-connect/certs",
"issuer": "https://keycloak.acme.com/realms/acme",
"audience": "acme-web", # your client_id
"provider": "keycloak", # used as Principal.provider (and the identity link key)
},
}
jwks_uri, issuer, and audience are required — if any is missing the guard raises a clear
ValueError at build time rather than quietly rejecting every login (fail loud, not silent).
A token becomes a Principal¶
Resolve the guard and verify an incoming request. The client sends the IdP's token as
Authorization: Bearer <jwt>; the guard validates it and returns a Principal:
from arvel.auth.guards import GuardManager
guard = GuardManager().guard("oidc")
principal = await guard.verify(request)
# Principal(
# provider="keycloak",
# subject="f:1d2c…:ada", # the stable `sub` — the identity key
# claims={"email": "[email protected]", "email_verified": True, "groups": ["/eng/backend"]},
# )
# → None if the token is missing, malformed, expired, or fails signature/iss/aud checks
The full login flow¶
Validate the token, then turn the Principal into your User with the
user provider:
guard = GuardManager().guard("oidc")
users = app.make("auth.user_provider") # configured from auth.user_model + trusted providers
async def sso_login(request):
principal = await guard.verify(request)
if principal is None:
return "Invalid token", 401
user = await users.resolve(principal) # finds-or-links-or-creates by `sub` / verified email
if user is None:
return "No account", 403
auth.login(user) # session established
return redirect("/dashboard")
For the first-time-SSO case to link onto an existing password account, add "keycloak" to
trusted_email_providers (see Identities & Account Linking) — Keycloak's
email_verified claim is what makes that safe.
Testing without a network¶
OidcGuard takes an injectable verifier — a callable token -> claims | None. The default is the
JWKS verifier above, but in tests you inject a fake so there's no IdP to reach:
from arvel.auth.oidc import OidcGuard
async def fake_verifier(token):
return {"sub": "kc-1", "email": "[email protected]", "email_verified": True, "groups": ["/eng"]}
principal = await OidcGuard(fake_verifier, provider="keycloak").verify(request_with_bearer)
Common mistakes & gotchas¶
- Decoding without validating. Never trust an unvalidated JWT. The guard only returns claims
that passed signature +
iss+aud+exp— a barejwt.decodewithout verification is a forged-token hole. Use the guard; don't hand-roll it. - Tokens with no
exp. The verifier requiresexp(plusiss/aud/sub), so a token missing an expiry is rejected rather than treated as living forever. - A tenant-influenced
jwks_uri. Keepjwks_uria trusted, operator-set constant — never derive it from the request, or you've built an SSRF and a "trust any issuer" hole. - Using
emailas the identity key. The join key issub(subject_claim); emails change. Email is only for linking a verified address to an existing account. - Confusing the access token and the id token. Send whichever your IdP signs and you've
configured
audiencefor (for Keycloak, typically the access token); both validate the same way.
How it works¶
The oidc guard pulls the Bearer token from the Authorization header and runs it through a
ClaimsVerifier. The default jwks_verifier builds a PyJWKClient once (it caches the IdP's
signing keys with a TTL — no per-request fetch) and calls jwt.decode with the configured issuer,
audience, a small clock leeway, and options={"require": ["exp", "iss", "aud", "sub"]}; any
PyJWTError becomes None (fail closed, no leaked reason). pyjwt is imported lazily inside the
verifier, so the OIDC guard never pulls crypto into arvel's light core.
See also¶
- Guards & Drivers — the guard contract the
oidcdriver implements. - Identities & Account Linking — turning the validated
subinto aUser. - Mapping IdP Groups to Roles — turning the
groupsclaim into authorization.