Files
traefikoidc/bearer_auth.go
T
lukaszraczyloandGitHub f0af6282dc feat(templates): add toJson helper for object claims (#165)
Header templates can now emit an object or array claim as one JSON
header. Example: {{get .Claims "tenant" | toJson}}. The backend parses
the claim, so the middleware does not need to know its schema.

The helper adds no new access path. The validator treats toJson like
default and checks its arguments. A claim must be on the whitelist, and
a dump of the whole claims map is rejected. An absent or empty claim
skips the header. The JSON encoder escapes control characters.

A rendered header value over 8192 bytes is still dropped. The drop is
now logged at error level, with the header name and without the value.
Other sanitizer drops stay at debug level.

Tests cover value types, edge cases, whitelist bypass attempts, token
leaks and header injection. A new yaegi check runs the helper under the
interpreter. README, configuration docs and the plugin manifest show the
new helper.
2026-10-05 00:20:01 +01:00

1095 lines
43 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.
// - No path writes an access-token JTI to a replay cache (FIX-17 removed
// that writer; the only JTI replay tracking left is for backchannel
// logout tokens, logout.go checkAndMarkLogoutJTIProcessed). RevokeToken
// (called from logout) still blacklists the raw token and, for a JWT,
// its jti; both the JWT and opaque-introspection bearer paths check
// that blacklist before accepting a token, so revocation stays
// effective without replay-detection overhead.
// - 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
// retryAfter carries the actual remaining penalty when known (e.g. the
// per-IP penalty box), so writeBearerError emits the true value instead
// of the full configured penalty.
retryAfter time.Duration
}
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"`
}
// joseHeaderWellFormed reports whether token is structurally a JWT: exactly
// two dot-separated segments whose header decodes as a JSON JOSE header.
// It intentionally applies NO alg/kid policy (unlike parseBearerJOSEHeader)
// so the JWT-vs-opaque routing gate classifies purely on structure: a
// structurally-valid JWT with a disallowed alg stays on the JWT path
// (where local policy rejects it), while an opaque token whose two dots
// are coincidental falls through to opaque introspection (R166).
func joseHeaderWellFormed(token string) bool {
dot := strings.IndexByte(token, '.')
if dot <= 0 {
return false
}
if strings.Count(token[dot+1:], ".") != 1 {
return false
}
raw, err := base64.RawURLEncoding.DecodeString(token[:dot])
if err != nil {
raw, err = base64.URLEncoding.DecodeString(token[:dot])
if err != nil {
return false
}
}
var hdr joseHeader
return json.Unmarshal(raw, &hdr) == nil
}
// 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 , ; = plus the bracketing
// characters " { } — each of these in a group name / identifier would break a
// downstream comma-joined list or a naive CSV/JSON-style parser consuming the
// header (a comma would inject extra entries into a comma-joined header).
func headerClaimRuneReason(r rune) string {
if reason := headerInjectionRuneReason(r); reason != "" {
return reason
}
// The , ; = delimiters and the " { } bracketing chars 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 == '=' || r == '"' || r == '{' || r == '}' {
return "delimiter or bracketing 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 ""
}
// headerReasonTooLong is the headerValueReason result for an over-long value.
const headerReasonTooLong = "exceeds max length"
// 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 headerReasonTooLong
}
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))
}
// Accept scalar numbers (e.g. a numeric sub) by stringifying them,
// so a numeric identifier claim doesn't 401 a valid principal.
if s, isScalar := claimScalarString(raw); isScalar {
return s, nil
}
return "", newBearerError(bearerErrInvalidIdentifier, fmt.Sprintf("claim %q is not a scalar string or number", claimName))
}
// 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 {
// iat is OPTIONAL per RFC 7519 §4.1.6, and jwt.Verify validates it
// only when present (R126) — this branch IS reachable. maxAge is NOT
// an operator opt-in: New() (main.go's config-default closure) always
// sets maxTokenAge > 0 — 0/unset becomes 24h — so this branch rejects
// every iat-less bearer JWT in production, not just when an operator
// has explicitly configured maxTokenAgeSeconds. A token with no iat
// has nothing to bound its age against, so fail closed rather than
// silently skip the check.
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
// nextSweepAt is the map size recordFailure next scans the map at. A
// static per-call gate (len > defaultBearerEntrySweepThreshold) made
// EVERY recordFailure call re-scan the entire map once a flood of
// distinct source IPs crossed the threshold, turning attacker-controlled
// traffic into O(n^2) work under b.mu while blocked() (called on every
// bearer request) waits on the same lock (FIX-28). nextSweepAt doubles
// after each sweep (bounded below by defaultBearerEntrySweepThreshold),
// amortizing the scan so its frequency grows logarithmically with the
// flood size instead of linearly.
nextSweepAt int
// sweepPasses counts how many times recordFailure has run the full-map
// staleness scan. Exposed for tests to confirm the amortization actually
// bounds scan frequency.
sweepPasses int
}
type bearerFailureEntry struct {
firstFailureAt time.Time
penaltyUntil time.Time
count int
}
// defaultBearerEntrySweepThreshold is the map size above which recordFailure
// sweeps stale entries, bounding memory under a flood of distinct source IPs.
const defaultBearerEntrySweepThreshold = 1024
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,
nextSweepAt: defaultBearerEntrySweepThreshold,
}
}
// 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()
// Bound per-IP map growth (R117): entries for source IPs that fail once
// and never return were never removed. Sweep stale entries (no active
// penalty box, too old to matter) once the map grows past a nominal
// size, keeping steady-state memory bounded.
//
// An entry that has not tripped the penalty box yet has a ZERO
// penaltyUntil, which is always Before(cutoff) — so checking only
// e.penaltyUntil.Before(cutoff) discarded every in-progress (not yet
// tripped) counter on every sweep, regardless of how recently it
// started. Once the map passed the threshold, a source whose failures
// interleaved with sweeps could never accumulate enough to trip (FIX-28).
// Only remove an entry once it is BOTH untripped-or-expired AND outside
// the counting window, so a fresh, still-accumulating counter survives.
//
// The sweep itself is gated on b.nextSweepAt, not the static threshold:
// a flood of distinct, still-fresh source IPs (attacker-controlled) that
// crosses defaultBearerEntrySweepThreshold and stays above it made every
// subsequent recordFailure call re-scan the whole map for nothing (the
// predicate above finds nothing to delete while entries stay fresh),
// which is O(n) work under b.mu on every call — O(n^2) to fill the map —
// while blocked() waits on the same mutex on every bearer request
// (FIX-28). nextSweepAt doubles after each sweep so the scan frequency
// grows logarithmically with the flood size instead of on every call.
if len(b.entries) > b.nextSweepAt {
cutoff := now.Add(-(b.window + b.penalty))
for k, e := range b.entries {
if e.penaltyUntil.Before(cutoff) && now.Sub(e.firstFailureAt) > b.window {
delete(b.entries, k)
}
}
b.sweepPasses++
next := 2 * len(b.entries)
if next < defaultBearerEntrySweepThreshold {
next = defaultBearerEntrySweepThreshold
}
b.nextSweepAt = next
}
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 = err.retryAfter
if retryAfter <= 0 {
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())))
}
// Auth-rejection responses must never be cached (R101 contract): a
// cached 429/401 could be replayed to a client well after the
// penalty box expired or the session recovered.
rw.Header().Set("Cache-Control", "no-store")
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 {
// Carry the actual remaining penalty (diverges from the configured
// default on clock-skew, partial-window expiry) so writeBearerError
// emits the true Retry-After rather than the full penalty.
throttled := newBearerError(bearerErrThrottled, "ip in penalty box")
throttled.retryAfter = retryAfter
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
}
// Determine whether this is genuinely a JWT. RFC 7662 token
// introspection covers opaque access tokens; when the operator
// requires live introspection (requireTokenIntrospection, whose
// documented purpose is exactly opaque-token support via
// client_secret_basic), introspect the opaque token on the bearer
// path instead of rejecting it — the session path already does
// (validateOpaqueToken). Previously the JWT-shape gate ran before
// the introspection branch, so every opaque token got a 401 even
// with introspection on (R159).
//
// A token is only treated as a JWT when it has exactly two dots AND
// its header actually parses as a JOSE header. Dot count alone is
// insufficient: an opaque token that coincidentally contains exactly
// two dots used to be routed onto the JWT path and rejected even
// under requireTokenIntrospection (header parse would fail) — it must
// instead be introspected like any other opaque token (R166).
isJWT := joseHeaderWellFormed(token)
if !isJWT {
if t.requireTokenIntrospection {
p, bErr := t.buildPrincipalFromOpaqueIntrospection(token)
if bErr != nil {
if bErr.kind != bearerErrIntrospectionUnavailable {
t.bearerFailureTracker.recordFailure(ip)
}
t.writeBearerError(rw, req, bErr)
return
}
t.bearerFailureTracker.recordSuccess(ip)
if t.logger != nil {
t.logger.Debugf("bearer auth success (introspected opaque): identifier_hash=%s path=%s",
hashIdentifierForLog(p.Identifier), req.URL.Path)
}
t.forwardAuthorized(rw, req, p)
return
}
t.bearerFailureTracker.recordFailure(ip)
t.writeBearerError(rw, req, newBearerError(bearerErrInvalidToken, "token is not a valid JWT"))
return
}
// Structurally a JWT: enforce the alg/kid policy EARLY (before the
// cached fast-path in buildPrincipalFromBearerToken can short-circuit
// on a prior positive verdict). Without this pin an alg=none or
// oversized-kid token that had been cached as verified would sail
// through without re-checking.
if bErr := parseBearerJOSEHeader(token); bErr != nil {
t.bearerFailureTracker.recordFailure(ip)
t.writeBearerError(rw, req, bErr)
return
}
p, bErr := t.buildPrincipalFromBearerToken(token)
if bErr != nil {
// Count only authentication failures (401/403) toward the per-IP
// throttle. An introspection outage (503) is infrastructure, not a
// client failure; recording it would trip a 429 penalty that a
// single success can't clear even after the endpoint recovers.
if bErr.kind != bearerErrIntrospectionUnavailable {
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); 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")
}
// Snapshot clientID under metadataMu: DCR rewrites it at runtime (R137).
clientID, _, _, _, _ := t.clientCredentials()
if bErr := enforceMultiAudienceAzp(claims, 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
}
}
// Honor IdP-initiated (backchannel/front-channel) logout. The cookie
// path re-checks sessionInvalidationCache on every request; the bearer
// path previously did not, so a still-cryptographically-valid access
// token for a logged-out subject kept returning 200 (and, with
// requireTokenIntrospection, a cached positive verdict extended the
// stale window). Reject here, mirroring the cookie path: use the
// token's iat as its creation time so a token issued before the
// logout is invalidated, while a legitimately freshly-issued token
// (iat after logout) still passes (R146). iat is OPTIONAL per RFC 7519
// §4.1.6 and jwt.Verify no longer requires it (R126), so an iat-less
// token DOES reach here now. Falling back to time.Now() made such a
// token always look newer than any logout, so IdP-initiated logout
// never revoked it — reintroducing the R98 defect the cookie path's
// own fallback (sessionCreatedAtForInvalidation) avoids by using the
// zero time instead: fail closed rather than open (FIX-24). In
// production this branch is mostly defense in depth: enforceIatAge
// above already rejects an iat-less token before reaching here whenever
// maxTokenAge > 0. New() always sets it so: config.MaxTokenAgeSeconds
// <= 0 (unset, or an operator explicitly configuring 0) maps to a 24h
// default (main.go:359-364), it is never mapped to "disabled" — there
// is no config value that makes maxTokenAge 0. This fallback protects
// an iat-less token's logout check only in the case New()'s mapping
// doesn't reach: a test that constructs TraefikOidc with maxTokenAge=0
// directly, bypassing New().
subjectForInvalidation, _ := claims["sub"].(string)
sidForInvalidation, _ := claims["sid"].(string)
var createdAt time.Time
if iat, ok := claims["iat"].(float64); ok {
createdAt = time.Unix(int64(iat), 0)
}
if t.isSessionInvalidated(sidForInvalidation, subjectForInvalidation, createdAt) {
return nil, newBearerError(bearerErrInvalidToken, "session has been invalidated (logout)")
}
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
}
// buildPrincipalFromOpaqueIntrospection authenticates a bearer access
// token that has no JWT shape by live RFC 7662 introspection, and builds
// a principal whose identifier is the introspected subject. It mirrors
// introspectOnBearerPath's classification (active, token_type, expiry,
// audience) and binds the compliant subject. Only used on the bearer
// path when requireTokenIntrospection is enabled (R159).
//
// Unlike a JWT bearer token — whose signature, aud and azp are already
// bound to this client by jwt.Verify/enforceMultiAudienceAzp before this
// function's counterpart ever runs — an opaque token has no local
// verification at all; introspection is the only gate. So this function
// re-applies every JWT-path gate the introspection branch used to skip
// (R159 gap): allowOpaqueTokens, client binding, bearerIdentifierClaim +
// sanitizeBearerIdentifier, maxTokenAge, isSessionInvalidated, the local
// tokenBlacklist (raw token and jti) and nbf.
func (t *TraefikOidc) buildPrincipalFromOpaqueIntrospection(token string) (*principal, *bearerError) {
if !t.allowOpaqueTokens {
return nil, newBearerError(bearerErrInvalidToken, "opaque tokens are not enabled (set allowOpaqueTokens to true)")
}
// Same local-revocation gate the JWT path applies first
// (verifyTokenWithOpts, token_manager.go:71-75): handleLogout calls
// RevokeToken on the session's access token specifically so a token
// captured before logout cannot be reused (helpers.go). Without this
// check here, a captured opaque bearer token kept authenticating as the
// revoked subject for as long as the IdP still reported it active —
// provider-side revocation is best-effort (RevokeTokenWithProvider logs
// and continues on failure) and is not configured for most deployments.
if t.tokenBlacklist != nil {
if b, exists := t.tokenBlacklist.Get(token); exists && b != nil {
return nil, newBearerError(bearerErrTokenInactive, "token has been revoked")
}
}
resp, err := t.introspectToken(token)
if err != nil {
// Any introspection transport/HTTP error — including a 4xx, which
// per RFC 7662 s2.3 means OUR OWN client credentials were rejected,
// not that the presented token is bad — is an availability problem,
// not a verdict on the token. Only a 200 response with active=false
// is (FIX-13, supersedes R156's blanket 4xx-as-invalid mapping).
return nil, newBearerError(bearerErrIntrospectionUnavailable, "introspection failed: "+err.Error())
}
if !resp.Active {
return nil, newBearerError(bearerErrTokenInactive, "introspection reports token inactive")
}
// Mirror introspectOnBearerPath (R149): reject a definite non-access
// token_type (e.g. refresh_token); accept "access_token" and "Bearer".
if resp.TokenType != "" && resp.TokenType != "access_token" && resp.TokenType != "Bearer" {
return nil, newBearerError(bearerErrTokenInactive, "introspection token_type is not a bearer access token")
}
if resp.Exp > 0 && time.Now().After(time.Unix(resp.Exp, 0)) {
return nil, newBearerError(bearerErrTokenInactive, "introspection reports token expired")
}
// Mirrors the session path's strict nbf comparison in
// validateOpaqueToken (token_introspection.go:258-263): no clock-skew
// tolerance. This is NOT the same check the JWT path applies — its
// verifyNotBefore (jwt.go:281, via verifyTimeConstraint) allows
// ClockSkewTolerancePast (jwt.go:23, 10s) before failing. RFC 7662
// s2.2 defines nbf with the same semantics as RFC 7519's nbf claim.
if resp.Nbf > 0 && time.Now().Before(time.Unix(resp.Nbf, 0)) {
return nil, newBearerError(bearerErrTokenInactive, "introspection reports token not yet valid (nbf)")
}
// Same jti-blacklist gate the JWT path applies on a cached-token hit
// (token_manager.go:90-96): RevokeToken blacklists a JWT's jti too, and
// an IdP may echo that same jti in an opaque token's introspection
// response (e.g. a rotated token sharing the revoked JTI). Gated on
// disableReplayDetection like the JWT path, so an operator who has
// disabled replay/JTI tracking is not surprised by this new check.
if resp.Jti != "" && !t.disableReplayDetection && t.tokenBlacklist != nil {
if b, exists := t.tokenBlacklist.Get(resp.Jti); exists && b != nil {
return nil, newBearerError(bearerErrTokenInactive, "token replay detected (jti blacklisted)")
}
}
clientID, _, _, audience, _ := t.clientCredentials()
if audience != "" && audience != clientID {
// A distinct API audience is configured: the introspection response
// MUST carry a matching audience.
if resp.Aud == nil || verifyAudience(resp.Aud, audience) != nil {
return nil, newBearerError(bearerErrTokenInactive, "introspection audience mismatch")
}
} else {
// audience == clientID (the common single-app default): RFC 7662
// s2.2 leaves client scoping to the authorization server's own
// policy, so without an explicit check here an opaque token issued
// to a completely different client at the same IdP would still
// report active=true and pass. Bind it to this client via either
// client_id or aud (client_id is RFC-optional).
clientBound := resp.ClientID == clientID ||
(resp.Aud != nil && verifyAudience(resp.Aud, clientID) == nil)
if !clientBound {
return nil, newBearerError(bearerErrTokenInactive, "introspection client_id/audience does not match this client")
}
}
// maxTokenAge bound on iat, via the SAME enforceIatAge the JWT path
// uses (not a hand-rolled duplicate): RFC 7662 iat is optional, but
// New() always sets maxTokenAge > 0 (0/unset becomes 24h,
// main.go's config-default closure), so a response with no iat gives
// nothing to bound the token's age against and must fail closed —
// exactly like enforceIatAge already does for a JWT with no iat
// (R126/FIX-27 made iat optional on the JWT path too, and that path
// fails closed rather than silently skipping the bound).
iatClaims := map[string]interface{}{}
if resp.Iat > 0 {
iatClaims["iat"] = float64(resp.Iat)
}
if bErr := enforceIatAge(iatClaims, t.maxTokenAge); bErr != nil {
return nil, bErr
}
// Honor IdP-initiated (backchannel/front-channel) logout, mirroring the
// JWT bearer path (R146). RFC 7662 does not define sid, but some
// providers return it anyway (FIX-03): when the response carries one,
// check it exactly like the JWT path does, so a sid-only (front-channel)
// logout — front-channel logout always records by sid only, and so does
// a backchannel logout token that carries only sid — can revoke an
// opaque token too, not just a backchannel logout recorded by sub. iat
// absent -> zero time (fail closed, same as the JWT path's FIX-24
// contract), so a token whose age cannot be bounded is treated as
// pre-dating any logout rather than as freshly issued.
var createdAt time.Time
if resp.Iat > 0 {
createdAt = time.Unix(resp.Iat, 0)
}
if t.isSessionInvalidated(resp.Sid, resp.Sub, createdAt) {
return nil, newBearerError(bearerErrInvalidToken, "session has been invalidated (logout)")
}
// Populate every non-empty IntrospectionResponse member the RFC 7662
// response can carry, not just sub/username, so bearerIdentifierClaim
// can resolve the identifier from any of them (FIX-03) — mirroring how
// the JWT bearer path resolves against the full decoded claim set.
claims := map[string]interface{}{}
if resp.Sub != "" {
claims["sub"] = resp.Sub
}
if resp.Username != "" {
claims["username"] = resp.Username
}
if resp.ClientID != "" {
claims["client_id"] = resp.ClientID
}
if resp.Scope != "" {
claims["scope"] = resp.Scope
}
if resp.Iss != "" {
claims["iss"] = resp.Iss
}
if resp.Jti != "" {
claims["jti"] = resp.Jti
}
if resp.Aud != nil {
claims["aud"] = resp.Aud
}
rawIdentifier, bErr := resolveBearerIdentifier(claims, t.bearerIdentifierClaim)
if bErr != nil {
return nil, bErr
}
identifier, bErr := sanitizeBearerIdentifier(rawIdentifier, t.maxIdentifierLength)
if bErr != nil {
return nil, bErr
}
return &principal{
Claims: claims,
Identifier: identifier,
Subject: resp.Sub,
AccessToken: token,
Source: sourceBearer,
}, 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 {
// Any introspection transport/HTTP error is an availability
// problem, never a verdict on the token: RFC 7662 s2.3 defines a
// 401/403 from the introspection endpoint as OUR OWN client
// credentials being rejected (e.g. a rotated client_secret), and
// 408/429 as the endpoint throttling or timing out. Only a 200
// response with active=false is a definite "not active" (s2.2).
// (FIX-13, supersedes R156's blanket 4xx-as-invalid mapping — a
// misconfigured or throttled endpoint must not turn every valid
// token into a 401.)
return newBearerError(bearerErrIntrospectionUnavailable, "introspection failed: "+err.Error())
}
if !resp.Active {
return newBearerError(bearerErrTokenInactive, "introspection reports token inactive")
}
// Mirror the session path (validateOpaqueToken): an opaque-or-any
// token whose RFC 7662 token_type classifies it as a refresh token
// must not be honored as a bearer access token (R149). Only reject on
// a definite non-access match; compliant providers may omit it. RFC
// 7662's token_type is the RFC 6749 token type, whose value for an
// access token is "Bearer" (RFC 6750) — accept both spellings
// providers use (R156).
if resp.TokenType != "" && resp.TokenType != "access_token" && resp.TokenType != "Bearer" {
return newBearerError(bearerErrTokenInactive, "introspection token_type is not a bearer access token")
}
// Mirror the session path (validateOpaqueToken): an active but
// already-expired result must not pass. The positive-only cache is
// capped by time-until-exp when Exp is present, but a provider
// returning active=1 past exp would otherwise let the token pass on
// the bearer path while the identical token is rejected on the
// session path.
if resp.Exp > 0 {
expTime := time.Unix(resp.Exp, 0)
if time.Now().After(expTime) {
return newBearerError(bearerErrTokenInactive, "introspection reports token expired")
}
}
// Mirror the session path (validateOpaqueToken): when a distinct API
// audience is configured (audience != clientID), the introspection
// response MUST carry a matching audience. Fail closed on a missing or
// mismatched aud, otherwise a token minted for a different audience
// would pass the bearer path while the identical token is rejected on
// the session path. aud may be a single string or an array (RFC 7662).
// (R143)
clientID, _, _, audience, _ := t.clientCredentials()
if audience != "" && audience != clientID {
if resp.Aud == nil {
return newBearerError(bearerErrTokenInactive, "introspection reports no audience")
}
if err := verifyAudience(resp.Aud, audience); err != nil {
return newBearerError(bearerErrTokenInactive, "introspection audience mismatch")
}
}
return nil
}