mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-14 04:07:46 +00:00
* fix(settings): allow documented range + static header templates (#149)
Upgrading past the release where NewWithContext began calling
config.Validate() (546ceb9) broke `headers` configs that worked in 0.8.25:
New() returned a nil handler and Traefik logged the opaque "invalid handler
type: <nil>". Two header-validation checks were stricter than the documented,
legitimate usage:
- validateTemplateSecure rejected the documented multi-valued-claim join
"{{range $i,$e := .Claims.groups}}...{{end}}" as a dangerous {{range}},
even though README/CONFIGURATION show exactly that for groups/roles.
- Config.Validate required every header value to contain {{ }}, rejecting
static/literal values such as a shared proxy secret.
Fixes:
- Allow static/literal header values (reject only CR/LF/NUL header-injection
chars); flag unbalanced {{ }} explicitly.
- Allow {{range}} only when its clause targets .Claims.<field>; a range over
the bare .Claims map stays blocked and a decoy {{.Claims.x}} elsewhere no
longer launders it. Generalize the claims whitelist scan to .Claims. so a
claim reached via range/with/default is whitelisted identically to a direct
{{.Claims.x}} reference.
- normalizeTemplateDelimiters now collapses ALL whitespace inside {{ }}
(newline/CR/FF too), closing a newline-obfuscation bypass ({{\nrange,
{{\nindex) found in review.
- Log the concrete validation reason at construction so operators see WHY the
middleware failed to build instead of only "invalid handler type: <nil>".
Adds issue149_regression_test.go: pins the exact issue config, the documented
groups template rendering "admin,users" past the runtime sanitizer, static and
delimiter edge cases, and the security boundary (whole-Claims-map dump via
range, non-whitelisted claims, call/define/template/printf, and
newline-obfuscated actions all rejected).
* fix(settings): AST-based header template validation, close whitelist bypasses
Follow-up hardening to the #149 work. An adversarial review showed the
substring-based validateTemplateSecure could not actually bound what a header
template reads: get/index with arbitrary keys, {{with .Claims}} field access,
and a whitelisted-decoy + bare {{.}}/{{$}}/{{.Claims}} render all leaked the
whole root (every raw token) or the whole Claims map past validation. The core
problem is structural — the meaning of "." depends on scope, which substrings
cannot see.
Replace the substring validator with an AST walk (text/template/parse):
- Parse with only get/default known; every other function (printf, call, index,
js, urlquery, ...) then fails to parse and is rejected — no blocklist.
- Reject {{define}}/{{block}} (they produce extra trees).
- Walk the tree tracking whether "." is the root. Permit ONLY: literal text;
the four root token fields (AccessToken, IdToken, IDToken, RefreshToken);
whitelisted .Claims.<field>; range/with over a whitelisted .Claims.<field>
("." inside the body is the already-whitelisted element); get on .Claims with
a whitelisted literal key; default over safe values; {{if}} control flow and
$-scoped range/with/decl variables. Everything else — bare {{.}} / {{$}}
(whole-root dump), bare {{.Claims}} (whole-map dump), non-whitelisted claims,
$.Claims root reach — is rejected.
Also closes the get/default exfil and the with-over-bare-map vectors, and fixes
ID-token forwarding: templateData now carries both IDToken and IdToken and the
validator accepts {{.IdToken}} (documented) and {{.IDToken}}; previously the
documented spelling rendered empty and the working one was rejected.
Runtime `get` additionally enforces the claims whitelist on its key, and
safeClaimsFields is promoted to a package-level single source of truth shared by
the validator and the get helper. The header template funcMap is extracted to
headerTemplateFuncMap() so tests exercise the exact production map. Removes the
now-dead normalizeTemplateDelimiters/regex (the parser handles whitespace).
Extends issue149_regression_test.go: renders through the real funcMap to prove
get cannot leak; asserts the whole-root/whole-map/decoy/var-launder/index
vectors are rejected while every documented pattern (groups/roles join,
with .Claims.email, get .Claims "internal_role", default, both IdToken
spellings) still validates and renders.
* feat(settings): allowedClaims to extend the header-template claim whitelist
The AST validator and the runtime `get` only permit a fixed set of claim fields
in header value templates. Providers issue custom claims (e.g. employee_id) that
aren't built in, and there was no supported way to forward them — the previous
`get` escape hatch was closed by the security hardening.
Add `allowedClaims []string` to Config. Listed names extend the effective
whitelist for BOTH static validation and the runtime `get` (a single
claimsWhitelist() = built-in safeClaimsFields + AllowedClaims, so the two never
drift). Built-in claims need not be listed; unlisted claims stay rejected.
allowedClaims:
- employee_id
headers:
- name: X-Employee-Id
value: "{{`{{.Claims.employee_id}}`}}"
Implementation: the whitelist is threaded through the validator (now a small
templateValidator struct carrying the allowed set) and headerTemplateFuncMap
instead of the package global, so it is per-config with no shared mutable state.
Docs: README and docs/CONFIGURATION document the whitelist, the validation
behaviour, and allowedClaims (grounded in safeClaimsFields).
Tests: TestIssue149_AllowedClaimsExtendsWhitelist pins that a custom claim is
rejected by default and validates + renders once listed, via both direct access
and get, while an unlisted claim stays rejected. A regression assertion that
coupled to the old validator's "dangerous" wording now checks the stable
"failed security validation" wrapper (the templates are still rejected).
700 lines
23 KiB
Markdown
700 lines
23 KiB
Markdown
# Configuration Reference
|
|
|
|
Complete reference for all Traefik OIDC middleware configuration options.
|
|
|
|
## Table of Contents
|
|
|
|
- [Required Parameters](#required-parameters)
|
|
- [Client Authentication](#client-authentication)
|
|
- [Optional Parameters](#optional-parameters)
|
|
- [Security Options](#security-options)
|
|
- [Session Management](#session-management)
|
|
- [Access Control](#access-control)
|
|
- [Headers Configuration](#headers-configuration)
|
|
- [Security Headers](#security-headers)
|
|
- [Scope Configuration](#scope-configuration)
|
|
- [Advanced Options](#advanced-options)
|
|
|
|
---
|
|
|
|
## Required Parameters
|
|
|
|
| Parameter | Type | Description | Example |
|
|
|-----------|------|-------------|---------|
|
|
| `providerURL` | string | Base URL of the OIDC provider | `https://accounts.google.com` |
|
|
| `clientID` | string | OAuth 2.0 client identifier | `1234567890.apps.googleusercontent.com` |
|
|
| `clientSecret` | string | OAuth 2.0 client secret. Required when `clientAuthMethod` is unset, `client_secret_post`, or `client_secret_basic`. Optional when `clientAuthMethod: private_key_jwt`. | `your-client-secret` |
|
|
| `sessionEncryptionKey` | string | Key for encrypting session data (min 32 bytes) | `your-32-byte-encryption-key-here` |
|
|
| `callbackURL` | string | Path where provider redirects after authentication | `/oauth2/callback` |
|
|
|
|
### Basic Configuration Example
|
|
|
|
```yaml
|
|
apiVersion: traefik.io/v1alpha1
|
|
kind: Middleware
|
|
metadata:
|
|
name: oidc-auth
|
|
spec:
|
|
plugin:
|
|
traefikoidc:
|
|
providerURL: https://accounts.google.com
|
|
clientID: your-client-id.apps.googleusercontent.com
|
|
clientSecret: your-client-secret
|
|
sessionEncryptionKey: your-32-byte-encryption-key-here
|
|
callbackURL: /oauth2/callback
|
|
```
|
|
|
|
---
|
|
|
|
## Client Authentication
|
|
|
|
The middleware supports three client authentication methods at the token and
|
|
revocation endpoints. The default is `client_secret_post` (current behavior);
|
|
`private_key_jwt` is opt-in and backwards compatible.
|
|
|
|
| Method | Default | Description |
|
|
|--------|---------|-------------|
|
|
| `client_secret_post` | yes | `client_id` + `client_secret` in the request body. |
|
|
| `client_secret_basic` | no | RFC 6749 §2.3.1 — `client_id` + `client_secret` in the `Authorization: Basic` header (form-urlencoded then base64); not in the body. |
|
|
| `private_key_jwt` | no | RFC 7523 §2.2 — plugin signs a short-lived JWT with a private key and sends it as `client_assertion`. |
|
|
|
|
Select via `clientAuthMethod`:
|
|
|
|
```yaml
|
|
clientAuthMethod: private_key_jwt
|
|
```
|
|
|
|
### client_secret_post
|
|
|
|
Default. The plugin sends `client_id` and `client_secret` as form parameters
|
|
in the token / revocation request body. No additional configuration required.
|
|
|
|
### private_key_jwt
|
|
|
|
Asymmetric client authentication per
|
|
[RFC 7523 §2.2](https://www.rfc-editor.org/rfc/rfc7523). Use this when your
|
|
IdP enforces short secret TTLs, when policy mandates secretless clients, or
|
|
when you want to avoid distributing a shared secret to the proxy.
|
|
|
|
For each token / revocation request the plugin builds a JWS with:
|
|
|
|
- `iss` = `sub` = `clientID`
|
|
- `aud` = token endpoint URL
|
|
- `iat` = now, `exp` = now + 60s
|
|
- `jti` = random hex per request
|
|
- `kid` header = `clientAssertionKeyID`
|
|
|
|
**Required fields:**
|
|
|
|
| Parameter | Type | Default | Description |
|
|
|-----------|------|---------|-------------|
|
|
| `clientAuthMethod` | string | `client_secret_post` | Set to `private_key_jwt`. |
|
|
| `clientAssertionPrivateKey` | string | none | Inline PEM private key. Mutually exclusive with `clientAssertionKeyPath`. PKCS#8, PKCS#1, and SEC1 formats accepted. |
|
|
| `clientAssertionKeyPath` | string | none | Path to PEM private key on disk. Mutually exclusive with `clientAssertionPrivateKey`. |
|
|
| `clientAssertionKeyID` | string | none | `kid` header inserted in the JWS. Must match the public key registered with the IdP. |
|
|
| `clientAssertionAlg` | string | `RS256` | One of `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384`, `ES512`. |
|
|
|
|
When `clientAuthMethod: private_key_jwt`, `clientSecret` is optional.
|
|
|
|
**Example — inline PEM:**
|
|
|
|
```yaml
|
|
apiVersion: traefik.io/v1alpha1
|
|
kind: Middleware
|
|
metadata:
|
|
name: oidc-auth
|
|
spec:
|
|
plugin:
|
|
traefikoidc:
|
|
providerURL: https://idp.example.com
|
|
clientID: my-client-id
|
|
sessionEncryptionKey: your-32-byte-encryption-key-here
|
|
callbackURL: /oauth2/callback
|
|
clientAuthMethod: private_key_jwt
|
|
clientAssertionKeyID: key-2026-01
|
|
clientAssertionAlg: RS256
|
|
clientAssertionPrivateKey: |
|
|
-----BEGIN PRIVATE KEY-----
|
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj
|
|
MZj4ev7QnMa1mYV3Kx1jRkH5YwXQ7N2J2j8K5pP6h0oZmXq1yQv4r8wZb3sH9D2k
|
|
... (truncated) ...
|
|
-----END PRIVATE KEY-----
|
|
```
|
|
|
|
**Example — key on disk:**
|
|
|
|
```yaml
|
|
clientAuthMethod: private_key_jwt
|
|
clientAssertionKeyPath: /etc/traefik/oidc/client-key.pem
|
|
clientAssertionKeyID: key-2026-01
|
|
clientAssertionAlg: RS256
|
|
```
|
|
|
|
**Generating an RS256 key with OpenSSL:**
|
|
|
|
```bash
|
|
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
|
|
-out client-key.pem
|
|
openssl rsa -in client-key.pem -pubout -out client-pub.pem
|
|
```
|
|
|
|
Register `client-pub.pem` (or its JWK form) with your IdP under the same
|
|
`kid` you set in `clientAssertionKeyID`.
|
|
|
|
**Notes:**
|
|
|
|
- The private key is parsed once at plugin startup. Key rotation requires a
|
|
Traefik reload.
|
|
- Assertion lifetime is fixed at 60 seconds.
|
|
- A fresh random `jti` is generated per request.
|
|
- The `aud` claim is the token endpoint URL (from discovery).
|
|
- Tracking issue:
|
|
[#135](https://github.com/lukaszraczylo/traefikoidc/issues/135).
|
|
|
|
### client_secret_basic
|
|
|
|
Per [RFC 6749 §2.3.1][rfc6749-2-3-1], the plugin sends the client credentials
|
|
in an `Authorization: Basic` header instead of the body. Both halves
|
|
(`client_id`, `client_secret`) are form-urlencoded individually, joined with
|
|
a colon, then base64-encoded. Use this when your IdP requires Basic auth at
|
|
the token endpoint and rejects credentials in the body.
|
|
|
|
```yaml
|
|
clientAuthMethod: client_secret_basic
|
|
clientID: your-client-id
|
|
clientSecret: your-client-secret
|
|
```
|
|
|
|
[rfc6749-2-3-1]: https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1
|
|
|
|
---
|
|
|
|
## Optional Parameters
|
|
|
|
| Parameter | Type | Default | Description |
|
|
|-----------|------|---------|-------------|
|
|
| `logoutURL` | string | `callbackURL + "/logout"` | Path for logout requests |
|
|
| `postLogoutRedirectURI` | string | `/` | Redirect URL after logout |
|
|
| `logLevel` | string | `info` | Logging verbosity (`debug`, `info`, `error`) |
|
|
| `forceHTTPS` | bool | `true` | Force HTTPS for redirect URIs (set `false` only for plaintext HTTP local dev) |
|
|
| `rateLimit` | int | `100` | Maximum requests per second |
|
|
| `excludedURLs` | []string | none | Paths that bypass authentication, matched at a path-segment or file-extension boundary |
|
|
| `revocationURL` | string | auto-discovered | Token revocation endpoint |
|
|
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint |
|
|
| `enablePKCE` | bool | `false` | Enable PKCE for authorization code flow |
|
|
| `minimalHeaders` | bool | `false` | Reduce forwarded headers |
|
|
| `clientAuthMethod` | string | `client_secret_post` | Client authentication method at token/revocation endpoints. One of `client_secret_post`, `client_secret_basic`, `private_key_jwt`. See [Client Authentication](#client-authentication). |
|
|
| `clientAssertionPrivateKey` | string | none | Inline PEM private key for `private_key_jwt`. Mutually exclusive with `clientAssertionKeyPath`. PKCS#8 / PKCS#1 / SEC1. |
|
|
| `clientAssertionKeyPath` | string | none | Path to PEM private key on disk for `private_key_jwt`. Mutually exclusive with `clientAssertionPrivateKey`. |
|
|
| `clientAssertionKeyID` | string | none | `kid` header for `private_key_jwt` assertions. Required when `clientAuthMethod: private_key_jwt`. |
|
|
| `clientAssertionAlg` | string | `RS256` | Signing algorithm for `private_key_jwt`. One of `RS256/384/512`, `PS256/384/512`, `ES256/384/512`. |
|
|
|
|
### TLS Termination at Load Balancer
|
|
|
|
`forceHTTPS` defaults to `true`, so redirect URIs always use `https://`. This is
|
|
the correct default behind any TLS-terminating load balancer (AWS ALB, Google
|
|
Cloud LB, Azure App Gateway) — `X-Forwarded-Proto` cannot be trusted (ALB may
|
|
overwrite it).
|
|
|
|
Set `forceHTTPS: false` only when you serve OIDC over plaintext HTTP (local
|
|
dev). Otherwise leave it at default.
|
|
|
|
### Streaming Endpoints (SSE and WebSocket)
|
|
|
|
The middleware automatically bypasses the OIDC redirect for two request kinds
|
|
that browsers cannot follow a 302 on:
|
|
|
|
| Bypass | Triggered by |
|
|
|--------|--------------|
|
|
| Server-Sent Events (SSE) | `Accept: text/event-stream` |
|
|
| WebSocket upgrade | `Upgrade: websocket` + `Connection: upgrade` (RFC 6455) |
|
|
|
|
These requests do **not** require any explicit configuration — they are
|
|
handled implicitly. However, the bypass is **not** unauthenticated:
|
|
|
|
- A valid, encrypted session cookie is required. Requests without one are
|
|
rejected (the connection cannot proceed to the backend).
|
|
- The session cookie is sealed with `sessionEncryptionKey`, so the
|
|
`authenticated` flag cannot be forged.
|
|
- Validation is cookie-only — no JWK fetch / signature verification — so
|
|
streaming endpoints keep working when the OIDC provider is briefly
|
|
unavailable.
|
|
- The user identifier from the session is forwarded as `X-Forwarded-User`
|
|
(and `X-Auth-Request-User` unless `minimalHeaders: true`).
|
|
|
|
For browser clients, the user must complete the normal OIDC flow on a
|
|
regular HTTP page first; the resulting session cookie is then reused on the
|
|
SSE / WebSocket connection.
|
|
|
|
---
|
|
|
|
## Security Options
|
|
|
|
### Audience Validation
|
|
|
|
| Parameter | Type | Default | Description |
|
|
|-----------|------|---------|-------------|
|
|
| `audience` | string | `clientID` | Expected audience for access token validation |
|
|
| `strictAudienceValidation` | bool | `false` | Reject sessions with audience mismatch |
|
|
| `allowOpaqueTokens` | bool | `false` | Enable opaque token support via RFC 7662 |
|
|
| `requireTokenIntrospection` | bool | `false` | Require introspection for opaque tokens |
|
|
|
|
#### Production Security Configuration
|
|
|
|
```yaml
|
|
audience: "https://my-api.example.com"
|
|
strictAudienceValidation: true
|
|
```
|
|
|
|
#### Opaque Token Support
|
|
|
|
```yaml
|
|
allowOpaqueTokens: true
|
|
requireTokenIntrospection: true
|
|
strictAudienceValidation: true
|
|
```
|
|
|
|
### Other Security Options
|
|
|
|
| Parameter | Type | Default | Description |
|
|
|-----------|------|---------|-------------|
|
|
| `disableReplayDetection` | bool | `false` | Disable JTI-based replay attack detection |
|
|
| `allowPrivateIPAddresses` | bool | `false` | Allow private IPs in provider URLs |
|
|
|
|
### Bearer-token (M2M) authentication
|
|
|
|
Opt-in path that accepts `Authorization: Bearer <jwt>` instead of the cookie
|
|
session flow. M2M-only, default off, audience-mandatory. See
|
|
[docs/BEARER_AUTH.md](BEARER_AUTH.md) for the threat model and operational
|
|
guidance.
|
|
|
|
| Parameter | Type | Default | Description |
|
|
|-----------|------|---------|-------------|
|
|
| `enableBearerAuth` | bool | `false` | Master switch. Startup fails if true with empty `audience` or with `bearerIdentifierClaim=email`. |
|
|
| `bearerIdentifierClaim` | string | `"sub"` | JWT claim used as the principal identifier. `"email"` is rejected at startup. |
|
|
| `stripAuthorizationHeader` | bool | `true` | Strip `Authorization` from forwarded requests after successful bearer auth. |
|
|
| `bearerEmitWWWAuthenticate` | bool | `true` | Emit RFC 6750 `WWW-Authenticate: Bearer error="..."` hints on 401. |
|
|
| `bearerOverridesCookie` | bool | `false` | Cookie wins when both bearer and cookie are present (default). Set true for bearer-wins. |
|
|
| `maxTokenAgeSeconds` | int64 | `86400` | Upper bound on `iat` claim age (24h). 0 disables the check. |
|
|
| `maxIdentifierLength` | int | `256` | Length cap on the sanitised principal identifier. |
|
|
| `bearerFailureThreshold` | int | `20` | Consecutive 401s from one source IP that trip the throttle. |
|
|
| `bearerFailureWindowSeconds` | int | `60` | Rolling window for counting 401s. |
|
|
| `bearerFailurePenaltySeconds` | int | `60` | 429 + `Retry-After` duration after the threshold trips. |
|
|
|
|
---
|
|
|
|
## Session Management
|
|
|
|
| Parameter | Type | Default | Description |
|
|
|-----------|------|---------|-------------|
|
|
| `sessionMaxAge` | int | `86400` (24h) | Maximum session age in seconds |
|
|
| `refreshGracePeriodSeconds` | int | `60` | Seconds before expiry to attempt refresh |
|
|
| `maxRefreshTokenAgeSeconds` | int | `21600` | Heuristic max age (in seconds) of a stored refresh token. Once exceeded, requests treat the RT as expired up front (returns 401 to AJAX, triggers full re-auth on navigations) instead of grant-spamming the IdP with `invalid_grant` retries. IdPs do not advertise RT TTL on the wire, so this is intentionally a conservative heuristic — tune to match your provider. Set `0` to disable. Default `21600` (6h). |
|
|
| `cookieDomain` | string | auto-detected | Domain for session cookies |
|
|
| `cookiePrefix` | string | `_oidc_raczylo_` | Prefix for cookie names |
|
|
|
|
### Multi-Subdomain Setup
|
|
|
|
```yaml
|
|
cookieDomain: .example.com # Share cookies across subdomains
|
|
```
|
|
|
|
### Multiple Middleware Instances
|
|
|
|
When running multiple middleware instances with different authorization requirements, use unique prefixes:
|
|
|
|
```yaml
|
|
# User authentication middleware
|
|
---
|
|
apiVersion: traefik.io/v1alpha1
|
|
kind: Middleware
|
|
metadata:
|
|
name: oidc-userauth
|
|
spec:
|
|
plugin:
|
|
traefikoidc:
|
|
cookiePrefix: "_oidc_userauth_"
|
|
sessionEncryptionKey: user-encryption-key-min-32-bytes
|
|
# ... other config
|
|
---
|
|
# Admin authentication middleware
|
|
apiVersion: traefik.io/v1alpha1
|
|
kind: Middleware
|
|
metadata:
|
|
name: oidc-adminauth
|
|
spec:
|
|
plugin:
|
|
traefikoidc:
|
|
cookiePrefix: "_oidc_adminauth_"
|
|
sessionEncryptionKey: admin-encryption-key-min-32-bytes
|
|
allowedUsers:
|
|
- [email protected]
|
|
# ... other config
|
|
```
|
|
|
|
### Extended Session Duration
|
|
|
|
```yaml
|
|
sessionMaxAge: 604800 # 7 days
|
|
# Common values:
|
|
# 3600 - 1 hour (high security)
|
|
# 86400 - 1 day (default)
|
|
# 259200 - 3 days
|
|
# 604800 - 7 days
|
|
# 2592000 - 30 days
|
|
```
|
|
|
|
---
|
|
|
|
## Access Control
|
|
|
|
### User Restrictions
|
|
|
|
| Parameter | Type | Description |
|
|
|-----------|------|-------------|
|
|
| `allowedUserDomains` | []string | Restrict to specific email domains |
|
|
| `allowedUsers` | []string | Specific email addresses allowed |
|
|
| `allowedRolesAndGroups` | []string | Required roles or groups |
|
|
| `roleClaimName` | string | JWT claim for roles (default: `roles`) |
|
|
| `groupClaimName` | string | JWT claim for groups (default: `groups`) |
|
|
| `userIdentifierClaim` | string | Claim for user ID (default: `email`) |
|
|
|
|
### Domain Restriction
|
|
|
|
```yaml
|
|
allowedUserDomains:
|
|
- company.com
|
|
- subsidiary.com
|
|
```
|
|
|
|
### Specific User Access
|
|
|
|
```yaml
|
|
allowedUsers:
|
|
- [email protected]
|
|
- [email protected]
|
|
```
|
|
|
|
### Role-Based Access Control
|
|
|
|
```yaml
|
|
allowedRolesAndGroups:
|
|
- admin
|
|
- developer
|
|
roleClaimName: "https://myapp.com/roles" # For namespaced claims (Auth0)
|
|
```
|
|
|
|
### Access Control Logic
|
|
|
|
- If only `allowedUsers` is set: Only specified emails can access
|
|
- If only `allowedUserDomains` is set: Only specified domains can access
|
|
- If both are set: Access granted if email is in `allowedUsers` OR domain is in `allowedUserDomains`
|
|
- If neither is set: Any authenticated user can access
|
|
|
|
### Users Without Email (Azure AD)
|
|
|
|
For Azure AD service accounts or users without email:
|
|
|
|
```yaml
|
|
userIdentifierClaim: sub # Options: sub, oid, upn, preferred_username
|
|
allowedUsers:
|
|
- "abc12345-6789-0abc-def0-123456789abc" # User object ID
|
|
```
|
|
|
|
---
|
|
|
|
## Headers Configuration
|
|
|
|
### Default Headers
|
|
|
|
The middleware sets these headers for downstream services:
|
|
|
|
| Header | Description |
|
|
|--------|-------------|
|
|
| `X-Forwarded-User` | User's email address |
|
|
| `X-User-Groups` | Comma-separated user groups |
|
|
| `X-User-Roles` | Comma-separated user roles |
|
|
| `X-Auth-Request-Redirect` | Original request URI |
|
|
| `X-Auth-Request-User` | User's email address |
|
|
| `X-Auth-Request-Token` | User's ID token |
|
|
|
|
### Minimal Headers Mode
|
|
|
|
For "431 Request Header Fields Too Large" errors:
|
|
|
|
```yaml
|
|
minimalHeaders: true # Only forwards X-Forwarded-User
|
|
```
|
|
|
|
### Custom Templated Headers
|
|
|
|
```yaml
|
|
headers:
|
|
- name: "X-User-Email"
|
|
value: "{{{{.Claims.email}}}}"
|
|
- name: "X-User-ID"
|
|
value: "{{{{.Claims.sub}}}}"
|
|
- name: "Authorization"
|
|
value: "Bearer {{{{.AccessToken}}}}"
|
|
- name: "X-User-Roles"
|
|
value: "{{{{range $i, $e := .Claims.roles}}}}{{{{if $i}}}},{{{{end}}}}{{{{$e}}}}{{{{end}}}}"
|
|
```
|
|
|
|
**Template Variables:**
|
|
- `{{.Claims.field}}` - ID token claims (only whitelisted fields; see below)
|
|
- `{{.AccessToken}}` - Raw access token
|
|
- `{{.IdToken}}` / `{{.IDToken}}` - Raw ID token (both spellings work)
|
|
- `{{.RefreshToken}}` - Raw refresh token
|
|
|
|
**Important:** Use double curly braces (`{{{{` and `}}}}`) to escape templates in YAML.
|
|
|
|
**Validation and the claims whitelist.** Header templates are parsed and
|
|
validated when the middleware loads; an invalid or disallowed template stops the
|
|
middleware from starting (the route then returns Traefik's
|
|
`invalid handler type: <nil>`). To prevent a template from accidentally
|
|
forwarding raw tokens or sensitive claims, only a fixed set of claim fields may
|
|
be emitted — the standard OIDC claims plus common provider claims (`email`,
|
|
`name`, `given_name`, `family_name`, `preferred_username`, `sub`, `groups`,
|
|
`roles`, `internal_role`, `role`, `department`, `organization`, `realm_access`,
|
|
`resource_access`, `oid`, `tid`, `upn`, `hd`, `picture`, `locale`, `zoneinfo`,
|
|
`phone_number`, `email_verified`, `updated_at`; the authoritative list is
|
|
`safeClaimsFields` in `template_validation.go`). Rejected: rendering the whole
|
|
context (`{{.}}`, `{{$}}`) or the whole claims map (`{{.Claims}}`), any claim not
|
|
on the list, functions other than `get`/`default`, and `range`/`with` that do not
|
|
target a specific listed claim (e.g. `{{range .Claims}}` is rejected; `{{range
|
|
.Claims.groups}}` is allowed).
|
|
|
|
**`allowedClaims`** extends the whitelist for claims your provider issues that
|
|
are not built in:
|
|
|
|
```yaml
|
|
allowedClaims:
|
|
- employee_id
|
|
- cost_center
|
|
headers:
|
|
- name: "X-Employee-Id"
|
|
value: "{{{{.Claims.employee_id}}}}"
|
|
```
|
|
|
|
Listed names apply to both direct access (`{{.Claims.employee_id}}`) and
|
|
`get` (`{{get .Claims "employee_id"}}`). Subfields of a whitelisted map-valued
|
|
claim (e.g. `realm_access.roles`) are already accessible without listing them
|
|
individually.
|
|
|
|
---
|
|
|
|
## Security Headers
|
|
|
|
### Security Profiles
|
|
|
|
| Profile | Use Case | Security Level |
|
|
|---------|----------|----------------|
|
|
| `default` | Standard web apps | High |
|
|
| `strict` | Maximum security | Very High |
|
|
| `development` | Local development | Medium |
|
|
| `api` | API endpoints | High |
|
|
| `custom` | Custom requirements | Configurable |
|
|
|
|
### Basic Configuration
|
|
|
|
```yaml
|
|
securityHeaders:
|
|
enabled: true
|
|
profile: "default"
|
|
```
|
|
|
|
### API with CORS
|
|
|
|
```yaml
|
|
securityHeaders:
|
|
enabled: true
|
|
profile: "api"
|
|
corsEnabled: true
|
|
corsAllowedOrigins:
|
|
- "https://your-frontend.com"
|
|
- "https://*.example.com"
|
|
corsAllowCredentials: true
|
|
```
|
|
|
|
### Custom Security Configuration
|
|
|
|
```yaml
|
|
securityHeaders:
|
|
enabled: true
|
|
profile: "custom"
|
|
|
|
# Content Security Policy
|
|
contentSecurityPolicy: "default-src 'self'; script-src 'self'"
|
|
|
|
# HSTS
|
|
strictTransportSecurity: true
|
|
strictTransportSecurityMaxAge: 31536000
|
|
strictTransportSecuritySubdomains: true
|
|
strictTransportSecurityPreload: true
|
|
|
|
# Frame and Content Protection
|
|
frameOptions: "DENY"
|
|
contentTypeOptions: "nosniff"
|
|
xssProtection: "1; mode=block"
|
|
referrerPolicy: "strict-origin-when-cross-origin"
|
|
|
|
# CORS
|
|
corsEnabled: true
|
|
corsAllowedOrigins: ["https://app.example.com"]
|
|
corsAllowedMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
|
corsAllowedHeaders: ["Authorization", "Content-Type"]
|
|
corsAllowCredentials: true
|
|
corsMaxAge: 86400
|
|
|
|
# Custom Headers
|
|
customHeaders:
|
|
X-Custom-Header: "value"
|
|
|
|
# Server Identification
|
|
disableServerHeader: true
|
|
disablePoweredByHeader: true
|
|
```
|
|
|
|
### CORS Origin Patterns
|
|
|
|
```yaml
|
|
corsAllowedOrigins:
|
|
- "https://example.com" # Exact match
|
|
- "https://*.example.com" # Subdomain wildcard
|
|
- "http://localhost:*" # Port wildcard (development)
|
|
```
|
|
|
|
---
|
|
|
|
## Scope Configuration
|
|
|
|
### Default Behavior (Append Mode)
|
|
|
|
```yaml
|
|
scopes:
|
|
- roles
|
|
- custom_scope
|
|
# Result: ["openid", "profile", "email", "roles", "custom_scope"]
|
|
```
|
|
|
|
### Override Mode
|
|
|
|
```yaml
|
|
overrideScopes: true
|
|
scopes:
|
|
- openid
|
|
- profile
|
|
- custom_scope
|
|
# Result: ["openid", "profile", "custom_scope"]
|
|
```
|
|
|
|
---
|
|
|
|
## Advanced Options
|
|
|
|
### Dynamic Client Registration (RFC 7591)
|
|
|
|
Dynamic Client Registration allows the middleware to automatically register itself with the OIDC provider, eliminating the need to manually create client credentials.
|
|
|
|
**Basic Configuration (Single Instance):**
|
|
|
|
```yaml
|
|
dynamicClientRegistration:
|
|
enabled: true
|
|
initialAccessToken: "your-token" # Optional, if provider requires it
|
|
persistCredentials: true
|
|
credentialsFile: "/tmp/oidc-credentials.json"
|
|
clientMetadata:
|
|
redirect_uris:
|
|
- "https://your-app.com/oauth2/callback"
|
|
client_name: "My Application"
|
|
application_type: "web"
|
|
grant_types:
|
|
- "authorization_code"
|
|
- "refresh_token"
|
|
```
|
|
|
|
**Multi-Replica Deployment (Kubernetes):**
|
|
|
|
For Kubernetes deployments with multiple replicas, use Redis storage to share credentials across all instances and prevent registration race conditions:
|
|
|
|
```yaml
|
|
dynamicClientRegistration:
|
|
enabled: true
|
|
persistCredentials: true
|
|
storageBackend: "redis" # Share credentials via Redis
|
|
redisKeyPrefix: "myapp:dcr:" # Optional custom prefix
|
|
clientMetadata:
|
|
redirect_uris:
|
|
- "https://your-app.com/oauth2/callback"
|
|
client_name: "My Application"
|
|
|
|
redis:
|
|
enabled: true
|
|
address: "redis:6379"
|
|
cacheMode: "redis"
|
|
```
|
|
|
|
**Storage Backend Options:**
|
|
|
|
| Backend | Description | Use Case |
|
|
|---------|-------------|----------|
|
|
| `file` | Store credentials in local file | Single instance deployments |
|
|
| `redis` | Store credentials in Redis | Multi-replica Kubernetes deployments |
|
|
| `auto` | Use Redis if available, fallback to file | Flexible deployments (default) |
|
|
|
|
### Multi-Replica Deployment
|
|
|
|
Without Redis, disable replay detection:
|
|
|
|
```yaml
|
|
disableReplayDetection: true
|
|
```
|
|
|
|
With Redis (recommended):
|
|
|
|
```yaml
|
|
redis:
|
|
enabled: true
|
|
address: "redis:6379"
|
|
cacheMode: "hybrid"
|
|
```
|
|
|
|
See [REDIS.md](REDIS.md) for complete Redis configuration.
|
|
|
|
---
|
|
|
|
## Kubernetes Secrets
|
|
|
|
Reference secrets instead of hardcoding sensitive values:
|
|
|
|
```yaml
|
|
providerURL: urn:k8s:secret:oidc-secret:ISSUER
|
|
clientID: urn:k8s:secret:oidc-secret:CLIENT_ID
|
|
clientSecret: urn:k8s:secret:oidc-secret:SECRET
|
|
```
|
|
|
|
Create the secret:
|
|
|
|
```bash
|
|
kubectl create secret generic oidc-secret \
|
|
--from-literal=ISSUER=https://accounts.google.com \
|
|
--from-literal=CLIENT_ID=your-client-id \
|
|
--from-literal=SECRET=your-client-secret \
|
|
-n traefik
|
|
```
|
|
|
|
---
|
|
|
|
## Environment Variable Naming
|
|
|
|
**Important:** Avoid using "API" as a substring in environment variable names when using `${VAR}` syntax in Traefik configuration. Traefik reserves `TRAEFIK_API_*` variables and the substring may cause conflicts.
|
|
|
|
```yaml
|
|
# Bad - may cause issues
|
|
sessionEncryptionKey: ${OIDC_SECRET_API}
|
|
|
|
# Good
|
|
sessionEncryptionKey: ${OIDC_SECRET_SVC}
|
|
```
|