lukaszraczylo 1e33bb0a4d feat(auth): support private_key_jwt and client_secret_basic (#137)
revocation endpoints, joining the existing client_secret_post default.
Both are opt-in via the new clientAuthMethod config field. Closes #135.

private_key_jwt (RFC 7523 §2.2 / OpenID Connect Core §9)
========================================================
Plugin signs a short-lived JWT with a configured private key and presents
it as client_assertion. Use when the IdP enforces short secret TTLs or
requires secretless client auth (Microsoft Entra ID / Azure AD, Okta,
Auth0, Keycloak).

New Config fields:
  clientAuthMethod          (default: client_secret_post)
  clientAssertionPrivateKey (inline PEM)
  clientAssertionKeyPath    (PEM file path; mutually exclusive)
  clientAssertionKeyID      (JWS kid header — required)
  clientAssertionAlg        (default: RS256; RS/PS/ES 256–512 supported)

PEM forms accepted: PKCS#8, PKCS#1, SEC1.
Assertion claims: iss=sub=clientID, aud=tokenURL, iat=now, exp=now+60s,
random 16-byte hex jti per request. ECDSA signatures are raw r||s per
RFC 7515 (not ASN.1).

client_secret_basic (RFC 6749 §2.3.1)
=====================================
Sends credentials in the Authorization: Basic header instead of the
body. Both halves are form-urlencoded individually before base64 — that
encoding step is required by the spec and is NOT what stdlib's
http.Request.SetBasicAuth does, so the plugin uses its own helper. The
form body omits client_id and client_secret on this path.

Wire-up
=======
Both methods are dispatched at the same two call sites:
  helpers.go:exchangeTokens — auth_code + refresh_token grants
  token_manager.go:RevokeTokenWithProvider — RFC 7009 revocation

Existing clientSecret deployments are unaffected — empty
clientAuthMethod maps to the historical client_secret_post behavior, and
clientAssertion remains nil unless the new fields are set.

Yaegi compatibility
===================
All required crypto/rsa, crypto/ecdsa, crypto/x509, encoding/pem and
crypto/sha256/384/512 symbols are exposed by the traefik/yaegi stdlib
symbol tables (RSA SignPKCS1v15 + SignPSS, ECDSA Sign,
ParsePKCS8/1PrivateKey, ParseECPrivateKey).

Tests (16 new)
==============
Algorithm-family coverage:
  TestIssue135_SignerRSAFamily — RS256/384/512 + PS256/384/512
  TestIssue135_SignerECDSAFamily — ES256/384/512, raw r||s shape
  TestIssue135_SignerRejectsAlgKeyMismatch
  TestIssue135_SignerJTIUniqueness — 50 sigs, all jti distinct
  TestIssue135_SignerPEMVariants — PKCS#8, PKCS#1, SEC1

Config validation:
  TestIssue135_ConfigValidation — full Validate() matrix
  TestIssue135_ConfigKeyPathLoadsFile

Wire-up:
  TestIssue135_AuthCodeExchangeUsesAssertion
  TestIssue135_RefreshTokenUsesAssertion
  TestIssue135_BackcompatClientSecretPath
  TestIssue135_RevocationUsesAssertion
  TestIssue135_BuildSignerFromInlineConfig
  TestIssue135_BuildSignerDefaultsToRS256
  TestIssue135_ClientSecretBasicAuth — Authorization header, no body creds
  TestIssue135_ClientSecretBasicURLEncodesReservedChars — :, +, /, @, =, &
  TestIssue135_ClientSecretBasicRevocation — revocation parity

Documentation
=============
  README.md — required-row note + 5 optional rows + dedicated section
  docs/CONFIGURATION.md — new Client Authentication section with three
    method subsections, OpenSSL keygen snippet, RFC links
  docs/index.html — 5 new config-table rows + Private Key JWT
    explainer card
  .traefik.yml + examples/complete-traefik-config.yaml — commented
    opt-in example

Out of scope (deferred)
=======================
mTLS / tls_client_auth (RFC 8705) — separate change; requires per-call
http.Client with tls.Config.Certificates and conflicts with the current
pooled HTTP client architecture.
2026-05-09 18:02:41 +01:00
2026-04-19 10:12:00 +01:00
2026-01-04 01:59:50 +00:00
2025-10-16 10:56:28 +01:00
2026-04-19 10:12:00 +01:00
2025-12-09 01:38:02 +00:00
2026-04-19 10:12:00 +01:00
2026-04-19 10:12:00 +01:00
2025-10-16 10:56:28 +01:00
2026-04-19 10:12:00 +01:00
2025-10-16 10:56:28 +01:00
2025-04-10 01:39:57 +01:00
2025-10-08 11:44:00 +01:00
2026-04-19 10:12:00 +01:00
2025-10-16 10:56:28 +01:00
2025-10-16 10:56:28 +01:00
2025-10-01 12:13:10 +01:00
2025-09-25 12:52:53 +01:00
2025-10-08 11:44:00 +01:00
2026-01-08 22:50:46 +00:00
2025-02-01 12:16:50 +00:00
2025-12-09 01:38:02 +00:00
2025-10-16 10:56:28 +01:00
2026-01-08 22:50:46 +00:00
2025-12-09 01:38:02 +00:00
2025-12-09 01:38:02 +00:00
2025-12-09 01:38:02 +00:00

Traefik OIDC Middleware

OpenID Connect authentication middleware for Traefik. Replaces forward-auth + oauth2-proxy. Auto-detects all major OIDC providers, validates ID tokens, manages sessions, and forwards user identity to downstream services.

Documentation

Provider support

Provider OIDC Refresh Auto-detected by
Google Full Yes accounts.google.com
Azure AD Full Yes login.microsoftonline.com, sts.windows.net
Auth0 Full Yes *.auth0.com
Okta Full Yes *.okta.com, *.oktapreview.com, *.okta-emea.com
Keycloak Full Yes host containing keycloak, or /realms/ in path (covers KC <17 /auth/realms/ and 17+ /realms/)
AWS Cognito Full Yes cognito-idp.*.amazonaws.com
GitLab Full Yes gitlab.com
GitHub OAuth 2.0 only — no ID token, no refresh No github.com
Generic Full Yes any RFC-compliant .well-known/openid-configuration

Authentication and claim extraction use the ID token. Ensure your provider includes required claims (email, roles, groups) in the ID token, not just the access token or UserInfo endpoint.

Install

Enable the plugin in Traefik's static configuration:

# traefik.yml
experimental:
  plugins:
    traefikoidc:
      moduleName: github.com/lukaszraczylo/traefikoidc
      version: v0.7.10

Then attach the middleware in your dynamic configuration (see Quickstart below).

This middleware tracks the current Traefik helm chart release. If it fails to load, update Traefik first.

Verify release signatures

Release checksums are signed with cosign keyless signing:

cosign verify-blob \
  --certificate-identity-regexp "https://github.com/lukaszraczylo/traefikoidc/.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  --bundle "traefikoidc_v<version>_checksums.txt.sigstore.json" \
  traefikoidc_v<version>_checksums.txt

Quickstart

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: oidc-auth
  namespace: traefik
spec:
  plugin:
    traefikoidc:
      providerURL: https://accounts.google.com
      clientID: 1234567890.apps.googleusercontent.com
      clientSecret: urn:k8s:secret:traefik-oidc:CLIENT_SECRET
      sessionEncryptionKey: urn:k8s:secret:traefik-oidc:SESSION_KEY
      callbackURL: /oauth2/callback
      logoutURL: /oauth2/logout
      postLogoutRedirectURI: /
      # forceHTTPS defaults to true (secure-by-default). Only set false if you
      # serve OIDC over plaintext HTTP for local dev.
      allowedUserDomains: [company.com]
      allowedRolesAndGroups: [admin, developer]
      excludedURLs: [/health, /metrics]

More example configs in examples/.

Required parameters

Parameter Description
providerURL Issuer URL (used for OIDC discovery).
clientID OAuth 2.0 client ID.
clientSecret OAuth 2.0 client secret. Supports urn:k8s:secret:ns:name:key. Required when clientAuthMethod is unset, client_secret_post, or client_secret_basic; optional with private_key_jwt.
sessionEncryptionKey Cookie encryption key, min 32 bytes.
callbackURL Callback path, e.g. /oauth2/callback.

Common optional parameters

Full reference in docs/CONFIGURATION.md.

Parameter Default Purpose
forceHTTPS true Forces https:// in redirect URIs. Leave at default behind any TLS-terminating LB (AWS ALB, GCP LB, Azure App Gateway). Set false only for plaintext HTTP local dev.
logoutURL callbackURL + "/logout" RP-initiated logout path.
postLogoutRedirectURI / Where to send users after logout.
scopes appended to openid profile email Extra OAuth scopes. Set overrideScopes: true to replace defaults.
excludedURLs none Prefix-matched paths that bypass auth.
allowedUserDomains none Restrict to email domains.
allowedUsers none Restrict to specific addresses (or claim values when userIdentifierClaim != email).
allowedRolesAndGroups none Require any of these roles/groups from ID-token claims.
roleClaimName / groupClaimName roles / groups For namespaced claims (Auth0).
userIdentifierClaim email Use sub, oid, upn, or preferred_username for users without email.
enablePKCE false PKCE on the auth code flow.
cookieDomain auto Set explicitly for multi-subdomain setups (.example.com).
cookiePrefix _oidc_raczylo_ Unique prefix per middleware instance to isolate sessions.
sessionMaxAge 86400 Session lifetime in seconds.
refreshGracePeriodSeconds 60 Proactively refresh tokens this many seconds before expiry.
maxRefreshTokenAgeSeconds 21600 Heuristic max stored refresh-token lifetime (6h). Past this, the plugin treats the RT as expired without contacting the IdP — returns 401 to AJAX, full re-auth on navigations. Set 0 to disable. Tune to match your IdP's RT TTL.
rateLimit 100 Requests/sec. Min 10.
logLevel info debug, info, error.
audience clientID Custom access-token audience (Auth0 custom APIs).
strictAudienceValidation false Reject mismatched audiences. Set true in production.
allowOpaqueTokens / requireTokenIntrospection false Accept opaque access tokens via RFC 7662.
disableReplayDetection false Disable JTI cache. Use Redis instead for multi-replica.
allowPrivateIPAddresses false Permit private-IP providerURL (internal Keycloak, etc.).
minimalHeaders false Reduce forwarded headers (mitigates HTTP 431).
stripAuthCookies false Strip OIDC cookies from backend hop (mitigates HTTP 431).
caCertPath / caCertPEM none Trust an internal CA for the provider's TLS.
insecureSkipVerify false Local dev only. Disables TLS verification, logs a security warning.
clientAuthMethod client_secret_post Client auth method. Set private_key_jwt for RFC 7523 JWT assertions (Entra ID, Okta, Auth0, Keycloak). See Client authentication via private key JWT.
clientAssertionPrivateKey none Inline PEM private key for private_key_jwt. Mutually exclusive with clientAssertionKeyPath.
clientAssertionKeyPath none File path to PEM private key for private_key_jwt.
clientAssertionKeyID none JWS kid header. Required when clientAuthMethod=private_key_jwt; must match the public key registered with the IdP.
clientAssertionAlg RS256 JWS alg for private_key_jwt. Supported: RS256/384/512, PS256/384/512, ES256/384/512.
enableBackchannelLogout / backchannelLogoutURL false / none OIDC Back-Channel Logout (server-to-server).
enableFrontchannelLogout / frontchannelLogoutURL false / none OIDC Front-Channel Logout (iframe).
redis disabled See docs/REDIS.md.
dynamicClientRegistration disabled See docs/DCR.md.

Production gotchas

TLS termination at a load balancer

forceHTTPS defaults to true, so redirect URIs always use https://. This is the right default behind AWS ALB, GCP LB, Azure App Gateway, or any LB that terminates TLS — X-Forwarded-Proto is unreliable (ALB may overwrite it).

Only set forceHTTPS: false when you actually serve OIDC over plaintext HTTP (local dev). See issue #82.

Multi-replica deployments

Each replica keeps its own in-memory JTI cache → false positive "token replay detected" when the same token hits different replicas. Two options:

  1. Set disableReplayDetection: true (loses replay protection).
  2. Enable Redis for shared state (recommended) — see docs/REDIS.md.

For IdP-initiated logout (back/front-channel) in multi-replica setups, Redis is required so a logout on one instance invalidates sessions on the others.

Multiple middleware instances on the same host

Each instance must use a unique cookiePrefix and sessionEncryptionKey, otherwise a session minted by one instance can grant access through another. See issue #87.

SSE and WebSocket endpoints

Browser clients cannot follow an OIDC 302 redirect on an SSE stream or a WebSocket upgrade. The middleware handles this automatically:

  • SSE (Accept: text/event-stream) and WebSocket (Upgrade: websocket) requests skip the OIDC redirect.
  • They are not unauthenticated — a valid encrypted session cookie is required, otherwise the request is rejected. The session must already exist (i.e. the user logged in via a normal HTTP page first).
  • X-Forwarded-User is forwarded from the session.
  • Validation is cookie-only (no JWK fetch), so streaming keeps working during brief IdP outages.

No configuration needed — this is implicit behavior.

HTTP 431 from backends

Either the ID token or the chunked OIDC cookies overflow your backend's header buffer. Combine these as needed:

minimalHeaders: true     # drop X-Auth-Request-Token et al.
stripAuthCookies: true   # strip _oidc_raczylo_* cookies on the backend hop

Cookies remain in the browser; only the Traefik→backend hop is affected. See #64, #122.

Internal CA for the provider

If the provider's TLS cert is signed by a private CA (self-hosted GitLab, internal Keycloak, ADFS):

caCertPath: /etc/ssl/certs/internal-ca.pem
# or, inline:
caCertPEM: |
  -----BEGIN CERTIFICATE-----
  ...
  -----END CERTIFICATE-----

Both can be combined. An unparseable bundle fails the plugin at startup. See #125.

Client authentication via private key JWT

Use when your IdP enforces short-lived secrets or pushes secretless client auth — Microsoft Entra ID / Azure AD, Okta, Auth0, Keycloak. Instead of sending a static clientSecret, the plugin signs a short-lived JWT and submits it as client_assertion per RFC 7523.

Minimal config:

clientAuthMethod: private_key_jwt
clientAssertionKeyPath: /etc/traefik/oidc/client-key.pem
clientAssertionKeyID: my-key-2026
# clientAssertionAlg: RS256   # default; or PS256/384/512, ES256/384/512

Or inline:

clientAuthMethod: private_key_jwt
clientAssertionPrivateKey: |
  -----BEGIN PRIVATE KEY-----
  ...
  -----END PRIVATE KEY-----
clientAssertionKeyID: my-key-2026

Accepted PEM forms: PKCS#8 (PRIVATE KEY), PKCS#1 (RSA PRIVATE KEY), SEC1 (EC PRIVATE KEY). The assertion uses iss=sub=clientID, aud=tokenURL, 60s lifetime, random hex jti per request. Sent on /token (auth-code + refresh) and /revoke. The kid must match the public key registered with the IdP.

clientSecret becomes optional with private_key_jwt. Existing client_secret_post setups are unaffected. Keys are parsed once at startup — rotation requires a Traefik reload.

See issue #135.

Environment variable names containing API

Traefik reserves TRAEFIK_API_*. User vars whose name contains API (e.g. OIDC_ENCRYPTION_SECRET_API) make the plugin fail with invalid handler type: <nil>. Rename to anything without the literal API substring. See #98.

Templated headers

Forward identity to backends via Go templates over ID-token claims and tokens:

headers:
  - name: X-User-Email
    value: "{{{{.Claims.email}}}}"
  - name: Authorization
    value: "Bearer {{{{.AccessToken}}}}"
  - name: X-User-Roles
    value: "{{{{range $i, $e := .Claims.roles}}}}{{{{if $i}}}},{{{{end}}}}{{{{$e}}}}{{{{end}}}}"

Available bindings: .Claims.<field>, .AccessToken, .IdToken, .RefreshToken. Names are case-sensitive (.Claims, not .claims).

Escape with quadruple braces. If you see can't evaluate field AccessToken in type bool, Traefik's YAML parser ate your {{ }}. The fix that actually works is {{{{ }}}} — the YAML pass turns it into {{ }} for the Go template engine. Other escaping tricks (literal blocks, single quotes) do not work reliably.

Default downstream headers

When a request is authenticated, the middleware sets:

Header Notes
X-Forwarded-User User's email (always).
X-User-Groups Comma-separated.
X-User-Roles Comma-separated.
X-Auth-Request-User User's email.
X-Auth-Request-Redirect Original request URI.
X-Auth-Request-Token Full ID token — the largest header; suppressed by minimalHeaders.

Plus security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy) controlled by the securityHeaders section — see docs/CONFIGURATION.md.

Common errors

Symptom Cause
Token verification failed Wrong/unreachable providerURL, or clock skew.
Session encryption key too short sessionEncryptionKey is < 32 bytes.
No matching public key found JWKS endpoint down, or kid mismatch.
Access denied: Your email domain is not allowed User's domain not in allowedUserDomains.
Access denied: You do not have any of the allowed roles or groups Claims missing or not in allowedRolesAndGroups.
can't evaluate field AccessToken in type bool Template not escaped — use {{{{ }}}}.
tls: failed to verify certificate: x509: certificate signed by unknown authority Internal CA — set caCertPath / caCertPEM.
invalid handler type: <nil> Env var name contains API — rename it.
false positive replay detected Multi-replica without Redis — see Multi-replica deployments.
Google sessions expire after ~1h Consent screen still in "Testing" mode. Do not add offline_access — Google rejects it; the middleware sets access_type=offline automatically.

Provider-specific issues (Keycloak mappers, Azure AD group overage, Auth0 namespaced claims, Cognito regions, GitLab self-hosted) live in docs/PROVIDERS.md.

Set logLevel: debug to surface detail.

License

See LICENSE.

S
Description
Mirror of github.com/lukaszraczylo/traefikoidc
Readme MIT 5.1 MiB
Languages
Go 99.8%