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
167 lines
5.1 KiB
Go
167 lines
5.1 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(httptest.NewRecorder(), 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")
|
|
}
|
|
}
|