mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-14 04:07:46 +00:00
- fail-on-old regression tests for every shipped fix (R-rounds) - fuzz targets (parseJWT, bearer), concurrency-stress, shuffle-flake guards - coverage-closing contract tests (health-check backend, TLS-posture reuse) - r154 ID-token fixture built from parts to avoid false-positive secret scan
187 lines
6.2 KiB
Go
187 lines
6.2 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// TestAuthCallback_ExchangeFailureInvalidatesState guards the fix that
|
|
// persists the one-time auth-state cleanup when the authorization-code
|
|
// exchange fails. The browser's session cookie must no longer hold a valid
|
|
// CSRF/nonce/code_verifier so a partially-failed flow cannot be continued
|
|
// or the (possibly unconsumed) code replayed against a stale state.
|
|
func TestAuthCallback_ExchangeFailureInvalidatesState(t *testing.T) {
|
|
testLogger := newNoOpLogger()
|
|
|
|
sessionManager, err := NewSessionManager(
|
|
"test-encryption-key-32-bytes-long!!",
|
|
false,
|
|
"",
|
|
"",
|
|
0,
|
|
testLogger,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewSessionManager: %v", err)
|
|
}
|
|
defer sessionManager.Shutdown()
|
|
|
|
oidc := &TraefikOidc{
|
|
sessionManager: sessionManager,
|
|
logger: testLogger,
|
|
enablePKCE: true,
|
|
tokenExchanger: &EnhancedMockTokenExchanger{ExchangeErr: errors.New("token exchange failed")},
|
|
}
|
|
|
|
csrfToken := "valid-csrf-token"
|
|
req := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil)
|
|
rw := httptest.NewRecorder()
|
|
|
|
session, err := sessionManager.GetSession(req)
|
|
if err != nil {
|
|
t.Fatalf("GetSession: %v", err)
|
|
}
|
|
session.SetCSRF(csrfToken)
|
|
session.SetNonce("test-nonce")
|
|
session.SetCodeVerifier("test-code-verifier")
|
|
if err := session.Save(req, rw); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
session.returnToPoolSafely()
|
|
|
|
// Drive the callback with the session cookies from the first save.
|
|
req2 := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil)
|
|
for _, c := range rw.Result().Cookies() {
|
|
req2.AddCookie(c)
|
|
}
|
|
rw2 := httptest.NewRecorder()
|
|
oidc.handleCallback(rw2, req2, "https://example.com/callback")
|
|
|
|
if rw2.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500 on exchange failure, got %d", rw2.Code)
|
|
}
|
|
|
|
// Reload the session from the *callback response* cookies. The
|
|
// one-time auth state must have been cleared and persisted.
|
|
req3 := httptest.NewRequest(http.MethodGet, "/callback", nil)
|
|
if n := len(rw2.Result().Cookies()); n == 0 {
|
|
t.Fatal("expected session cookies in the callback response")
|
|
}
|
|
for _, c := range rw2.Result().Cookies() {
|
|
req3.AddCookie(c)
|
|
}
|
|
session3, err := sessionManager.GetSession(req3)
|
|
if err != nil {
|
|
t.Fatalf("GetSession after callback: %v", err)
|
|
}
|
|
defer session3.returnToPoolSafely()
|
|
|
|
if got := session3.GetCSRF(); got != "" {
|
|
t.Errorf("expected CSRF cleared after failed exchange, got %q", got)
|
|
}
|
|
if got := session3.GetNonce(); got != "" {
|
|
t.Errorf("expected nonce cleared after failed exchange, got %q", got)
|
|
}
|
|
if got := session3.GetCodeVerifier(); got != "" {
|
|
t.Errorf("expected code_verifier cleared after failed exchange, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestAuthCallback_PostExchangeFailureClearsOneTimeState guards the fix for the
|
|
// post-exchange (code-consuming) failure branches: after a successful
|
|
// authorization-code exchange followed by a failed id-token verification, the
|
|
// one-time CSRF/nonce/code_verifier state must be cleared and persisted, just
|
|
// like the exchange-failure path. Otherwise the session stays in a stale
|
|
// pending-auth state and a retried callback re-exchanges a fresh code before
|
|
// failing again (R134).
|
|
func TestAuthCallback_PostExchangeFailureClearsOneTimeState(t *testing.T) {
|
|
testLogger := newNoOpLogger()
|
|
|
|
sessionManager, err := NewSessionManager(
|
|
"test-encryption-key-32-bytes-long!!",
|
|
false,
|
|
"",
|
|
"",
|
|
0,
|
|
testLogger,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewSessionManager: %v", err)
|
|
}
|
|
defer sessionManager.Shutdown()
|
|
|
|
oidc := &TraefikOidc{
|
|
sessionManager: sessionManager,
|
|
logger: testLogger,
|
|
enablePKCE: true,
|
|
// Exchange succeeds (consumes the code) but subsequent id-token
|
|
// verification fails, so we land in a post-exchange failure branch.
|
|
tokenExchanger: &EnhancedMockTokenExchanger{
|
|
ExchangeResponse: &TokenResponse{AccessToken: "access-token", IDToken: "id-token"},
|
|
},
|
|
tokenVerifier: &EnhancedMockTokenVerifier{Err: errors.New("verification failed")},
|
|
}
|
|
|
|
csrfToken := "valid-csrf-token"
|
|
req := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil)
|
|
rw := httptest.NewRecorder()
|
|
|
|
session, err := sessionManager.GetSession(req)
|
|
if err != nil {
|
|
t.Fatalf("GetSession: %v", err)
|
|
}
|
|
session.SetCSRF(csrfToken)
|
|
session.SetNonce("test-nonce")
|
|
session.SetCodeVerifier("test-code-verifier")
|
|
if err := session.Save(req, rw); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
session.returnToPoolSafely()
|
|
|
|
// Drive the callback with the session cookies from the first save.
|
|
req2 := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil)
|
|
for _, c := range rw.Result().Cookies() {
|
|
req2.AddCookie(c)
|
|
}
|
|
rw2 := httptest.NewRecorder()
|
|
oidc.handleCallback(rw2, req2, "https://example.com/callback")
|
|
|
|
if rw2.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500 on id-token verify failure, got %d", rw2.Code)
|
|
}
|
|
|
|
// The post-exchange failure must persist the cleared one-time state,
|
|
// which means the callback response must carry a session cookie. On the
|
|
// pre-fix code the branch returned without saving, so there is no
|
|
// cookie and this is the fail-on-old discriminator.
|
|
respCookies := rw2.Result().Cookies()
|
|
if len(respCookies) == 0 {
|
|
t.Fatal("expected callback to persist the cleared session cookie after post-exchange failure")
|
|
}
|
|
|
|
// Reload the session from the callback's own cookie (which, on the old
|
|
// code, was never written): the code was consumed, so the one-time
|
|
// auth state must have been cleared there.
|
|
req3 := httptest.NewRequest(http.MethodGet, "/callback", nil)
|
|
for _, c := range respCookies {
|
|
req3.AddCookie(c)
|
|
}
|
|
session3, err := sessionManager.GetSession(req3)
|
|
if err != nil {
|
|
t.Fatalf("GetSession after callback: %v", err)
|
|
}
|
|
defer session3.returnToPoolSafely()
|
|
|
|
if got := session3.GetCSRF(); got != "" {
|
|
t.Errorf("expected CSRF cleared after post-exchange verify failure, got %q", got)
|
|
}
|
|
if got := session3.GetNonce(); got != "" {
|
|
t.Errorf("expected nonce cleared after post-exchange verify failure, got %q", got)
|
|
}
|
|
if got := session3.GetCodeVerifier(); got != "" {
|
|
t.Errorf("expected code_verifier cleared after post-exchange verify failure, got %q", got)
|
|
}
|
|
}
|