mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
546ceb949c
* fix(security): encrypt session cookies + fail closed on invalid config
Batch 1 of security audit remediation (ranks 1, 2, 6).
- session.go: derive independent HMAC + AES-256 keys via stdlib HKDF-SHA256
and build the gorilla cookie store with both, so session cookies are now
encrypted, not merely signed. The single-key store previously left OIDC
access/refresh/ID tokens recoverable from raw cookie bytes. Cookie format
changes, so existing sessions are invalidated on deploy (one-time re-login).
- main.go: call config.Validate() at construction and error out on failure,
instead of silently substituting a public hardcoded encryption key for
empty/short keys (which allowed session forgery). The yaegi analyzer
passes via .traefik.yml testData.
- settings.go: isValidSecureURL permits plaintext HTTP for loopback hosts
only (RFC 8252); remote providers must still use HTTPS.
- tests: complete configs that did not satisfy Validate(); add regression
tests in security_audit_fixes_test.go.
Configs below documented minimums (rateLimit < 10, key < 32 chars) are now
rejected at startup (fail closed).
* fix(security): validate discovered OIDC endpoints + pin introspection host
Batch 2 of security audit remediation (ranks 3, 4).
- url_helpers.go: add validateDiscoveredEndpoint, an SSRF screen for endpoints
taken from the provider discovery document (jwks_uri, token, authorization,
revocation, end_session, introspection, registration). Blocks link-local
(cloud metadata 169.254.169.254), multicast, unspecified and private
addresses (unless allowPrivateIPAddresses); blocks loopback unless the
configured providerURL is itself loopback (dev/test). Cross-domain JWKS
hosts (e.g. Google) stay allowed. Add sameHost helper.
- main.go: updateMetadataEndpoints screens every discovered endpoint and
blanks any that fail (fail closed downstream). The introspection endpoint
carries the client secret via HTTP Basic, so it is additionally pinned to
the providerURL host to stop a poisoned discovery document exfiltrating the
secret to an attacker-controlled host.
- tests: regression tests for the SSRF guard and the host pin.
* fix(security): close open redirects + anchor excluded-URL matching
Batch 3 of security audit remediation (ranks 5, 14, 15).
- auth_flow.go: run the stored incoming path through normalizeLogoutPath
before using it as the post-login redirect, so //evil.com and /\evil.com
payloads become host-relative (open-redirect, rank 5).
- url_helpers.go: excluded-URL matching is anchored at a natural boundary
(exact, sub-path "/", or file extension "."), so excluding "/public" no
longer also bypasses auth on "/publicsecret"; "/favicon" still matches
"/favicon.ico" (rank 14).
- internal/utils: X-Forwarded-Host is sanitized (first value only; reject
CRLF/whitespace/multi-value) before building redirect URLs (rank 15).
- helpers.go: the logout redirect used when there is no provider end-session
endpoint is host-relative, never an absolute URL derived from the
client-controllable request host (logout open-redirect, rank 15).
- tests: update two logout cases that asserted the old absolute redirect;
add regression tests.
* fix(security): reject unverified Azure tokens; fix transport TLS reuse
Batch 4 of security audit remediation (ranks 7, 11).
- token_validation_rs.go: an Azure nonce-bearing access token that cannot be
cryptographically verified no longer returns "authenticated" when there is
no ID token to corroborate it; it refreshes (if possible) or forces
re-authentication instead of failing open (rank 7).
- http_client_pool.go: the at-limit transport-reuse path now takes the write
lock before mutating refCount (fixes a data race) and only reuses a
transport whose TLS settings (CA pool + InsecureSkipVerify) match the
caller's, never one with a different trust store; if none matches it returns
nil so the caller falls back to a verifying default transport (rank 11).
- tests: add a transport-pool TLS-isolation regression test.
* fix(security): stop logging templated header values (token leak)
Batch 5 of security audit remediation (rank 16).
middleware.go: templated downstream headers commonly carry the access token
(e.g. "Authorization: Bearer {{.AccessToken}}"). The debug log line printed
the full header value, leaking credentials into logs. Log the header name and
byte length instead.
* fix(security): cache-key collision, cache-config divergence, fleet cleanup
Batch 6 of security audit remediation (ranks 9, 10, 12).
- token_manager.go: detectTokenType keys its cache on a SHA-256 hash of the
full token instead of the first 32 chars (which are only the base64url JWT
header). Distinct tokens sharing alg+kid no longer collide and get
mis-classified (rank 10).
- cache_manager.go: the process-global cache manager is initialized once and
shared across plugin instances; it now logs a loud warning when a later
instance requests a different explicit Redis backend that is silently
ignored, surfacing the cross-instance state-isolation hazard (rank 9).
- singleton_resources.go / main.go / utilities.go: track a process-global live
instance count; the shared singleton-token-cleanup task is stopped only when
the LAST instance shuts down, so one instance's Close() (e.g. a config reload)
no longer kills cleanup for surviving instances (rank 12).
- tests: update TestDetectTokenTypeCaching for the new key; add regression tests.
* fix(security): bound introspection cache + cookie lifetime to config
Batch 7 of security audit remediation (ranks 8, 13).
- token_introspection.go: when requireTokenIntrospection is enabled, cap the
positive introspection-result cache at 30s (instead of 5m) so a token
revoked at the provider stops passing within ~30s, matching the operator's
near-real-time revocation expectation (rank 8).
- session.go: bind the cookie store's MaxAge to the configured sessionMaxAge,
so the cookie codec's cryptographic timestamp validity is no longer fixed at
gorilla's 30-day default; a stolen cookie is valid only for the configured
session lifetime (rank 13).
- tests: add a cookie-lifetime regression test.
* fix(security): low-severity hardening (cache, DoS caps, PKCE, throttle)
Batch 8 of security audit remediation — low severity
(ranks 24, 25, 27, 29, 31, 36, 37, 41, 45, 46, 49).
- universal_cache.go: updateLocalCache updates an existing key in place instead
of orphaning its LRU element and double-counting currentSize/currentMemory
(rank 36 — the only production-reachable bug in this batch).
- jwk.go / metadata_cache.go / token_introspection.go: bound response bodies
with io.LimitReader (1 MiB) to prevent memory exhaustion from a hostile or
buggy provider (ranks 24, 25).
- jwk.go: skip JWKs not usable for signature verification (use != sig, or
key_ops without "verify") when building the key set (rank 49).
- auth_flow.go: fail closed at the callback when PKCE is enabled but the code
verifier is missing, instead of silently dropping it (rank 27).
- utilities.go / main.go: match allowedUserDomains case-insensitively (rank 31).
- bearer_auth.go: a single success no longer wipes an active per-IP penalty;
the counter resets only when no penalty is in effect (rank 29).
- main.go: handle (not discard) the NewSessionManager error (rank 37).
- error_recovery.go: take a write lock in isServiceDegraded (it deletes from a
map); compare retryable-error substrings case-insensitively (ranks 45, 46).
- singleton_resources.go: bind the generic-cache cleanup goroutine to the
resource-manager shutdown channel so it cannot outlive its owner (rank 41).
- tests: update the bearer throttle test to the corrected penalty semantics.
* fix(security): header sanitization, issuer pinning, fail-closed paths
Batch 9 of security audit remediation (ranks 18, 19, 20, 21, 22, 30, 33, 34).
- middleware.go / bearer_auth.go: sanitize claim-derived values on the cookie
auth path before injecting them into downstream headers. Drop group/role and
identifier values containing control chars, bidi-override runes, or the
, ; = delimiters (a comma would inject phantom entries into X-User-Groups);
reject control/bidi/over-length in rendered templated header output (but
permit , ; = in free-form values such as a bearer token). The bearer path
already sanitized; the cookie path did not (ranks 33, 34).
- main.go / metadata_cache.go: pin the discovered issuer to the configured
provider host (sameHost) and refuse/never-cache a mismatch, so a poisoned
discovery document cannot redefine the JWT trust anchor (ranks 21, 22).
- token_introspection.go: when a distinct API audience is configured, fail
closed on a missing or mismatched introspection audience; aud parsed as
string-or-array per RFC 7662 (rank 19).
- logout.go: front-channel logout requires a matching issuer; an empty iss is
rejected (blocks unauthenticated forced-logout via a known sid) (rank 30).
- token_validation_rs.go: an opaque access token with no ID token and no
successful introspection fails closed (re-auth) instead of authenticating
(ranks 18, 20).
- tests: realistic same-host provider mocks; regression tests for the header
sanitization distinction and the fail-closed paths.
* chore(security): remove unwired dead code with latent footguns
Batch 10 of security audit remediation — delete confirmed-dead, unwired
subsystems (ranks 26, 35, 50). None had a production caller (grep-verified);
removal eliminates the latent footguns and ~2.1k lines of dead code.
- token_validator.go (deleted): an unused *TokenValidator whose validateJWT set
Valid=true with NO signature verification — a severe footgun if ever wired
(rank 50). The wired RS-aware validators are unaffected.
- security_monitoring.go (deleted): an unused *SecurityMonitor / ExtractClientIP
that trusted spoofable X-Forwarded-For / X-Real-IP. The live bearer throttle
uses clientIPForBearer (RemoteAddr-only), unchanged (rank 35).
- dynamic_client_registration.go: removed the RFC 7592 management methods
(Update/Read/DeleteClientRegistration) that dereferenced an attacker-
influenced RegistrationClientURI with the registration token attached and no
HTTPS/SSRF gate, and had no callers. The wired RFC 7591 RegisterClient and
credential-store helpers are kept (rank 26).
- tests: removed the tests covering the deleted code.
* chore: add Makefile with yaegi load validation
No Makefile existed. The new `yaegi-validate` target interprets the plugin
under the yaegi interpreter the same way Traefik loads it, catching yaegi-only
incompatibilities (unsupported stdlib symbols, reflection edge cases) that the
native `go build` / `go test` toolchain does not. Importing the plugin forces
yaegi to interpret every file plus its vendored deps; CreateConfig + New
exercise the instantiation path.
- cmd/yaegicheck/main.go: the load driver, marked //go:build ignore so it is
excluded from `go build ./...` (avoids VCS-stamping a main binary, which
fails in git-worktree layouts) yet is run explicitly by yaegi.
- Makefile: build / fmt / vet / lint / test / vendor / yaegi-validate / check
targets; `make check` runs vet + tests + yaegi-validate.
Verified: `make yaegi-validate` passes on this branch — the HKDF cookie
encryption, net-based endpoint validation, and claim sanitizers all interpret
and instantiate cleanly under yaegi.
* ci: bump workflow Go toolchain to 1.25; pin yaegi-validate to v0.16.1
Traefik v3.7.1 (the deployed version) is built with `go 1.25.0`, so the PR and
release workflows now use Go 1.25.x to match the toolchain Traefik uses.
Important distinction: the CI Go version is the build TOOLCHAIN. The plugin's
actual interpreter-compatibility ceiling is the yaegi version Traefik bundles
(v0.16.1, which declares go 1.21 and ships a ~Go 1.22 stdlib symbol surface),
NOT the CI Go version. That ceiling is enforced by `make yaegi-validate` plus
the go.mod language directive — e.g. it is why HKDF is hand-rolled with
hmac+sha256 rather than Go 1.24's crypto/hkdf, which yaegi v0.16.1 lacks.
Also pin Makefile YAEGI_VERSION to v0.16.1 (what Traefik v3.7.1 vendors) so
yaegi-validate exercises the real deployed interpreter instead of @latest,
which could pass on a newer yaegi that supports symbols the deployed one does
not.
* docs: align README/CONFIGURATION with branch behavior changes
- excludedURLs: documented as segment/extension-boundary matching (was
"prefix-matched") — "/public" no longer also matches "/publicsecret" (rank 14).
- Front-channel logout now requires a matching `iss`; requests without one are
rejected with 400 (rank 30).
- Add an "Upgrading from an earlier release" note: session cookies are now
AES-256 encrypted with lifetime tracking sessionMaxAge (one-time re-login on
upgrade), and invalid configuration (rateLimit < 10, key < 32 bytes, missing
callbackURL, non-HTTPS remote providerURL) now fails closed at startup.
* fix: remove staticcheck-flagged unused functions; wire staticcheck into make check
CI Static Analysis (standalone staticcheck) failed with U1000 "unused":
- dynamic_client_registration.go: deleteCredentialsFromStore — its only caller
was the RFC 7592 DeleteClientRegistration removed in the dead-code batch.
- token_test.go: createTestJWTSimple — its only callers were the TokenValidator
tests removed in the same batch.
Both confirmed to have zero remaining callers and removed. build / vet /
go test ./... / staticcheck ./... all green.
The pre-commit hook runs golangci-lint, but CI runs standalone staticcheck
(which flags U1000). Add a `staticcheck` Makefile target and include it in
`make check` so this class of finding is caught locally before push.
* fix(test): stabilize flaky TestWorkerPool_TaskPanic
tasksFailed is incremented in the worker's deferred recover(), which runs after the panicking task's own defer wg.Done(). wg.Wait() could therefore return before the failure was recorded, so reading the counter immediately raced and flaked on slow CI runners. Poll until the failure lands (2s budget) instead. Verified 200x plain + 50x under -race/GOMAXPROCS=1.
684 lines
23 KiB
Go
684 lines
23 KiB
Go
// Package traefikoidc — bearer-token (M2M) authentication path.
|
|
//
|
|
// Disabled by default. When enabled via Config.EnableBearerAuth, requests
|
|
// presenting "Authorization: Bearer <jwt>" are validated against the
|
|
// configured OIDC provider (signature, issuer, audience, exp, replay-Get)
|
|
// and the request is forwarded downstream without creating a cookie session.
|
|
//
|
|
// Design rules (kept here in code as the single source of truth):
|
|
// - Access tokens only. ID tokens are rejected via detectTokenType.
|
|
// - Audience is mandatory (enforced at startup in main.go).
|
|
// - alg + kid pinned BEFORE JWKS fetch to deny amplification probes.
|
|
// - iat upper-age cap bounds clock-skew / forever-token abuse.
|
|
// - Multi-audience tokens require matching azp.
|
|
// - Per-IP 401 throttle returns 429 + Retry-After after a threshold.
|
|
// - JTI Set is suppressed (skipReplayMarking) but JTI Get stays — revoked
|
|
// tokens (RevokeToken adds to blacklist) are still rejected.
|
|
// - Identifier is read from BearerIdentifierClaim (default "sub"), never
|
|
// from UserIdentifierClaim, to avoid the unverified-email spoofing path.
|
|
// - Identifier is sanitized: length cap, control chars, bidi-override,
|
|
// delimiter chars (, ; =) rejected.
|
|
// - On excluded URLs the Authorization header is stripped before forwarding.
|
|
//
|
|
// See docs/superpowers/specs/2026-05-18-bearer-token-auth-design.md and
|
|
// docs/BEARER_AUTH.md for the full threat model.
|
|
package traefikoidc
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
const bearerPrefix = "Bearer "
|
|
|
|
// bearerAlgAllowlist is the set of JWS algorithms accepted on the bearer
|
|
// path. Asymmetric-only — HS* would allow public-key-as-HMAC-secret attacks
|
|
// if any operator ever rotates a key into the symmetric branch by mistake;
|
|
// "none" is obvious. Matches the allowlist enforced inside jwt.Verify but is
|
|
// checked here BEFORE the JWKS fetch so attacker noise can't amplify.
|
|
var bearerAlgAllowlist = map[string]struct{}{
|
|
"RS256": {}, "RS384": {}, "RS512": {},
|
|
"PS256": {}, "PS384": {}, "PS512": {},
|
|
"ES256": {}, "ES384": {}, "ES512": {},
|
|
}
|
|
|
|
// bearerKidMaxLen caps the JOSE kid header length to keep memory and cache-key
|
|
// usage bounded against attacker-controlled values.
|
|
const bearerKidMaxLen = 256
|
|
|
|
// validKidChar is the allowlist for kid header characters. Letters, digits,
|
|
// dot, underscore, hyphen, equals. Intentionally narrow; real-world kid
|
|
// values are short URL-safe-base64-ish identifiers.
|
|
func validKidChar(r rune) bool {
|
|
if r >= 'a' && r <= 'z' {
|
|
return true
|
|
}
|
|
if r >= 'A' && r <= 'Z' {
|
|
return true
|
|
}
|
|
if r >= '0' && r <= '9' {
|
|
return true
|
|
}
|
|
switch r {
|
|
case '.', '_', '-', '=':
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// bearerError categorizes failure modes for the response builder. Categories
|
|
// map 1:1 to the table in docs/superpowers/specs/2026-05-18-bearer-token-auth-design.md
|
|
// §9 so behavior is auditable from spec to code.
|
|
type bearerErrorKind int
|
|
|
|
const (
|
|
bearerErrInvalidRequest bearerErrorKind = iota
|
|
bearerErrInvalidToken
|
|
bearerErrTokenInactive
|
|
bearerErrInvalidIdentifier
|
|
bearerErrForbidden
|
|
bearerErrThrottled
|
|
bearerErrIntrospectionUnavailable
|
|
)
|
|
|
|
type bearerError struct {
|
|
kind bearerErrorKind
|
|
reason string
|
|
}
|
|
|
|
func (e *bearerError) Error() string { return e.reason }
|
|
|
|
func newBearerError(kind bearerErrorKind, reason string) *bearerError {
|
|
return &bearerError{kind: kind, reason: reason}
|
|
}
|
|
|
|
// joseHeader is the minimal subset of the JWS protected header we inspect
|
|
// BEFORE running the full verification pipeline. Lifted out so the alg+kid
|
|
// pin can run without paying for parseJWT's full claim decode.
|
|
type joseHeader struct {
|
|
Alg string `json:"alg"`
|
|
Kid string `json:"kid"`
|
|
Typ string `json:"typ"`
|
|
}
|
|
|
|
// parseBearerJOSEHeader decodes the first JWT segment for early alg/kid pinning.
|
|
// Does not touch the payload or signature — those are the verifier's job.
|
|
// Returns nil on success; *bearerError on rejection so the handler can map
|
|
// directly to a status code. The decoded header itself is not surfaced because
|
|
// callers don't need it (verifyTokenWithOpts re-parses internally).
|
|
func parseBearerJOSEHeader(token string) *bearerError {
|
|
dot := strings.IndexByte(token, '.')
|
|
if dot <= 0 {
|
|
return newBearerError(bearerErrInvalidToken, "malformed JWT: no header segment")
|
|
}
|
|
raw, err := base64.RawURLEncoding.DecodeString(token[:dot])
|
|
if err != nil {
|
|
// Some IdPs pad with '='; tolerate by retrying with StdEncoding.
|
|
raw, err = base64.URLEncoding.DecodeString(token[:dot])
|
|
if err != nil {
|
|
return newBearerError(bearerErrInvalidToken, "malformed JWT: header not base64url")
|
|
}
|
|
}
|
|
var hdr joseHeader
|
|
if err := json.Unmarshal(raw, &hdr); err != nil {
|
|
return newBearerError(bearerErrInvalidToken, "malformed JWT: header not JSON")
|
|
}
|
|
if _, ok := bearerAlgAllowlist[hdr.Alg]; !ok {
|
|
return newBearerError(bearerErrInvalidToken, fmt.Sprintf("disallowed alg %q on bearer path", hdr.Alg))
|
|
}
|
|
if hdr.Kid == "" {
|
|
return newBearerError(bearerErrInvalidToken, "missing kid header")
|
|
}
|
|
if len(hdr.Kid) > bearerKidMaxLen {
|
|
return newBearerError(bearerErrInvalidToken, "kid header exceeds max length")
|
|
}
|
|
for _, r := range hdr.Kid {
|
|
if !validKidChar(r) {
|
|
return newBearerError(bearerErrInvalidToken, "kid header contains disallowed characters")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// headerClaimRuneReason reports why a rune is unsafe to inject into a request
|
|
// header value, or "" if the rune is acceptable. Shared core of the bearer-path
|
|
// identifier sanitizer and the cookie-path header claim sanitizer: rejects
|
|
// control chars (CRLF/header injection), Unicode bidi-override runes (RTL
|
|
// spoofing of admin UI / SIEM), and the delimiters , ; = (a comma in a group
|
|
// name would inject extra entries into a comma-joined header).
|
|
func headerClaimRuneReason(r rune) string {
|
|
if reason := headerInjectionRuneReason(r); reason != "" {
|
|
return reason
|
|
}
|
|
// The , ; = delimiters are only unsafe for values placed into delimited or
|
|
// list contexts (a comma-joined header, or an identifier downstreams may
|
|
// split). They are valid in arbitrary single header values, so this stricter
|
|
// check is used for the cookie-path identifier and the group/role list, NOT
|
|
// for free-form templated header output (see headerValueReason).
|
|
if r == ',' || r == ';' || r == '=' {
|
|
return "delimiter character"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// headerInjectionRuneReason reports why a rune is unsafe in ANY HTTP header
|
|
// value, or "" if acceptable. Rejects control characters (CR/LF header
|
|
// injection) and Unicode bidi-override runes (RTL spoofing of admin UIs/SIEMs).
|
|
// Unlike headerClaimRuneReason it does NOT reject , ; = which are legitimate in
|
|
// free-form header values (e.g. an opaque "Authorization: Bearer <token>").
|
|
func headerInjectionRuneReason(r rune) string {
|
|
if unicode.IsControl(r) {
|
|
return "control character"
|
|
}
|
|
if (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) {
|
|
return "bidi-override character"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// headerValueReason reports why value is unsafe to forward as a free-form HTTP
|
|
// header value, or "" if acceptable. It rejects values over maxLen (maxLen<=0
|
|
// disables the check) and values containing control or bidi-override runes, but
|
|
// permits , ; = (valid in header values). Empty is allowed. The reason string
|
|
// never includes the value, so it is safe to log.
|
|
func headerValueReason(value string, maxLen int) string {
|
|
if maxLen > 0 && len(value) > maxLen {
|
|
return "exceeds max length"
|
|
}
|
|
for _, r := range value {
|
|
if reason := headerInjectionRuneReason(r); reason != "" {
|
|
return reason
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// headerClaimValueReason reports why value is unsafe to inject into a
|
|
// downstream request header, or "" if it is acceptable. It rejects empty
|
|
// values, values exceeding maxLen (maxLen<=0 disables the length check), and
|
|
// values containing any rune rejected by headerClaimRuneReason. The reason
|
|
// string is safe to log (it never includes the value itself).
|
|
func headerClaimValueReason(value string, maxLen int) string {
|
|
if value == "" {
|
|
return "empty value"
|
|
}
|
|
if maxLen > 0 && len(value) > maxLen {
|
|
return "exceeds max length"
|
|
}
|
|
for _, r := range value {
|
|
if reason := headerClaimRuneReason(r); reason != "" {
|
|
return reason
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// sanitizeHeaderClaimValue validates a claim-derived value before it is
|
|
// injected into a downstream request header. It trims surrounding whitespace
|
|
// and fails closed (ok=false) on empty values, values exceeding maxLen
|
|
// (maxLen<=0 disables the length check), or values containing any rune rejected
|
|
// by headerClaimRuneReason. Used by the cookie/session path, which — unlike the
|
|
// bearer path — does not otherwise sanitize the principal identifier or the
|
|
// group/role strings joined into X-User-Groups / X-User-Roles.
|
|
func sanitizeHeaderClaimValue(raw string, maxLen int) (string, bool) {
|
|
value := strings.TrimSpace(raw)
|
|
if headerClaimValueReason(value, maxLen) != "" {
|
|
return "", false
|
|
}
|
|
return value, true
|
|
}
|
|
|
|
// sanitizeBearerIdentifier validates and trims a principal identifier before
|
|
// it is injected into request headers. Layered defense: net/http will reject
|
|
// CRLF on the wire too, but rejecting early gives clearer error logs and
|
|
// prevents bidi-override / delimiter chars that pass net/http's narrower
|
|
// checks but confuse downstream parsers and admin UIs.
|
|
func sanitizeBearerIdentifier(raw string, maxLen int) (string, *bearerError) {
|
|
identifier := strings.TrimSpace(raw)
|
|
if identifier == "" {
|
|
return "", newBearerError(bearerErrInvalidIdentifier, "identifier claim empty")
|
|
}
|
|
if maxLen > 0 && len(identifier) > maxLen {
|
|
return "", newBearerError(bearerErrInvalidIdentifier, "identifier exceeds max length")
|
|
}
|
|
for _, r := range identifier {
|
|
if reason := headerClaimRuneReason(r); reason != "" {
|
|
return "", newBearerError(bearerErrInvalidIdentifier, "identifier contains "+reason)
|
|
}
|
|
}
|
|
return identifier, nil
|
|
}
|
|
|
|
// resolveBearerIdentifier picks the principal identifier from claims using
|
|
// the configured BearerIdentifierClaim (default "sub"). Decoupled from
|
|
// userIdentifierClaim (cookie path) to avoid the unverified-email spoofing
|
|
// vector documented in the spec §13.
|
|
func resolveBearerIdentifier(claims map[string]interface{}, claimName string) (string, *bearerError) {
|
|
if claimName == "" {
|
|
claimName = "sub"
|
|
}
|
|
raw, ok := claims[claimName]
|
|
if !ok {
|
|
return "", newBearerError(bearerErrInvalidIdentifier, fmt.Sprintf("missing claim %q", claimName))
|
|
}
|
|
str, ok := raw.(string)
|
|
if !ok {
|
|
return "", newBearerError(bearerErrInvalidIdentifier, fmt.Sprintf("claim %q not a string", claimName))
|
|
}
|
|
return str, nil
|
|
}
|
|
|
|
// enforceMultiAudienceAzp implements the spec hardening: when aud is a
|
|
// multi-element array, require an azp claim equal to clientID. Single-string
|
|
// aud is unaffected (existing verifyAudience handles it).
|
|
func enforceMultiAudienceAzp(claims map[string]interface{}, clientID string) *bearerError {
|
|
audRaw, ok := claims["aud"]
|
|
if !ok {
|
|
return nil // verifyToken already rejects missing aud
|
|
}
|
|
arr, ok := audRaw.([]interface{})
|
|
if !ok {
|
|
return nil // single-string aud
|
|
}
|
|
if len(arr) <= 1 {
|
|
return nil
|
|
}
|
|
azpRaw, ok := claims["azp"]
|
|
if !ok {
|
|
return newBearerError(bearerErrInvalidToken, "multi-audience token missing azp")
|
|
}
|
|
azp, ok := azpRaw.(string)
|
|
if !ok || azp == "" {
|
|
return newBearerError(bearerErrInvalidToken, "multi-audience token has empty/non-string azp")
|
|
}
|
|
if azp != clientID {
|
|
return newBearerError(bearerErrInvalidToken, "multi-audience token azp does not match clientID")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// enforceIatAge implements the spec MaxTokenAgeSeconds bound on iat. Bounds
|
|
// clock-manipulation / forever-token abuse without rejecting tokens with a
|
|
// normal iat just because the issuer's clock skews a few seconds.
|
|
func enforceIatAge(claims map[string]interface{}, maxAge time.Duration) *bearerError {
|
|
if maxAge <= 0 {
|
|
return nil
|
|
}
|
|
iatRaw, ok := claims["iat"].(float64)
|
|
if !ok {
|
|
// jwt.Verify already requires iat; this branch shouldn't be reached.
|
|
return newBearerError(bearerErrInvalidToken, "missing iat claim")
|
|
}
|
|
iat := time.Unix(int64(iatRaw), 0)
|
|
if time.Since(iat) > maxAge {
|
|
return newBearerError(bearerErrInvalidToken, "token iat outside age bound")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// hashIdentifierForLog returns a short SHA-256 prefix safe for info-level
|
|
// logs. Full identifier is only emitted at debug. Satisfies the audit
|
|
// requirement (trace which principal was rejected) without leaking PII.
|
|
func hashIdentifierForLog(identifier string) string {
|
|
if identifier == "" {
|
|
return "(none)"
|
|
}
|
|
sum := sha256.Sum256([]byte(identifier))
|
|
return hex.EncodeToString(sum[:4]) // 8 hex chars
|
|
}
|
|
|
|
// --- Per-IP failure throttle ---
|
|
|
|
// bearerFailureTracker records consecutive bearer-auth 401s per source IP and
|
|
// parks repeat offenders in a 429 penalty box. Limits offline-guessing-style
|
|
// attacks and protects the shared rate-limiter / JWKS endpoint from being
|
|
// burned by a single source.
|
|
type bearerFailureTracker struct {
|
|
mu sync.Mutex
|
|
entries map[string]*bearerFailureEntry
|
|
// Configuration snapshot. Captured at construction so a hot reconfigure
|
|
// doesn't race with the per-request paths.
|
|
threshold int
|
|
window time.Duration
|
|
penalty time.Duration
|
|
}
|
|
|
|
type bearerFailureEntry struct {
|
|
firstFailureAt time.Time
|
|
penaltyUntil time.Time
|
|
count int
|
|
}
|
|
|
|
func newBearerFailureTracker(threshold int, window, penalty time.Duration) *bearerFailureTracker {
|
|
if threshold <= 0 {
|
|
threshold = 20
|
|
}
|
|
if window <= 0 {
|
|
window = 60 * time.Second
|
|
}
|
|
if penalty <= 0 {
|
|
penalty = 60 * time.Second
|
|
}
|
|
return &bearerFailureTracker{
|
|
entries: make(map[string]*bearerFailureEntry),
|
|
threshold: threshold,
|
|
window: window,
|
|
penalty: penalty,
|
|
}
|
|
}
|
|
|
|
// blocked reports whether the source IP is currently in the penalty box.
|
|
// Returns (true, retryAfter) when blocked; (false, 0) when allowed.
|
|
func (b *bearerFailureTracker) blocked(ip string) (bool, time.Duration) {
|
|
if b == nil || ip == "" {
|
|
return false, 0
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
e, ok := b.entries[ip]
|
|
if !ok {
|
|
return false, 0
|
|
}
|
|
now := time.Now()
|
|
if !e.penaltyUntil.IsZero() && now.Before(e.penaltyUntil) {
|
|
return true, time.Until(e.penaltyUntil)
|
|
}
|
|
return false, 0
|
|
}
|
|
|
|
// recordFailure increments the failure counter for the given IP and trips
|
|
// the penalty box once threshold-within-window is exceeded.
|
|
func (b *bearerFailureTracker) recordFailure(ip string) {
|
|
if b == nil || ip == "" {
|
|
return
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
now := time.Now()
|
|
e, ok := b.entries[ip]
|
|
if !ok || now.Sub(e.firstFailureAt) > b.window {
|
|
e = &bearerFailureEntry{firstFailureAt: now}
|
|
b.entries[ip] = e
|
|
}
|
|
e.count++
|
|
if e.count >= b.threshold {
|
|
e.penaltyUntil = now.Add(b.penalty)
|
|
}
|
|
}
|
|
|
|
// recordSuccess clears the failure counter for the given IP after a
|
|
// successful bearer auth.
|
|
func (b *bearerFailureTracker) recordSuccess(ip string) {
|
|
if b == nil || ip == "" {
|
|
return
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
e, ok := b.entries[ip]
|
|
if !ok {
|
|
return
|
|
}
|
|
// Preserve an active penalty so a single success cannot wipe an in-effect
|
|
// lockout; only reset the counter when no penalty is active or it has expired.
|
|
now := time.Now()
|
|
if e.penaltyUntil.IsZero() || now.After(e.penaltyUntil) {
|
|
e.count = 0
|
|
e.firstFailureAt = now
|
|
}
|
|
}
|
|
|
|
// clientIPForBearer returns the source IP used to key the failure tracker.
|
|
// Trusts only the request's transport-level RemoteAddr; X-Forwarded-For is
|
|
// intentionally ignored to avoid attacker-controlled key spoofing. Behind a
|
|
// trusted reverse proxy where every request shares one IP, the throttle is
|
|
// still useful (caps attacker churn through that proxy) — operators wanting
|
|
// per-real-client throttling must terminate at this middleware.
|
|
func clientIPForBearer(req *http.Request) string {
|
|
if req == nil {
|
|
return ""
|
|
}
|
|
host, _, err := net.SplitHostPort(req.RemoteAddr)
|
|
if err != nil {
|
|
return req.RemoteAddr
|
|
}
|
|
return host
|
|
}
|
|
|
|
// --- Bearer auth entrypoint ---
|
|
|
|
// detectBearerToken returns (token, true) when the request carries a usable
|
|
// Authorization: Bearer header. Case-insensitive on the scheme. Returns
|
|
// ("", false) for any other shape.
|
|
func detectBearerToken(req *http.Request) (string, bool) {
|
|
if req == nil {
|
|
return "", false
|
|
}
|
|
h := req.Header.Get("Authorization")
|
|
if len(h) < len(bearerPrefix) {
|
|
return "", false
|
|
}
|
|
if !strings.EqualFold(h[:len(bearerPrefix)], bearerPrefix) {
|
|
return "", false
|
|
}
|
|
token := strings.TrimSpace(h[len(bearerPrefix):])
|
|
if token == "" {
|
|
return "", false
|
|
}
|
|
return token, true
|
|
}
|
|
|
|
// hasSessionCookie reports whether the request carries any cookie matching
|
|
// the session prefix. Used to implement the cookie-wins-by-default
|
|
// precedence rule when both bearer and cookie are present.
|
|
func (t *TraefikOidc) hasSessionCookie(req *http.Request) bool {
|
|
if t.sessionManager == nil {
|
|
return false
|
|
}
|
|
prefix := t.sessionManager.GetCookiePrefix()
|
|
if prefix == "" {
|
|
return false
|
|
}
|
|
for _, c := range req.Cookies() {
|
|
if strings.HasPrefix(c.Name, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// writeBearerError writes the canonical 401/403/429/503 response per spec §9.
|
|
// Body is always generic; reason is logged at debug only. The
|
|
// WWW-Authenticate hint is gated by config (default on, RFC 6750 compliant).
|
|
func (t *TraefikOidc) writeBearerError(rw http.ResponseWriter, req *http.Request, err *bearerError) {
|
|
var (
|
|
status int
|
|
errCode string
|
|
body string
|
|
retryAfter time.Duration
|
|
)
|
|
switch err.kind {
|
|
case bearerErrInvalidRequest:
|
|
status = http.StatusUnauthorized
|
|
errCode = "invalid_request"
|
|
body = "Unauthorized"
|
|
case bearerErrInvalidToken, bearerErrTokenInactive, bearerErrInvalidIdentifier:
|
|
status = http.StatusUnauthorized
|
|
errCode = "invalid_token"
|
|
body = "Unauthorized"
|
|
case bearerErrForbidden:
|
|
status = http.StatusForbidden
|
|
body = "Access denied"
|
|
case bearerErrThrottled:
|
|
status = http.StatusTooManyRequests
|
|
body = "Too Many Requests"
|
|
retryAfter = t.bearerFailurePenalty
|
|
case bearerErrIntrospectionUnavailable:
|
|
status = http.StatusServiceUnavailable
|
|
body = "Service Unavailable"
|
|
default:
|
|
status = http.StatusUnauthorized
|
|
body = "Unauthorized"
|
|
}
|
|
|
|
if t.bearerEmitWWWAuthenticate && errCode != "" {
|
|
rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer error=%q`, errCode))
|
|
}
|
|
if retryAfter > 0 {
|
|
rw.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
|
|
}
|
|
rw.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
rw.WriteHeader(status)
|
|
_, _ = rw.Write([]byte(body)) // Safe to ignore: best-effort error body write
|
|
|
|
if t.logger != nil {
|
|
t.logger.Debugf("bearer auth rejected: status=%d category=%v reason=%q path=%s",
|
|
status, err.kind, err.reason, req.URL.Path)
|
|
}
|
|
}
|
|
|
|
// handleBearerRequest is the entry point invoked by ServeHTTP when the
|
|
// EnableBearerAuth flag is set, the request carries an Authorization: Bearer
|
|
// header, and the (configurable) cookie-precedence rule allows the bearer
|
|
// path to run.
|
|
func (t *TraefikOidc) handleBearerRequest(rw http.ResponseWriter, req *http.Request) {
|
|
ip := clientIPForBearer(req)
|
|
|
|
if blocked, retryAfter := t.bearerFailureTracker.blocked(ip); blocked {
|
|
throttled := newBearerError(bearerErrThrottled, "ip in penalty box")
|
|
// Preserve the actual retry-after even if it diverged from the
|
|
// configured default (clock-skew, partial-window expiry).
|
|
if retryAfter > 0 {
|
|
rw.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
|
|
}
|
|
t.writeBearerError(rw, req, throttled)
|
|
return
|
|
}
|
|
|
|
token, ok := detectBearerToken(req)
|
|
if !ok {
|
|
t.bearerFailureTracker.recordFailure(ip)
|
|
t.writeBearerError(rw, req, newBearerError(bearerErrInvalidRequest, "missing or empty bearer token"))
|
|
return
|
|
}
|
|
if len(token) > AccessTokenConfig.MaxLength {
|
|
t.bearerFailureTracker.recordFailure(ip)
|
|
t.writeBearerError(rw, req, newBearerError(bearerErrInvalidToken, "token exceeds max length"))
|
|
return
|
|
}
|
|
if strings.Count(token, ".") != 2 {
|
|
t.bearerFailureTracker.recordFailure(ip)
|
|
t.writeBearerError(rw, req, newBearerError(bearerErrInvalidToken, "token is not a 3-segment JWT"))
|
|
return
|
|
}
|
|
|
|
if bErr := parseBearerJOSEHeader(token); bErr != nil {
|
|
t.bearerFailureTracker.recordFailure(ip)
|
|
t.writeBearerError(rw, req, bErr)
|
|
return
|
|
}
|
|
|
|
p, bErr := t.buildPrincipalFromBearerToken(token)
|
|
if bErr != nil {
|
|
t.bearerFailureTracker.recordFailure(ip)
|
|
t.writeBearerError(rw, req, bErr)
|
|
return
|
|
}
|
|
|
|
t.bearerFailureTracker.recordSuccess(ip)
|
|
if t.logger != nil {
|
|
t.logger.Debugf("bearer auth success: identifier_hash=%s path=%s",
|
|
hashIdentifierForLog(p.Identifier), req.URL.Path)
|
|
}
|
|
t.forwardAuthorized(rw, req, p)
|
|
}
|
|
|
|
// buildPrincipalFromBearerToken runs the full bearer verification pipeline
|
|
// described in spec §7.3 and returns a principal ready for forwardAuthorized.
|
|
// Returns a typed *bearerError on failure so the caller can map to status.
|
|
func (t *TraefikOidc) buildPrincipalFromBearerToken(token string) (*principal, *bearerError) {
|
|
if err := t.verifyTokenWithOpts(token, verifyOpts{skipReplayMarking: true}); err != nil {
|
|
return nil, newBearerError(bearerErrInvalidToken, "token verification failed: "+err.Error())
|
|
}
|
|
|
|
parsed, err := parseJWT(token)
|
|
if err != nil {
|
|
return nil, newBearerError(bearerErrInvalidToken, "post-verify parseJWT failed: "+err.Error())
|
|
}
|
|
claims := parsed.Claims
|
|
|
|
// Token-type guard. Reuse the well-tested classifier which already
|
|
// checks nonce / typ=at+jwt / token_use / scope / aud-vs-clientID.
|
|
if t.detectTokenType(parsed, token) {
|
|
return nil, newBearerError(bearerErrInvalidToken, "ID tokens are not accepted on the bearer path")
|
|
}
|
|
// Belt-and-braces explicit rejection (cheap, catches edge cases not
|
|
// covered by detectTokenType's heuristic).
|
|
if nonce, ok := claims["nonce"].(string); ok && nonce != "" {
|
|
return nil, newBearerError(bearerErrInvalidToken, "nonce claim present (ID-token shape)")
|
|
}
|
|
if tu, ok := claims["token_use"].(string); ok && tu == "id" {
|
|
return nil, newBearerError(bearerErrInvalidToken, "token_use=id rejected")
|
|
}
|
|
|
|
if bErr := enforceMultiAudienceAzp(claims, t.clientID); bErr != nil {
|
|
return nil, bErr
|
|
}
|
|
if bErr := enforceIatAge(claims, t.maxTokenAge); bErr != nil {
|
|
return nil, bErr
|
|
}
|
|
|
|
if t.requireTokenIntrospection {
|
|
if bErr := t.introspectOnBearerPath(token); bErr != nil {
|
|
return nil, bErr
|
|
}
|
|
}
|
|
|
|
rawIdentifier, bErr := resolveBearerIdentifier(claims, t.bearerIdentifierClaim)
|
|
if bErr != nil {
|
|
return nil, bErr
|
|
}
|
|
identifier, bErr := sanitizeBearerIdentifier(rawIdentifier, t.maxIdentifierLength)
|
|
if bErr != nil {
|
|
return nil, bErr
|
|
}
|
|
|
|
subject, _ := claims["sub"].(string)
|
|
clientID, _ := claims["azp"].(string)
|
|
if clientID == "" {
|
|
clientID, _ = claims["client_id"].(string)
|
|
}
|
|
|
|
return &principal{
|
|
Source: sourceBearer,
|
|
Identifier: identifier,
|
|
Subject: subject,
|
|
ClientID: clientID,
|
|
Claims: claims,
|
|
AccessToken: token,
|
|
}, nil
|
|
}
|
|
|
|
// introspectOnBearerPath calls the existing RFC 7662 introspector when the
|
|
// operator demands real-time revocation. Distinguishes "token revoked" (401)
|
|
// from "endpoint unavailable" (503) so transient infra failures don't look
|
|
// like credential failures.
|
|
func (t *TraefikOidc) introspectOnBearerPath(token string) *bearerError {
|
|
resp, err := t.introspectToken(token)
|
|
if err != nil {
|
|
return newBearerError(bearerErrIntrospectionUnavailable, "introspection failed: "+err.Error())
|
|
}
|
|
if !resp.Active {
|
|
return newBearerError(bearerErrTokenInactive, "introspection reports token inactive")
|
|
}
|
|
return nil
|
|
}
|