diff --git a/ingestion/cmd/server/jwt_validator.go b/ingestion/cmd/server/jwt_validator.go new file mode 100644 index 0000000..002eeb6 --- /dev/null +++ b/ingestion/cmd/server/jwt_validator.go @@ -0,0 +1,25 @@ +package main + +import ( + "context" + "log/slog" + + chassisauth "git.d-ma.be/mathias/mcp-chassis/auth" + + "github.com/mathiasbq/hyperguild/ingestion/internal/startupretry" +) + +// buildJWTValidator runs OIDC discovery under policy, so a 502 from an auth +// upstream that is not serving yet on a cold boot is retried instead of +// exiting (#83). It honours ctx between attempts. +func buildJWTValidator( + ctx context.Context, logger *slog.Logger, issuer, audience string, policy startupretry.Policy, +) (*chassisauth.JWTValidator, error) { + return startupretry.Do(ctx, policy, func(ctx context.Context) (*chassisauth.JWTValidator, error) { + v, err := chassisauth.NewJWTValidator(ctx, issuer, audience) + if err != nil { + logger.Warn("oidc discovery not ready, retrying", "err", err) + } + return v, err + }) +} diff --git a/ingestion/cmd/server/jwt_validator_test.go b/ingestion/cmd/server/jwt_validator_test.go new file mode 100644 index 0000000..1066f04 --- /dev/null +++ b/ingestion/cmd/server/jwt_validator_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mathiasbq/hyperguild/ingestion/internal/startupretry" +) + +// fakeIssuer answers OIDC discovery with 502 for the first fail requests, as +// ingress does while dex is not yet serving on a cold boot (#83), then with a +// real discovery document and an empty JWKS. +func fakeIssuer(t *testing.T, fail int32) (*httptest.Server, *atomic.Int32) { + t.Helper() + var discoveries atomic.Int32 + mux := http.NewServeMux() + var srv *httptest.Server + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + if discoveries.Add(1) <= fail { + w.WriteHeader(http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"issuer":"`+srv.URL+`","jwks_uri":"`+srv.URL+`/keys"}`) + }) + mux.HandleFunc("/keys", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"keys":[]}`) + }) + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv, &discoveries +} + +var fastPolicy = startupretry.Policy{Attempts: 8, Base: time.Millisecond, Max: 5 * time.Millisecond} + +func TestBuildJWTValidatorRetriesDiscovery502sThenSucceeds(t *testing.T) { + srv, discoveries := fakeIssuer(t, 3) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + v, err := buildJWTValidator(ctx, slog.New(slog.DiscardHandler), srv.URL, "brain", fastPolicy) + require.NoError(t, err) + assert.NotNil(t, v) + assert.Equal(t, int32(4), discoveries.Load(), "three 502s, then the successful discovery") +} + +func TestBuildJWTValidatorGivesUpAfterTheBudgetWithTheLastError(t *testing.T) { + srv, discoveries := fakeIssuer(t, 1000) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, err := buildJWTValidator(ctx, slog.New(slog.DiscardHandler), srv.URL, "brain", fastPolicy) + require.Error(t, err) + assert.Contains(t, err.Error(), "status 502") + assert.Equal(t, int32(8), discoveries.Load(), "bounded: exactly the attempt budget") +} + +func TestBuildJWTValidatorStopsRetryingWhenTheContextIsCancelled(t *testing.T) { + srv, _ := fakeIssuer(t, 1000) + ctx, cancel := context.WithCancel(context.Background()) + slow := startupretry.Policy{Attempts: 8, Base: time.Hour, Max: time.Hour} + + done := make(chan error, 1) + go func() { + _, err := buildJWTValidator(ctx, slog.New(slog.DiscardHandler), srv.URL, "brain", slow) + done <- err + }() + time.Sleep(50 * time.Millisecond) + cancel() + select { + case err := <-done: + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(3 * time.Second): + t.Fatal("did not return after the context was cancelled") + } +} diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index f0fc95d..6baafc1 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -381,15 +381,9 @@ func main() { // // The budget stays bounded: retrying forever would turn a misconfiguration // into a process that looks alive and never serves. - jwtValidator, err := startupretry.Do(ctx, - startupretry.Policy{Attempts: 8, Base: time.Second, Max: 30 * time.Second}, - func(ctx context.Context) (*chassisauth.JWTValidator, error) { - v, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE")) - if err != nil { - logger.Warn("oidc discovery not ready, retrying", "err", err) - } - return v, err - }) + jwtValidator, err := buildJWTValidator(ctx, logger, + os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"), + startupretry.Policy{Attempts: 8, Base: time.Second, Max: 30 * time.Second}) if err != nil { logger.Error("build jwt validator", "err", err) os.Exit(1)