package auth import ( "context" "errors" "testing" "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) }