1. Rejections are logged. chassis v0.2.0 refused without a log line, so since #149 an unknown REST caller got silent 401s. v0.7.1 writes `mcp auth rejected` (reason, remote, token fingerprint, path) and `mcp auth accepted` per authenticated request. Tested through buildMux. 2. Fallout: since chassis v0.7 (mcp-chassis#4) NewJWTValidator no longer fails on OIDC discovery; the issuer resolves lazily and a JWT gets 503-and-retry until it does. #83's boot retry loop therefore never retried, and its tests failed. buildJWTValidator now builds once and logs an issuer still unresolved at boot. Behaviour change: a misconfigured issuer no longer exits the process; JWTs get 503, the static bearer keeps working. internal/startupretry is now unused. 3. Metrics: authenticated REST requests were already labelled by route (the REST mux sets Pattern on the same *http.Request the middleware reads). Requests refused by auth never reached the REST mux and were labelled path="/". labelRESTPattern now sets the REST route's pattern before auth; unmatched paths keep "/", so the label set stays bounded. Closes #155 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
81 lines
2.9 KiB
Go
81 lines
2.9 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// #83 retried discovery at boot because chassis v0.2.0 failed construction on
|
|
// a 502. Since chassis v0.7 (mcp-chassis#4) the validator is built anyway and
|
|
// resolves the issuer lazily, so boot no longer waits or exits on it
|
|
// (hyperguild#155). An unresolved issuer is logged at boot.
|
|
func TestBuildJWTValidatorReturnsAValidatorWhileDiscovery502s(t *testing.T) {
|
|
srv, _ := fakeIssuer(t, 1000)
|
|
var buf bytes.Buffer
|
|
v, err := buildJWTValidator(context.Background(), slog.New(slog.NewTextHandler(&buf, nil)), srv.URL, "brain")
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, v)
|
|
assert.Contains(t, buf.String(), "jwt issuer not resolved at boot")
|
|
}
|
|
|
|
func TestBuildJWTValidatorLogsNothingAgainstAHealthyIssuer(t *testing.T) {
|
|
srv, _ := fakeIssuer(t, 0)
|
|
var buf bytes.Buffer
|
|
v, err := buildJWTValidator(context.Background(), slog.New(slog.NewTextHandler(&buf, nil)), srv.URL, "brain")
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, v)
|
|
assert.NotContains(t, buf.String(), "not resolved")
|
|
}
|
|
|
|
func TestBuildJWTValidatorIsNilWithoutAnIssuer(t *testing.T) {
|
|
v, err := buildJWTValidator(context.Background(), slog.New(slog.DiscardHandler), "", "brain")
|
|
require.NoError(t, err)
|
|
assert.Nil(t, v)
|
|
}
|
|
|
|
// With the issuer unresolved, a JWT on a REST route answers 503 (retry), not
|
|
// 401: the token may be fine, the server just cannot check it yet.
|
|
func TestBuildMuxAnswers503ToAJWTWhileTheIssuerIsUnresolved(t *testing.T) {
|
|
srv, _ := fakeIssuer(t, 1000)
|
|
v, err := buildJWTValidator(context.Background(), slog.New(slog.DiscardHandler), srv.URL, "brain")
|
|
require.NoError(t, err)
|
|
mux, err := buildMux(restAuth{token: routesTok, validator: v, realm: "brain"}, testHandler(t), nil)
|
|
require.NoError(t, err)
|
|
code, _ := do(t, mux, "GET", "/pending", "Bearer aaa.bbb.ccc")
|
|
assert.Equal(t, http.StatusServiceUnavailable, code)
|
|
}
|