From 67decddc8a38313ff97bf94c33c6aff0b67f9881 Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 22 May 2026 09:07:53 +0200 Subject: [PATCH] feat: initial mcp-chassis with auth primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared Go library for Mathias-owned MCP servers, born from spike S3 of the 2026-05 homelab architecture review (see gitea.d-ma.be/mathias/infra/docs/superpowers/handoffs/2026-05-22-mcp-chassis-spike.md for the viability assessment and abort-criterion check). Provides three primitives every MCP server today re-implements: - auth.JWTValidator — Dex OIDC JWT validation. nil-safe (nil = "JWT disabled"), audience-optional. Lifted from the identical ~80-LOC implementations in gitea-mcp and hyperguild/ingestion. - auth.BearerMiddleware — dual-mode static-Bearer-or-Dex-JWT gate. Static wins first to avoid emitting a WWW-Authenticate challenge that would flip claude.ai's MCP client into OAuth discovery for static-only deployments. The fall-through 401 emits the RFC 9728 resource_metadata header only when explicitly configured. - auth.ProtectedResourceHandler — RFC 9728 /.well-known/oauth-protected-resource metadata document handler. Test coverage exercises every branch (static OK, JWT-disabled, empty bearer, wrong static, with-challenge vs without-challenge, nil-validator-Validate). go test -race clean. Deps: github.com/lestrrat-go/jwx/v2 (already a dep of every consumer) and testify (test-only). No new transitive deps. First migration target: gitea-mcp. If that port lands in one PR (abort criterion from spec), brain-mcp (ingestion) follows. Otherwise chassis reverts per the spec. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 96 +++++++++++++++++++++++++ auth/bearer.go | 77 ++++++++++++++++++++ auth/bearer_test.go | 114 ++++++++++++++++++++++++++++++ auth/jwt.go | 120 ++++++++++++++++++++++++++++++++ auth/protected_resource.go | 29 ++++++++ auth/protected_resource_test.go | 33 +++++++++ go.mod | 24 +++++++ go.sum | 37 ++++++++++ 8 files changed, 530 insertions(+) create mode 100644 README.md create mode 100644 auth/bearer.go create mode 100644 auth/bearer_test.go create mode 100644 auth/jwt.go create mode 100644 auth/protected_resource.go create mode 100644 auth/protected_resource_test.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa05c32 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# mcp-chassis + +Shared Go library for Mathias-owned MCP servers. Provides the +auth + middleware primitives that every MCP server needs. + +## Why + +By 2026-05-22 there were three+ MCP servers (`gitea-mcp`, `brain-mcp` / +`ingestion`, future ones from `template-go-agent`) each carrying their +own near-identical: + +- Dex JWT validator (~80 LOC, identical `jwx/v2` plumbing) +- Bearer middleware (~50 LOC, dual-mode static + JWT) +- RFC 9728 protected-resource metadata handler (~25 LOC) + +The homelab architecture review's spike S3 (see +`gitea.d-ma.be/mathias/infra/docs/superpowers/handoffs/2026-05-22-mcp-chassis-spike.md`) +concluded a thin shared lib pays for itself within the first migration. +This is that lib. + +## Non-goals + +- Replacing each MCP's tool registration / handler logic — that is per-domain. +- Solving HTTP routing — consumers keep their own `http.ServeMux`. +- Solving observability — see `gitea.d-ma.be/mathias/hyperguild/ingestion/internal/metrics` + for the hand-rolled Prometheus pattern. May absorb a `metrics` subpackage here + later, once a second consumer needs it. + +## Packages + +### `auth` + +- `JWTValidator` — Dex OIDC JWT validation. `nil` is a valid value meaning + "JWT auth disabled". +- `BearerMiddleware` — static-Bearer-or-Dex-JWT gate. Static wins first; only + emits `WWW-Authenticate: Bearer ... resource_metadata=...` on 401 when + `resourceMetadataURL` is non-empty (claude.ai OAuth discovery). +- `ProtectedResourceHandler` — RFC 9728 metadata document for + `GET /.well-known/oauth-protected-resource`. + +## Usage + +```go +package main + +import ( + "context" + "net/http" + "os" + + "gitea.d-ma.be/mathias/mcp-chassis/auth" +) + +func main() { + staticToken := os.Getenv("BRAIN_MCP_TOKEN") + dexIssuer := os.Getenv("DEX_ISSUER_URL") + audience := os.Getenv("MCP_AUDIENCE") + resourceURL := os.Getenv("MCP_RESOURCE_URL") + + validator, err := auth.NewJWTValidator(context.Background(), dexIssuer, audience) + if err != nil { + panic(err) + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /.well-known/oauth-protected-resource", + auth.ProtectedResourceHandler(resourceURL, dexIssuer)) + mux.Handle("/mcp", auth.BearerMiddleware( + staticToken, + validator, + "brain", + resourceURL+"/.well-known/oauth-protected-resource", + mcpHandler(), + )) + + _ = http.ListenAndServe(":3300", mux) +} + +func mcpHandler() http.Handler { /* per-domain */ return nil } +``` + +## Versioning + +Trunk-based development on `main`. Tagged with semver. Consumers pin +specific tags (`go.mod` `require gitea.d-ma.be/mathias/mcp-chassis v0.x.y`) +and bump deliberately. + +Migrations are documented per-consumer in the consumer's CHANGELOG / commits. + +## Dependencies + +- `github.com/lestrrat-go/jwx/v2` — JWKS cache + JWT parsing. Same dep + every MCP already had; no new transitive cost when adopting the chassis. +- `github.com/stretchr/testify` — tests only. + +stdlib otherwise. diff --git a/auth/bearer.go b/auth/bearer.go new file mode 100644 index 0000000..c77b7c3 --- /dev/null +++ b/auth/bearer.go @@ -0,0 +1,77 @@ +package auth + +import ( + "crypto/subtle" + "net/http" + "strings" +) + +// BearerMiddleware gates next behind dual-mode authentication. It is the +// canonical pattern across every Mathias-owned MCP server. +// +// Auth precedence: +// +// 1. Static Bearer match (constant-time compare against staticToken). +// Wins immediately and never emits a WWW-Authenticate header. This is +// the path used by internal CLI callers that supply +// `Authorization: Bearer $XXX_MCP_TOKEN` via `.mcp.json`. Returning +// 401 without a WWW-Authenticate prevents the MCP client from +// speculatively flipping into OAuth-discovery mode and discarding +// the static token. +// 2. Dex JWT validation (when validator is non-nil). Used by claude.ai +// custom MCP connectors that finished the OAuth handshake. +// 3. Otherwise 401. When resourceMetadataURL is non-empty, a +// `WWW-Authenticate: Bearer realm="", resource_metadata="…"` +// header is emitted per RFC 9728 §6.2 so claude.ai's OAuth discovery +// flow can find the server's protected-resource metadata document. +// +// The order matters: a valid static Bearer must short-circuit BEFORE the +// JWT path runs, because the WWW-Authenticate emitted on the fall-through +// 401 confuses static-Bearer-only clients into discarding their header +// and starting an OAuth handshake instead. +// +// staticToken may be empty (static auth disabled — only JWT accepted). +// validator may be nil (JWT auth disabled — only static accepted). +// realm is a free-text identifier used in the WWW-Authenticate challenge; +// MCP servers conventionally use their service name (e.g. "brain", "gitea"). +func BearerMiddleware( + staticToken string, + validator *JWTValidator, + realm string, + resourceMetadataURL string, + next http.Handler, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawToken, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok || rawToken == "" { + unauthorized(w, realm, resourceMetadataURL) + return + } + + // 1. Static Bearer wins first — never emits a challenge. + if staticToken != "" && + subtle.ConstantTimeCompare([]byte(rawToken), []byte(staticToken)) == 1 { + next.ServeHTTP(w, r) + return + } + + // 2. Then Dex JWT, if configured. + if validator != nil { + if _, err := validator.Validate(r.Context(), rawToken); err == nil { + next.ServeHTTP(w, r) + return + } + } + + // 3. Reject with an OAuth resource-metadata challenge if configured. + unauthorized(w, realm, resourceMetadataURL) + }) +} + +func unauthorized(w http.ResponseWriter, realm, resourceMetadataURL string) { + if resourceMetadataURL != "" { + w.Header().Set("WWW-Authenticate", + `Bearer realm="`+realm+`", resource_metadata="`+resourceMetadataURL+`"`) + } + http.Error(w, "unauthorized", http.StatusUnauthorized) +} diff --git a/auth/bearer_test.go b/auth/bearer_test.go new file mode 100644 index 0000000..637eddb --- /dev/null +++ b/auth/bearer_test.go @@ -0,0 +1,114 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBearerMiddleware_StaticTokenWins(t *testing.T) { + t.Parallel() + + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + }) + + h := BearerMiddleware("supersecret", nil, "brain", "", next) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer supersecret") + h.ServeHTTP(rec, req) + + require.True(t, called, "next must be called on valid static token") + require.Equal(t, http.StatusNoContent, rec.Code) +} + +func TestBearerMiddleware_NoHeader_401NoChallengeWhenMetadataEmpty(t *testing.T) { + t.Parallel() + + h := BearerMiddleware("any", nil, "brain", "", http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + require.Empty(t, rec.Header().Get("WWW-Authenticate")) +} + +func TestBearerMiddleware_NoHeader_EmitsChallengeWhenMetadataSet(t *testing.T) { + t.Parallel() + + h := BearerMiddleware("any", nil, "brain", + "https://brain-mcp.d-ma.be/.well-known/oauth-protected-resource", + http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + require.Equal(t, + `Bearer realm="brain", resource_metadata="https://brain-mcp.d-ma.be/.well-known/oauth-protected-resource"`, + rec.Header().Get("WWW-Authenticate"), + ) +} + +func TestBearerMiddleware_WrongStaticToken_401(t *testing.T) { + t.Parallel() + + h := BearerMiddleware("expected", nil, "brain", "", http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("next must NOT be called on wrong token") + })) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer wrong") + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestBearerMiddleware_EmptyBearer_401(t *testing.T) { + t.Parallel() + + h := BearerMiddleware("expected", nil, "brain", "", + http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("next must NOT be called on empty bearer") + })) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer ") + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestBearerMiddleware_StaticOnly_NilValidator_OK(t *testing.T) { + t.Parallel() + + // Verifies that JWT-disabled deployments (validator == nil) work end-to-end. + called := false + h := BearerMiddleware("tok", nil, "brain", "", + http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer tok") + h.ServeHTTP(rec, req) + + require.True(t, called) +} + +func TestJWTValidator_NilReturnsError(t *testing.T) { + t.Parallel() + + var v *JWTValidator + subj, err := v.Validate(t.Context(), "anything") + require.Empty(t, subj) + require.Error(t, err) +} diff --git a/auth/jwt.go b/auth/jwt.go new file mode 100644 index 0000000..2bcb191 --- /dev/null +++ b/auth/jwt.go @@ -0,0 +1,120 @@ +// Package auth provides the Dex-JWT + static-Bearer authentication primitives +// shared by every Mathias-owned MCP server (gitea-mcp, brain-mcp / ingestion, +// future MCPs spawned from template-go-agent). +// +// Replaces ~80 LOC of near-identical jwt.go in each consumer, ~50 LOC of +// Bearer middleware, and ~25 LOC of RFC 9728 protected-resource metadata +// handler. See `gitea.d-ma.be/mathias/infra` docs/superpowers/handoffs/ +// 2026-05-22-mcp-chassis-spike.md for the design rationale and the +// abort-criterion check. +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +// JWTValidator validates Bearer JWTs issued by a Dex (OIDC) authorization server. +// Audience is optional; leave empty to skip audience validation. +// +// A nil *JWTValidator behaves as "JWT auth disabled" — Validate returns an +// error without panicking. Callers can construct one validator at startup +// keyed on whether DEX_ISSUER_URL is set, and pass nil through the rest of +// the codebase without further branching. +type JWTValidator struct { + issuer string + audience string + jwksURI string + cache *jwk.Cache +} + +// NewJWTValidator fetches the OIDC discovery document from issuerURL, +// extracts jwks_uri, warms the JWKS cache, and returns a ready validator. +// Empty issuerURL returns (nil, nil) so callers can use a single +// constructor regardless of whether Dex is configured. +func NewJWTValidator(ctx context.Context, issuerURL, audience string) (*JWTValidator, error) { + if issuerURL == "" { + return nil, nil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + issuerURL+"/.well-known/openid-configuration", nil) + if err != nil { + return nil, fmt.Errorf("build oidc discovery request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch oidc discovery: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("oidc discovery: status %d", resp.StatusCode) + } + + var doc struct { + JWKSURI string `json:"jwks_uri"` + } + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return nil, fmt.Errorf("decode oidc discovery: %w", err) + } + if doc.JWKSURI == "" { + return nil, fmt.Errorf("oidc discovery: empty jwks_uri") + } + + cache := jwk.NewCache(ctx) + if err := cache.Register(doc.JWKSURI, jwk.WithMinRefreshInterval(time.Hour)); err != nil { + return nil, fmt.Errorf("register jwks cache: %w", err) + } + if _, err := cache.Refresh(ctx, doc.JWKSURI); err != nil { + return nil, fmt.Errorf("initial jwks fetch: %w", err) + } + + return &JWTValidator{ + issuer: issuerURL, + audience: audience, + jwksURI: doc.JWKSURI, + cache: cache, + }, nil +} + +// Validate parses and validates rawToken against the OIDC issuer. Returns +// the subject claim on success. A nil receiver returns +// (`""`, errDisabled) so callers can dispatch on `err != nil` without +// nil-checks at every call site. +func (v *JWTValidator) Validate(ctx context.Context, rawToken string) (string, error) { + if v == nil { + return "", errDisabled + } + + keySet, err := v.cache.Get(ctx, v.jwksURI) + if err != nil { + return "", fmt.Errorf("get jwks: %w", err) + } + + opts := []jwt.ParseOption{ + jwt.WithKeySet(keySet), + jwt.WithValidate(true), + jwt.WithIssuer(v.issuer), + } + if v.audience != "" { + opts = append(opts, jwt.WithAudience(v.audience)) + } + + tok, err := jwt.ParseString(rawToken, opts...) + if err != nil { + return "", fmt.Errorf("validate jwt: %w", err) + } + return tok.Subject(), nil +} + +// errDisabled is the sentinel returned by Validate on a nil receiver. +// Not exported because callers care about "not authorized" not "why"; +// this lets consumers treat any err the same way without depending on +// the specific value. +var errDisabled = fmt.Errorf("jwt auth disabled") diff --git a/auth/protected_resource.go b/auth/protected_resource.go new file mode 100644 index 0000000..6697f07 --- /dev/null +++ b/auth/protected_resource.go @@ -0,0 +1,29 @@ +package auth + +import ( + "encoding/json" + "net/http" +) + +// ProtectedResourceHandler returns an RFC 9728 oauth-protected-resource +// metadata handler. Mount at GET /.well-known/oauth-protected-resource +// (no auth required). +// +// claude.ai's OAuth discovery flow fetches this endpoint when an MCP +// server's WWW-Authenticate challenge points at it via the +// `resource_metadata` parameter; the document points back at the Dex +// authorization server, completing the discovery loop. +func ProtectedResourceHandler(resourceURL, issuerURL string) http.HandlerFunc { + type metadata struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + } + body, _ := json.Marshal(metadata{ + Resource: resourceURL, + AuthorizationServers: []string{issuerURL}, + }) + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + } +} diff --git a/auth/protected_resource_test.go b/auth/protected_resource_test.go new file mode 100644 index 0000000..17cd747 --- /dev/null +++ b/auth/protected_resource_test.go @@ -0,0 +1,33 @@ +package auth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestProtectedResourceHandler(t *testing.T) { + t.Parallel() + + h := ProtectedResourceHandler( + "https://brain-mcp.d-ma.be", + "https://auth.d-ma.be", + ) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource", nil)) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + var got struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, "https://brain-mcp.d-ma.be", got.Resource) + require.Equal(t, []string{"https://auth.d-ma.be"}, got.AuthorizationServers) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..64a2c3f --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module gitea.d-ma.be/mathias/mcp-chassis + +go 1.26.1 + +require ( + github.com/lestrrat-go/jwx/v2 v2.1.6 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/segmentio/asm v1.2.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/sys v0.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d8ec42d --- /dev/null +++ b/go.sum @@ -0,0 +1,37 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=