Files
mcp-chassis/auth/jwt_bootrace_test.go
T
mathiasandClaude Opus 5 3257364f23
CI / Lint / Test / Vet (push) Successful in 5s
fix(auth): a validator whose issuer is down at boot is built anyway
Closes #4. Closes gitea-mcp#57.

Filed 2026-07-17, filed again 2026-09-07, recurred 2026-09-14. Each occurrence
ended with a human running `kubectl rollout restart`; the last one ran for six
days and was found by chance while gathering context for unrelated work.

THE BUG

NewMultiJWTValidator resolved OIDC discovery eagerly and returned (nil, err) if
the issuer was not answering. BearerMiddleware reads a nil validator as "this
server does not do JWT auth", so it fell through to the static-token comparison
and rejected every Authentik JWT:

    WARN mcp auth rejected reason=static_token_mismatch token_type=jwt status=401

Two states collapsed into one nil:

    static-token-only by design      -> nil   correct
    JWT required, IdP down at boot   -> nil   wrong, and permanent

Permanent because discovery was attempted exactly once, at construction.

WHY THE FIX IS SMALL

Everything downstream was already right. Validate distinguishes ErrUnavailable
(issuer unreachable -> 503, client retries) from a definitive rejection (-> 401),
and BearerMiddleware already honours that — gitea-mcp#6 got it right. That path
was simply unreachable when the validator was never built.

So: build it anyway. jwks_uri now resolves lazily on first use and re-resolves on
failure, no sooner than WithRetryInterval apart (default 30s) so a down issuer
costs one discovery request per interval, not one per request. Until it resolves,
Validate returns ErrUnavailable and the existing machinery does the rest. The
constructor still warms discovery eagerly on a healthy start; it just no longer
fails on it.

Ready(ctx) reports whether every issuer resolved, and attempts resolution for any
that have not. That makes a readiness probe the retry loop — no background
goroutine, no context to leak, and the platform's own cadence drives recovery.
A nil validator is Ready: "JWT auth disabled" is a configuration, not a fault.

WHAT DID NOT CHANGE

No issuer configured still returns (nil, nil). That is the one case where nil is
not a lie, and collapsing it would turn every static-only server into a JWT
server. Pinned by TestNewMultiJWTValidator_NoIssuer_StillReturnsNilNil.

ONE EXISTING TEST CHANGED ITS MECHANISM, NOT ITS CLAIM

TestNewMultiJWTValidator_UsesPerIssuerHTTPClientForAuthedFetch asserted that the
constructor ERRORS when discovery 401s without the fetch credential. It cannot,
now, by design. Rewritten to assert the property that actually mattered, and
more directly than before: without the credential the validator is not Ready AND
a correctly-minted token from that very issuer is refused as ErrUnavailable. The
old test only checked that a constructor returned an error.

Written test-first: jwt_bootrace_test.go red (API absent), then green. Also red
in the behavioural sense — the constructor-errors assertion above had to be
rewritten precisely because the semantics genuinely changed.

Verified: golangci-lint 0 issues; go vet clean; go test -race -count=1 ./... ok.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:03:50 +02:00

116 lines
4.7 KiB
Go

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)
}