Skip to content

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.roles and groups in the groups claim; wittgenstein-oidc-backend exposes them through get_roles and get_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

  1. Claims — protocol mappers add the roles, groups and attributes your services need to the token.
  2. JIT provisioning — on first login, jit_provision_user creates the local user, links the Keycloak sub, and syncs roles on every subsequent login.
  3. Gateway headers — behind AgentGateway, the extProc sidecar validates the JWT against the JWKS and injects x-user-id and x-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).

./setup-keycloak.sh[keycloak] realm 'acme' ... created
[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.
curl -s https://auth.example.com/realms/acme/.well-known/openid-configuration | jq '{issuer, jwks_uri, token_endpoint}'{
"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.