mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-17 04:37:48 +00:00
Seven findings are fixed. Each fix has a regression test. - Compare X-Forwarded-Host and the serving host case-insensitively, so a case variant cannot widen the session cookie Domain. - Reject a future iat on the opaque introspection path. - Serve the cached parsed JWKS inside the signature-refresh cooldown. - Release the half-open circuit slot when a probe panics. - Cap requests that wait for provider initialisation at 512. Extra requests receive 503 with Retry-After. - Cap cookie deletion headers at 32 cookie names per response. - Cap front-channel sid markers at 1000, so unauthenticated requests cannot evict backchannel or jti replay markers.
1100 lines
44 KiB
Go
1100 lines
44 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")
|
|
}
|
|
// The opaque introspection path never runs jwt.Verify, so a future iat
|
|
// would otherwise defeat the age bound for the token's whole lifetime.
|
|
if time.Until(iat) > ClockSkewToleranceFuture {
|
|
return newBearerError(bearerErrInvalidToken, "token iat in the future")
|
|
}
|
|
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
|
|
}
|