mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-12 04:04:26 +00:00
The SSE and WebSocket bypass forwarded only X-Forwarded-User. It did not apply the configured headers. Backends that authenticate the proxy with a shared-secret header returned 401 on WebSocket upgrades. Version 0.8.x sent WebSocket upgrades through the normal flow, so this is a regression. The bypass now renders the header templates through applyHeaderTemplates, which forwardAuthorized also uses. Templates use the ID-token claims from the session cookie. If the session has no ID token, they use the access-token claims. The bypass now also rejects a session that the IdP logged out through back-channel or front-channel logout. Without this check, a logged-out cookie can open streams with the proxy secret attached. Add a yaegi check for the WebSocket case. Move tokenWithGroups to the shared JWT test helpers, as FIX-41 requires. Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
984 lines
38 KiB
Go
984 lines
38 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)
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|