mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
a548665edb
* docs: bearer-token auth design spec * docs: harden bearer-auth spec with security review findings * feat(bearer): opt-in M2M bearer-token authentication Adds an opt-in Authorization: Bearer <jwt> path for machine-to-machine clients. Replaces and supersedes the broken approach in PR #93 (synthetic-session that omitted user_identifier and skipped ID-token rejection / replay-protection-semantics / kid-pinning / etc.). Design Two auth entrypoints feed one shared post-auth pipeline: cookie path ─┐ ├── forwardAuthorized(rw, req, *principal) bearer path ─┘ (roles/groups, header injection, security headers, cookie strip, forward) buildPrincipalFromSession and buildPrincipalFromBearerToken produce the same `principal` value type. forwardAuthorized is session-agnostic and runs the existing post-auth work; processAuthorizedRequest now wraps it with the session-specific concerns (backchannel-logout, dirty/Save). The cookie path's behaviour is byte-identical to before this PR; the existing test suite passes unmodified. Security hardening baked into the bearer path - Audience MANDATORY. Startup fails when EnableBearerAuth=true and Audience is empty. - BearerIdentifierClaim defaults to "sub"; "email" is rejected at startup to avoid the unverified-email spoofing footgun. Cookie path's UserIdentifierClaim is unaffected and still defaults to "email". - ID tokens explicitly rejected via the existing detectTokenType helper (nonce, typ=at+jwt, token_use, scope, aud-vs-clientID heuristics); belt-and-braces nonce/token_use=id rejection on top. - alg pinned to asymmetric allowlist (RS/PS/ES 256/384/512) BEFORE JWKS fetch, blocking alg=none and alg=HS* probes from amplifying into upstream calls. - kid length capped at 256 bytes and charset-restricted before JWKS fetch, blocking pathological-kid JWKS amplification. - Multi-audience tokens require azp == clientID. - iat upper-age bound (MaxTokenAgeSeconds, default 24h) bounds clock- manipulation and forever-token abuse. - Identifier sanitization: length cap, control-char + bidi-override + delimiter (, ; =) rejection. - Per-IP failure throttle: configurable threshold/window/penalty; returns 429 + Retry-After. Limits offline-guessing-style attacks and protects the shared rate-limiter / JWKS endpoint. - JTI replay marking suppressed via new internal verifyOpts {skipReplayMarking} so the same bearer can be reused until exp; the blacklist Get stays active so RevokeToken still terminates a bearer token immediately. The existing exported VerifyToken interface is unchanged so all mocks continue to work. - Cookie wins by default when both bearer and cookie are present (safer against browser/extension/proxy bearer injection). Operator can flip via BearerOverridesCookie. - Authorization header stripped on forward by default; also stripped on excluded URLs so the token can't leak into health/metrics downstream logs. - Optional RFC 7662 introspection via existing requireTokenIntrospection. Introspection-endpoint failure returns 503 (distinguishes infra from token rejection). - 401s use RFC 6750 WWW-Authenticate hints (toggleable). Failure reason is logged at debug; raw tokens are never logged. Implementation - principal.go: pure-data principal type and buildPrincipalFromSession. - bearer_auth.go: alg/kid pin, classifier, identifier sanitization, multi-aud azp gate, iat age check, per-IP failure tracker, handleBearerRequest, buildPrincipalFromBearerToken. - token_manager.go: VerifyToken now wraps a new verifyTokenWithOpts that accepts internal-only verifyOpts. Existing callers, the TokenVerifier interface, and all mocks unchanged. - middleware.go: extracted forwardAuthorized from processAuthorizedRequest; wired bearer detection after init wait + after bypass; excluded-URL Authorization strip when bearer enabled. - settings.go: ten new config fields with defaults applied in CreateConfig. - main.go: startup validation for audience + identifier-claim guard; bearer failure tracker init. Tests - bearer_auth_test.go: table-driven helper tests for every new component (parseBearerJOSEHeader, sanitizeBearerIdentifier, resolveBearerIdentifier, enforceMultiAudienceAzp, enforceIatAge, bearerFailureTracker, detectBearerToken). Integration tests through ServeHTTP covering happy path, ID-token rejection, alg=none rejection, oversized kid, multi-aud with/without azp, iat-too-old, bidi identifier, replay (100x reuse), 429 throttle trip, excluded-URL strip, roles gate, cookie-wins precedence, BearerOverridesCookie, oversized token, malformed JWT, feature-off pass-through. Startup validation for audience- required and email-identifier-rejected. - All existing tests pass unmodified (cookie-path regression). - go vet clean. golangci-lint clean (0 issues). Race detector clean on bearer tests. Documentation - README.md: bearer auth section with security highlights and config snippet; doc link in the index. - .traefik.yml: commented config block exposing every bearer knob. - docs/CONFIGURATION.md: new subsection with full parameter table. - docs/BEARER_AUTH.md: threat model, hardening matrix, failure response table, operational guidance, known follow-ups. - docs/superpowers/specs/2026-05-18-bearer-token-auth-design.md: design spec + security-review hardening history. * fix(cache): redact raw cache keys in debug logs (CodeQL go/clear-text-logging) CodeQL flagged 9 high-severity alerts (go/clear-text-logging) where the in-memory cache and the hybrid L1+L2 backend printed `key=%s` at debug. Cache callers (token cache, blacklist, introspection cache) pass raw access / refresh / id tokens as cache keys, so any debug-enabled deployment would write them to log streams. Pre-existing issue. CodeQL started flagging it on this PR because the new bearer-auth path adds a data-flow source (req.Header.Get("Authorization")) that reaches the existing logging sinks via the same cache. The cookie path had the same risk but wasn't tracked as taint by CodeQL. Fix: hash the key (SHA-256[:8] hex) before printing. Same approach the bearer-auth logger uses for principal identifiers (spec §13). Doesn't change cache semantics — same key still produces the same hash, so debug correlation across log lines is preserved without exposing the raw value. Touches both affected packages: - internal/cache/cache.go (2 sites: Set + LRU eviction) - internal/cache/backends/hybrid.go (12 sites: L1/L2 read/write/fallback) New helper `redactKey` colocated with each package (unexported, package-local) keeps the change blast radius narrow. Tests green; lint clean. * docs(bearer): how to obtain bearer tokens from the OIDC provider Adds a section walking operators through the OAuth 2.0 client_credentials flow (RFC 6749 §4.4) and the JWT bearer assertion alternative (RFC 7523), with a worked Auth0-shape curl example, a per-provider quick reference (Auth0, Okta, Keycloak, Entra v2, Cognito, GitLab, Google), operational notes (token TTL, caching, JWKS rotation, revocation, scope vs audience, secret hygiene), and a three-line validation loop. Most common operator confusion: "I enabled the feature but tokens get 401'd" — almost always missing or wrong audience. The new section makes the audience-matching requirement loud, with per-provider parameter names so people don't have to dig through IdP docs. Locations: - docs/BEARER_AUTH.md — full section under "Quick start" - README.md — short snippet + deep link
813 lines
28 KiB
Go
813 lines
28 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// =============================================================================
|
|
// Helper builders
|
|
// =============================================================================
|
|
|
|
// makeBearerJWT constructs a JWT with explicit header + claims for tests.
|
|
// Signature is opaque (b64("signature")) — bearer tests don't exercise the
|
|
// real cryptographic verifier; verification is bypassed via tokenCache pre-
|
|
// seed so the bearer pipeline under test sees a "verified" token.
|
|
func makeBearerJWT(t *testing.T, header, claims map[string]interface{}) string {
|
|
t.Helper()
|
|
hb, err := json.Marshal(header)
|
|
if err != nil {
|
|
t.Fatalf("marshal header: %v", err)
|
|
}
|
|
cb, err := json.Marshal(claims)
|
|
if err != nil {
|
|
t.Fatalf("marshal claims: %v", err)
|
|
}
|
|
return fmt.Sprintf("%s.%s.%s",
|
|
base64.RawURLEncoding.EncodeToString(hb),
|
|
base64.RawURLEncoding.EncodeToString(cb),
|
|
base64.RawURLEncoding.EncodeToString([]byte("signature")),
|
|
)
|
|
}
|
|
|
|
// defaultBearerHeader produces the standard RS256+kid header used in tests.
|
|
func defaultBearerHeader() map[string]interface{} {
|
|
return map[string]interface{}{"alg": "RS256", "kid": "test-kid"}
|
|
}
|
|
|
|
// defaultBearerClaims produces a baseline access-token claim set. Tests
|
|
// shallow-clone and override fields as needed.
|
|
func defaultBearerClaims() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"iss": "https://issuer.example.com",
|
|
"aud": "https://api.example.com",
|
|
"sub": "service-account-1",
|
|
"scope": "api:read api:write",
|
|
"exp": float64(time.Now().Add(time.Hour).Unix()),
|
|
"iat": float64(time.Now().Unix()),
|
|
}
|
|
}
|
|
|
|
// makeBearerOIDC constructs a TraefikOidc wired for bearer auth tests. The
|
|
// real verifyTokenWithOpts pipeline is short-circuited via tokenCache pre-
|
|
// seed: any token Set into t.tokenCache returns nil from VerifyToken,
|
|
// letting tests exercise the post-verify bearer logic (classifier, identifier,
|
|
// throttle, header forwarding) without standing up JWKs.
|
|
func makeBearerOIDC(t *testing.T, next http.Handler) *TraefikOidc {
|
|
t.Helper()
|
|
sm := createTestSessionManager(t)
|
|
oidc := &TraefikOidc{
|
|
next: next,
|
|
logger: NewLogger("error"),
|
|
initComplete: make(chan struct{}),
|
|
sessionManager: sm,
|
|
firstRequestReceived: true,
|
|
metadataRefreshStarted: true,
|
|
issuerURL: "https://issuer.example.com",
|
|
audience: "https://api.example.com",
|
|
clientID: "https://api.example.com",
|
|
tokenCache: NewTokenCache(),
|
|
excludedURLs: map[string]struct{}{"/favicon.ico": {}},
|
|
allowedRolesAndGroups: map[string]struct{}{},
|
|
limiter: rate.NewLimiter(rate.Every(time.Second), 1000),
|
|
ctx: context.Background(),
|
|
enableBearerAuth: true,
|
|
stripAuthorizationHeader: true,
|
|
bearerEmitWWWAuthenticate: true,
|
|
bearerOverridesCookie: false,
|
|
bearerIdentifierClaim: "sub",
|
|
maxIdentifierLength: 256,
|
|
maxTokenAge: 24 * time.Hour,
|
|
bearerFailureThreshold: 20,
|
|
bearerFailureWindow: 60 * time.Second,
|
|
bearerFailurePenalty: 60 * time.Second,
|
|
bearerFailureTracker: newBearerFailureTracker(20, 60*time.Second, 60*time.Second),
|
|
}
|
|
oidc.extractClaimsFunc = extractClaims
|
|
close(oidc.initComplete)
|
|
return oidc
|
|
}
|
|
|
|
// seedVerified pre-populates the tokenCache so verifyTokenWithOpts short-
|
|
// circuits to nil for the given token. Mirrors the production fast-return
|
|
// path at token_manager.go for previously-verified tokens.
|
|
func seedVerified(t *testing.T, oidc *TraefikOidc, token string, claims map[string]interface{}) {
|
|
t.Helper()
|
|
if oidc.tokenCache == nil {
|
|
oidc.tokenCache = NewTokenCache()
|
|
}
|
|
oidc.tokenCache.Set(token, claims, time.Hour)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Unit tests — small helpers
|
|
// =============================================================================
|
|
|
|
func TestDetectBearerToken(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
header string
|
|
want string
|
|
ok bool
|
|
}{
|
|
{"missing header", "", "", false},
|
|
{"basic auth", "Basic abc", "", false},
|
|
{"bearer with token", "Bearer abc.def.ghi", "abc.def.ghi", true},
|
|
{"lowercase bearer", "bearer abc.def.ghi", "abc.def.ghi", true},
|
|
{"mixed case", "BeArEr abc.def.ghi", "abc.def.ghi", true},
|
|
{"empty token after prefix", "Bearer ", "", false},
|
|
{"bearer no space", "Bearerabc", "", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
if tc.header != "" {
|
|
req.Header.Set("Authorization", tc.header)
|
|
}
|
|
got, ok := detectBearerToken(req)
|
|
if ok != tc.ok || got != tc.want {
|
|
t.Fatalf("got=(%q, %v), want=(%q, %v)", got, ok, tc.want, tc.ok)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseBearerJOSEHeader(t *testing.T) {
|
|
t.Parallel()
|
|
mk := func(t *testing.T, h map[string]interface{}) string {
|
|
return makeBearerJWT(t, h, map[string]interface{}{"sub": "x"})
|
|
}
|
|
cases := []struct {
|
|
header map[string]interface{}
|
|
name string
|
|
wantErr bool
|
|
}{
|
|
{name: "valid RS256", header: map[string]interface{}{"alg": "RS256", "kid": "k1"}, wantErr: false},
|
|
{name: "valid ES512", header: map[string]interface{}{"alg": "ES512", "kid": "abc-_.="}, wantErr: false},
|
|
{name: "alg=none rejected", header: map[string]interface{}{"alg": "none", "kid": "k1"}, wantErr: true},
|
|
{name: "alg=HS256 rejected", header: map[string]interface{}{"alg": "HS256", "kid": "k1"}, wantErr: true},
|
|
{name: "missing kid", header: map[string]interface{}{"alg": "RS256"}, wantErr: true},
|
|
{name: "kid too long", header: map[string]interface{}{"alg": "RS256", "kid": strings.Repeat("a", bearerKidMaxLen+1)}, wantErr: true},
|
|
{name: "kid bad chars", header: map[string]interface{}{"alg": "RS256", "kid": "evil/../etc/passwd"}, wantErr: true},
|
|
{name: "kid with space", header: map[string]interface{}{"alg": "RS256", "kid": "key one"}, wantErr: true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
token := mk(t, tc.header)
|
|
err := parseBearerJOSEHeader(token)
|
|
if (err != nil) != tc.wantErr {
|
|
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSanitiseBearerIdentifier(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{"normal sub", "service-account-1", "service-account-1", false},
|
|
{"email-like", "alice@example.com", "alice@example.com", false},
|
|
{"trim whitespace", " abc ", "abc", false},
|
|
{"empty", "", "", true},
|
|
{"only whitespace", " ", "", true},
|
|
{"control char (newline)", "alice\nbob", "", true},
|
|
{"control char (CR)", "alice\rbob", "", true},
|
|
{"control char (NUL)", "alice\x00bob", "", true},
|
|
{"bidi override", "alice\u202ebob", "", true},
|
|
{"bidi isolate", "alice\u2066bob", "", true},
|
|
{"comma delimiter", "alice,bob", "", true},
|
|
{"semicolon delimiter", "alice;bob", "", true},
|
|
{"equals delimiter", "alice=bob", "", true},
|
|
{"over length", strings.Repeat("a", 257), "", true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := sanitizeBearerIdentifier(tc.in, 256)
|
|
if (err != nil) != tc.wantErr {
|
|
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
|
|
}
|
|
if !tc.wantErr && got != tc.want {
|
|
t.Fatalf("got=%q want=%q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveBearerIdentifier(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
claims map[string]interface{}
|
|
name string
|
|
claim string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{name: "default sub", claims: map[string]interface{}{"sub": "abc"}, claim: "", want: "abc"},
|
|
{name: "explicit sub", claims: map[string]interface{}{"sub": "abc"}, claim: "sub", want: "abc"},
|
|
{name: "custom client_id claim", claims: map[string]interface{}{"client_id": "svc"}, claim: "client_id", want: "svc"},
|
|
{name: "missing claim", claims: map[string]interface{}{"other": "x"}, claim: "sub", wantErr: true},
|
|
{name: "non-string claim", claims: map[string]interface{}{"sub": 123}, claim: "sub", wantErr: true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := resolveBearerIdentifier(tc.claims, tc.claim)
|
|
if (err != nil) != tc.wantErr {
|
|
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
|
|
}
|
|
if !tc.wantErr && got != tc.want {
|
|
t.Fatalf("got=%q want=%q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnforceMultiAudienceAzp(t *testing.T) {
|
|
t.Parallel()
|
|
const cid = "https://api.example.com"
|
|
cases := []struct {
|
|
claims map[string]interface{}
|
|
name string
|
|
wantErr bool
|
|
}{
|
|
{name: "single string aud", claims: map[string]interface{}{"aud": "x"}, wantErr: false},
|
|
{name: "single element array", claims: map[string]interface{}{"aud": []interface{}{"x"}}, wantErr: false},
|
|
{name: "multi-aud with matching azp", claims: map[string]interface{}{"aud": []interface{}{"a", "b"}, "azp": cid}, wantErr: false},
|
|
{name: "multi-aud missing azp", claims: map[string]interface{}{"aud": []interface{}{"a", "b"}}, wantErr: true},
|
|
{name: "multi-aud empty azp", claims: map[string]interface{}{"aud": []interface{}{"a", "b"}, "azp": ""}, wantErr: true},
|
|
{name: "multi-aud wrong azp", claims: map[string]interface{}{"aud": []interface{}{"a", "b"}, "azp": "other"}, wantErr: true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := enforceMultiAudienceAzp(tc.claims, cid)
|
|
if (err != nil) != tc.wantErr {
|
|
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnforceIatAge(t *testing.T) {
|
|
t.Parallel()
|
|
now := time.Now()
|
|
cases := []struct {
|
|
name string
|
|
iat float64
|
|
maxAge time.Duration
|
|
wantErr bool
|
|
}{
|
|
{name: "fresh", iat: float64(now.Unix()), maxAge: time.Hour, wantErr: false},
|
|
{name: "23h59m old, max 24h", iat: float64(now.Add(-23*time.Hour - 59*time.Minute).Unix()), maxAge: 24 * time.Hour, wantErr: false},
|
|
{name: "25h old, max 24h", iat: float64(now.Add(-25 * time.Hour).Unix()), maxAge: 24 * time.Hour, wantErr: true},
|
|
{name: "1970 token", iat: float64(0), maxAge: 24 * time.Hour, wantErr: true},
|
|
{name: "maxAge disabled (0)", iat: float64(0), maxAge: 0, wantErr: false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := enforceIatAge(map[string]interface{}{"iat": tc.iat}, tc.maxAge)
|
|
if (err != nil) != tc.wantErr {
|
|
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBearerFailureTracker(t *testing.T) {
|
|
t.Parallel()
|
|
tr := newBearerFailureTracker(3, 60*time.Second, 60*time.Second)
|
|
const ip = "10.0.0.1"
|
|
// Below threshold: not blocked.
|
|
for i := 0; i < 2; i++ {
|
|
tr.recordFailure(ip)
|
|
if b, _ := tr.blocked(ip); b {
|
|
t.Fatalf("blocked too early after %d failures", i+1)
|
|
}
|
|
}
|
|
// Threshold reached: blocked.
|
|
tr.recordFailure(ip)
|
|
if b, retry := tr.blocked(ip); !b || retry <= 0 {
|
|
t.Fatalf("expected blocked with positive retry, got=%v retry=%v", b, retry)
|
|
}
|
|
// Success clears the counter.
|
|
tr.recordSuccess(ip)
|
|
if b, _ := tr.blocked(ip); b {
|
|
t.Fatalf("expected unblocked after success")
|
|
}
|
|
// Other IPs are unaffected.
|
|
if b, _ := tr.blocked("10.0.0.2"); b {
|
|
t.Fatalf("unrelated IP should not be blocked")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Integration tests — full ServeHTTP via the bearer pipeline
|
|
// =============================================================================
|
|
|
|
func TestServeHTTP_Bearer_HappyPath(t *testing.T) {
|
|
t.Parallel()
|
|
var nextCalled atomic.Bool
|
|
var capturedHeaders http.Header
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
nextCalled.Store(true)
|
|
capturedHeaders = r.Header.Clone()
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
claims := defaultBearerClaims()
|
|
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
|
seedVerified(t, oidc, token, claims)
|
|
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
if !nextCalled.Load() {
|
|
t.Fatalf("expected next handler to run; got status=%d body=%q", rw.Code, rw.Body.String())
|
|
}
|
|
if rw.Code != http.StatusOK {
|
|
t.Fatalf("status=%d, want 200", rw.Code)
|
|
}
|
|
if got := capturedHeaders.Get("X-Forwarded-User"); got != "service-account-1" {
|
|
t.Fatalf("X-Forwarded-User=%q, want service-account-1", got)
|
|
}
|
|
if got := capturedHeaders.Get("Authorization"); got != "" {
|
|
t.Fatalf("Authorization should be stripped, got=%q", got)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_StripAuthDisabled(t *testing.T) {
|
|
t.Parallel()
|
|
var capturedAuth string
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
capturedAuth = r.Header.Get("Authorization")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
oidc.stripAuthorizationHeader = false
|
|
claims := defaultBearerClaims()
|
|
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
|
seedVerified(t, oidc, token, claims)
|
|
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
if !strings.HasPrefix(capturedAuth, "Bearer ") {
|
|
t.Fatalf("expected Authorization to be forwarded, got=%q", capturedAuth)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_RejectIDToken(t *testing.T) {
|
|
t.Parallel()
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatalf("next must not run for ID token rejection")
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
// ID-token shape: nonce claim present and no scope. detectTokenType
|
|
// returns true.
|
|
claims := map[string]interface{}{
|
|
"iss": "https://issuer.example.com",
|
|
"aud": "https://api.example.com",
|
|
"sub": "user-1",
|
|
"nonce": "n-0S6_WzA2Mj",
|
|
"exp": float64(time.Now().Add(time.Hour).Unix()),
|
|
"iat": float64(time.Now().Unix()),
|
|
}
|
|
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
|
seedVerified(t, oidc, token, claims)
|
|
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
if rw.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status=%d, want 401", rw.Code)
|
|
}
|
|
if wa := rw.Header().Get("WWW-Authenticate"); !strings.Contains(wa, `error="invalid_token"`) {
|
|
t.Fatalf("expected WWW-Authenticate invalid_token, got=%q", wa)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_AlgNoneRejected(t *testing.T) {
|
|
t.Parallel()
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatalf("next must not run for alg=none")
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
header := map[string]interface{}{"alg": "none", "kid": "k1"}
|
|
claims := defaultBearerClaims()
|
|
token := makeBearerJWT(t, header, claims)
|
|
// Even if we pre-seeded the cache, the early alg pin runs FIRST.
|
|
seedVerified(t, oidc, token, claims)
|
|
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
if rw.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status=%d, want 401", rw.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_KidTooLongRejected(t *testing.T) {
|
|
t.Parallel()
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatalf("next must not run for oversized kid")
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
header := map[string]interface{}{"alg": "RS256", "kid": strings.Repeat("a", bearerKidMaxLen+1)}
|
|
claims := defaultBearerClaims()
|
|
token := makeBearerJWT(t, header, claims)
|
|
seedVerified(t, oidc, token, claims)
|
|
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
if rw.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status=%d, want 401", rw.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_MultiAudRequiresAzp(t *testing.T) {
|
|
t.Parallel()
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatalf("next must not run for multi-aud without azp")
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
claims := defaultBearerClaims()
|
|
claims["aud"] = []interface{}{"https://api.example.com", "https://other.example.com"}
|
|
delete(claims, "azp")
|
|
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
|
seedVerified(t, oidc, token, claims)
|
|
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
if rw.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status=%d, want 401", rw.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_MultiAudWithAzpAccepted(t *testing.T) {
|
|
t.Parallel()
|
|
var nextCalled atomic.Bool
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
nextCalled.Store(true)
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
claims := defaultBearerClaims()
|
|
claims["aud"] = []interface{}{"https://api.example.com", "https://other.example.com"}
|
|
claims["azp"] = oidc.clientID
|
|
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
|
seedVerified(t, oidc, token, claims)
|
|
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
if rw.Code != http.StatusOK || !nextCalled.Load() {
|
|
t.Fatalf("expected 200 + next called; got status=%d called=%v", rw.Code, nextCalled.Load())
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_IatTooOldRejected(t *testing.T) {
|
|
t.Parallel()
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatalf("next must not run for old iat")
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
claims := defaultBearerClaims()
|
|
claims["iat"] = float64(time.Now().Add(-25 * time.Hour).Unix())
|
|
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
|
seedVerified(t, oidc, token, claims)
|
|
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
if rw.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status=%d, want 401", rw.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_IdentifierWithBidiRejected(t *testing.T) {
|
|
t.Parallel()
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatalf("next must not run for bidi identifier")
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
claims := defaultBearerClaims()
|
|
claims["sub"] = "alice\u202ebob"
|
|
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
|
seedVerified(t, oidc, token, claims)
|
|
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
if rw.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status=%d, want 401", rw.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_ReplayRegression(t *testing.T) {
|
|
t.Parallel()
|
|
var successCount atomic.Int32
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
successCount.Add(1)
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
claims := defaultBearerClaims()
|
|
claims["jti"] = "regression-jti"
|
|
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
|
seedVerified(t, oidc, token, claims)
|
|
|
|
for i := 0; i < 100; i++ {
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
if rw.Code != http.StatusOK {
|
|
t.Fatalf("iteration %d: status=%d, want 200", i, rw.Code)
|
|
}
|
|
}
|
|
if successCount.Load() != 100 {
|
|
t.Fatalf("successCount=%d, want 100", successCount.Load())
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_ThrottleTrips429(t *testing.T) {
|
|
t.Parallel()
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatalf("next must not run during throttle test")
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
oidc.bearerFailureTracker = newBearerFailureTracker(3, 60*time.Second, 60*time.Second)
|
|
|
|
// Send malformed bearers from the same RemoteAddr until threshold trips.
|
|
send := func() *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.RemoteAddr = "10.0.0.5:1234"
|
|
req.Header.Set("Authorization", "Bearer not-a-jwt")
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
return rw
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
rw := send()
|
|
if rw.Code != http.StatusUnauthorized {
|
|
t.Fatalf("pre-throttle iteration %d: status=%d, want 401", i, rw.Code)
|
|
}
|
|
}
|
|
// 4th request: throttled.
|
|
rw := send()
|
|
if rw.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("expected 429 after threshold, got %d", rw.Code)
|
|
}
|
|
if ra := rw.Header().Get("Retry-After"); ra == "" {
|
|
t.Fatalf("expected Retry-After header on 429")
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_ExcludedURLStripsAuth(t *testing.T) {
|
|
t.Parallel()
|
|
var capturedAuth string
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
capturedAuth = r.Header.Get("Authorization")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
oidc.excludedURLs = map[string]struct{}{"/favicon.ico": {}}
|
|
|
|
req := httptest.NewRequest("GET", "/favicon.ico", nil)
|
|
req.Header.Set("Authorization", "Bearer abc.def.ghi")
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
if rw.Code != http.StatusOK {
|
|
t.Fatalf("excluded path should pass; got %d", rw.Code)
|
|
}
|
|
if capturedAuth != "" {
|
|
t.Fatalf("Authorization must be stripped on excluded paths, got=%q", capturedAuth)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_RolesGate(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
rolesClaim []interface{}
|
|
want int
|
|
}{
|
|
{name: "matching role", rolesClaim: []interface{}{"admin"}, want: http.StatusOK},
|
|
{name: "no matching role", rolesClaim: []interface{}{"viewer"}, want: http.StatusForbidden},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
oidc.allowedRolesAndGroups = map[string]struct{}{"admin": {}}
|
|
oidc.roleClaimName = "roles"
|
|
claims := defaultBearerClaims()
|
|
claims["roles"] = tc.rolesClaim
|
|
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
|
seedVerified(t, oidc, token, claims)
|
|
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
if rw.Code != tc.want {
|
|
t.Fatalf("status=%d, want %d", rw.Code, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_CookieWinsByDefault(t *testing.T) {
|
|
t.Parallel()
|
|
// Both cookie and bearer present: cookie path runs (which will redirect
|
|
// to /authorize since the cookie is empty/unauthenticated).
|
|
var nextCalled atomic.Bool
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
nextCalled.Store(true)
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
claims := defaultBearerClaims()
|
|
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
|
seedVerified(t, oidc, token, claims)
|
|
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
prefix := oidc.sessionManager.GetCookiePrefix()
|
|
req.AddCookie(&http.Cookie{Name: prefix + "main", Value: "irrelevant"})
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
// Cookie path consumed the request; bearer was ignored. Since the
|
|
// cookie is empty, the cookie path will either 302 to /authorize or
|
|
// return 401 — in either case, next must NOT be called.
|
|
if nextCalled.Load() {
|
|
t.Fatalf("next must not be called when bearer is ignored due to cookie precedence")
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_BearerOverridesCookie(t *testing.T) {
|
|
t.Parallel()
|
|
var nextCalled atomic.Bool
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
nextCalled.Store(true)
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
oidc.bearerOverridesCookie = true
|
|
claims := defaultBearerClaims()
|
|
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
|
seedVerified(t, oidc, token, claims)
|
|
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
prefix := oidc.sessionManager.GetCookiePrefix()
|
|
req.AddCookie(&http.Cookie{Name: prefix + "main", Value: "irrelevant"})
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
if !nextCalled.Load() || rw.Code != http.StatusOK {
|
|
t.Fatalf("expected bearer to win with override; status=%d called=%v", rw.Code, nextCalled.Load())
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_OversizedToken(t *testing.T) {
|
|
t.Parallel()
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatalf("next must not run for oversized token")
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
huge := strings.Repeat("a", AccessTokenConfig.MaxLength+1)
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer "+huge)
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
if rw.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status=%d, want 401", rw.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_MalformedJWT(t *testing.T) {
|
|
t.Parallel()
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatalf("next must not run for malformed JWT")
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer not.jwt") // 1 dot
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
if rw.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status=%d, want 401", rw.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_Bearer_FeatureOffPassesThrough(t *testing.T) {
|
|
t.Parallel()
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Should not be reached: cookie path runs and (with no session)
|
|
// will redirect or 401. We assert no panic / next not called.
|
|
t.Fatalf("next must not run when bearer is off and no valid session exists")
|
|
})
|
|
oidc := makeBearerOIDC(t, next)
|
|
oidc.enableBearerAuth = false
|
|
claims := defaultBearerClaims()
|
|
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
|
seedVerified(t, oidc, token, claims)
|
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rw := httptest.NewRecorder()
|
|
oidc.ServeHTTP(rw, req)
|
|
// Expect non-200: either 302 to /authorize or 401. The point is the
|
|
// bearer pipeline didn't run.
|
|
if rw.Code == http.StatusOK {
|
|
t.Fatalf("expected non-200 when bearer is off; got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Startup validation tests
|
|
// =============================================================================
|
|
|
|
func TestStartupValidation_BearerRequiresAudience(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := CreateConfig()
|
|
cfg.ProviderURL = "https://issuer.example.com"
|
|
cfg.ClientID = "id"
|
|
cfg.ClientSecret = "secret"
|
|
cfg.CallbackURL = "/oauth/callback"
|
|
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
cfg.EnableBearerAuth = true
|
|
cfg.Audience = ""
|
|
_, err := New(context.Background(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), cfg, "bearer-test")
|
|
if err == nil || !strings.Contains(err.Error(), "requires Audience") {
|
|
t.Fatalf("expected audience-required error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStartupValidation_BearerRejectsEmailIdentifier(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := CreateConfig()
|
|
cfg.ProviderURL = "https://issuer.example.com"
|
|
cfg.ClientID = "id"
|
|
cfg.ClientSecret = "secret"
|
|
cfg.CallbackURL = "/oauth/callback"
|
|
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
cfg.EnableBearerAuth = true
|
|
cfg.Audience = "https://api.example.com"
|
|
cfg.BearerIdentifierClaim = "email"
|
|
_, err := New(context.Background(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), cfg, "bearer-test")
|
|
if err == nil || !strings.Contains(err.Error(), "bearerIdentifierClaim=\"email\"") {
|
|
t.Fatalf("expected email-identifier rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Principal invariants
|
|
// =============================================================================
|
|
|
|
func TestBuildPrincipalFromSession_NoIdentifier(t *testing.T) {
|
|
t.Parallel()
|
|
oidc := &TraefikOidc{logger: NewLogger("error")}
|
|
if p := oidc.buildPrincipalFromSession(nil); p != nil {
|
|
t.Fatalf("nil session must produce nil principal")
|
|
}
|
|
}
|