OIDC & Token Security
Every Wittgenstein service delegates identity to Keycloak over OpenID Connect (OIDC) with PKCE, and every application built on the platform gets the same hardened token handling out of the box through two shared libraries:
| Library | Runs in | Responsibility |
|---|---|---|
wittgenstein-oidc-backend (Python) |
API / BFF | Confidential-client token exchange, refresh-token custody, JWT verification, role checks, JIT user provisioning |
@wittgenstein/oidc-client (npm) |
Browser SPA | PKCE login, same-origin /oidc endpoint, automatic refresh-and-retry on 401 |
The goal is a single, audited implementation of the parts of OIDC that are easy to get subtly wrong — so that no application has to re-invent token storage, refresh or signature validation.
Security by construction
An application that adopts both libraries never exposes a refresh token to JavaScript, never ships a client secret to the browser, and never accepts an unsigned token where a signed one is required. These are properties of the libraries, not conventions each team must remember.
Threat model at a glance
| Threat | Mitigation |
|---|---|
| XSS steals a long-lived session | The refresh token lives only in an HttpOnly cookie; JavaScript only ever sees a short-lived access token |
| Authorization-code interception | PKCE (S256) binds the code to the browser that started the flow |
| Client secret leaked from the SPA | The backend is the confidential client; the secret never leaves the server |
| Cross-site request forges a token refresh | SameSite=Lax cookie plus an Origin allow-list on the refresh grant |
| Stolen credentials replayed via password grant | Direct access grants (ROPC) disabled on SPA clients |
| Forged or tampered JWT | RS256 signature verification against the realm JWKS, with issuer checks |
| Brute-force login attempts | Realm-level brute-force detection and lockout |
| Stale or revoked session lingers | A rejected refresh evicts the cookie; the SPA falls back to the login redirect |
| Weak single-factor login | Mandatory email verification and MFA enforced at the IdP |
Reference architecture
The browser only ever talks to its own origin. The /oidc route is served by the application's backend, which is the real OIDC client.
sequenceDiagram
participant User
participant SPA as React SPA<br/>(@wittgenstein/oidc-client)
participant BFF as Backend<br/>(wittgenstein-oidc-backend)
participant KC as Keycloak
User->>SPA: Click "Sign in"
SPA->>SPA: Generate code_verifier + code_challenge (S256)
SPA->>KC: Redirect to /auth (code_challenge)
KC-->>User: Login page: password, then MFA
User->>KC: Credentials + second factor
KC-->>SPA: Redirect to /callback?code=...
SPA->>BFF: POST /oidc/.../token (code, code_verifier)
BFF->>KC: POST /token (code, code_verifier, client_id + client_secret)
KC-->>BFF: access_token, id_token, refresh_token
BFF-->>SPA: access_token, id_token + Set-Cookie: refresh_token (HttpOnly, Secure, SameSite=Lax)
Note over SPA,BFF: The refresh token never reaches JavaScript
SPA->>BFF: API call with Bearer access_token
BFF-->>SPA: 401 (access token expired)
SPA->>BFF: POST /oidc/.../token (grant_type=refresh_token)
BFF->>KC: refresh_token from cookie + client secret
KC-->>BFF: new tokens (refresh token rotated)
BFF-->>SPA: new access_token + rotated cookie
SPA->>BFF: Retry the original call
Backend: wittgenstein-oidc-backend
The token endpoint in one line
create_oidc_bff_router builds the same-origin /oidc router. It exchanges the authorization code, performs refresh grants, strips the refresh token from the JSON body and moves it into a cookie, and relays JWKS/userinfo/logout.
from fastapi import FastAPI
from wittgenstein_oidc_backend import create_oidc_bff_router
app = FastAPI()
app.include_router(
create_oidc_bff_router(
authority="https://auth.example.com/realms/acme",
client_id="acme-app",
client_secret=settings.oidc_client_secret, # stays server-side
allowed_origins=["https://app.example.com"], # fences the refresh grant
),
prefix="/oidc",
)
What this gives you without further code:
- Confidential exchange —
client_secretis attached by the backend; the SPA only sends thecodeandcode_verifier. - Refresh-token custody —
HttpOnly,Secure,SameSite=Lax, scoped to the/oidcpath, and rotated on every refresh. - Origin fence — a refresh request carrying an
Originheader that is not inallowed_originsis refused with403. - Fail-closed eviction — if Keycloak rejects a refresh (expired, revoked, reused), the dead cookie is deleted so the next attempt goes through a full login.
Verifying tokens
decode_token mirrors the two modes of AgentGateway's own JWT policy, so a service can be protected at the gateway, at the service, or both:
| Mode | Behaviour | Use when |
|---|---|---|
"validate" |
Full RS256 signature verification against the realm JWKS (cached with a TTL), plus issuer check | The token reaches the service directly |
"trust" |
Decode only, no signature check | An upstream gateway already validated the token |
import functools
from wittgenstein_oidc_backend import JwksClient, OidcMiddleware, decode_token, has_role
issuer = "https://auth.example.com/realms/acme"
jwks = JwksClient(f"{issuer}/protocol/openid-connect/certs")
decode = functools.partial(decode_token, mode="validate", jwks_client=jwks, issuer=issuer)
app.add_middleware(OidcMiddleware, decode=decode) # parses the token once per request
@app.post("/access")
@has_role(["admin", "manager"]) # 401 without a valid token, 403 without the role
async def update_access(): ...
trust is opt-in
trust mode is never a silent default. Use it only behind a gateway that validates signatures; otherwise use validate.
Identity provisioning
jit_provision_user(payload, store) finds or creates the local user behind a Keycloak identity and synchronises its roles. It is written against a small UserStore protocol, so it works with any ORM and adds no database dependency to the library.
Frontend: @wittgenstein/oidc-client
The SPA never holds a refresh token, so silent renewal is done on demand, only when the API says the access token is no longer good:
import { OidcProvider, createAuthFetch } from "@wittgenstein/oidc-client";
let apiFetch = fetch;
<OidcProvider
authority="https://auth.example.com/realms/acme"
clientId="acme-app"
onManager={(userManager) => { apiFetch = createAuthFetch(userManager); }}
>
{children}
</OidcProvider>
createAuthFetch attaches the access token and, on a 401/403, refreshes once through the backend and retries the request instead of signing the user out. A user in the middle of a long form does not lose their work when a token expires.
Defence in depth
Token handling is one layer of several:
- Edge — TLS terminates at AgentGateway, which validates JWTs against the Keycloak JWKS (through the
extProcsidecar) and injects trusted identity headers upstream. - Service —
wittgenstein-oidc-backendverifies the token again (validate) or trusts the gateway (trust), then enforces roles per route. - State-changing calls — service-to-service
POSTs use a CSRF double-submit token (cookie andX-CSRF-Tokenheader must match). - Identity provider — brute-force detection, email verification and MFA, and a per-application Keycloak realm/client model.
Inspect it yourself
Confirm the properties above from a shell. Replace the host with your own environment.
$ curl -s -o /dev/null -D - -X POST https://app.example.com/oidc/protocol/openid-connect/token \
-d "grant_type=refresh_token" | grep -i -E "^(HTTP|set-cookie)"
HTTP/2 400
$ # no refresh cookie was sent, so the backend refuses instead of guessing
$ curl -s -X POST https://app.example.com/oidc/protocol/openid-connect/token \
-H "Origin: https://evil.example.net" -d "grant_type=refresh_token"
{"error":"origin not allowed"}
$ # the Origin fence rejects cross-site refresh attempts
$ curl -s https://auth.example.com/realms/acme/.well-known/openid-configuration | jq '.code_challenge_methods_supported'
[
"plain",
"S256"
]
Decode, don't trust
To debug a token, paste it into a local decoder (jq -R 'split(".")[1] | @base64d | fromjson') rather than an online one. Access tokens are credentials.