mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-12 04:04:26 +00:00
The SSE and WebSocket bypass does not check the token expiry. A session stays usable for streaming until the session cookie expires, also after the IdP disables the user. The bypass now forwards the configured headers (#162), so this gap also gives the proxy secret to such a session. Add the streamingRefresh option. The default is false. When it is true and the access token has expired, the bypass refreshes the session. It uses the ID token expiry if the access token is opaque. If the IdP rejects the refresh with invalid_grant, the request gets 401. If the IdP cannot be reached, the request is forwarded as before. The bypass waits for the full coordinated refresh. A shorter wait can drop a rotated refresh token, and the next refresh then logs the user out. applyBypassUserHeaders now takes the ResponseWriter, so a refresh can write the new session cookie. Add a yaegi check for the invalid_grant path through the real token endpoint and RefreshCoordinator. Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
195 lines
7.2 KiB
Go
195 lines
7.2 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestApplyBypassUserHeaders_ClearsForgedIdentityHeaders regresses the SSE/
|
|
// WebSocket bypass path (applyBypassUserHeaders) letting client-forged
|
|
// identity headers through to the backend. Unlike forwardAuthorized, the
|
|
// bypass path previously set only X-Forwarded-User / X-Auth-Request-User
|
|
// without clearing the other owned identity headers first, so a request
|
|
// carrying e.g. "X-User-Groups: admin" reached the backend unmodified —
|
|
// a spoofable trust boundary for backends that authorize on those headers.
|
|
func TestApplyBypassUserHeaders_ClearsForgedIdentityHeaders(t *testing.T) {
|
|
sm := createTestSessionManager(t)
|
|
|
|
base := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
baseRec := httptest.NewRecorder()
|
|
|
|
session, err := sm.GetSession(base)
|
|
if err != nil {
|
|
t.Fatalf("GetSession: %v", err)
|
|
}
|
|
if err := session.SetAuthenticated(true); err != nil {
|
|
t.Fatalf("SetAuthenticated: %v", err)
|
|
}
|
|
session.SetUserIdentifier("[email protected]")
|
|
if err := session.Save(base, baseRec); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
for _, c := range baseRec.Result().Cookies() {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
// Client forges identity headers that this path does not compute.
|
|
req.Header.Set("X-User-Groups", "admin")
|
|
req.Header.Set("X-User-Roles", "superuser")
|
|
req.Header.Set("X-Auth-Request-Redirect", "/evil")
|
|
req.Header.Set("X-Auth-Request-Token", "forged-token")
|
|
req.Header.Set("X-Forwarded-User", "forged")
|
|
req.Header.Set("X-Auth-Request-User", "forged")
|
|
|
|
oidc := &TraefikOidc{
|
|
sessionManager: sm,
|
|
logger: NewLogger("debug"),
|
|
}
|
|
|
|
if ok, _ := oidc.applyBypassUserHeaders(httptest.NewRecorder(), req, "test"); !ok {
|
|
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
|
|
}
|
|
|
|
// The authoritative value wins; every other owned identity header must be
|
|
// cleared so no forged value reaches the backend.
|
|
if v := req.Header.Get("X-Forwarded-User"); v != "[email protected]" {
|
|
t.Fatalf("X-Forwarded-User = %q, want authoritative %q", v, "[email protected]")
|
|
}
|
|
for _, h := range []string{
|
|
"X-User-Groups",
|
|
"X-User-Roles",
|
|
"X-Auth-Request-Redirect",
|
|
"X-Auth-Request-Token",
|
|
} {
|
|
if v := req.Header.Get(h); v != "" {
|
|
t.Fatalf("%s survived the bypass with forged value %q; must be cleared", h, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestApplyBypassUserHeaders_UnsafeIdentifierClearsForgedValue regresses the
|
|
// sanitize-failure early return in applyBypassUserHeaders previously
|
|
// leaving an inbound forged X-Forwarded-User / X-Auth-Request-User
|
|
// intact (it returned without touching headers). Clear-before-sanitize now
|
|
// guarantees forged values are gone even when the authoritative one is
|
|
// dropped as unsafe.
|
|
func TestApplyBypassUserHeaders_UnsafeIdentifierClearsForgedValue(t *testing.T) {
|
|
sm := createTestSessionManager(t)
|
|
|
|
base := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
baseRec := httptest.NewRecorder()
|
|
|
|
session, err := sm.GetSession(base)
|
|
if err != nil {
|
|
t.Fatalf("GetSession: %v", err)
|
|
}
|
|
if err := session.SetAuthenticated(true); err != nil {
|
|
t.Fatalf("SetAuthenticated: %v", err)
|
|
}
|
|
session.SetUserIdentifier("user\nX-Injected: yes") // unsafe -> dropped
|
|
if err := session.Save(base, baseRec); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
for _, c := range baseRec.Result().Cookies() {
|
|
req.AddCookie(c)
|
|
}
|
|
req.Header.Set("X-Forwarded-User", "forged")
|
|
req.Header.Set("X-Auth-Request-User", "forged")
|
|
|
|
oidc := &TraefikOidc{
|
|
sessionManager: sm,
|
|
logger: NewLogger("debug"),
|
|
}
|
|
|
|
if ok, _ := oidc.applyBypassUserHeaders(httptest.NewRecorder(), req, "test"); !ok {
|
|
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
|
|
}
|
|
if v := req.Header.Get("X-Forwarded-User"); v != "" {
|
|
t.Fatalf("X-Forwarded-User = %q, want empty (authoritative dropped, forged cleared)", v)
|
|
}
|
|
if v := req.Header.Get("X-Auth-Request-User"); v != "" {
|
|
t.Fatalf("X-Auth-Request-User = %q, want empty (authoritative dropped, forged cleared)", v)
|
|
}
|
|
}
|
|
|
|
// TestEmailIdentityPermitted_NumericUnverifiedRejected regresses that an
|
|
// email explicitly marked unverified via a numeric email_verified claim
|
|
// (0, as emitted by some IdPs) is rejected from an email/domain
|
|
// allowlist. emailVerifiedTrue previously handled only bool/string, so a
|
|
// numeric 0 was treated as "claim absent" and the unverified email was
|
|
// permitted.
|
|
func TestEmailIdentityPermitted_NumericUnverifiedRejected(t *testing.T) {
|
|
for name, claims := range map[string]map[string]interface{}{
|
|
"numeric-zero": {"email_verified": float64(0)},
|
|
"json-number-zero": {"email_verified": json.Number("0")},
|
|
"string-zero": {"email_verified": "0"},
|
|
"bool-false": {"email_verified": false},
|
|
} {
|
|
if emailIdentityPermitted("[email protected]", claims) {
|
|
t.Fatalf("case %q: unverified email must be rejected from the allowlist", name)
|
|
}
|
|
}
|
|
|
|
for name, claims := range map[string]map[string]interface{}{
|
|
"numeric-one": {"email_verified": float64(1)},
|
|
"string-true": {"email_verified": "true"},
|
|
"bool-true": {"email_verified": true},
|
|
"absent": {},
|
|
} {
|
|
if !emailIdentityPermitted("[email protected]", claims) {
|
|
t.Fatalf("case %q: verified/absent email must be permitted", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestForceJWKSRefresh_FailedFetchDoesNotSetCooldown regresses that a
|
|
// failed live JWKS refresh does not consume the FULL per-URL cooldown
|
|
// window. Recording the cooldown before the fetch meant a transient
|
|
// 5xx/network blip (during a key rotation) routed every new-kid request
|
|
// to the stale cached keyset for the next jwksForceRefreshCooldown,
|
|
// producing "no matching public key" 401s with no upstream retry.
|
|
//
|
|
// FIX-30 (R30) intentionally records a short jwksFailureCooldown after a
|
|
// failure instead of none at all, so a burst of unknown-kid requests
|
|
// during an IdP outage does not each hit the JWKS endpoint. This test now
|
|
// pins the bound that matters: a retry becomes due well before the full
|
|
// success cooldown would have elapsed, not that no cooldown is recorded.
|
|
func TestForceJWKSRefresh_FailedFetchDoesNotSetCooldown(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := &JWKCache{
|
|
cache: NewUniversalCache(UniversalCacheConfig{Type: CacheTypeJWK}),
|
|
inflightFetches: sync.Map{},
|
|
lastForceRefresh: make(map[string]time.Time),
|
|
}
|
|
|
|
if _, _, err := c.forceJWKSRefresh(context.Background(), srv.URL, srv.Client()); err == nil {
|
|
t.Fatal("expected error from 500 JWKS response")
|
|
}
|
|
|
|
c.forceMu.Lock()
|
|
recordedAt, recorded := c.lastForceRefresh[srv.URL]
|
|
c.forceMu.Unlock()
|
|
if !recorded {
|
|
t.Fatal("expected a short failure cooldown to be recorded (R30)")
|
|
}
|
|
elapsedUntilRetryDue := jwksForceRefreshCooldown - time.Since(recordedAt)
|
|
if elapsedUntilRetryDue > jwksFailureCooldown {
|
|
t.Fatalf("failed fetch blocks retries for %v, want at most the short failure cooldown (%v); "+
|
|
"next new-kid request must not be served the stale keyset for up to the full success cooldown",
|
|
elapsedUntilRetryDue, jwksFailureCooldown)
|
|
}
|
|
}
|