Skip to content

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_secret is attached by the backend; the SPA only sends the code and code_verifier.
  • Refresh-token custody — HttpOnly, Secure, SameSite=Lax, scoped to the /oidc path, and rotated on every refresh.
  • Origin fence — a refresh request carrying an Origin header that is not in allowed_origins is refused with 403.
  • 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:

  1. Edge — TLS terminates at AgentGateway, which validates JWTs against the Keycloak JWKS (through the extProc sidecar) and injects trusted identity headers upstream.
  2. Service — wittgenstein-oidc-backend verifies the token again (validate) or trusts the gateway (trust), then enforces roles per route.
  3. State-changing calls — service-to-service POSTs use a CSRF double-submit token (cookie and X-CSRF-Token header must match).
  4. 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.