147 lines
4.6 KiB
Go
147 lines
4.6 KiB
Go
// 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
|
|
}
|