Skip to content

Commit 70e1bbf

Browse files
committed
auth: add integration tests for security best practices conformance
Add HTTP middleware integration tests to validate MCP Security Best Practices conformance: - Invalid tokens (e.g., wrong audience/unknown issuer) return 401, set WWW-Authenticate, and do not invoke the handler - Missing required scopes return 403 and set WWW-Authenticate Valid tokens succeed (200) - No token passthrough: downstream requests do not receive the client Authorization header These tests improve confidence that the SDK’s bearer auth middleware enforces authentication and authorization correctly and avoids token passthrough risks, per: https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices
1 parent eddef06 commit 70e1bbf

File tree

1 file changed

+103
-0
lines changed

1 file changed

+103
-0
lines changed

auth/auth_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"context"
99
"errors"
1010
"net/http"
11+
"net/http/httptest"
1112
"testing"
1213
"time"
1314
)
@@ -76,3 +77,105 @@ func TestVerify(t *testing.T) {
7677
})
7778
}
7879
}
80+
81+
// Integration tests for Security Best Practices conformance.
82+
// 2.2 Token Passthrough.
83+
// https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices.
84+
// Table-driven middleware tests covering invalid tokens, scope enforcement, and OK path.
85+
func TestBearerMiddleware(t *testing.T) {
86+
const resourceMetadata = "https://auth.example/meta"
87+
verifier := func(_ context.Context, tok string, _ *http.Request) (*TokenInfo, error) {
88+
switch tok {
89+
case "valid":
90+
return &TokenInfo{Expiration: time.Now().Add(time.Hour)}, nil
91+
default:
92+
return nil, ErrInvalidToken
93+
}
94+
}
95+
96+
tests := []struct {
97+
name string
98+
token string
99+
scopes []string
100+
wantCode int
101+
wantCalled bool
102+
}{
103+
{name: "invalid-aud", token: "bad-aud", wantCode: http.StatusUnauthorized, wantCalled: false},
104+
{name: "unknown-issuer", token: "unknown-issuer", wantCode: http.StatusUnauthorized, wantCalled: false},
105+
{name: "missing-scope", token: "valid", scopes: []string{"s1"}, wantCode: http.StatusForbidden, wantCalled: false},
106+
{name: "ok", token: "valid", wantCode: http.StatusOK, wantCalled: true},
107+
}
108+
109+
for _, tt := range tests {
110+
t.Run(tt.name, func(t *testing.T) {
111+
called := false
112+
h := RequireBearerToken(verifier, &RequireBearerTokenOptions{
113+
ResourceMetadataURL: resourceMetadata,
114+
Scopes: tt.scopes,
115+
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
116+
called = true
117+
w.WriteHeader(http.StatusOK)
118+
}))
119+
120+
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
121+
req.Header.Set("Authorization", "Bearer "+tt.token)
122+
rw := httptest.NewRecorder()
123+
124+
h.ServeHTTP(rw, req)
125+
126+
if rw.Code != tt.wantCode {
127+
t.Fatalf("got status %d, want %d", rw.Code, tt.wantCode)
128+
}
129+
if called != tt.wantCalled {
130+
t.Fatalf("handler called=%v, want %v", called, tt.wantCalled)
131+
}
132+
if tt.wantCode == http.StatusUnauthorized || tt.wantCode == http.StatusForbidden {
133+
want := "Bearer resource_metadata=" + resourceMetadata
134+
if rw.Header().Get("WWW-Authenticate") != want {
135+
t.Fatalf("unexpected WWW-Authenticate header: %q", rw.Header().Get("WWW-Authenticate"))
136+
}
137+
}
138+
})
139+
}
140+
}
141+
142+
func TestHTTPMiddleware_NoTokenPassthrough(t *testing.T) {
143+
// Downstream fake API that records the incoming Authorization header.
144+
var gotAuth string
145+
downstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
146+
gotAuth = r.Header.Get("Authorization")
147+
w.WriteHeader(http.StatusOK)
148+
}))
149+
defer downstream.Close()
150+
151+
// Verifier accepts the incoming client token.
152+
verifier := func(_ context.Context, token string, _ *http.Request) (*TokenInfo, error) {
153+
if token != "client-token" {
154+
return nil, ErrInvalidToken
155+
}
156+
return &TokenInfo{Expiration: time.Now().Add(time.Hour)}, nil
157+
}
158+
159+
wrapped := RequireBearerToken(verifier, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
160+
// Simulate proxy-like behavior: perform a downstream request without
161+
// forwarding the client's Authorization header.
162+
resp, err := http.Get(downstream.URL)
163+
if err != nil {
164+
t.Fatalf("downstream request failed: %v", err)
165+
}
166+
resp.Body.Close()
167+
w.WriteHeader(http.StatusOK)
168+
}))
169+
170+
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
171+
req.Header.Set("Authorization", "Bearer client-token")
172+
rw := httptest.NewRecorder()
173+
wrapped.ServeHTTP(rw, req)
174+
175+
if rw.Code != http.StatusOK {
176+
t.Fatalf("got status %d, want %d", rw.Code, http.StatusOK)
177+
}
178+
if gotAuth != "" {
179+
t.Fatalf("downstream Authorization header should be empty; got %q", gotAuth)
180+
}
181+
}

0 commit comments

Comments
 (0)