Keycloak authorizer middleware
A Traefik plugin that extracts roles and/or permissions from a request's
bearer JWT (optionally cross-checking permissions against Keycloak) and
forwards them as request headers, rejecting requests that don't satisfy the
configured gating rules.
Security model
This plugin performs authorization only — it does not authenticate the
request. It assumes the bearer JWT has already been verified (signature and
expiry checked) by an earlier middleware in the Traefik chain, and it trusts
the claims in that token as given. This plugin itself never verifies a JWT
signature or expiry, regardless of which config blocks (roles,
permissions) are enabled — including when permissions.enabled is true,
where the call to Keycloak's token endpoint is for exchanging/scoping
permissions, not for validating the original token. Deploy this plugin only
behind (i.e. after) an authentication middleware that verifies the token;
otherwise any request with an unverified, self-signed, or expired bearer
token will be authorized based on its claims alone.
Both roles and permissions can gate requests, symmetrically: if enabled is
true, the request is rejected when nothing is found at all, or when some/
every don't match — unless enforce is explicitly set to false, in which
case that block only extracts and forwards the header, without rejecting the
request (see roles.enforce/permissions.enforce below). The request is
also rejected when the bearer token is missing/malformed, or when the
Keycloak permissions request fails, regardless of enforce.
Installation
For a plugin to be active, it must be declared in Traefik's static
configuration, referencing a released git tag of this repository:
# Static configuration
experimental:
plugins:
jwtAuthorization:
moduleName: github.com/sasd13/traefik-keycloak-authorizer
version: v1.1.0
Once loaded, the plugin is configured per-middleware in the dynamic
configuration, under the alias declared above (jwtAuthorization here):
# Dynamic configuration
http:
middlewares:
my-authorizer:
plugin:
jwtAuthorization:
issuer: https://auth.example.com/realms/myrealm
roles:
enabled: true
permissions:
enabled: true
audience: my-client
Configuration
The plugin.jwtAuthorization block above accepts the following fields.
Here it is shown in full, as a Kubernetes Middleware CRD (the same fields
apply under http.middlewares.<name>.plugin.jwtAuthorization in any other
dynamic configuration provider):
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: jwt-authorization
spec:
plugin:
jwtAuthorization:
issuer: https://auth.example.com/realms/myrealm
roles:
enabled: true
enforce: true # default: true
headerName: X-User-Rol # default: X-User-Rol
some:
- my-client:admin
every:
- my-client:verified
permissions:
enabled: true
enforce: true # default: true
headerName: X-User-Prm # default: X-User-Prm
audience: my-client
some:
- orders:read
every:
- billing:write
headerMap:
X-User-Email: email
issuer — the Keycloak realm issuer URL. Required (plugin construction
fails otherwise) when permissions.enabled is true, since it's used to
build the Keycloak token-exchange request. Not used, and not required,
when only roles.enabled is true — roles-only configs never call
Keycloak.
- If neither
roles.enabled nor permissions.enabled is true, the plugin
still requires and parses a bearer JWT but performs no gating and forwards
every request that carries a parseable token — only headerMap still
applies. Set at least one of them to actually enforce authorization.
roles.enabled — when true, extracts roles from the request token's
resource_access claim directly (no network call to Keycloak). Roles from
realm_access are not included. All clients present in resource_access
are included — there's no audience filter for roles. Rejects the request
(403) if no roles are found at all, unless roles.enforce is false
(see below).
roles.enforce — default true. When false, roles are still extracted
and forwarded via roles.headerName, but never gate the request: an empty
role list and unmatched some/every are both allowed through. Use this
when a downstream service already performs its own per-route authorization
from the forwarded header/claims (e.g. app-level guards keyed on role or
permission, fed by this plugin's output) and this plugin's job is reduced
to authentication-adjacent extraction rather than gating — for example a
single shared middleware fronting routes that require either a role or a
permission, never unconditionally both, where a blanket non-empty check on
either block would incorrectly reject legitimate callers that only carry
one of the two (e.g. a machine-to-machine client authorized purely via
permissions, with no roles at all).
roles.headerName — header the roles are written to. Format:
client:role,client:role2,client2:role3 — clients and roles sorted
alphabetically. Role and client names must not contain , or :.
roles.some — if non-empty, and roles.enforce is true, at least one
of these client:role pairs must be present, or the request is rejected
(403). Ignored when roles.enforce is false.
roles.every — if non-empty, and roles.enforce is true, all of these
client:role pairs must be present, or the request is rejected (403).
Ignored when roles.enforce is false.
permissions.enabled — when true, exchanges the request token with
Keycloak's token endpoint (urn:ietf:params:oauth:grant-type:uma-ticket)
for a permissions-bearing token, scoped to permissions.audience. Rejects
the request (403) if the resulting permissions list is empty, unless
permissions.enforce is false (see below).
permissions.enforce — default true. Same semantics as roles.enforce,
applied to the permissions block: when false, permissions are still
fetched from Keycloak and forwarded via permissions.headerName, but an
empty permissions list and unmatched some/every no longer reject the
request.
permissions.headerName — header the permissions are written to. Format:
resource:scope,resource2:scope2.
permissions.audience — the Keycloak client id (resource server) to
request permissions for. Required when permissions.enabled is true.
permissions.some — if non-empty, and permissions.enforce is true,
at least one of these resource:scope pairs must be present, or the
request is rejected (403). Ignored when permissions.enforce is false.
permissions.every — if non-empty, and permissions.enforce is true,
all of these resource:scope pairs must be present, or the request is
rejected (403). Ignored when permissions.enforce is false.
headerMap — maps arbitrary token claims to request headers
(<header-name>: <claim-name>). Applied against the original request
token, or against the Keycloak-issued token when permissions.enabled is
true.