mcp: add HTTP bearer auth middleware

Amolith created

Change summary

internal/mcp/http.go      |  29 ++++++++++
internal/mcp/http_test.go | 112 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 141 insertions(+)

Detailed changes

internal/mcp/http.go 🔗

@@ -0,0 +1,29 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package mcp
+
+import (
+	"crypto/subtle"
+	"net/http"
+	"strings"
+)
+
+func requireBearerToken(token string, next http.Handler) http.Handler {
+	if token == "" {
+		return next
+	}
+
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		scheme, value, ok := strings.Cut(r.Header.Get("Authorization"), " ")
+		if !ok || !strings.EqualFold(scheme, "Bearer") ||
+			subtle.ConstantTimeCompare([]byte(value), []byte(token)) != 1 {
+			w.Header().Set("WWW-Authenticate", "Bearer")
+			http.Error(w, "unauthorized", http.StatusUnauthorized)
+			return
+		}
+
+		next.ServeHTTP(w, r)
+	})
+}

internal/mcp/http_test.go 🔗

@@ -0,0 +1,112 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package mcp
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+// server.SECURITY.3 server.SECURITY.6
+func TestHTTPBearerAuthACIDServerSecurity3And6AllowsMatchingBearerToken(t *testing.T) {
+	calls := 0
+	handler := requireBearerToken("secret-token", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		calls++
+		w.WriteHeader(http.StatusNoContent)
+	}))
+
+	recorder := httptest.NewRecorder()
+	request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/mcp", nil)
+	request.Header.Set("Authorization", "Bearer secret-token")
+	handler.ServeHTTP(recorder, request)
+
+	if recorder.Code != http.StatusNoContent {
+		t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNoContent)
+	}
+	if calls != 1 {
+		t.Fatalf("next calls = %d, want 1", calls)
+	}
+}
+
+// server.SECURITY.7
+func TestHTTPBearerAuthACIDServerSecurity7RejectsInvalidTokens(t *testing.T) {
+	tests := []struct {
+		name   string
+		header string
+	}{
+		{name: "missing", header: ""},
+		{name: "wrong", header: "Bearer wrong-token"},
+		{name: "basic", header: "Basic secret-token"},
+		{name: "malformed", header: "Bearer"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			calls := 0
+			handler := requireBearerToken("secret-token", http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+				calls++
+			}))
+
+			recorder := httptest.NewRecorder()
+			request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/mcp", nil)
+			if tt.header != "" {
+				request.Header.Set("Authorization", tt.header)
+			}
+			handler.ServeHTTP(recorder, request)
+
+			if recorder.Code != http.StatusUnauthorized {
+				t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized)
+			}
+			if got := recorder.Header().Get("WWW-Authenticate"); got != "Bearer" {
+				t.Fatalf("WWW-Authenticate = %q, want Bearer", got)
+			}
+			if calls != 0 {
+				t.Fatalf("next calls = %d, want 0", calls)
+			}
+		})
+	}
+}
+
+func TestHTTPBearerAuthAllowsRequestsWhenNoTokenConfigured(t *testing.T) {
+	calls := 0
+	handler := requireBearerToken("", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		calls++
+		w.WriteHeader(http.StatusNoContent)
+	}))
+
+	recorder := httptest.NewRecorder()
+	request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/mcp", nil)
+	handler.ServeHTTP(recorder, request)
+
+	if recorder.Code != http.StatusNoContent {
+		t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNoContent)
+	}
+	if calls != 1 {
+		t.Fatalf("next calls = %d, want 1", calls)
+	}
+}
+
+// server.SECURITY.2
+func TestHTTPBearerAuthACIDServerSecurity2DoesNotExposeTokensInUnauthorizedResponse(t *testing.T) {
+	handler := requireBearerToken("secret-token", http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+		t.Fatal("next handler was called")
+	}))
+
+	recorder := httptest.NewRecorder()
+	request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/mcp", nil)
+	request.Header.Set("Authorization", "Bearer submitted-token")
+	handler.ServeHTTP(recorder, request)
+
+	body := recorder.Body.String()
+	if strings.Contains(body, "secret-token") {
+		t.Fatalf("response body exposed configured token: %q", body)
+	}
+	if strings.Contains(body, "submitted-token") {
+		t.Fatalf("response body exposed submitted token: %q", body)
+	}
+}