Files
traefikoidc/review_r118_test.go
T
lukaszraczylo c9844d0ff2 fix(middleware): forward configured headers on SSE/WebSocket bypass (#162)
The SSE and WebSocket bypass forwarded only X-Forwarded-User. It did not
apply the configured headers. Backends that authenticate the proxy with a
shared-secret header returned 401 on WebSocket upgrades. Version 0.8.x sent
WebSocket upgrades through the normal flow, so this is a regression.

The bypass now renders the header templates through applyHeaderTemplates,
which forwardAuthorized also uses. Templates use the ID-token claims from
the session cookie. If the session has no ID token, they use the
access-token claims.

The bypass now also rejects a session that the IdP logged out through
back-channel or front-channel logout. Without this check, a logged-out
cookie can open streams with the proxy secret attached.

Add a yaegi check for the WebSocket case. Move tokenWithGroups to the
shared JWT test helpers, as FIX-41 requires.

Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
2026-09-28 15:24:33 +01:00

167 lines
5.0 KiB
Go

package traefikoidc
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// --- R118 fix A: token-endpoint 5xx must be retryable -----------------
// TestExchangeTokens_ReturnsHTTPErrorOn5xx regresses the dead retry branch:
// exchangeTokens returned a plain fmt.Errorf for non-200, so the retry
// executor's *HTTPError 5xx/429 classification (errors.As) never matched in
// production and transient IdP 5xx on refresh was never retried.
func TestExchangeTokens_ReturnsHTTPErrorOn5xx(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte("upstream down"))
}))
defer srv.Close()
oidc := &TraefikOidc{
tokenURL: srv.URL,
tokenHTTPClient: srv.Client(),
}
_, err := oidc.exchangeTokens(context.Background(), "refresh_token", "rt", "", "")
if err == nil {
t.Fatal("expected error from 503 token endpoint")
}
var httpErr *HTTPError
if !errors.As(err, &httpErr) {
t.Fatalf("expected *HTTPError so 5xx can be retried, got %T: %v", err, err)
}
if httpErr.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("HTTPError StatusCode = %d, want 503", httpErr.StatusCode)
}
}
func TestIsRetryableError_HTTPErrorStatus(t *testing.T) {
re := &RetryExecutor{}
cases := []struct {
status int
want bool
}{
{500, true},
{503, true},
{429, true},
{400, false},
{0, false}, // unset status = message carrier only, not retryable
}
for _, c := range cases {
err := &HTTPError{StatusCode: c.status, Message: "m"}
if got := re.isRetryableError(err); got != c.want {
t.Fatalf("isRetryableError(%d) = %v, want %v", c.status, got, c.want)
}
}
}
// --- R118 fix C: bypass role gate must fall back to access-token claims ---
// TestApplyBypassUserHeaders_AccessTokenGroupFallback regresses R118: the
// SSE/WebSocket bypass role gate read only the session's ID token, while the
// main authorization path falls back to access-token claims when no ID token
// is present. For opaque-ID-token providers (groups only in the access
// token) a legitimate user was granted on normal requests but 403'd on
// streaming, fail-closed but wrong.
func TestApplyBypassUserHeaders_AccessTokenGroupFallback(t *testing.T) {
sm := createTestSessionManager(t)
base := httptest.NewRequest(http.MethodGet, "/stream", 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]")
// Groups live in the ACCESS token; no ID token is set.
session.SetAccessToken(tokenWithGroups(t, map[string]interface{}{"groups": []string{"team-a"}}))
if err := session.Save(base, baseRec); err != nil {
t.Fatalf("Save: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
for _, c := range baseRec.Result().Cookies() {
req.AddCookie(c)
}
oidc := &TraefikOidc{
sessionManager: sm,
logger: NewLogger("debug"),
extractClaimsFunc: extractClaims,
groupClaimName: "groups", // default; set explicitly for direct construction
allowedRolesAndGroups: map[string]struct{}{"team-a": {}},
}
ok, status := oidc.applyBypassUserHeaders(req, "test")
if !ok {
t.Fatalf("bypass role gate denied access-token group; got status %d, want granted", status)
}
}
// --- R118 fix D: Start/StopProfiling must not self-deadlock ------------
func TestProfilingStartStop_NoSelfDeadlock(t *testing.T) {
pm := NewProfilingManager(NewLogger("error"))
started := make(chan error, 1)
go func() {
started <- pm.StartProfiling(ProfilingConfig{})
}()
select {
case err := <-started:
if err != nil {
t.Fatalf("StartProfiling error: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("StartProfiling deadlocked (held write lock then re-acquired read lock)")
}
stopped := make(chan struct{})
go func() {
_, _ = pm.StopProfiling()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(5 * time.Second):
t.Fatal("StopProfiling deadlocked")
}
}
// --- R118 fix E: AnalyzeLeaks must diff against baseline ---------------
func TestAnalyzeLeaks_GoroutineBaselineDiff(t *testing.T) {
pm := NewProfilingManager(NewLogger("error"))
// Inflate the live goroutine count deterministically so the diff is
// meaningful regardless of ambient (possibly very low) test count.
release := make(chan struct{})
defer close(release)
for i := 0; i < 30; i++ {
go func() {
<-release
}()
}
// Baseline captured much earlier (count 0) vs a later snapshot taken
// while 30+ goroutines are alive -> a positive, meaningful increase.
baseline := &MemorySnapshot{Goroutines: 0}
current := &MemorySnapshot{}
analysis := pm.AnalyzeLeaks(baseline, current)
if analysis.GoroutineIncrease <= 10 {
t.Fatalf("expected goroutine increase from baseline diff, got %d", analysis.GoroutineIncrease)
}
if !analysis.HasLeak {
t.Fatal("expected a goroutine-leak flag given the baseline diff")
}
}