234 lines
6.8 KiB
Go
234 lines
6.8 KiB
Go
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)
|
|
}
|
|
}
|