// 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: // 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 // 3. Legacy shared key — MCP_API_KEY (dev compose sets devkey; production // reads it from the stack-secrets secret). Presented as: // X-API-Key: or Authorization: Bearer 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 }