go-sdk/auth/auth_test.go

286 lines
7.5 KiB
Go

// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package auth
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/modelcontextprotocol/go-sdk/oauthex"
)
func TestVerify(t *testing.T) {
verifier := func(_ context.Context, token string, _ *http.Request) (*TokenInfo, error) {
switch token {
case "valid":
return &TokenInfo{Expiration: time.Now().Add(time.Hour)}, nil
case "invalid":
return nil, ErrInvalidToken
case "oauth":
return nil, ErrOAuth
case "noexp":
return &TokenInfo{}, nil
case "expired":
return &TokenInfo{Expiration: time.Now().Add(-time.Hour)}, nil
default:
return nil, errors.New("unknown")
}
}
for _, tt := range []struct {
name string
opts *RequireBearerTokenOptions
header string
wantMsg string
wantCode int
}{
{
"valid", nil, "Bearer valid",
"", 0,
},
{
"bad header", nil, "Barer valid",
"no bearer token", 401,
},
{
"invalid", nil, "bearer invalid",
"invalid token", 401,
},
{
"oauth error", nil, "Bearer oauth",
"oauth error", 400,
},
{
"no expiration", nil, "Bearer noexp",
"token missing expiration", 401,
},
{
"expired", nil, "Bearer expired",
"token expired", 401,
},
{
"missing scope", &RequireBearerTokenOptions{Scopes: []string{"s1"}}, "Bearer valid",
"insufficient scope", 403,
},
} {
t.Run(tt.name, func(t *testing.T) {
_, gotMsg, gotCode := verify(&http.Request{
Header: http.Header{"Authorization": {tt.header}},
}, verifier, tt.opts)
if gotMsg != tt.wantMsg || gotCode != tt.wantCode {
t.Errorf("got (%q, %d), want (%q, %d)", gotMsg, gotCode, tt.wantMsg, tt.wantCode)
}
})
}
}
func TestProtectedResourceMetadataHandler(t *testing.T) {
metadata := &oauthex.ProtectedResourceMetadata{
Resource: "https://example.com/mcp",
AuthorizationServers: []string{
"https://auth.example.com/.well-known/openid-configuration",
},
ScopesSupported: []string{"read", "write"},
}
handler := ProtectedResourceMetadataHandler(metadata)
tests := []struct {
name string
method string
wantStatus int
checkJSON bool
}{
{
name: "GET returns metadata",
method: http.MethodGet,
wantStatus: http.StatusOK,
checkJSON: true,
},
{
name: "OPTIONS for CORS preflight",
method: http.MethodOptions,
wantStatus: http.StatusNoContent,
},
{
name: "POST not allowed",
method: http.MethodPost,
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "PUT not allowed",
method: http.MethodPut,
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "DELETE not allowed",
method: http.MethodDelete,
wantStatus: http.StatusMethodNotAllowed,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/.well-known/oauth-protected-resource", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
// All responses should have CORS headers
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, "*")
}
if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "GET, OPTIONS" {
t.Errorf("Access-Control-Allow-Methods = %q, want %q", got, "GET, OPTIONS")
}
// Validate error response body for disallowed methods
if tt.wantStatus == http.StatusMethodNotAllowed {
if !strings.Contains(rec.Body.String(), "Method not allowed") {
t.Errorf("error body = %q, want to contain %q", rec.Body.String(), "Method not allowed")
}
}
if tt.checkJSON {
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want %q", got, "application/json")
}
var got oauthex.ProtectedResourceMetadata
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if got.Resource != metadata.Resource {
t.Errorf("Resource = %q, want %q", got.Resource, metadata.Resource)
}
if len(got.AuthorizationServers) != len(metadata.AuthorizationServers) {
t.Errorf("AuthorizationServers length = %d, want %d",
len(got.AuthorizationServers), len(metadata.AuthorizationServers))
}
for i, server := range got.AuthorizationServers {
if server != metadata.AuthorizationServers[i] {
t.Errorf("AuthorizationServers[%d] = %q, want %q",
i, server, metadata.AuthorizationServers[i])
}
}
if len(got.ScopesSupported) != len(metadata.ScopesSupported) {
t.Errorf("ScopesSupported length = %d, want %d",
len(got.ScopesSupported), len(metadata.ScopesSupported))
}
}
})
}
}
func TestRequireBearerToken(t *testing.T) {
verifier := func(_ context.Context, token string, _ *http.Request) (*TokenInfo, error) {
if token == "valid" {
return &TokenInfo{Expiration: time.Now().Add(time.Hour), Scopes: []string{"read"}}, nil
}
return nil, ErrInvalidToken
}
tests := []struct {
name string
opts *RequireBearerTokenOptions
authHeader string
wantHeader string
wantStatus int
}{
{
name: "no middleware options",
opts: nil,
authHeader: "Bearer invalid",
wantHeader: "",
wantStatus: http.StatusUnauthorized,
},
{
name: "metadata only",
opts: &RequireBearerTokenOptions{
ResourceMetadataURL: "https://example.com/resource-metadata",
},
authHeader: "Bearer invalid",
wantHeader: "Bearer resource_metadata=\"https://example.com/resource-metadata\"",
wantStatus: http.StatusUnauthorized,
},
{
name: "scopes only",
opts: &RequireBearerTokenOptions{
Scopes: []string{"read", "write"},
},
authHeader: "Bearer invalid",
wantHeader: "Bearer scope=\"read write\"",
wantStatus: http.StatusUnauthorized,
},
{
name: "metadata and scopes",
opts: &RequireBearerTokenOptions{
ResourceMetadataURL: "https://example.com/resource-metadata",
Scopes: []string{"read", "write"},
},
authHeader: "Bearer invalid",
wantHeader: "Bearer resource_metadata=\"https://example.com/resource-metadata\", scope=\"read write\"",
wantStatus: http.StatusUnauthorized,
},
{
name: "insufficient scope",
opts: &RequireBearerTokenOptions{
Scopes: []string{"admin"},
},
authHeader: "Bearer valid", // Has "read", needs "admin" -> 403
wantHeader: "Bearer scope=\"admin\"",
wantStatus: http.StatusForbidden,
},
{
name: "success",
opts: &RequireBearerTokenOptions{
Scopes: []string{"read"},
},
authHeader: "Bearer valid",
wantHeader: "",
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := RequireBearerToken(verifier, tt.opts)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/", nil)
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
got := rec.Header().Get("WWW-Authenticate")
if got != tt.wantHeader {
t.Errorf("WWW-Authenticate = %q, want %q", got, tt.wantHeader)
}
})
}
}