mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-14 04:07:46 +00:00
Header templates can now emit an object or array claim as one JSON
header. Example: {{get .Claims "tenant" | toJson}}. The backend parses
the claim, so the middleware does not need to know its schema.
The helper adds no new access path. The validator treats toJson like
default and checks its arguments. A claim must be on the whitelist, and
a dump of the whole claims map is rejected. An absent or empty claim
skips the header. The JSON encoder escapes control characters.
A rendered header value over 8192 bytes is still dropped. The drop is
now logged at error level, with the header name and without the value.
Other sanitizer drops stay at debug level.
Tests cover value types, edge cases, whitelist bypass attempts, token
leaks and header injection. A new yaegi check runs the helper under the
interpreter. README, configuration docs and the plugin manifest show the
new helper.
1160 lines
45 KiB
Go
1160 lines
45 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("tojson-header-template", checkToJsonHeaderTemplate)
|
|
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
|
|
}
|
|
|
|
// checkToJsonHeaderTemplate pins the toJson template helper under the
|
|
// interpreter: its (string, error) closure, json.Marshal of an object claim,
|
|
// the nil/absent-claim skip and the validator's rejection of a whole-claims dump.
|
|
func checkToJsonHeaderTemplate() (string, error) {
|
|
discovery := servers.NewOIDCServer(nil)
|
|
defer discovery.Close()
|
|
|
|
cfg := oidc.CreateConfig()
|
|
cfg.ProviderURL = discovery.URL
|
|
cfg.ClientID = "yaegi-check-tojson-client"
|
|
cfg.ClientSecret = "yaegi-check-tojson-secret"
|
|
cfg.CallbackURL = "/oauth2/callback"
|
|
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef"
|
|
cfg.AllowedClaims = []string{"tenant"}
|
|
cfg.Headers = []oidc.TemplatedHeader{
|
|
{Name: "X-Tenant", Value: `{{ get .Claims "tenant" | toJson }}`},
|
|
{Name: "X-Groups", Value: "{{ toJson .Claims.groups }}"},
|
|
{Name: "X-Missing", Value: "{{ toJson .Claims.department }}"},
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
return "", fmt.Errorf("toJson headers must validate: %w", err)
|
|
}
|
|
|
|
bad := oidc.CreateConfig()
|
|
*bad = *cfg
|
|
bad.Headers = []oidc.TemplatedHeader{{Name: "X-All", Value: "{{ toJson .Claims }}"}}
|
|
if err := bad.Validate(); err == nil {
|
|
return "", fmt.Errorf("toJson of the whole claims map must fail validation")
|
|
}
|
|
|
|
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-tojson")
|
|
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]")
|
|
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(`{"tenant":{"id":"t-1","plan":"pro"},"groups":["a","b"]}`)) + "." + 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, want := seen.Get("X-Tenant"), `{"id":"t-1","plan":"pro"}`; got != want {
|
|
return "", fmt.Errorf("X-Tenant = %q, want %q", got, want)
|
|
}
|
|
if got, want := seen.Get("X-Groups"), `["a","b"]`; got != want {
|
|
return "", fmt.Errorf("X-Groups = %q, want %q", got, want)
|
|
}
|
|
if got := seen.Get("X-Missing"); got != "" {
|
|
return "", fmt.Errorf("X-Missing = %q, want header skipped", got)
|
|
}
|
|
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
|
|
}
|