package auth import ( "context" "errors" "testing" "time" "github.com/stretchr/testify/require" ) // The boot race, pinned. // // WHAT HAPPENED, THREE TIMES // gitea-mcp starts before Authentik is serving. OIDC discovery returns 502/503. // The constructor errors, the caller is left holding nil, and `nil` means "this // server does not do JWT auth" — so every subsequent Authentik JWT is // string-compared against the static token and rejected: // // WARN mcp auth rejected reason=static_token_mismatch token_type=jwt status=401 // // Permanently, because discovery was attempted exactly once. The pod stays 1/1 // Running and readiness-green throughout. // // 2026-07-17 gitea-mcp#57 ~1 debugging session // 2026-09-07 mcp-chassis#4 ~16 hours // 2026-09-14 (this) ~6 days, found by chance // // Each recovery was a human running `kubectl rollout restart`. // // WHY THE FIX IS SMALL // Everything after construction is already correct. `Validate` distinguishes // ErrUnavailable (issuer unreachable → 503, retry) from a definitive rejection // (→ 401), and BearerMiddleware already honours that. The correct behaviour was // unreachable for this case only because the validator never got built. // // So: build it anyway, resolve discovery lazily, and let the existing machinery // do what it already does. // A validator must be constructible while its issuer is down, must report // ErrUnavailable rather than a rejection while it stays down, and must start // working when the issuer returns — without a restart. func TestNewMultiJWTValidator_SurvivesIssuerDownAtBoot(t *testing.T) { ctx := context.Background() iss := newTestIssuer(t) // Three, because three resolution attempts happen before recovery is // asserted: the constructor's eager warm-up, the Validate below, and the // Ready below. Each must see the issuer still down. iss.discoveryFailures = 3 // 1. Construction succeeds despite discovery being down. This is the whole // bug: today this returns (nil, err) and the caller degrades silently. v, err := NewMultiJWTValidator(ctx, []IssuerConfig{{IssuerURL: iss.url, Audience: "test-aud"}}, WithRetryInterval(0), // no suppression, so the test drives the retries ) require.NoError(t, err, "a validator whose issuer is down at boot must still be constructed") require.NotNil(t, v, "nil would be indistinguishable from 'JWT auth disabled'") // 2. While down, a well-formed token is UNAVAILABLE, never a rejection. // BearerMiddleware turns this into 503 + retry; a rejection would become // 401 static_token_mismatch, which is the lie that cost three sessions. tok := iss.mint(t, "test-aud", "alice") _, err = v.Validate(ctx, tok) require.Error(t, err) require.ErrorIs(t, err, ErrUnavailable, "issuer down must be transient, not a verdict on the token") // 3. The server knows it is degraded, so it can stop calling itself healthy. ready, why := v.Ready(ctx) require.False(t, ready) require.Error(t, why, "Ready must say WHY, so /healthz can report it") // 4. The issuer recovers. The SAME validator starts working — no restart, // no rollout, no human. require.GreaterOrEqual(t, iss.discoveryAttempts(), 1, "discovery must be retried, not cached as failed") sub, err := v.Validate(ctx, tok) require.NoError(t, err, "a recovered issuer must work without reconstructing the validator") require.Equal(t, "alice", sub) ready, why = v.Ready(ctx) require.True(t, ready) require.NoError(t, why) } // Regression guard. A server that genuinely wants static-token-only auth passes // no issuer, and must keep getting nil — that is what makes BearerMiddleware // report static_token_mismatch correctly for it. Fixing the boot race must not // turn every static-only server into a JWT server. func TestNewMultiJWTValidator_NoIssuer_StillReturnsNilNil(t *testing.T) { v, err := NewMultiJWTValidator(context.Background(), []IssuerConfig{{IssuerURL: ""}}) require.NoError(t, err) require.Nil(t, v, "no issuer configured still means JWT auth disabled") } // A permanently bad issuer must not look like a transient one forever in a way // that hides a config error — but it must still be ErrUnavailable rather than a // token verdict, because the token was never the problem. The distinction that // matters is the one Ready() reports. func TestJWTValidator_UnresolvableIssuer_IsUnavailableAndNotReady(t *testing.T) { ctx := context.Background() v, err := NewMultiJWTValidator(ctx, []IssuerConfig{{IssuerURL: "http://127.0.0.1:1/nonexistent"}}, WithRetryInterval(0), ) require.NoError(t, err) require.NotNil(t, v) _, err = v.Validate(ctx, "not.a.real.token") require.True(t, errors.Is(err, ErrUnavailable), "an unreachable issuer is unavailable, whatever the token looks like") ready, why := v.Ready(ctx) require.False(t, ready) require.Error(t, why) } // mcp-chassis#1: the JWKS min-refresh interval was hardcoded at 1h, so a signing // key rotation could leave every consumer rejecting valid JWTs for up to an hour. // // The interval reaching jwx's cache is not observable from outside jwx, and // testing jwx's own refresh behaviour is jwx's job. What IS this package's job, // and what this asserts, is that the configured value reaches the registration // site and is stated in the log — which is also #1's second acceptance criterion // ("effective value logged at init"). A silently-ignored option is the failure // mode worth pinning. func TestWithRefreshInterval_ReachesRegistrationAndIsLogged(t *testing.T) { for _, tc := range []struct { name string opts []Option want string }{ {name: "default is 1h, unchanged", want: "1h0m0s"}, {name: "override", opts: []Option{WithRefreshInterval(5 * time.Minute)}, want: "5m0s"}, } { t.Run(tc.name, func(t *testing.T) { iss := newTestIssuer(t) logs := captureLogs(t) v, err := NewMultiJWTValidator(t.Context(), []IssuerConfig{{IssuerURL: iss.url, Audience: "aud"}}, tc.opts...) require.NoError(t, err) require.NotNil(t, v) var found bool for _, l := range logs() { if l["msg"] == "jwt issuer resolved" { require.Equal(t, tc.want, l["jwks_refresh_interval"]) found = true } } require.True(t, found, "resolving an issuer must state the effective refresh interval") }) } }