diff --git a/cmd/oidcgate/interceptor.go b/cmd/oidcgate/interceptor.go index 7558b03..f3147dd 100644 --- a/cmd/oidcgate/interceptor.go +++ b/cmd/oidcgate/interceptor.go @@ -8,8 +8,8 @@ import "net/http" // silent-probe contracts cannot follow redirects. authInterceptor buffers // the header/body and, at Finalize() time: // -// - if status was 302 or 303 (redirect class we care about), rewrites -// it to 401, moves the original Location header to X-Auth-Redirect +// - if status was a redirect class (302, 303, 307, 308), rewrites it +// to 401, moves the original Location header to X-Auth-Redirect // (advisory), strips Location, preserves Set-Cookie headers (state, // PKCE, nonce — the browser will carry them into the next request), // and writes an empty body. @@ -20,6 +20,7 @@ type authInterceptor struct { status int body []byte wroteHeader bool + finalized bool } func newAuthInterceptor(inner http.ResponseWriter) *authInterceptor { @@ -51,6 +52,10 @@ func (w *authInterceptor) Write(b []byte) (int, error) { //nolint:unparam // sig // Finalize flushes the buffered response, applying the 302/303 → 401 rewrite. // Must be called exactly once after the wrapped handler returns. func (w *authInterceptor) Finalize() { + if w.finalized { + return + } + w.finalized = true switch w.status { case http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect: // Move Location → X-Auth-Redirect, strip Location, force 401, drop body. diff --git a/cmd/oidcgate/interceptor_test.go b/cmd/oidcgate/interceptor_test.go index c19b4ba..35a305c 100644 --- a/cmd/oidcgate/interceptor_test.go +++ b/cmd/oidcgate/interceptor_test.go @@ -68,3 +68,41 @@ func TestInterceptor_303SeeOtherAlsoIntercepted(t *testing.T) { t.Fatalf("303 should be intercepted to 401, got %d", rec.Code) } } + +func TestInterceptor_307TemporaryRedirectIntercepted(t *testing.T) { + rec := httptest.NewRecorder() + w := newAuthInterceptor(rec) + w.Header().Set("Location", "/elsewhere") + w.WriteHeader(http.StatusTemporaryRedirect) + w.Finalize() + if rec.Code != http.StatusUnauthorized { + t.Fatalf("307 should be intercepted to 401, got %d", rec.Code) + } +} + +func TestInterceptor_308PermanentRedirectIntercepted(t *testing.T) { + rec := httptest.NewRecorder() + w := newAuthInterceptor(rec) + w.Header().Set("Location", "/elsewhere") + w.WriteHeader(http.StatusPermanentRedirect) + w.Finalize() + if rec.Code != http.StatusUnauthorized { + t.Fatalf("308 should be intercepted to 401, got %d", rec.Code) + } +} + +func TestInterceptor_DoubleFinalizeIsNoop(t *testing.T) { + rec := httptest.NewRecorder() + w := newAuthInterceptor(rec) + w.Header().Set("X-Forwarded-User", "alice") + w.WriteHeader(http.StatusOK) + w.Finalize() + // Second call must not panic, must not change anything observable. + w.Finalize() + if rec.Code != http.StatusOK { + t.Fatalf("double Finalize must not change status, got %d", rec.Code) + } + if got := rec.Header().Get("X-Forwarded-User"); got != "alice" { + t.Errorf("double Finalize must not duplicate headers, got %q", got) + } +}