mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
a548665edb
* docs: bearer-token auth design spec * docs: harden bearer-auth spec with security review findings * feat(bearer): opt-in M2M bearer-token authentication Adds an opt-in Authorization: Bearer <jwt> path for machine-to-machine clients. Replaces and supersedes the broken approach in PR #93 (synthetic-session that omitted user_identifier and skipped ID-token rejection / replay-protection-semantics / kid-pinning / etc.). Design Two auth entrypoints feed one shared post-auth pipeline: cookie path ─┐ ├── forwardAuthorized(rw, req, *principal) bearer path ─┘ (roles/groups, header injection, security headers, cookie strip, forward) buildPrincipalFromSession and buildPrincipalFromBearerToken produce the same `principal` value type. forwardAuthorized is session-agnostic and runs the existing post-auth work; processAuthorizedRequest now wraps it with the session-specific concerns (backchannel-logout, dirty/Save). The cookie path's behaviour is byte-identical to before this PR; the existing test suite passes unmodified. Security hardening baked into the bearer path - Audience MANDATORY. Startup fails when EnableBearerAuth=true and Audience is empty. - BearerIdentifierClaim defaults to "sub"; "email" is rejected at startup to avoid the unverified-email spoofing footgun. Cookie path's UserIdentifierClaim is unaffected and still defaults to "email". - ID tokens explicitly rejected via the existing detectTokenType helper (nonce, typ=at+jwt, token_use, scope, aud-vs-clientID heuristics); belt-and-braces nonce/token_use=id rejection on top. - alg pinned to asymmetric allowlist (RS/PS/ES 256/384/512) BEFORE JWKS fetch, blocking alg=none and alg=HS* probes from amplifying into upstream calls. - kid length capped at 256 bytes and charset-restricted before JWKS fetch, blocking pathological-kid JWKS amplification. - Multi-audience tokens require azp == clientID. - iat upper-age bound (MaxTokenAgeSeconds, default 24h) bounds clock- manipulation and forever-token abuse. - Identifier sanitization: length cap, control-char + bidi-override + delimiter (, ; =) rejection. - Per-IP failure throttle: configurable threshold/window/penalty; returns 429 + Retry-After. Limits offline-guessing-style attacks and protects the shared rate-limiter / JWKS endpoint. - JTI replay marking suppressed via new internal verifyOpts {skipReplayMarking} so the same bearer can be reused until exp; the blacklist Get stays active so RevokeToken still terminates a bearer token immediately. The existing exported VerifyToken interface is unchanged so all mocks continue to work. - Cookie wins by default when both bearer and cookie are present (safer against browser/extension/proxy bearer injection). Operator can flip via BearerOverridesCookie. - Authorization header stripped on forward by default; also stripped on excluded URLs so the token can't leak into health/metrics downstream logs. - Optional RFC 7662 introspection via existing requireTokenIntrospection. Introspection-endpoint failure returns 503 (distinguishes infra from token rejection). - 401s use RFC 6750 WWW-Authenticate hints (toggleable). Failure reason is logged at debug; raw tokens are never logged. Implementation - principal.go: pure-data principal type and buildPrincipalFromSession. - bearer_auth.go: alg/kid pin, classifier, identifier sanitization, multi-aud azp gate, iat age check, per-IP failure tracker, handleBearerRequest, buildPrincipalFromBearerToken. - token_manager.go: VerifyToken now wraps a new verifyTokenWithOpts that accepts internal-only verifyOpts. Existing callers, the TokenVerifier interface, and all mocks unchanged. - middleware.go: extracted forwardAuthorized from processAuthorizedRequest; wired bearer detection after init wait + after bypass; excluded-URL Authorization strip when bearer enabled. - settings.go: ten new config fields with defaults applied in CreateConfig. - main.go: startup validation for audience + identifier-claim guard; bearer failure tracker init. Tests - bearer_auth_test.go: table-driven helper tests for every new component (parseBearerJOSEHeader, sanitizeBearerIdentifier, resolveBearerIdentifier, enforceMultiAudienceAzp, enforceIatAge, bearerFailureTracker, detectBearerToken). Integration tests through ServeHTTP covering happy path, ID-token rejection, alg=none rejection, oversized kid, multi-aud with/without azp, iat-too-old, bidi identifier, replay (100x reuse), 429 throttle trip, excluded-URL strip, roles gate, cookie-wins precedence, BearerOverridesCookie, oversized token, malformed JWT, feature-off pass-through. Startup validation for audience- required and email-identifier-rejected. - All existing tests pass unmodified (cookie-path regression). - go vet clean. golangci-lint clean (0 issues). Race detector clean on bearer tests. Documentation - README.md: bearer auth section with security highlights and config snippet; doc link in the index. - .traefik.yml: commented config block exposing every bearer knob. - docs/CONFIGURATION.md: new subsection with full parameter table. - docs/BEARER_AUTH.md: threat model, hardening matrix, failure response table, operational guidance, known follow-ups. - docs/superpowers/specs/2026-05-18-bearer-token-auth-design.md: design spec + security-review hardening history. * fix(cache): redact raw cache keys in debug logs (CodeQL go/clear-text-logging) CodeQL flagged 9 high-severity alerts (go/clear-text-logging) where the in-memory cache and the hybrid L1+L2 backend printed `key=%s` at debug. Cache callers (token cache, blacklist, introspection cache) pass raw access / refresh / id tokens as cache keys, so any debug-enabled deployment would write them to log streams. Pre-existing issue. CodeQL started flagging it on this PR because the new bearer-auth path adds a data-flow source (req.Header.Get("Authorization")) that reaches the existing logging sinks via the same cache. The cookie path had the same risk but wasn't tracked as taint by CodeQL. Fix: hash the key (SHA-256[:8] hex) before printing. Same approach the bearer-auth logger uses for principal identifiers (spec §13). Doesn't change cache semantics — same key still produces the same hash, so debug correlation across log lines is preserved without exposing the raw value. Touches both affected packages: - internal/cache/cache.go (2 sites: Set + LRU eviction) - internal/cache/backends/hybrid.go (12 sites: L1/L2 read/write/fallback) New helper `redactKey` colocated with each package (unexported, package-local) keeps the change blast radius narrow. Tests green; lint clean. * docs(bearer): how to obtain bearer tokens from the OIDC provider Adds a section walking operators through the OAuth 2.0 client_credentials flow (RFC 6749 §4.4) and the JWT bearer assertion alternative (RFC 7523), with a worked Auth0-shape curl example, a per-provider quick reference (Auth0, Okta, Keycloak, Entra v2, Cognito, GitLab, Google), operational notes (token TTL, caching, JWKS rotation, revocation, scope vs audience, secret hygiene), and a three-line validation loop. Most common operator confusion: "I enabled the feature but tokens get 401'd" — almost always missing or wrong audience. The new section makes the audience-matching requirement loud, with per-provider parameter names so people don't have to dig through IdP docs. Locations: - docs/BEARER_AUTH.md — full section under "Quick start" - README.md — short snippet + deep link
105 lines
4.3 KiB
YAML
105 lines
4.3 KiB
YAML
displayName: Traefik OIDC
|
|
type: middleware
|
|
|
|
import: github.com/lukaszraczylo/traefikoidc
|
|
|
|
summary: |
|
|
OpenID Connect authentication middleware for Traefik. Replaces forward-auth
|
|
+ oauth2-proxy with a single plugin that auto-detects all major OIDC
|
|
providers (Google, Azure AD, Auth0, Okta, Keycloak, AWS Cognito, GitLab,
|
|
generic) and OAuth 2.0 for GitHub.
|
|
|
|
Features: ID-token validation with auto-discovery, session encryption,
|
|
proactive token refresh, RBAC via roles/groups claims, domain restriction,
|
|
templated downstream headers, security headers (CSP, HSTS, CORS), rate
|
|
limiting, PKCE, opaque-token introspection (RFC 7662), back/front-channel
|
|
logout, Dynamic Client Registration (RFC 7591), and Redis-backed shared
|
|
state for multi-replica deployments.
|
|
|
|
Full documentation: https://github.com/lukaszraczylo/traefikoidc
|
|
|
|
testData:
|
|
# Required
|
|
providerURL: https://accounts.google.com
|
|
clientID: 1234567890.apps.googleusercontent.com
|
|
clientSecret: your-client-secret
|
|
# Alternative: RFC 7523 private_key_jwt client authentication (Entra ID,
|
|
# Okta, Auth0, Keycloak). Replaces clientSecret with a signed JWT assertion.
|
|
# See README "Client authentication via private key JWT".
|
|
# clientAuthMethod: private_key_jwt
|
|
# clientAssertionKeyID: my-key-2026
|
|
# clientAssertionAlg: RS256 # default; or PS256/384/512, ES256/384/512
|
|
# # File path option:
|
|
# clientAssertionKeyPath: /etc/traefik/oidc/client-key.pem
|
|
# # Or inline PEM (PKCS#8 / PKCS#1 / SEC1):
|
|
# clientAssertionPrivateKey: |
|
|
# -----BEGIN PRIVATE KEY-----
|
|
# MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDexampleexample
|
|
# -----END PRIVATE KEY-----
|
|
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
|
callbackURL: /oauth2/callback
|
|
|
|
# Common production knobs
|
|
logoutURL: /oauth2/logout
|
|
postLogoutRedirectURI: /
|
|
forceHTTPS: true # default; only set false for plaintext HTTP local dev
|
|
logLevel: info
|
|
rateLimit: 100
|
|
|
|
# Access control
|
|
allowedUserDomains:
|
|
- company.com
|
|
allowedRolesAndGroups:
|
|
- admin
|
|
- developer
|
|
excludedURLs:
|
|
- /health
|
|
- /metrics
|
|
|
|
# Scopes are appended to the defaults ["openid", "profile", "email"]
|
|
scopes:
|
|
- roles
|
|
|
|
# Templated headers forwarded to backends.
|
|
# NOTE: use quadruple braces — the YAML parser collapses {{{{ → {{ so the
|
|
# Go template engine receives the correct expression.
|
|
headers:
|
|
- name: X-User-Email
|
|
value: "{{{{.Claims.email}}}}"
|
|
- name: Authorization
|
|
value: "Bearer {{{{.AccessToken}}}}"
|
|
|
|
# Security headers (default profile is enabled out of the box)
|
|
securityHeaders:
|
|
enabled: true
|
|
profile: default
|
|
|
|
# Optional: Redis for multi-replica deployments. See docs/REDIS.md.
|
|
# redis:
|
|
# enabled: true
|
|
# address: redis:6379
|
|
# password: urn:k8s:secret:redis:password
|
|
# cacheMode: hybrid
|
|
|
|
# Optional: bearer-token authentication for M2M (machine-to-machine) API
|
|
# clients. Default off. When enabled, requests presenting
|
|
# "Authorization: Bearer <jwt>" are validated against the configured OIDC
|
|
# provider (signature/issuer/audience/exp) and forwarded without creating
|
|
# a cookie session. The bearer path REJECTS ID tokens, requires a non-
|
|
# default audience, and never trusts the `email` claim as the identifier.
|
|
# See docs/BEARER_AUTH.md for the full threat model.
|
|
#
|
|
# enableBearerAuth: true # opt-in
|
|
# audience: https://api.example.com # REQUIRED when bearer is enabled
|
|
# bearerIdentifierClaim: sub # default; used as X-Forwarded-User. `email` is rejected.
|
|
# stripAuthorizationHeader: true # default; drops the raw token before forwarding
|
|
# bearerEmitWWWAuthenticate: true # default; RFC 6750 hint on 401s
|
|
# bearerOverridesCookie: false # default; cookie wins when both are present
|
|
# requireTokenIntrospection: false # opt-in; calls RFC 7662 introspection per request
|
|
# maxTokenAgeSeconds: 86400 # 24h cap on iat (rejects clock-skew/forever tokens)
|
|
# maxIdentifierLength: 256 # cap on the sanitised principal identifier
|
|
# bearerFailureThreshold: 20 # consecutive 401s/IP that trip the throttle
|
|
# bearerFailureWindowSeconds: 60 # rolling window over which 401s are counted
|
|
# bearerFailurePenaltySeconds: 60 # 429 + Retry-After duration after threshold trips
|
|
|