mcp
This commit is contained in:
1 parent
9f578780bd
commit
5807132ac6
6 files changed
+838
-81
No files matched your search
@@ -0,0 +1,146 @@
|
||||
// Package mcpauth guards the MCP HTTP endpoints (cart-mcp, order-mcp,
|
||||
// wishlist-mcp, promotions-mcp, ledger-mcp, finder-mcp) with authentication.
|
||||
//
|
||||
// These are admin tooling surfaces for AI agents / operator tooling, so the
|
||||
// default is FAIL-CLOSED: when no credential source is configured the
|
||||
// endpoint returns 503 rather than serving unauthenticated.
|
||||
//
|
||||
// Credential sources, checked in order (first match wins):
|
||||
//
|
||||
// 1. Minted API keys — AUTH_APIKEYS_FILE points at the shared key store
|
||||
// (the same /commerce/apikeys.json the backoffice and CMS use). Keys
|
||||
// must be minted with an "mcp" or "admin" scope. The store is reloaded
|
||||
// on mtime change, so a newly minted key works without a restart.
|
||||
// Presented as: X-API-Key: <key>
|
||||
// 2. Device access tokens — AUTH_TOKEN_SECRET enables the platform
|
||||
// device-authorization flow's local JWTs (HS256). The token's scopes
|
||||
// must include "mcp" or "admin". Presented as: Authorization: Bearer <jwt>
|
||||
// 3. Legacy shared key — MCP_API_KEY (dev compose sets devkey; production
|
||||
// reads it from the stack-secrets secret). Presented as:
|
||||
// X-API-Key: <key> or Authorization: Bearer <key>
|
||||
package mcpauth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.k6n.net/mats/platform/auth"
|
||||
)
|
||||
|
||||
// authz is the combined validator built from the configured sources.
|
||||
type authz struct {
|
||||
keys *auth.KeyStore // minted keys (AUTH_APIKEYS_FILE); nil if unset
|
||||
jwt *auth.LocalJWT // device tokens (AUTH_TOKEN_SECRET); nil if unset
|
||||
legacyKey string // MCP_API_KEY; empty if unset
|
||||
configured bool // at least one source enabled
|
||||
}
|
||||
|
||||
var (
|
||||
authzOnce sync.Once
|
||||
current *authz
|
||||
)
|
||||
|
||||
// buildAuthz initializes the validator from the environment once. Env is
|
||||
// read at first use (Middleware construction), which happens at service
|
||||
// startup.
|
||||
func buildAuthz() *authz {
|
||||
authzOnce.Do(func() {
|
||||
a := &authz{}
|
||||
if p := os.Getenv("AUTH_APIKEYS_FILE"); p != "" {
|
||||
if ks, err := auth.OpenKeyStore(p); err == nil {
|
||||
a.keys = ks
|
||||
a.configured = true
|
||||
}
|
||||
}
|
||||
if s := os.Getenv("AUTH_TOKEN_SECRET"); s != "" {
|
||||
a.jwt = auth.NewLocalJWT(s)
|
||||
a.configured = true
|
||||
}
|
||||
if k := os.Getenv("MCP_API_KEY"); k != "" {
|
||||
a.legacyKey = k
|
||||
a.configured = true
|
||||
}
|
||||
current = a
|
||||
})
|
||||
return current
|
||||
}
|
||||
|
||||
// Middleware protects the wrapped MCP handler. See the package comment for
|
||||
// the credential contract. The same source order applies; a request fails
|
||||
// with 401 unless exactly one source validates it.
|
||||
func Middleware(next http.Handler) http.Handler {
|
||||
a := buildAuthz()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.configured {
|
||||
http.Error(w, "mcp: no credential source configured (set AUTH_APIKEYS_FILE, AUTH_TOKEN_SECRET, or MCP_API_KEY)", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
// Reload the key store when the shared file changed (cheap stat).
|
||||
if a.keys != nil {
|
||||
_ = a.keys.ReloadIfChanged()
|
||||
}
|
||||
if !a.authenticate(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// authenticate tries each configured source and applies scope gating (mcp or
|
||||
// admin required; a plain read/write-only key is not enough for admin MCP
|
||||
// surfaces).
|
||||
func (a *authz) authenticate(r *http.Request) bool {
|
||||
p, err := a.principal(r)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, s := range p.Scopes {
|
||||
if s == "mcp" || s == "admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// principal resolves the request to a Principal via the configured sources,
|
||||
// in order: minted key → device token → legacy key. A source that rejects a
|
||||
// presented credential falls through to the next one, so a shared dev key
|
||||
// still works when a keystore is also configured.
|
||||
func (a *authz) principal(r *http.Request) (*auth.Principal, error) {
|
||||
key := r.Header.Get("X-API-Key")
|
||||
bearer, hasBearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
|
||||
var lastErr error
|
||||
if key != "" && a.keys != nil {
|
||||
if p, err := (auth.APIKeyAuth{Keys: a.keys}).Authenticate(r); err == nil {
|
||||
return p, nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if hasBearer && a.jwt != nil {
|
||||
if p, err := a.jwt.Authenticate(r); err == nil {
|
||||
return p, nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
// Legacy: accept the shared key via either header form.
|
||||
if a.legacyKey != "" {
|
||||
presented := key
|
||||
if presented == "" && hasBearer {
|
||||
presented = bearer
|
||||
}
|
||||
if presented != "" && subtle.ConstantTimeCompare([]byte(presented), []byte(a.legacyKey)) == 1 {
|
||||
return &auth.Principal{Subject: "legacy-mcp", Scopes: []string{"mcp"}, Kind: auth.KindAPIKey}, nil
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, auth.ErrNoCredentials
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package mcpauth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/platform/auth"
|
||||
)
|
||||
|
||||
// freshAuthz resets the package-level singleton so each test can configure
|
||||
// its own env. buildAuthz is sync.Once'd, so tests mutate the env BEFORE the
|
||||
// first Middleware() call.
|
||||
func resetAuthz(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, k := range []string{"AUTH_APIKEYS_FILE", "AUTH_TOKEN_SECRET", "MCP_API_KEY"} {
|
||||
_ = os.Unsetenv(k)
|
||||
}
|
||||
authzOnce = sync.Once{}
|
||||
current = nil
|
||||
t.Cleanup(func() {
|
||||
authzOnce = sync.Once{}
|
||||
current = nil
|
||||
})
|
||||
}
|
||||
|
||||
func doReq(t *testing.T, r *http.Request) int {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})).ServeHTTP(rr, r)
|
||||
return rr.Code
|
||||
}
|
||||
|
||||
func TestNoSourceConfigured(t *testing.T) {
|
||||
resetAuthz(t)
|
||||
r := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil)
|
||||
if code := doReq(t, r); code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("no source configured: got %d, want 503", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintedKeyRequiresMCPOrAdminScope(t *testing.T) {
|
||||
resetAuthz(t)
|
||||
path := filepath.Join(t.TempDir(), "apikeys.json")
|
||||
ks, err := auth.OpenKeyStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Keys with mcp / admin scopes validate.
|
||||
mcpKey, _, err := ks.Mint("companion", []string{"mcp"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminKey, _, err := ks.Mint("ops", []string{"admin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A read-only key must NOT validate against MCP surfaces.
|
||||
readKey, _, err := ks.Mint("reader", []string{"read"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Setenv("AUTH_APIKEYS_FILE", path)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
key string
|
||||
want int
|
||||
}{
|
||||
{"mcp scope", mcpKey, http.StatusOK},
|
||||
{"admin scope", adminKey, http.StatusOK},
|
||||
{"read-only rejected", readKey, http.StatusUnauthorized},
|
||||
{"garbage rejected", "gck_bogus", http.StatusUnauthorized},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil)
|
||||
r.Header.Set("X-API-Key", tc.key)
|
||||
if code := doReq(t, r); code != tc.want {
|
||||
t.Fatalf("got %d, want %d", code, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyStoreReloadPicksUpNewKeys(t *testing.T) {
|
||||
resetAuthz(t)
|
||||
path := filepath.Join(t.TempDir(), "apikeys.json")
|
||||
ks, err := auth.OpenKeyStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstKey, _, err := ks.Mint("a", []string{"mcp"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Setenv("AUTH_APIKEYS_FILE", path)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/order-mcp", nil)
|
||||
r.Header.Set("X-API-Key", firstKey)
|
||||
if code := doReq(t, r); code != http.StatusOK {
|
||||
t.Fatalf("first key should validate: got %d", code)
|
||||
}
|
||||
|
||||
// A second key minted into the same file AFTER the middleware loaded
|
||||
// must validate via the mtime-based reload.
|
||||
secondKey, _, err := ks.Mint("b", []string{"mcp"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r2 := httptest.NewRequest(http.MethodGet, "/order-mcp", nil)
|
||||
r2.Header.Set("X-API-Key", secondKey)
|
||||
if code := doReq(t, r2); code != http.StatusOK {
|
||||
t.Fatalf("newly minted key should validate after reload: got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceToken(t *testing.T) {
|
||||
resetAuthz(t)
|
||||
_ = os.Setenv("AUTH_TOKEN_SECRET", "test-secret")
|
||||
jwt := auth.NewLocalJWT("test-secret")
|
||||
|
||||
tok, err := jwt.Mint("user-1", []string{"mcp"}, 0) // ttl 0 → exp now (still valid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+tok)
|
||||
if code := doReq(t, r); code != http.StatusOK {
|
||||
t.Fatalf("device token with mcp scope: got %d, want 200", code)
|
||||
}
|
||||
|
||||
// A device token without mcp/admin scope must be rejected.
|
||||
tokNoScope, err := jwt.Mint("user-2", []string{"read"}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r2 := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil)
|
||||
r2.Header.Set("Authorization", "Bearer "+tokNoScope)
|
||||
if code := doReq(t, r2); code != http.StatusUnauthorized {
|
||||
t.Fatalf("device token without mcp scope: got %d, want 401", code)
|
||||
}
|
||||
|
||||
// Garbage bearer with AUTH_TOKEN_SECRET set must be 401 (not 503).
|
||||
r3 := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil)
|
||||
r3.Header.Set("Authorization", "Bearer not.a.jwt")
|
||||
if code := doReq(t, r3); code != http.StatusUnauthorized {
|
||||
t.Fatalf("garbage token: got %d, want 401", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyKey(t *testing.T) {
|
||||
resetAuthz(t)
|
||||
_ = os.Setenv("MCP_API_KEY", "devkey")
|
||||
for _, hdr := range []string{"X-API-Key", "Authorization"} {
|
||||
r := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil)
|
||||
if hdr == "Authorization" {
|
||||
r.Header.Set(hdr, "Bearer devkey")
|
||||
} else {
|
||||
r.Header.Set(hdr, "devkey")
|
||||
}
|
||||
if code := doReq(t, r); code != http.StatusOK {
|
||||
t.Fatalf("legacy key via %s: got %d, want 200", hdr, code)
|
||||
}
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil)
|
||||
r.Header.Set("X-API-Key", "wrong")
|
||||
if code := doReq(t, r); code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong legacy key: got %d, want 401", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesCompose(t *testing.T) {
|
||||
resetAuthz(t)
|
||||
path := filepath.Join(t.TempDir(), "apikeys.json")
|
||||
ks, err := auth.OpenKeyStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _, err := ks.Mint("companion", []string{"mcp"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Setenv("AUTH_APIKEYS_FILE", path)
|
||||
_ = os.Setenv("AUTH_TOKEN_SECRET", "test-secret")
|
||||
_ = os.Setenv("MCP_API_KEY", "devkey")
|
||||
|
||||
// Minted key wins.
|
||||
r := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil)
|
||||
r.Header.Set("X-API-Key", key)
|
||||
if code := doReq(t, r); code != http.StatusOK {
|
||||
t.Fatalf("minted key: got %d, want 200", code)
|
||||
}
|
||||
// Device token works alongside.
|
||||
jwt := auth.NewLocalJWT("test-secret")
|
||||
tok, err := jwt.Mint("u", []string{"admin"}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r2 := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil)
|
||||
r2.Header.Set("Authorization", "Bearer "+tok)
|
||||
if code := doReq(t, r2); code != http.StatusOK {
|
||||
t.Fatalf("device token: got %d, want 200", code)
|
||||
}
|
||||
// Legacy key still works.
|
||||
r3 := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil)
|
||||
r3.Header.Set("X-API-Key", "devkey")
|
||||
if code := doReq(t, r3); code != http.StatusOK {
|
||||
t.Fatalf("legacy key: got %d, want 200", code)
|
||||
}
|
||||
// Without credentials → 401.
|
||||
r4 := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil)
|
||||
if code := doReq(t, r4); code != http.StatusUnauthorized {
|
||||
t.Fatalf("no creds: got %d, want 401", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the fail-closed behavior matches the documented contract even when
|
||||
// the env contains empty-but-set values.
|
||||
func TestEmptyEnvFallsClosed(t *testing.T) {
|
||||
resetAuthz(t)
|
||||
_ = os.Setenv("AUTH_APIKEYS_FILE", strings.Repeat(" ", 0))
|
||||
r := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil)
|
||||
if code := doReq(t, r); code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("empty env should fall closed: got %d, want 503", code)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user