Keycloak as the Identity Middleware
Wittgenstein does not implement login, password storage, or federation itself. It puts Keycloak between every user, every application and every upstream identity source, and treats it as identity middleware: applications speak one protocol (OIDC) to one place, and Keycloak adapts to whatever each organisation already uses.
flowchart LR
subgraph Sources["Identity sources"]
G[Social / workspace IdP]
E[Enterprise OIDC or SAML IdP]
L[LDAP / Active Directory]
P[Local users + MFA]
end
KC(["Keycloak<br/>realm per tenant"])
subgraph Consumers["Consumers"]
A[Web apps<br/>OIDC + PKCE]
S[Services & agents<br/>client credentials]
T[Tools<br/>SAML / OIDC]
end
G --> KC
E --> KC
L --> KC
P --> KC
KC --> A
KC --> S
KC --> T
KC -. JWKS .-> GW[AgentGateway<br/>+ extProc]
GW --> A
Why a middleware, not a library
| Need | Handled by Keycloak | Effect on the application |
|---|---|---|
| "Our users sign in with our corporate IdP" | Identity brokering (OIDC / SAML) | None — the app still receives a Keycloak token |
| "We already have a directory" | LDAP / Active Directory federation | None |
| "Sign in with a workspace account" | Social / workspace IdP, optionally restricted to a domain | None |
| "Require a second factor for admins" | Authentication flows, MFA | None |
| "Different customers must never see each other's users" | Realm per tenant | Point the app at a different authority |
| "Machines and agents need tokens too" | Service-account clients (client_credentials) |
Same JWT verification path |
| "Off-the-shelf tools need SSO" | SAML and OIDC clients | Configured once in the realm |
The application code is identical across all of these rows. That is what lets one platform satisfy customers with very different identity landscapes without forking anything.
Tenancy model
- One realm per tenant when isolation of users, policies and branding is required. Each realm has its own users, IdPs, MFA policy, token lifetimes and signing keys.
- One client per application inside the realm, with the smallest set of redirect URIs and scopes it needs.
- Roles and groups drive authorisation. Realm roles arrive in
realm_access.rolesand groups in thegroupsclaim;wittgenstein-oidc-backendexposes them throughget_rolesandget_groups, and@has_role([...])enforces them per route.
from wittgenstein_oidc_backend import get_current_payload, get_groups, get_roles
payload = get_current_payload()
roles = get_roles(payload) # ["manager", ...]
groups = get_groups(payload) # ["/customers/acme", ...]
Domain meaning stays in your service
The library extracts claims generically. Interpreting a group name (for example mapping it to a customer account) is your application's business logic, not the platform's.
Client types and grants
| Client | Type | Grant | Notes |
|---|---|---|---|
| Browser application | Confidential via the backend (or public + PKCE) | Authorization Code + PKCE | The backend owns the secret; see OIDC & Token Security |
| Backend / agent calling another service | Confidential | client_credentials |
Service account with narrowly scoped roles |
| Admin automation | Confidential | client_credentials |
Only the realm-management roles it needs |
| Third-party tool | SAML or OIDC | Per tool | Registered once per realm |
Direct access grants (the password grant) are disabled on browser clients, so credentials can only be typed into Keycloak's own pages, where MFA and brute-force protection apply.
Mapping identities into your application
- Claims — protocol mappers add the roles, groups and attributes your services need to the token.
- JIT provisioning — on first login,
jit_provision_usercreates the local user, links the Keycloaksub, and syncs roles on every subsequent login. - Gateway headers — behind AgentGateway, the
extProcsidecar validates the JWT against the JWKS and injectsx-user-idandx-user-name, so services that only need "who is calling" never parse a token at all.
Automated, repeatable realms
Realms are bootstrapped by idempotent scripts rather than clicked together: running the setup again creates what is missing and updates what drifted (identity providers are upserted, not just created). A new tenant realm is therefore a reviewed change, not a manual procedure (output below is abbreviated).
[keycloak] identity provider 'workspace' ... created
[keycloak] client 'acme-app' ... created (confidential, PKCE S256)
[keycloak] client 'acme-service' ... created (service account)
[keycloak] realm role 'manager' ... created./setup-keycloak.sh[keycloak] realm 'acme' ... exists, skipped
[keycloak] identity provider 'workspace' ... updated
[keycloak] client 'acme-app' ... exists, skipped
Operating Keycloak
- TLS — either terminated by AgentGateway (the identity provider runs behind it with edge proxy mode) or by Keycloak itself on a dedicated host, with certificates issued and renewed automatically.
- Storage — a dedicated PostgreSQL database; nothing else shares it.
- Discovery — every realm publishes
/.well-known/openid-configuration; no endpoint is hard-coded in applications.
"issuer": "https://auth.example.com/realms/acme",
"jwks_uri": "https://auth.example.com/realms/acme/protocol/openid-connect/certs",
"token_endpoint": "https://auth.example.com/realms/acme/protocol/openid-connect/token"
}curl -s -X POST https://auth.example.com/realms/acme/protocol/openid-connect/token \ -d grant_type=client_credentials -d client_id=acme-service -d client_secret="$SECRET" | jq 'keys'[
"access_token",
"expires_in",
"token_type"
]
Adding a new tenant
New realm → connect its IdP (or use local users with MFA) → create the application client → give the app the new authority. No application code changes.