Files
traefikoidc/cmd/yaegicheck/main.go
T
lukaszraczylo 937776516d feat(middleware): refresh expired sessions on SSE/WebSocket bypass
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
2026-09-28 15:31:18 +01:00

1066 lines
41 KiB
Go

//go:build ignore
// Command yaegicheck verifies that the traefikoidc plugin can be imported and
// instantiated by the yaegi interpreter — the same way Traefik loads a plugin.
//
// It is run by `make yaegi-validate`. Importing the plugin package forces yaegi
// to interpret every source file in the package (and its vendored
// dependencies), so any construct yaegi cannot handle (unsupported stdlib
// symbol, reflection edge case, etc.) surfaces here rather than at Traefik load
// time. CreateConfig + New additionally exercise the instantiation path
// (session manager, cookie codec, caches, key derivation) under the interpreter.
//
// Beyond the load/instantiate path, this also drives CircuitBreaker and
// RetryExecutor directly (FIX-09, FIX-14). yaegi v0.16.1 panics on
// errors.As(err, &target) whenever target's pointed-to type is itself
// interpreted ("errors: *target must be interface or implement error"), a
// case native `go test`/`go build` cannot see because there the target type
// is compiled, not interpreted. CircuitBreaker.ExecuteWithContext and
// RetryExecutor.ExecuteSingleUseWithContext both ran such a check on every
// fn() error, on the default-on token-exchange/refresh path, so this must
// run under the real interpreter to catch it.
package main
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os"
"sync"
"sync/atomic"
"time"
oidc "github.com/lukaszraczylo/traefikoidc"
recovery "github.com/lukaszraczylo/traefikoidc/internal/recovery"
servers "github.com/lukaszraczylo/traefikoidc/internal/testutil/servers"
)
// failingSetBackend is a minimal CacheBackend whose Set always fails with a
// plain (non-timeout) error. It guards FIX-04: UniversalCache.Set must not
// panic when isTimeoutOrDeadlineError inspects a non-timeout backend Set
// error under the yaegi interpreter.
type failingSetBackend struct{}
func (failingSetBackend) Set(_ context.Context, _ string, _ []byte, _ time.Duration) error {
return errors.New("x")
}
func (failingSetBackend) Get(_ context.Context, _ string) ([]byte, time.Duration, bool, error) {
return nil, 0, false, nil
}
func (failingSetBackend) Delete(_ context.Context, _ string) (bool, error) { return false, nil }
func (failingSetBackend) Exists(_ context.Context, _ string) (bool, error) { return false, nil }
func (failingSetBackend) Clear(_ context.Context) error { return nil }
func (failingSetBackend) GetStats() map[string]interface{} { return nil }
func (failingSetBackend) Close() error { return nil }
func (failingSetBackend) Ping(_ context.Context) error { return nil }
// checkUniversalCacheSetDoesNotPanicOnBackendError guards FIX-04: in Redis
// mode, UniversalCache.Set must degrade to the local write on any
// non-timeout backend Set error instead of panicking. errors.As against an
// anonymous interface target panics under yaegi v0.16.1, so a native `go
// test` run cannot see this; only the interpreter can.
func checkUniversalCacheSetDoesNotPanicOnBackendError() {
defer func() {
if r := recover(); r != nil {
fmt.Println("FAIL: UniversalCache.Set panicked on a non-timeout backend Set error:", r)
os.Exit(1)
}
}()
cache := oidc.NewUniversalCacheWithBackend(oidc.UniversalCacheConfig{
Logger: oidc.NewLogger("error"),
Type: oidc.CacheTypeToken,
DefaultTTL: time.Minute,
}, failingSetBackend{})
defer cache.Close()
if err := cache.Set("yaegi-check-key", "v", time.Minute); err != nil {
fmt.Println("FAIL: UniversalCache.Set returned an unexpected error:", err)
os.Exit(1)
}
fmt.Println("OK: UniversalCache.Set did not panic on a non-timeout backend Set error under yaegi")
}
func main() {
cfg := oidc.CreateConfig()
cfg.ProviderURL = "https://accounts.google.com"
cfg.ClientID = "yaegi-check-client"
cfg.ClientSecret = "yaegi-check-secret"
cfg.CallbackURL = "/oauth2/callback"
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef"
cfg.RateLimit = 100
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := oidc.New(context.Background(), next, cfg, "yaegi-check")
if err != nil {
fmt.Println("FAIL: New returned an error under yaegi:", err)
os.Exit(1)
}
if h == nil {
fmt.Println("FAIL: New returned a nil handler under yaegi")
os.Exit(1)
}
if closer, ok := h.(interface{ Close() error }); ok {
_ = closer.Close()
}
fmt.Println("OK: traefikoidc imported + CreateConfig + New succeeded under yaegi")
checkUniversalCacheSetDoesNotPanicOnBackendError()
runCheck("fix09-plain-error", checkFix09PlainError)
runCheck("fix09-halfopen-400-stays-halfopen", checkFix09HalfOpenTerminalStaysHalfOpen)
runCheck("fix09-closed-400-stays-closed", checkFix09ClosedTerminalStaysClosed)
runCheck("fix09-429-reopens", checkFix09RateLimitReopens)
runCheck("fix14-httperror-once", checkFix14HTTPErrorNeverRetried)
runCheck("fix14-real-dial-retried", checkFix14RealDialRetried)
// NEW-01: errors.As(err, &target) panics under yaegi v0.16.1 whenever
// target's pointed-to type is interpreted (*HTTPError, *OIDCError are
// both declared in this plugin / internal/recovery, so always are),
// regardless of err's own concrete type. These checks drive every
// reachable errors.As-replacement site directly under the interpreter.
runCheck("new01-retryexecutor-500-retried", checkNew01RetryExecutorHTTPError500Retried)
runCheck("new01-retryexecutor-400-not-retried", checkNew01RetryExecutorHTTPError400NotRetried)
runCheck("new01-retryexecutor-plain-error-no-panic", checkNew01RetryExecutorPlainErrorNoPanic)
runCheck("new01-internal-recovery-httperror-classification", checkNew01InternalRecoveryHTTPErrorClassification)
runCheck("new01-internal-recovery-oidcerror-classification", checkNew01InternalRecoveryOIDCErrorClassification)
runCheck("new01-introspection-bearer-4xx-no-panic", checkNew01IntrospectionBearer4xxNoPanic)
runCheck("new01-introspection-bearer-5xx-no-panic", checkNew01IntrospectionBearer5xxNoPanic)
runCheck("new01-opaque-session-introspection-classification", checkNew01OpaqueSessionIntrospectionClassification)
runCheck("fix06-sse-flush-reaches-next", checkSSEFlushReachesNext)
runCheck("middleware-erraborthandler-aborts-under-yaegi", checkErrAbortHandlerAbortsUnderYaegi)
runCheck("fix17-setifabsent-claims-once", checkSetIfAbsentUnderYaegi)
runCheck("issue162-websocket-forwards-header-templates", checkIssue162WebSocketHeaderTemplates)
runCheck("streaming-refresh-revoked-grant-rejected", checkStreamingRefreshRevokedGrant)
fmt.Println("OK: all yaegi regression checks passed")
}
// runCheck runs one named regression check and exits the process on
// failure, matching this command's existing OK/FAIL reporting style. detail
// is appended to the PASS line verbatim (e.g. " calls=3"), or empty.
func runCheck(name string, fn func() (string, error)) {
detail, err := fn()
if err != nil {
fmt.Printf("FAIL %s: %v\n", name, err)
os.Exit(1)
}
fmt.Printf("PASS %s%s\n", name, detail)
}
// circuitStateName renders a CircuitBreakerState for FAIL messages.
func circuitStateName(s oidc.CircuitBreakerState) string {
switch s {
case oidc.CircuitBreakerClosed:
return "closed"
case oidc.CircuitBreakerOpen:
return "open"
case oidc.CircuitBreakerHalfOpen:
return "half-open"
default:
return "unknown"
}
}
// checkFix09PlainError drives CircuitBreaker.ExecuteWithContext with a
// plain (non-HTTPError) error, the call shape that panicked first under the
// pre-fix isTerminalClientError: errors.As against an interpreted *HTTPError
// target panics under yaegi regardless of the error's own concrete type,
// because the check runs on every fn() error.
func checkFix09PlainError() (string, error) {
cb := oidc.NewCircuitBreaker(oidc.CircuitBreakerConfig{
MaxFailures: 1,
Timeout: 50 * time.Millisecond,
ResetTimeout: 50 * time.Millisecond,
}, oidc.NewLogger("error"))
err := cb.ExecuteWithContext(context.Background(), func() error {
return errors.New("boom")
})
if err == nil {
return "", fmt.Errorf("expected the plain error to be returned")
}
if cb.GetState() != oidc.CircuitBreakerOpen {
return "", fmt.Errorf("expected a plain error to trip the breaker open, got %s", circuitStateName(cb.GetState()))
}
return "", nil
}
// newHalfOpenBreaker builds a breaker, trips it with one real (non client)
// failure, and waits past Timeout so the next ExecuteWithContext call
// performs the Open -> HalfOpen probe transition itself.
func newHalfOpenBreaker() *oidc.CircuitBreaker {
cb := oidc.NewCircuitBreaker(oidc.CircuitBreakerConfig{
MaxFailures: 1,
Timeout: 100 * time.Millisecond,
ResetTimeout: 50 * time.Millisecond,
}, oidc.NewLogger("error"))
_ = cb.ExecuteWithContext(context.Background(), func() error {
return &oidc.HTTPError{StatusCode: 500, Message: "downstream unavailable"}
})
time.Sleep(150 * time.Millisecond)
return cb
}
// checkFix09HalfOpenTerminalStaysHalfOpen pins FIX-09: a terminal client 4xx
// (e.g. invalid_grant) must not reopen a half-open circuit.
func checkFix09HalfOpenTerminalStaysHalfOpen() (string, error) {
cb := newHalfOpenBreaker()
err := cb.ExecuteWithContext(context.Background(), func() error {
return &oidc.HTTPError{StatusCode: 400, Message: "invalid_grant"}
})
if err == nil {
return "", fmt.Errorf("expected the probe's own error to be returned")
}
if cb.GetState() != oidc.CircuitBreakerHalfOpen {
return "", fmt.Errorf("a terminal client 4xx must not reopen a half-open circuit, got %s", circuitStateName(cb.GetState()))
}
return "", nil
}
// checkFix09ClosedTerminalStaysClosed pins that the same exemption applies
// in the Closed state: a terminal client 4xx must not trip the breaker.
func checkFix09ClosedTerminalStaysClosed() (string, error) {
cb := oidc.NewCircuitBreaker(oidc.CircuitBreakerConfig{
MaxFailures: 1,
Timeout: 50 * time.Millisecond,
ResetTimeout: 50 * time.Millisecond,
}, oidc.NewLogger("error"))
err := cb.ExecuteWithContext(context.Background(), func() error {
return &oidc.HTTPError{StatusCode: 400, Message: "invalid_grant"}
})
if err == nil {
return "", fmt.Errorf("expected the probe's own error to be returned")
}
if cb.GetState() != oidc.CircuitBreakerClosed {
return "", fmt.Errorf("a terminal client 4xx must not trip a closed circuit, got %s", circuitStateName(cb.GetState()))
}
return "", nil
}
// checkFix09RateLimitReopens guards against over-broadening FIX-09: a 429
// (the service itself signaling overload) must still reopen a half-open
// circuit.
func checkFix09RateLimitReopens() (string, error) {
cb := newHalfOpenBreaker()
err := cb.ExecuteWithContext(context.Background(), func() error {
return &oidc.HTTPError{StatusCode: 429, Message: "rate limited"}
})
if err == nil {
return "", fmt.Errorf("expected the probe's own error to be returned")
}
if cb.GetState() != oidc.CircuitBreakerOpen {
return "", fmt.Errorf("a 429 must still reopen a half-open circuit, got %s", circuitStateName(cb.GetState()))
}
return "", nil
}
// checkFix14HTTPErrorNeverRetried pins FIX-14: a *HTTPError proves a
// response was received, so ExecuteSingleUseWithContext must never retry
// it even when its Message (up to 10 KiB of a real IdP response body, see
// helpers.go) happens to contain a singleUseRetryableErrors fragment such
// as "connection refused".
func checkFix14HTTPErrorNeverRetried() (string, error) {
re := oidc.NewRetryExecutor(oidc.RetryConfig{
MaxAttempts: 3,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 1 * time.Millisecond,
BackoffFactor: 1,
}, oidc.NewLogger("error"))
calls := 0
err := re.ExecuteSingleUseWithContext(context.Background(), func() error {
calls++
return &oidc.HTTPError{
StatusCode: 500,
Message: "token endpoint returned status 500: {\"error\":\"server_error\",\"detail\":\"upstream: connection refused\"}",
}
})
if err == nil {
return "", fmt.Errorf("expected the HTTPError to be returned")
}
if calls != 1 {
return "", fmt.Errorf("fn called %d times, want 1", calls)
}
return fmt.Sprintf(" calls=%d", calls), nil
}
// checkFix14RealDialRetried guards against over-broadening FIX-14: a real
// client.Do dial failure (net/http wraps it as *net.OpError, reached via
// errors.As unwrapping any %w chain) proves the request never reached the
// server and must still be retried.
func checkFix14RealDialRetried() (string, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", fmt.Errorf("setup: %w", err)
}
addr := ln.Addr().String()
ln.Close()
re := oidc.NewRetryExecutor(oidc.RetryConfig{
MaxAttempts: 3,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 1 * time.Millisecond,
BackoffFactor: 1,
}, oidc.NewLogger("error"))
client := &http.Client{Timeout: 3 * time.Second}
calls := 0
execErr := re.ExecuteSingleUseWithContext(context.Background(), func() error {
calls++
resp, derr := client.Get("http://" + addr + "/")
if derr != nil {
return fmt.Errorf("token endpoint request failed: %w", derr)
}
resp.Body.Close()
return nil
})
if execErr == nil {
return "", fmt.Errorf("expected the dial failure to be returned after exhausting retries")
}
if calls != 3 {
return "", fmt.Errorf("fn called %d times, want 3", calls)
}
return fmt.Sprintf(" calls=%d", calls), nil
}
// checkNew01RetryExecutorHTTPError500Retried pins NEW-01: RetryExecutor's
// isRetryableError classifies an *HTTPError via asHTTPError, a plain type
// assertion plus an errors.Unwrap walk, NOT errors.As -- errors.As(err,
// &target) panics under yaegi v0.16.1 whenever target's pointed-to type is
// interpreted, and *HTTPError is declared in this plugin so it always is,
// regardless of err's own concrete type (error_recovery.go, isRetryableError
// ~line 764/821). A 500 is a transient server error and must be retried to
// MaxAttempts.
func checkNew01RetryExecutorHTTPError500Retried() (string, error) {
re := oidc.NewRetryExecutor(oidc.RetryConfig{
MaxAttempts: 3,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 1 * time.Millisecond,
BackoffFactor: 1,
}, oidc.NewLogger("error"))
calls := 0
err := re.ExecuteWithContext(context.Background(), func() error {
calls++
return &oidc.HTTPError{StatusCode: 500, Message: "downstream unavailable"}
})
if err == nil {
return "", fmt.Errorf("expected the HTTPError to be returned after exhausting retries")
}
if calls != 3 {
return "", fmt.Errorf("fn called %d times, want 3 (a 500 must be retried)", calls)
}
return fmt.Sprintf(" calls=%d", calls), nil
}
// checkNew01RetryExecutorHTTPError400NotRetried is checkNew01RetryExecutorHTTPError500Retried's
// counterpart: a terminal 4xx (other than 429) must NOT be retried.
func checkNew01RetryExecutorHTTPError400NotRetried() (string, error) {
re := oidc.NewRetryExecutor(oidc.RetryConfig{
MaxAttempts: 3,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 1 * time.Millisecond,
BackoffFactor: 1,
}, oidc.NewLogger("error"))
calls := 0
err := re.ExecuteWithContext(context.Background(), func() error {
calls++
return &oidc.HTTPError{StatusCode: 400, Message: "invalid_grant"}
})
if err == nil {
return "", fmt.Errorf("expected the HTTPError to be returned")
}
if calls != 1 {
return "", fmt.Errorf("fn called %d times, want 1 (a terminal 400 must not be retried)", calls)
}
return fmt.Sprintf(" calls=%d", calls), nil
}
// checkNew01RetryExecutorPlainErrorNoPanic pins that a plain (non-HTTPError)
// error reaching isRetryableError's asHTTPError check never panics under
// yaegi -- the exact call shape that panicked pre-fix regardless of err's
// own concrete type (see asHTTPError's doc comment, error_recovery.go).
func checkNew01RetryExecutorPlainErrorNoPanic() (string, error) {
re := oidc.NewRetryExecutor(oidc.RetryConfig{
MaxAttempts: 3,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 1 * time.Millisecond,
BackoffFactor: 1,
}, oidc.NewLogger("error"))
calls := 0
err := re.ExecuteWithContext(context.Background(), func() error {
calls++
return errors.New("boom")
})
if err == nil {
return "", fmt.Errorf("expected the plain error to be returned")
}
if calls != 1 {
return "", fmt.Errorf("fn called %d times, want 1 (an unrecognized plain error must not be retried)", calls)
}
return fmt.Sprintf(" calls=%d", calls), nil
}
// checkNew01InternalRecoveryHTTPErrorClassification covers
// internal/recovery/metrics.go's RetryExecutor.isRetryableError, which has
// its OWN *HTTPError type (interpreted, same yaegi hazard as the top-level
// package's) and is reachable under yaegi because the traefikoidc plugin
// imports internal/recovery transitively (internal/utils/logger_wrapper.go),
// forcing yaegi to interpret it when the plugin loads.
func checkNew01InternalRecoveryHTTPErrorClassification() (string, error) {
retryCfg := recovery.RetryConfig{
MaxAttempts: 3,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 1 * time.Millisecond,
Multiplier: 1,
RetryableStatusCodes: []int{500, 503},
}
calls500 := 0
err := recovery.NewRetryExecutor(retryCfg, nil).ExecuteWithContext(context.Background(), func() error {
calls500++
return &recovery.HTTPError{StatusCode: 500, Message: "downstream unavailable"}
})
if err == nil {
return "", fmt.Errorf("500: expected the HTTPError to be returned after exhausting retries")
}
if calls500 != 3 {
return "", fmt.Errorf("500: fn called %d times, want 3", calls500)
}
calls400 := 0
err = recovery.NewRetryExecutor(retryCfg, nil).ExecuteWithContext(context.Background(), func() error {
calls400++
return &recovery.HTTPError{StatusCode: 400, Message: "bad request"}
})
if err == nil {
return "", fmt.Errorf("400: expected the HTTPError to be returned")
}
if calls400 != 1 {
return "", fmt.Errorf("400: fn called %d times, want 1", calls400)
}
return fmt.Sprintf(" calls500=%d calls400=%d", calls500, calls400), nil
}
// checkNew01InternalRecoveryOIDCErrorClassification is
// checkNew01InternalRecoveryHTTPErrorClassification's counterpart for
// internal/recovery's *OIDCError (also interpreted, also reached via
// errors.As pre-fix).
func checkNew01InternalRecoveryOIDCErrorClassification() (string, error) {
retryCfg := recovery.RetryConfig{
MaxAttempts: 3,
InitialDelay: 1 * time.Millisecond,
MaxDelay: 1 * time.Millisecond,
Multiplier: 1,
}
callsRetryable := 0
err := recovery.NewRetryExecutor(retryCfg, nil).ExecuteWithContext(context.Background(), func() error {
callsRetryable++
return &recovery.OIDCError{Code: "temporarily_unavailable", Description: "busy"}
})
if err == nil {
return "", fmt.Errorf("retryable: expected the OIDCError to be returned after exhausting retries")
}
if callsRetryable != 3 {
return "", fmt.Errorf("retryable: fn called %d times, want 3", callsRetryable)
}
callsTerminal := 0
err = recovery.NewRetryExecutor(retryCfg, nil).ExecuteWithContext(context.Background(), func() error {
callsTerminal++
return &recovery.OIDCError{Code: "invalid_grant", Description: "nope"}
})
if err == nil {
return "", fmt.Errorf("terminal: expected the OIDCError to be returned")
}
if callsTerminal != 1 {
return "", fmt.Errorf("terminal: fn called %d times, want 1", callsTerminal)
}
return fmt.Sprintf(" callsRetryable=%d callsTerminal=%d", callsRetryable, callsTerminal), nil
}
// checkNew01IntrospectionBearerStatus builds a full plugin instance with
// bearer auth + mandatory introspection enabled, points IntrospectionURL at
// a controllable server returning wantStatus, and drives one bearer request
// carrying an opaque (non-JWT) token through introspectToken end-to-end
// under yaegi. introspectToken is the shared producer for BOTH the bearer
// path (bearer_auth.go) and the session path (token_validation_rs.go, the
// NEW-01 fix site); this proves the round trip -- discovery, introspection
// POST, error classification, response write -- never panics under the real
// interpreter for a 4xx or a 5xx introspection response.
func checkNew01IntrospectionBearerStatus(wantStatus int) (string, error) {
discovery := servers.NewOIDCServer(nil)
defer discovery.Close()
introspect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(wantStatus)
}))
defer introspect.Close()
cfg := oidc.CreateConfig()
cfg.ProviderURL = discovery.URL
cfg.ClientID = "yaegi-check-bearer-client"
cfg.ClientSecret = "yaegi-check-bearer-secret"
cfg.CallbackURL = "/oauth2/callback"
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef"
cfg.RateLimit = 100
cfg.EnableBearerAuth = true
cfg.Audience = cfg.ClientID
cfg.RequireTokenIntrospection = true
cfg.AllowOpaqueTokens = true
cfg.IntrospectionURL = introspect.URL
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := oidc.New(context.Background(), next, cfg, fmt.Sprintf("yaegi-check-bearer-%d", wantStatus))
if err != nil {
return "", fmt.Errorf("New: %w", err)
}
if closer, ok := h.(interface{ Close() error }); ok {
defer closer.Close()
}
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer opaque-test-token-without-dots")
rw := httptest.NewRecorder()
h.ServeHTTP(rw, req)
// Every introspectToken error on the bearer path -- 4xx or 5xx alike --
// maps to bearerErrIntrospectionUnavailable, a 503 (bearer_auth.go). A
// recovered errors.As panic answers a DIFFERENT status: ServeHTTP's own
// deferred recover turns any panic into 500 (middleware.go), and
// httptest.NewRecorder starts at Code 200, so neither of those values
// can be mistaken for the real 503. rw.Code == 0 (the previous check
// here) can never happen -- the Recorder never leaves that zero value --
// so it accepted a recovered panic silently. Require the exact expected
// value instead.
if rw.Code != http.StatusServiceUnavailable {
return "", fmt.Errorf("expected response_status=%d (bearerErrIntrospectionUnavailable) for introspection_status=%d, got %d", http.StatusServiceUnavailable, wantStatus, rw.Code)
}
return fmt.Sprintf(" introspection_status=%d response_status=%d", wantStatus, rw.Code), nil
}
func checkNew01IntrospectionBearer4xxNoPanic() (string, error) {
return checkNew01IntrospectionBearerStatus(http.StatusTooManyRequests)
}
func checkNew01IntrospectionBearer5xxNoPanic() (string, error) {
return checkNew01IntrospectionBearerStatus(http.StatusInternalServerError)
}
// checkNew01OpaqueSessionIntrospectionClassification drives the SESSION
// path fixed by commit d7686c3, which checkNew01IntrospectionBearerStatus
// above does not reach: an authenticated cookie session holding an opaque
// access token, validated via isUserAuthenticatedRS ->
// validateStandardTokensRS -> validateOpaqueToken, when introspection
// answers a 5xx. Before d7686c3, validateStandardTokensRS classified
// validateOpaqueToken's error with errors.As(err, &httpErr) against
// *HTTPError -- interpreted under yaegi v0.16.1, so this panicked on every
// opaque-token session-path classification regardless of introspection
// status. The bearer path shares introspectToken but never reaches this
// call site, so a yaegicheck suite that only drove the bearer path could
// not have caught this.
//
// A cookie session is built directly (New's session manager is an
// unexported field, unreachable from this package) with a second
// SessionManager sharing the exact cfg fields oidc.New passes into its own
// NewSessionManager call (SessionEncryptionKey, ForceHTTPS, CookieDomain,
// CookiePrefix, SessionMaxAge). The cookie codec key is a deterministic
// function of those inputs alone (deriveCookieKeys, no per-instance
// randomness), so a cookie minted here decodes identically in the plugin's
// own session manager.
func checkNew01OpaqueSessionIntrospectionClassification() (string, error) {
discovery := servers.NewOIDCServer(nil)
defer discovery.Close()
// introspectionRequests counts requests that actually reach the
// introspection endpoint. A 302 alone does not prove ServeHTTP got as
// far as introspectToken/validateOpaqueToken (commit d7686c3's fix
// site) -- an unauthenticated request with no session at all also ends
// in a 302, to the same provider, without ever calling introspection.
// If the cookie rebuilt through the second SessionManager below ever
// stops decoding, this check would otherwise still print PASS while
// exercising nothing.
var introspectionRequests int32
introspect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&introspectionRequests, 1)
w.WriteHeader(http.StatusInternalServerError)
}))
defer introspect.Close()
cfg := oidc.CreateConfig()
cfg.ProviderURL = discovery.URL
cfg.ClientID = "yaegi-check-session-client"
cfg.ClientSecret = "yaegi-check-session-secret"
cfg.CallbackURL = "/oauth2/callback"
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef"
cfg.RateLimit = 100
cfg.AllowOpaqueTokens = true
cfg.RequireTokenIntrospection = true
cfg.IntrospectionURL = introspect.URL
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := oidc.New(context.Background(), next, cfg, "yaegi-check-session")
if err != nil {
return "", fmt.Errorf("New: %w", err)
}
if closer, ok := h.(interface{ Close() error }); ok {
defer closer.Close()
}
sm, err := oidc.NewSessionManager(cfg.SessionEncryptionKey, cfg.ForceHTTPS, cfg.CookieDomain, cfg.CookiePrefix, time.Duration(cfg.SessionMaxAge)*time.Second, oidc.NewLogger("error"))
if err != nil {
return "", fmt.Errorf("NewSessionManager: %w", err)
}
defer sm.Shutdown()
setupReq := httptest.NewRequest("GET", "/protected", nil)
session, err := sm.GetSession(setupReq)
if err != nil {
return "", fmt.Errorf("GetSession: %w", err)
}
if err := session.SetAuthenticated(true); err != nil {
return "", fmt.Errorf("SetAuthenticated: %w", err)
}
// No dots: isOpaqueToken (validateStandardTokensRS) reads it as opaque,
// routing it into validateOpaqueToken/introspectToken instead of JWT
// parsing. No refresh token is set, so a classification of "not
// verifiable, requireTokenIntrospection fails closed" resolves
// deterministically to expired (not needsRefresh) below.
session.SetAccessToken("opaque-session-token-without-dots")
rec := httptest.NewRecorder()
if err := session.Save(setupReq, rec); err != nil {
return "", fmt.Errorf("session.Save: %w", err)
}
session.ReturnToPool()
req := httptest.NewRequest("GET", "/protected", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
rw := httptest.NewRecorder()
h.ServeHTTP(rw, req)
// requireTokenIntrospection=true with no refresh token classifies a
// 5xx-introspection opaque access token as expired (not "token
// invalid", which is reserved for a definitive active=false/expired/
// revoked introspection body -- RFC 7662 s2.2/s2.3): ServeHTTP responds
// with handleExpiredToken -> defaultInitiateAuthentication, a 302 to
// the provider. A reintroduced errors.As panic is instead recovered by
// ServeHTTP's own deferred recover as a 500, before t.next is ever
// reached -- a status this check must reject just as firmly as the 200
// (rw.Code's unwritten zero value can never appear here either) a
// silently-swallowed panic could otherwise produce.
if rw.Code != http.StatusFound {
return "", fmt.Errorf("expected response_status=%d (re-authentication redirect) for a 5xx introspection response on the session path, got %d", http.StatusFound, rw.Code)
}
// A 302 alone is not proof this check reached the session-path
// classification: an unauthenticated request with no cookie at all
// also redirects with 302, without ever calling introspectToken. Only
// a non-zero introspection hit count proves the request got as far as
// validateOpaqueToken.
if got := atomic.LoadInt32(&introspectionRequests); got < 1 {
return "", fmt.Errorf("expected at least one request to reach the introspection endpoint (proving the check reached validateOpaqueToken/introspectToken), got %d -- a %d response alone does not prove this, since an unauthenticated request with no session also redirects with %d", got, rw.Code, http.StatusFound)
}
return fmt.Sprintf(" response_status=%d", rw.Code), nil
}
// checkSSEFlushReachesNext pins FIX-06: the writer ServeHTTP hands to next
// must support http.NewResponseController(w).Flush() under yaegi. Before
// the fix, next received the interpreted *trackingWriter, which exposes no
// Flusher across the interpreter boundary, so SSE responses were buffered
// in Traefik. The excluded-URL bypass reaches next without a session.
func checkSSEFlushReachesNext() (string, error) {
cfg := oidc.CreateConfig()
cfg.ProviderURL = "https://accounts.google.com"
cfg.ClientID = "yaegi-check-client"
cfg.ClientSecret = "yaegi-check-secret"
cfg.CallbackURL = "/oauth2/callback"
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef"
cfg.ExcludedURLs = []string{"/public"}
var flushErr error
reached := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: 1\n\n"))
flushErr = http.NewResponseController(w).Flush()
})
h, err := oidc.New(context.Background(), next, cfg, "yaegi-sse")
if err != nil {
return "", fmt.Errorf("New: %v", err)
}
if closer, ok := h.(interface{ Close() error }); ok {
defer func() { _ = closer.Close() }()
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/public/stream", nil)
req.Header.Set("Accept", "text/event-stream")
h.ServeHTTP(rec, req)
if !reached {
return "", fmt.Errorf("next was not reached (status %d)", rec.Code)
}
if flushErr != nil {
return "", fmt.Errorf("Flush on the writer handed to next failed: %v", flushErr)
}
if !rec.Flushed {
return "", fmt.Errorf("the underlying writer was not flushed")
}
return "", nil
}
// checkIssue162WebSocketHeaderTemplates pins #162 under the interpreter: an
// authenticated WebSocket upgrade takes the streaming bypass, which must
// still render the configured headers (static and claim-derived) for next.
func checkIssue162WebSocketHeaderTemplates() (string, error) {
discovery := servers.NewOIDCServer(nil)
defer discovery.Close()
cfg := oidc.CreateConfig()
cfg.ProviderURL = discovery.URL
cfg.ClientID = "yaegi-check-162-client"
cfg.ClientSecret = "yaegi-check-162-secret"
cfg.CallbackURL = "/oauth2/callback"
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef"
cfg.Headers = []oidc.TemplatedHeader{
{Name: "X-Proxy-Secret", Value: "s3cret"},
{Name: "X-Forwarded-Preferred-Username", Value: "{{.Claims.preferred_username}}"},
}
var seen http.Header
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.Header.Clone()
})
h, err := oidc.New(context.Background(), next, cfg, "yaegi-check-162")
if err != nil {
return "", fmt.Errorf("New: %w", err)
}
if closer, ok := h.(interface{ Close() error }); ok {
defer closer.Close()
}
sm, err := oidc.NewSessionManager(cfg.SessionEncryptionKey, cfg.ForceHTTPS, cfg.CookieDomain, cfg.CookiePrefix, time.Duration(cfg.SessionMaxAge)*time.Second, oidc.NewLogger("error"))
if err != nil {
return "", fmt.Errorf("NewSessionManager: %w", err)
}
defer sm.Shutdown()
setupReq := httptest.NewRequest("GET", "/ws", nil)
session, err := sm.GetSession(setupReq)
if err != nil {
return "", fmt.Errorf("GetSession: %w", err)
}
if err := session.SetAuthenticated(true); err != nil {
return "", fmt.Errorf("SetAuthenticated: %w", err)
}
session.SetUserIdentifier("[email protected]")
// Unsigned JWT: the bypass reads cookie-sealed claims without signature
// verification. Built at runtime because a JWT literal trips the
// pre-commit secret scanner.
b64 := base64.RawURLEncoding.EncodeToString
sig := make([]byte, 32)
for i := range sig {
sig[i] = byte(i + 1)
}
session.SetIDToken(b64([]byte(`{"alg":"RS256","typ":"JWT"}`)) + "." + b64([]byte(`{"preferred_username":"alice"}`)) + "." + b64(sig))
rec := httptest.NewRecorder()
if err := session.Save(setupReq, rec); err != nil {
return "", fmt.Errorf("session.Save: %w", err)
}
session.ReturnToPool()
req := httptest.NewRequest("GET", "/ws", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
rw := httptest.NewRecorder()
h.ServeHTTP(rw, req)
if seen == nil {
return "", fmt.Errorf("next was not reached (status %d)", rw.Code)
}
if got := seen.Get("X-Proxy-Secret"); got != "s3cret" {
return "", fmt.Errorf("X-Proxy-Secret = %q, want %q", got, "s3cret")
}
if got := seen.Get("X-Forwarded-Preferred-Username"); got != "alice" {
return "", fmt.Errorf("X-Forwarded-Preferred-Username = %q, want %q", got, "alice")
}
return "", nil
}
// checkStreamingRefreshRevokedGrant drives streamingRefresh under the
// interpreter through the real RefreshCoordinator and token exchanger: an
// expired session whose refresh the IdP answers with invalid_grant must get
// 401 on a WebSocket upgrade instead of reaching next.
func checkStreamingRefreshRevokedGrant() (string, error) {
idpCfg := servers.DefaultConfig()
idpCfg.RefreshError = &servers.OIDCError{Error: "invalid_grant"}
idp := servers.NewOIDCServer(idpCfg)
defer idp.Close()
cfg := oidc.CreateConfig()
cfg.ProviderURL = idp.URL
cfg.ClientID = "yaegi-check-refresh-client"
cfg.ClientSecret = "yaegi-check-refresh-secret"
cfg.CallbackURL = "/oauth2/callback"
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef"
cfg.StreamingRefresh = true
reached := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
})
h, err := oidc.New(context.Background(), next, cfg, "yaegi-check-refresh")
if err != nil {
return "", fmt.Errorf("New: %w", err)
}
if closer, ok := h.(interface{ Close() error }); ok {
defer closer.Close()
}
// The streaming bypass does not wait for provider metadata, so a normal
// request (which does) runs first to make the token endpoint known.
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
sm, err := oidc.NewSessionManager(cfg.SessionEncryptionKey, cfg.ForceHTTPS, cfg.CookieDomain, cfg.CookiePrefix, time.Duration(cfg.SessionMaxAge)*time.Second, oidc.NewLogger("error"))
if err != nil {
return "", fmt.Errorf("NewSessionManager: %w", err)
}
defer sm.Shutdown()
setupReq := httptest.NewRequest("GET", "/ws", nil)
session, err := sm.GetSession(setupReq)
if err != nil {
return "", fmt.Errorf("GetSession: %w", err)
}
if err := session.SetAuthenticated(true); err != nil {
return "", fmt.Errorf("SetAuthenticated: %w", err)
}
session.SetUserIdentifier("[email protected]")
// Built at runtime because a JWT literal trips the pre-commit secret scanner.
b64 := base64.RawURLEncoding.EncodeToString
sig := make([]byte, 32)
for i := range sig {
sig[i] = byte(i + 1)
}
payload := fmt.Sprintf(`{"email":"[email protected]","exp":%d}`, time.Now().Add(-time.Minute).Unix())
session.SetAccessToken(b64([]byte(`{"alg":"RS256","typ":"JWT"}`)) + "." + b64([]byte(payload)) + "." + b64(sig))
session.SetRefreshToken("yaegi-check-refresh-token")
rec := httptest.NewRecorder()
if err := session.Save(setupReq, rec); err != nil {
return "", fmt.Errorf("session.Save: %w", err)
}
session.ReturnToPool()
req := httptest.NewRequest("GET", "/ws", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
rw := httptest.NewRecorder()
h.ServeHTTP(rw, req)
if reached {
return "", fmt.Errorf("next reached for a revoked grant (status %d)", rw.Code)
}
if rw.Code != http.StatusUnauthorized {
return "", fmt.Errorf("status = %d, want %d", rw.Code, http.StatusUnauthorized)
}
return "", nil
}
// checkErrAbortHandlerAbortsUnderYaegi guards the re-review finding on
// ServeHTTP's ErrAbortHandler branch (middleware.go): under yaegi v0.16.1,
// `r == http.ErrAbortHandler` only ever matches a panic raised by COMPILED
// code -- which is exactly the real production shape, since only this
// plugin runs interpreted; every `next` handler downstream of it
// (Traefik's own router, a compiled httputil.ReverseProxy) is compiled.
// This drives that shape end-to-end under the interpreter: a live
// front-facing server running the interpreted plugin, whose next is a
// compiled httputil.ReverseProxy pointed at an upstream that writes a
// partial chunk, flushes it to the client, then Hijacks and closes the raw
// connection mid-stream -- the same way copyResponse
// (net/http/httputil/reverseproxy.go) panics with http.ErrAbortHandler
// when the upstream body read fails after headers were already sent. The
// client must observe the aborted connection (a non-nil read error on the
// truncated body), never a clean 200. Do not assign rp.ErrorLog: yaegi
// v0.16.1 rejects the *log.Logger field assignment.
func checkErrAbortHandlerAbortsUnderYaegi() (string, error) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("partial-chunk"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
hj, ok := w.(http.Hijacker)
if !ok {
return
}
conn, _, hjErr := hj.Hijack()
if hjErr != nil {
return
}
_ = conn.Close()
}))
defer upstream.Close()
upstreamURL, err := url.Parse(upstream.URL)
if err != nil {
return "", fmt.Errorf("parse upstream URL: %w", err)
}
rp := httputil.NewSingleHostReverseProxy(upstreamURL)
cfg := oidc.CreateConfig()
cfg.ProviderURL = "https://accounts.google.com"
cfg.ClientID = "yaegi-check-abort-client"
cfg.ClientSecret = "yaegi-check-abort-secret"
cfg.CallbackURL = "/oauth2/callback"
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef"
cfg.ExcludedURLs = []string{"/public"}
h, err := oidc.New(context.Background(), rp, cfg, "yaegi-check-abort")
if err != nil {
return "", fmt.Errorf("New: %w", err)
}
if closer, ok := h.(interface{ Close() error }); ok {
defer func() { _ = closer.Close() }()
}
front := httptest.NewServer(h)
defer front.Close()
resp, err := http.Get(front.URL + "/public")
if err != nil {
return "", fmt.Errorf("client GET: %w", err)
}
defer resp.Body.Close()
_, readErr := io.ReadAll(resp.Body)
if readErr == nil {
return "", fmt.Errorf("expected the client read to fail after the upstream aborted mid-body, got a clean read (status %d)", resp.StatusCode)
}
return fmt.Sprintf(" client_read_err=%v", readErr), nil
}
// memNXBackend is a minimal in-process CacheBackend that also implements
// SetNX, standing in for a Redis backend. It guards FIX-17: the
// optional-interface assertion in UniversalCache.SetIfAbsent must work
// under yaegi, and a second claim of the same key must lose.
type memNXBackend struct {
mu sync.Mutex
data map[string][]byte
}
func (b *memNXBackend) Set(_ context.Context, k string, v []byte, _ time.Duration) error {
b.mu.Lock()
defer b.mu.Unlock()
b.data[k] = v
return nil
}
func (b *memNXBackend) Get(_ context.Context, k string) ([]byte, time.Duration, bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
v, ok := b.data[k]
return v, 0, ok, nil
}
func (b *memNXBackend) Delete(_ context.Context, k string) (bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
_, ok := b.data[k]
delete(b.data, k)
return ok, nil
}
func (b *memNXBackend) Exists(_ context.Context, k string) (bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
_, ok := b.data[k]
return ok, nil
}
func (b *memNXBackend) Clear(_ context.Context) error {
b.mu.Lock()
defer b.mu.Unlock()
b.data = map[string][]byte{}
return nil
}
func (b *memNXBackend) GetStats() map[string]interface{} { return nil }
func (b *memNXBackend) Close() error { return nil }
func (b *memNXBackend) Ping(_ context.Context) error { return nil }
func (b *memNXBackend) SetNX(_ context.Context, k string, v []byte, _ time.Duration) (bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
if _, ok := b.data[k]; ok {
return false, nil
}
b.data[k] = v
return true, nil
}
// claimTwice calls SetIfAbsent twice on the same key and returns both
// results. The first call must claim the key and the second must lose.
func claimTwice(c *oidc.UniversalCache) (bool, bool, error) {
first, err := c.SetIfAbsent("yaegi-jti", true, time.Minute)
if err != nil {
return false, false, err
}
second, err := c.SetIfAbsent("yaegi-jti", true, time.Minute)
return first, second, err
}
// checkSetIfAbsentUnderYaegi pins FIX-17: UniversalCache.SetIfAbsent must
// claim a key exactly once in local-only mode and through a backend that
// implements SetNX, and must refuse a backend without SetNX instead of
// claiming locally.
func checkSetIfAbsentUnderYaegi() (string, error) {
cfg := oidc.UniversalCacheConfig{
Logger: oidc.NewLogger("error"),
Type: oidc.CacheTypeToken,
DefaultTTL: time.Minute,
SkipAutoCleanup: true,
}
local := oidc.NewUniversalCache(cfg)
defer local.Close()
if first, second, err := claimTwice(local); err != nil || !first || second {
return "", fmt.Errorf("local-only: first=%v second=%v err=%v, want true false nil", first, second, err)
}
nx := oidc.NewUniversalCacheWithBackend(cfg, &memNXBackend{data: map[string][]byte{}})
defer nx.Close()
if first, second, err := claimTwice(nx); err != nil || !first || second {
return "", fmt.Errorf("SetNX backend: first=%v second=%v err=%v, want true false nil", first, second, err)
}
noNX := oidc.NewUniversalCacheWithBackend(cfg, failingSetBackend{})
defer noNX.Close()
claimed, err := noNX.SetIfAbsent("yaegi-jti", true, time.Minute)
if err == nil || claimed {
return "", fmt.Errorf("backend without SetNX: claimed=%v err=%v, want false and an error", claimed, err)
}
return "", nil
}