Skip to content

Commit db9a6f5

Browse files
authored
Implement core authentication infrastructure for vMCP (#2393)
* Add context helpers for Identity propagation in vmcp auth Adds several context-related helpers that will be used to propagate Identity through vMCP. Related: #2377 * Add OIDC incoming authenticator for vmcp Implement IncomingAuthenticator interface using existing TokenValidator from pkg/auth. This adapter validates JWT tokens from clients connecting to the Virtual MCP Server and extracts identity information. Related: #2377 * Add a registry of outgoing auth strategies with a stub of AuthenticateRequest() Implement OutgoingAuthenticator interface with pluggable authentication strategies for backend MCP server connections. The actual strategies will be implemented in a follow-up commit. Fixes: #2377 * Add token redaction to Identity serialization Implement String() and MarshalJSON() methods on the Identity struct to prevent accidental token leakage when logging or serializing identities. * Document Groups field design decision Add concise documentation explaining why Identity.Groups is intentionally not populated by OIDCIncomingAuthenticator. This clarifies that group extraction is an authorization concern handled via the Claims map, as different OIDC providers use different claim names. * Document thread-safety guarantees for outgoing auth Add explicit documentation that RegisterStrategy and AuthenticateRequest are safe for concurrent use, and that Strategy implementations must be thread-safe. * Add metadata validation to AuthenticateRequest Call strategy.Validate() before strategy.Authenticate() to catch invalid or malicious metadata early. This prevents type confusion, injection attacks, and panics from invalid metadata in strategy implementations. Changes: - Add Validate() call in AuthenticateRequest() - Proper error wrapping with strategy name - Add test verifying validation is enforced - Update existing tests to expect Validate() calls * Add Claims-to-Identity conversion for vMCP auth Implement new authentication layer that converts JWT claims from pkg/auth.TokenValidator to vMCP's Identity domain type. Changes: - Add ClaimsToIdentity() to convert JWT claims to Identity struct * Validates required 'sub' claim per OIDC Core 1.0 § 5.1 * Preserves original Bearer token for passthrough scenarios * Intentionally does not populate Groups field (stays in Claims) - Add IdentityMiddleware() to apply conversion in HTTP middleware chain * Runs after TokenValidator.Middleware() which stores Claims * Passes through unauthenticated requests unchanged - Add NewOIDCAuthMiddleware() factory helper * Composes TokenValidator + IdentityMiddleware into single chain * Returns authInfo handler for OIDC discovery endpoint - Add comprehensive test coverage for conversion logic This establishes the foundation for simplified vMCP authentication that reuses pkg/auth.GetAuthenticationMiddleware() instead of duplicating token validation logic. Affected components: - pkg/vmcp/auth (new files) * Add authentication middleware support to vMCP server Update server configuration and routing to support optional authentication middleware for MCP endpoints while keeping health endpoints unauthenticated. * Wire OIDC authentication in vMCP serve command Integrate NewOIDCAuthMiddleware() into the serve command to enable authentication based on configuration. * Remove old OIDCIncomingAuthenticator implementation Remove the now-unused OIDCIncomingAuthenticator that wrapped TokenValidator, along with its interfaces and tests. This code is replaced by the simpler NewOIDCAuthMiddleware() helper that directly composes pkg/auth middleware with IdentityMiddleware. * Add reusable well-known OAuth discovery handler Create NewWellKnownHandler() in pkg/auth to provide RFC 9728 compliant routing for /.well-known/oauth-protected-resource endpoints. This fixes a bug where vMCP server only matched exact paths, causing requests to subpaths like /.well-known/oauth-protected-resource/mcp to fall through to authenticated routes and return 401. * Use NewWellKnownHandler in vMCP and transparent proxy Replace inline well-known handler implementations with the reusable auth.NewWellKnownHandler() to eliminate code duplication. This fixes a bug in vMCP server where only the exact path /.well-known/oauth-protected-resource was registered, causing requests to subpaths (e.g., /mcp) to fall through to authenticated routes and return 401 instead of discovery metadata. The reusable handler ensures consistent behavior across both components and maintains the requirement that discovery endpoints remain unauthenticated. * Add Resource parameter to vMCP OIDC config Add support for OAuth 2.0 resource indicators (RFC 8707) in vMCP incoming authentication configuration. The Resource field is used in WWW-Authenticate headers and OAuth discovery metadata per RFC 9728. When not specified, it defaults to the Audience value for backward compatibility. This allows operators to explicitly configure the resource identifier that appears in 401 responses, separate from the token audience claim validation. * Pass Resource parameter to OIDC auth middleware Wire the Resource field from config through to the TokenValidator by using it as the ResourceURL in TokenValidatorConfig. The ResourceURL is used in WWW-Authenticate challenge headers when authentication fails. This allows operators to specify a different resource identifier than the audience claim being validated. * Simplify Identity.String
1 parent a941bfe commit db9a6f5

19 files changed

+2000
-52
lines changed

cmd/vmcp/app/commands.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/stacklok/toolhive/pkg/groups"
1313
"github.com/stacklok/toolhive/pkg/logger"
1414
"github.com/stacklok/toolhive/pkg/vmcp/aggregator"
15+
vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth"
1516
vmcpclient "github.com/stacklok/toolhive/pkg/vmcp/client"
1617
"github.com/stacklok/toolhive/pkg/vmcp/config"
1718
vmcprouter "github.com/stacklok/toolhive/pkg/vmcp/router"
@@ -260,12 +261,24 @@ func runServe(cmd *cobra.Command, _ []string) error {
260261
// Create router
261262
rtr := vmcprouter.NewDefaultRouter()
262263

264+
// Setup authentication middleware
265+
logger.Infof("Setting up incoming authentication (type: %s)", cfg.IncomingAuth.Type)
266+
267+
authMiddleware, authInfoHandler, err := vmcpauth.NewIncomingAuthMiddleware(ctx, cfg.IncomingAuth)
268+
if err != nil {
269+
return fmt.Errorf("failed to create authentication middleware: %w", err)
270+
}
271+
272+
logger.Infof("Incoming authentication configured: %s", cfg.IncomingAuth.Type)
273+
263274
// Create server configuration
264275
serverCfg := &vmcpserver.Config{
265-
Name: cfg.Name,
266-
Version: getVersion(),
267-
Host: "127.0.0.1", // TODO: Make configurable
268-
Port: 4483, // TODO: Make configurable
276+
Name: cfg.Name,
277+
Version: getVersion(),
278+
Host: "127.0.0.1", // TODO: Make configurable
279+
Port: 4483, // TODO: Make configurable
280+
AuthMiddleware: authMiddleware,
281+
AuthInfoHandler: authInfoHandler,
269282
}
270283

271284
// Create server

cmd/vmcp/example-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ incoming_auth:
3030
# client_id: "vmcp-client"
3131
# client_secret_env: "VMCP_CLIENT_SECRET"
3232
# audience: "vmcp"
33+
# resource: "http://localhost:4483/mcp"
3334
# scopes: ["openid", "profile", "email"]
3435

3536
# ===== OUTGOING AUTHENTICATION (Virtual MCP → Backends) =====

pkg/auth/well_known.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Package auth provides authentication and authorization utilities.
2+
package auth
3+
4+
import (
5+
"net/http"
6+
"strings"
7+
)
8+
9+
// WellKnownOAuthResourcePath is the RFC 9728 standard path for OAuth Protected Resource metadata.
10+
// Per RFC 9728 Section 3, this endpoint and any subpaths under it should be accessible
11+
// without authentication to enable OIDC/OAuth discovery.
12+
//
13+
// Example valid paths:
14+
// - /.well-known/oauth-protected-resource
15+
// - /.well-known/oauth-protected-resource/mcp
16+
// - /.well-known/oauth-protected-resource/v1/metadata
17+
const WellKnownOAuthResourcePath = "/.well-known/oauth-protected-resource"
18+
19+
// NewWellKnownHandler creates an HTTP handler that routes requests under the
20+
// /.well-known/ path space to the appropriate handler.
21+
//
22+
// Per RFC 9728, the /.well-known/oauth-protected-resource endpoint and any subpaths
23+
// under it must be accessible without authentication. This handler ensures proper
24+
// routing of discovery requests while returning 404 for unknown paths.
25+
//
26+
// If authInfoHandler is nil, this function returns nil (no handler registration needed).
27+
//
28+
// Usage:
29+
//
30+
// authInfoHandler := auth.NewAuthInfoHandler(issuer, jwksURL, resourceURL, scopes)
31+
// wellKnownHandler := auth.NewWellKnownHandler(authInfoHandler)
32+
// if wellKnownHandler != nil {
33+
// mux.Handle("/.well-known/", wellKnownHandler)
34+
// }
35+
//
36+
// This handler matches:
37+
// - /.well-known/oauth-protected-resource (exact)
38+
// - /.well-known/oauth-protected-resource/* (subpaths)
39+
//
40+
// Returns 404 for other /.well-known/* paths.
41+
func NewWellKnownHandler(authInfoHandler http.Handler) http.Handler {
42+
if authInfoHandler == nil {
43+
return nil
44+
}
45+
46+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47+
// RFC 9728: Match /.well-known/oauth-protected-resource and any subpaths
48+
// Examples:
49+
// ✓ /.well-known/oauth-protected-resource
50+
// ✓ /.well-known/oauth-protected-resource/mcp
51+
// ✗ /.well-known/other-endpoint
52+
if strings.HasPrefix(r.URL.Path, WellKnownOAuthResourcePath) {
53+
authInfoHandler.ServeHTTP(w, r)
54+
return
55+
}
56+
57+
// Unknown .well-known path
58+
http.NotFound(w, r)
59+
})
60+
}

pkg/auth/well_known_test.go

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
package auth
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestNewWellKnownHandler(t *testing.T) {
13+
t.Parallel()
14+
15+
tests := []struct {
16+
name string
17+
authInfoHandler http.Handler
18+
expectedNil bool
19+
testRequests []testRequest
20+
}{
21+
{
22+
name: "nil authInfoHandler returns nil",
23+
authInfoHandler: nil,
24+
expectedNil: true,
25+
},
26+
{
27+
name: "exact path /.well-known/oauth-protected-resource routes to authInfoHandler",
28+
authInfoHandler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
29+
w.WriteHeader(http.StatusOK)
30+
_, _ = w.Write([]byte("auth-info"))
31+
}),
32+
expectedNil: false,
33+
testRequests: []testRequest{
34+
{
35+
path: "/.well-known/oauth-protected-resource",
36+
expectedStatus: http.StatusOK,
37+
expectedBody: "auth-info",
38+
},
39+
},
40+
},
41+
{
42+
name: "subpath /.well-known/oauth-protected-resource/mcp routes to authInfoHandler",
43+
authInfoHandler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
44+
w.WriteHeader(http.StatusOK)
45+
_, _ = w.Write([]byte("auth-info-mcp"))
46+
}),
47+
expectedNil: false,
48+
testRequests: []testRequest{
49+
{
50+
path: "/.well-known/oauth-protected-resource/mcp",
51+
expectedStatus: http.StatusOK,
52+
expectedBody: "auth-info-mcp",
53+
},
54+
},
55+
},
56+
{
57+
name: "subpath /.well-known/oauth-protected-resource/v1/metadata routes to authInfoHandler",
58+
authInfoHandler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
59+
w.WriteHeader(http.StatusOK)
60+
_, _ = w.Write([]byte("auth-info-v1"))
61+
}),
62+
expectedNil: false,
63+
testRequests: []testRequest{
64+
{
65+
path: "/.well-known/oauth-protected-resource/v1/metadata",
66+
expectedStatus: http.StatusOK,
67+
expectedBody: "auth-info-v1",
68+
},
69+
},
70+
},
71+
{
72+
name: "other .well-known paths return 404",
73+
authInfoHandler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
74+
w.WriteHeader(http.StatusOK)
75+
_, _ = w.Write([]byte("should-not-reach"))
76+
}),
77+
expectedNil: false,
78+
testRequests: []testRequest{
79+
{
80+
path: "/.well-known/openid-configuration",
81+
expectedStatus: http.StatusNotFound,
82+
expectedBody: "404 page not found\n",
83+
},
84+
{
85+
path: "/.well-known/other",
86+
expectedStatus: http.StatusNotFound,
87+
expectedBody: "404 page not found\n",
88+
},
89+
},
90+
},
91+
{
92+
name: "RFC 9728 compliance - all oauth-protected-resource paths work",
93+
authInfoHandler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
94+
w.WriteHeader(http.StatusOK)
95+
_, _ = w.Write([]byte("discovered"))
96+
}),
97+
expectedNil: false,
98+
testRequests: []testRequest{
99+
{
100+
path: "/.well-known/oauth-protected-resource",
101+
expectedStatus: http.StatusOK,
102+
expectedBody: "discovered",
103+
},
104+
{
105+
path: "/.well-known/oauth-protected-resource/",
106+
expectedStatus: http.StatusOK,
107+
expectedBody: "discovered",
108+
},
109+
{
110+
path: "/.well-known/oauth-protected-resource/any/deep/path",
111+
expectedStatus: http.StatusOK,
112+
expectedBody: "discovered",
113+
},
114+
},
115+
},
116+
{
117+
name: "handler preserves request context and headers",
118+
authInfoHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
119+
// Verify request is passed through correctly
120+
if r.Header.Get("X-Test-Header") == "test-value" {
121+
w.Header().Set("X-Response-Header", "response-value")
122+
w.WriteHeader(http.StatusOK)
123+
_, _ = w.Write([]byte("headers-ok"))
124+
} else {
125+
w.WriteHeader(http.StatusBadRequest)
126+
}
127+
}),
128+
expectedNil: false,
129+
testRequests: []testRequest{
130+
{
131+
path: "/.well-known/oauth-protected-resource",
132+
headers: map[string]string{"X-Test-Header": "test-value"},
133+
expectedStatus: http.StatusOK,
134+
expectedBody: "headers-ok",
135+
expectedHeaders: map[string]string{"X-Response-Header": "response-value"},
136+
},
137+
},
138+
},
139+
}
140+
141+
for _, tt := range tests {
142+
t.Run(tt.name, func(t *testing.T) {
143+
t.Parallel()
144+
145+
handler := NewWellKnownHandler(tt.authInfoHandler)
146+
147+
if tt.expectedNil {
148+
assert.Nil(t, handler, "expected nil handler")
149+
return
150+
}
151+
152+
require.NotNil(t, handler, "expected non-nil handler")
153+
154+
// Test each request scenario
155+
for _, req := range tt.testRequests {
156+
t.Run(req.path, func(t *testing.T) {
157+
request := httptest.NewRequest(http.MethodGet, req.path, nil)
158+
159+
// Add test headers
160+
for key, value := range req.headers {
161+
request.Header.Set(key, value)
162+
}
163+
164+
recorder := httptest.NewRecorder()
165+
handler.ServeHTTP(recorder, request)
166+
167+
assert.Equal(t, req.expectedStatus, recorder.Code, "status code mismatch")
168+
assert.Equal(t, req.expectedBody, recorder.Body.String(), "body mismatch")
169+
170+
// Check expected response headers
171+
for key, value := range req.expectedHeaders {
172+
assert.Equal(t, value, recorder.Header().Get(key), "header %s mismatch", key)
173+
}
174+
})
175+
}
176+
})
177+
}
178+
}
179+
180+
func TestWellKnownHandler_HTTPMethods(t *testing.T) {
181+
t.Parallel()
182+
183+
authInfoHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
184+
// Echo back the HTTP method
185+
w.WriteHeader(http.StatusOK)
186+
_, _ = w.Write([]byte(req.Method))
187+
})
188+
189+
handler := NewWellKnownHandler(authInfoHandler)
190+
require.NotNil(t, handler)
191+
192+
methods := []string{
193+
http.MethodGet,
194+
http.MethodPost,
195+
http.MethodPut,
196+
http.MethodDelete,
197+
http.MethodPatch,
198+
http.MethodOptions,
199+
}
200+
201+
for _, method := range methods {
202+
t.Run(method, func(t *testing.T) {
203+
t.Parallel()
204+
205+
request := httptest.NewRequest(method, "/.well-known/oauth-protected-resource", nil)
206+
recorder := httptest.NewRecorder()
207+
208+
handler.ServeHTTP(recorder, request)
209+
210+
assert.Equal(t, http.StatusOK, recorder.Code)
211+
assert.Equal(t, method, recorder.Body.String())
212+
})
213+
}
214+
}
215+
216+
func TestWellKnownHandler_EdgeCases(t *testing.T) {
217+
t.Parallel()
218+
219+
authInfoHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
220+
w.WriteHeader(http.StatusOK)
221+
_, _ = w.Write([]byte("ok"))
222+
})
223+
224+
handler := NewWellKnownHandler(authInfoHandler)
225+
require.NotNil(t, handler)
226+
227+
tests := []struct {
228+
name string
229+
path string
230+
expectedStatus int
231+
expectedBody string
232+
}{
233+
{
234+
name: "path with query parameters routes correctly",
235+
path: "/.well-known/oauth-protected-resource?format=json",
236+
expectedStatus: http.StatusOK,
237+
expectedBody: "ok",
238+
},
239+
{
240+
name: "path with trailing slash routes correctly",
241+
path: "/.well-known/oauth-protected-resource/",
242+
expectedStatus: http.StatusOK,
243+
expectedBody: "ok",
244+
},
245+
{
246+
name: "different .well-known path returns 404",
247+
path: "/.well-known/jwks.json", // Different endpoint
248+
expectedStatus: http.StatusNotFound,
249+
expectedBody: "404 page not found\n",
250+
},
251+
{
252+
name: "path prefix match is not sufficient",
253+
path: "/.well-known/oauth", // Prefix but not full path
254+
expectedStatus: http.StatusNotFound,
255+
expectedBody: "404 page not found\n",
256+
},
257+
}
258+
259+
for _, tt := range tests {
260+
t.Run(tt.name, func(t *testing.T) {
261+
t.Parallel()
262+
263+
request := httptest.NewRequest(http.MethodGet, tt.path, nil)
264+
recorder := httptest.NewRecorder()
265+
266+
handler.ServeHTTP(recorder, request)
267+
268+
assert.Equal(t, tt.expectedStatus, recorder.Code)
269+
assert.Equal(t, tt.expectedBody, recorder.Body.String())
270+
})
271+
}
272+
}
273+
274+
// testRequest defines a test request scenario
275+
type testRequest struct {
276+
path string
277+
headers map[string]string
278+
expectedStatus int
279+
expectedBody string
280+
expectedHeaders map[string]string
281+
}

0 commit comments

Comments
 (0)