* 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
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.
* Allow internal IPs for OIDC configuration via extra flag.
Addresses issue #97
* Allow for internal IPs in OIDC configuration.
Addresses issue #97.
* feat: Add allowPrivateIPAddresses config option for internal networks
Adds a new configuration option `allowPrivateIPAddresses` that allows
OIDC provider URLs to use private IP addresses (10.x.x.x, 172.16-31.x.x,
192.168.x.x). This is useful for internal deployments where Keycloak or
other OIDC providers run on private networks without DNS resolution.
Security considerations:
- Loopback addresses (127.0.0.1, localhost, ::1) remain blocked
- Link-local addresses (169.254.x.x) remain blocked
- Default is false (secure by default)
Fixes#97
* feat: Support non-email user identifiers for Azure AD
Add userIdentifierClaim configuration option to support Azure AD users
without email addresses. This allows using alternative JWT claims like
"sub", "oid", "upn", or "preferred_username" for user identification.
- Default behavior uses "email" claim (backward compatible)
- Falls back to "sub" claim if configured claim is missing
- allowedUsers matches against the configured claim value
- allowedUserDomains only applies when using email-based identification
Fixes#95
* Race condition on traefik pod startup
When the plugin initializes and calls GetMetadataWithRecovery():
1. Checks cache first (if metadata is cached, returns immediately)
2. Creates a retry executor with startup-optimized settings (10 attempts, 1s delays)
3. Attempts to fetch metadata from the OIDC provider
4. If the fetch fails with a retryable error (connection refused, EOF, TLS/certificate errors, Traefik default cert), it waits and retries
5. After 10 attempts or on a non-retryable error, returns the error
This allows the plugin to handle the race condition where:
- Traefik initializes the plugin before routes are established
- Traefik serves its default certificate before loading real ones
- The OIDC provider pod isn't fully ready yet
Fixes issue #90
* Race condition on traefik pod startup
When the plugin initializes and calls GetMetadataWithRecovery():
1. Checks cache first (if metadata is cached, returns immediately)
2. Creates a retry executor with startup-optimized settings (10 attempts, 1s delays)
3. Attempts to fetch metadata from the OIDC provider
4. If the fetch fails with a retryable error (connection refused, EOF, TLS/certificate errors, Traefik default cert), it waits and retries
5. After 10 attempts or on a non-retryable error, returns the error
This allows the plugin to handle the race condition where:
- Traefik initializes the plugin before routes are established
- Traefik serves its default certificate before loading real ones
- The OIDC provider pod isn't fully ready yet
Fixes issue #90
* Headers too big and 431 responses
Added new option `minimalHeaders` to reduce the size of forwarded headers from the auth middleware to backend services.
- When minimalHeaders: false (default): All headers are forwarded as before
- X-Forwarded-User (always set)
- X-Auth-Request-Redirect
- X-Auth-Request-User
- X-Auth-Request-Token (the large ID token)
- X-User-Groups, X-User-Roles (if configured)
- When minimalHeaders: true: Reduces header overhead
- X-Forwarded-User (always set)
- X-User-Groups, X-User-Roles (still forwarded if configured)
- Custom templated headers (still processed)
- Skipped: X-Auth-Request-Token, X-Auth-Request-User, X-Auth-Request-Redirect
Fixes issues #64 and #86
2025-12-08 14:21:17 +00:00
lukaszraczyloGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* Add redis support for distributed caching
* Move towards the self-provided Redis connection pool and RESP protocol implementation.
Official redis client library won't work with yaegi.
* fixup! Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi.
* fixup! fixup! Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi.
* fixup! fixup! fixup! Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi.
* fixup! fixup! fixup! fixup! Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi.
* fixup! fixup! fixup! fixup! fixup! Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi.
* ... and another all nighter.
* fixup! ... and another all nighter.
* fixup! fixup! ... and another all nighter.
* fixup! fixup! fixup! ... and another all nighter.
* Resolve issue #85 by adding ability to set custom claims in JWT tokens
* Remove redundant validation in auth middleware ( issue #89 )
* Add ability to set cookie prefix for session cookies ( #87 )
* fixup! Add ability to set cookie prefix for session cookies ( #87 )
* Add ability to set cookie max age - issue #91
* Potential fix for code scanning alert no. 10: Size computation for allocation may overflow
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* fixup! Merge main into 0.8.0-redis: resolve conflicts
---------
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* Add ability to disable replay protection. - This is useful for runs with multiple traefik replicas to avoid false positives and tokens re-creation.
* Enhance the CI/CD pipelines
* Increase test coverage.
* Update vendored dependencies.
* Update behaviour on forceHTTPS as per issue #82
* Speed improvements.
After introduction of introspection the plugin became significantly slower.
This commit introduces several optimizations to bring the speed back up.
* Add relevant documentation and tests.
* Fix bug affecting Azure OIDC authentication ( and most likely others )
* Fixes issue #51
* Ensure that appended roles are unique. Update the documentation.
* Improvements targetting possible memory usage spikes.
* Additional fixes and cleanup
* Refactoring code to fix the issues identified by the users.
* Modernize run
* Fieldalignment
* Multiple changes to improve performance and reduce complexity.
- Optimise the errors and recovery.
- Deduplicate code in metadata cache.
- Remove unused performance monitoring code.
- Simplify session management and settings handling.
* Fix claims issue.
* Add ability to overwrite the default scopes in the settings file
* Well.. that escalated quickly.
Completely forgot that Traefik uses outdated Yaegi and requires compatibility with 1.20 ( pre-generic Go code ).
* Bugfix #51: Ensures that user provided scopes overrides work.
* fixup! Bugfix #51: Ensures that user provided scopes overrides work.
* fixup! fixup! Bugfix #51: Ensures that user provided scopes overrides work.
* Abstract the provider logic into a separate package.
* Additional micro fixes and cleanups.
* Simplify all the things.
* fixup! Simplify all the things.
* fixup! fixup! Simplify all the things.
* fixup! fixup! fixup! Simplify all the things.
* fixup! fixup! fixup! fixup! Simplify all the things.
* ...
* Cleanup tests.
* fixup! Cleanup tests.
* fixup! fixup! fixup! Cleanup tests.
* fixup! fixup! fixup! fixup! Cleanup tests.
* fixup! fixup! fixup! fixup! fixup! Cleanup tests.
* Issue #53: Fix CSRF token handling in reverse proxy
1. ✅ HTTPS Detection Fixed (session.go:723)
- Now uses X-Forwarded-Proto header instead of r.URL.Scheme
- Properly detects HTTPS in reverse proxy environments
2. ✅ SameSite Cookie Attribute Fixed
- Removed automatic SameSiteStrictMode for HTTPS (would break OAuth)
- Keeps SameSiteLaxMode to allow OAuth callbacks from external domains
- Only uses Strict for AJAX requests which don't involve OAuth redirects
3. ✅ Cookie Domain Handling Fixed
- Now respects X-Forwarded-Host header for cookie domain
- Ensures cookies are set for the public domain, not internal proxy domain
4. ✅ EnhanceSessionSecurity Properly Integrated
- Function is now actually called during session save
- Applies security enhancements without breaking OAuth flow
Why Issue #53 Failed Before:
1. Cookies were not marked Secure in HTTPS environments (browser wouldn't send them back)
2. If they had been Secure with SameSite=Strict, Azure callbacks would still fail
3. Cookie domain might have been wrong (internal vs public domain)
Why It Works Now:
1. Cookies are properly marked Secure for HTTPS
2. Uses SameSite=Lax to allow OAuth provider callbacks
3. Cookie domain uses public domain from X-Forwarded-Host
4. CSRF token persists through the entire OAuth flow
* Next set of enhancements together with memory usage improvements.
* Memory leak fixes and optimisations.
* CSRF and Cookie Domain fixes
* fixup! CSRF and Cookie Domain fixes
* Metadata cache leak fix + profiling
* fixup! Metadata cache leak fix + profiling
* Memory leaks hunting, part 1337.
* Further pursue of perfection.
* fixup! Further pursue of perfection.
* fixup! fixup! Further pursue of perfection.
* fixup! fixup! fixup! Further pursue of perfection.
* fixup! fixup! fixup! fixup! Further pursue of perfection.
* fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.
* fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.
* fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.
* fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.
* fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.
* Clear race conditions
* fixup! Clear race conditions
* Weekend fun with memory leaks
* Splitting code into multiple files with reasonable testing coverage.
```
ok github.com/lukaszraczylo/traefikoidc 117.017s coverage: 72.6% of statements
ok github.com/lukaszraczylo/traefikoidc/auth 0.505s coverage: 87.1% of statements
ok github.com/lukaszraczylo/traefikoidc/circuit_breaker 0.283s coverage: 99.0% of statements
github.com/lukaszraczylo/traefikoidc/config coverage: 0.0% of statements
ok github.com/lukaszraczylo/traefikoidc/handlers 0.349s coverage: 98.2% of statements
ok github.com/lukaszraczylo/traefikoidc/internal/providers (cached) coverage: 94.3% of statements
ok github.com/lukaszraczylo/traefikoidc/middleware 0.808s coverage: 78.0% of statements
ok github.com/lukaszraczylo/traefikoidc/recovery 0.653s coverage: 100.0% of statements
ok github.com/lukaszraczylo/traefikoidc/session/chunking (cached) coverage: 87.8% of statements
ok github.com/lukaszraczylo/traefikoidc/session/core (cached) coverage: 85.6% of statements
ok github.com/lukaszraczylo/traefikoidc/session/crypto (cached) coverage: 81.8% of statements
ok github.com/lukaszraczylo/traefikoidc/session/storage (cached) coverage: 93.5% of statements
ok github.com/lukaszraczylo/traefikoidc/session/validators (cached) coverage: 98.8% of statements
````
* fixup! Splitting code into multiple files with reasonable testing coverage.
* fixup! fixup! Splitting code into multiple files with reasonable testing coverage.
* Weekend fun with further optimisations.
* fixup! Weekend fun with further optimisations.
* fixup! fixup! Weekend fun with further optimisations.
* fixup! fixup! fixup! Weekend fun with further optimisations.
* fixup! fixup! fixup! fixup! Weekend fun with further optimisations.
* fixup! fixup! fixup! fixup! fixup! Weekend fun with further optimisations.
* Pre-release cleanup.
* Enhance test coverage.
* fixup! Enhance test coverage.
* fixup! fixup! Enhance test coverage.
* fixup! fixup! fixup! Enhance test coverage.