refactor(oidcgate): idempotent Finalize; document and test 307/308 intercept

This commit is contained in:
2026-05-19 13:57:15 +01:00
parent c465fc888b
commit 91966c1bec
2 changed files with 45 additions and 2 deletions
+7 -2
View File
@@ -8,8 +8,8 @@ import "net/http"
// silent-probe contracts cannot follow redirects. authInterceptor buffers // silent-probe contracts cannot follow redirects. authInterceptor buffers
// the header/body and, at Finalize() time: // the header/body and, at Finalize() time:
// //
// - if status was 302 or 303 (redirect class we care about), rewrites // - if status was a redirect class (302, 303, 307, 308), rewrites it
// it to 401, moves the original Location header to X-Auth-Redirect // to 401, moves the original Location header to X-Auth-Redirect
// (advisory), strips Location, preserves Set-Cookie headers (state, // (advisory), strips Location, preserves Set-Cookie headers (state,
// PKCE, nonce — the browser will carry them into the next request), // PKCE, nonce — the browser will carry them into the next request),
// and writes an empty body. // and writes an empty body.
@@ -20,6 +20,7 @@ type authInterceptor struct {
status int status int
body []byte body []byte
wroteHeader bool wroteHeader bool
finalized bool
} }
func newAuthInterceptor(inner http.ResponseWriter) *authInterceptor { 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. // Finalize flushes the buffered response, applying the 302/303 → 401 rewrite.
// Must be called exactly once after the wrapped handler returns. // Must be called exactly once after the wrapped handler returns.
func (w *authInterceptor) Finalize() { func (w *authInterceptor) Finalize() {
if w.finalized {
return
}
w.finalized = true
switch w.status { switch w.status {
case http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect: case http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
// Move Location → X-Auth-Redirect, strip Location, force 401, drop body. // Move Location → X-Auth-Redirect, strip Location, force 401, drop body.
+38
View File
@@ -68,3 +68,41 @@ func TestInterceptor_303SeeOtherAlsoIntercepted(t *testing.T) {
t.Fatalf("303 should be intercepted to 401, got %d", rec.Code) 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)
}
}