Skip to content

Commit 71999ad

Browse files
authored
feat: Add RFC 8414 OAuth metadata endpoint for MCP auth discovery (#1639)
* feat: Add RFC 8414 OAuth metadata endpoint for MCP auth discovery MCP clients (e.g. Claude.ai) require a /.well-known/oauth-authorization-server endpoint to discover OAuth configuration before initiating authentication. Without this endpoint, clients treat the server as not requiring auth. Implements the metadata handler returning issuer, authorization_endpoint, token_endpoint, supported response types, grant types, PKCE methods, and token endpoint auth methods per RFC 8414. * fix: Use NewRequestWithContext in MCP auth tests (noctx lint) Replace httptest.NewRequest with httptest.NewRequestWithContext across oauth_test.go, oidc_test.go, and subdomain_test.go to satisfy the noctx linter rule. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent faba6be commit 71999ad

5 files changed

Lines changed: 118 additions & 33 deletions

File tree

services/mcp-server/cmd/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,10 @@ func runHTTP(logger *slog.Logger, cfg server.Config) error {
227227
tokenHandler := mcpauth.NewTokenHandler(oauthCfg, codeStore, issuer)
228228
mux.Handle("/oauth/token", tokenHandler)
229229

230+
// RFC 8414 OAuth Authorization Server Metadata — required by MCP clients
231+
// (e.g. Claude.ai) to discover auth endpoints before connecting.
232+
mux.HandleFunc("/.well-known/oauth-authorization-server", mcpauth.NewMetadataHandler(baseURL, oauthCfg))
233+
230234
meta := mcpauth.Metadata{
231235
AuthorizationURL: oauthCfg.AuthorizationURL,
232236
TokenURL: oauthCfg.TokenURL,

services/mcp-server/internal/auth/oauth.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,53 @@ type BearerValidator interface {
182182
ValidateBearer(token string) error
183183
}
184184

185+
// -----------------------------------------------------------------------
186+
// Authorization Server Metadata — GET /.well-known/oauth-authorization-server
187+
// -----------------------------------------------------------------------
188+
189+
// AuthorizationServerMetadata represents the OAuth 2.0 Authorization Server
190+
// Metadata response per RFC 8414. MCP clients (e.g. Claude.ai) fetch this
191+
// endpoint to discover how to authenticate.
192+
type AuthorizationServerMetadata struct {
193+
Issuer string `json:"issuer"`
194+
AuthorizationEndpoint string `json:"authorization_endpoint"`
195+
TokenEndpoint string `json:"token_endpoint"`
196+
ResponseTypesSupported []string `json:"response_types_supported"`
197+
GrantTypesSupported []string `json:"grant_types_supported"`
198+
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
199+
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
200+
}
201+
202+
// NewMetadataHandler returns an http.HandlerFunc that serves the OAuth 2.0
203+
// Authorization Server Metadata document (RFC 8414) at
204+
// /.well-known/oauth-authorization-server.
205+
//
206+
// The response is pre-serialized at construction time for efficiency.
207+
func NewMetadataHandler(baseURL string, cfg OAuthConfig) http.HandlerFunc {
208+
meta := AuthorizationServerMetadata{
209+
Issuer: baseURL,
210+
AuthorizationEndpoint: cfg.AuthorizationURL,
211+
TokenEndpoint: cfg.TokenURL,
212+
ResponseTypesSupported: []string{"code"},
213+
GrantTypesSupported: []string{"authorization_code"},
214+
CodeChallengeMethodsSupported: []string{"S256"},
215+
TokenEndpointAuthMethodsSupported: []string{"none"},
216+
}
217+
218+
body, err := json.Marshal(meta)
219+
if err != nil {
220+
return func(w http.ResponseWriter, _ *http.Request) {
221+
http.Error(w, "internal error: failed to serialize metadata", http.StatusInternalServerError)
222+
}
223+
}
224+
225+
return func(w http.ResponseWriter, _ *http.Request) {
226+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
227+
w.Header().Set("Cache-Control", "public, max-age=3600")
228+
_, _ = w.Write(body)
229+
}
230+
}
231+
185232
// -----------------------------------------------------------------------
186233
// AuthorizationHandler — GET /oauth/authorize
187234
// -----------------------------------------------------------------------

services/mcp-server/internal/auth/oauth_test.go

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package auth_test
22

33
import (
4+
"context"
45
"crypto/sha256"
56
"encoding/base64"
67
"encoding/json"
@@ -69,6 +70,38 @@ func TestAuthMetadata_JSON(t *testing.T) {
6970
assert.Equal(t, "https://auth.example.com/token", decoded["token_url"])
7071
}
7172

73+
// -----------------------------------------------------------------------
74+
// MetadataHandler — /.well-known/oauth-authorization-server
75+
// -----------------------------------------------------------------------
76+
77+
func TestMetadataHandler_ServesRFC8414(t *testing.T) {
78+
cfg := auth.OAuthConfig{
79+
ClientID: "meridian-mcp",
80+
AuthorizationURL: "https://mcp.example.com/oauth/authorize",
81+
TokenURL: "https://mcp.example.com/oauth/token",
82+
}
83+
84+
handler := auth.NewMetadataHandler("https://mcp.example.com", cfg)
85+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/.well-known/oauth-authorization-server", nil)
86+
rec := httptest.NewRecorder()
87+
handler(rec, req)
88+
89+
assert.Equal(t, http.StatusOK, rec.Code)
90+
assert.Equal(t, "application/json; charset=utf-8", rec.Header().Get("Content-Type"))
91+
assert.Contains(t, rec.Header().Get("Cache-Control"), "public")
92+
93+
var meta auth.AuthorizationServerMetadata
94+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &meta))
95+
96+
assert.Equal(t, "https://mcp.example.com", meta.Issuer)
97+
assert.Equal(t, "https://mcp.example.com/oauth/authorize", meta.AuthorizationEndpoint)
98+
assert.Equal(t, "https://mcp.example.com/oauth/token", meta.TokenEndpoint)
99+
assert.Equal(t, []string{"code"}, meta.ResponseTypesSupported)
100+
assert.Equal(t, []string{"authorization_code"}, meta.GrantTypesSupported)
101+
assert.Equal(t, []string{"S256"}, meta.CodeChallengeMethodsSupported)
102+
assert.Equal(t, []string{"none"}, meta.TokenEndpointAuthMethodsSupported)
103+
}
104+
72105
// -----------------------------------------------------------------------
73106
// AuthorizationHandler
74107
// -----------------------------------------------------------------------
@@ -85,7 +118,7 @@ func TestAuthorizationHandler_GeneratesCode(t *testing.T) {
85118

86119
_, challenge := generatePKCEPair(t)
87120

88-
req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+url.Values{
121+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/oauth/authorize?"+url.Values{
89122
"response_type": {"code"},
90123
"client_id": {cfg.ClientID},
91124
"redirect_uri": {cfg.RedirectURI},
@@ -119,7 +152,7 @@ func TestAuthorizationHandler_MissingChallenge_ReturnsBadRequest(t *testing.T) {
119152
}
120153
handler := auth.NewAuthorizationHandler(cfg, store)
121154

122-
req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?response_type=code&client_id=meridian-mcp", nil)
155+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/oauth/authorize?response_type=code&client_id=meridian-mcp", nil)
123156
w := httptest.NewRecorder()
124157
handler.ServeHTTP(w, req)
125158

@@ -136,7 +169,7 @@ func TestAuthorizationHandler_WrongClientID_ReturnsBadRequest(t *testing.T) {
136169

137170
_, challenge := generatePKCEPair(t)
138171

139-
req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+url.Values{
172+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/oauth/authorize?"+url.Values{
140173
"response_type": {"code"},
141174
"client_id": {"wrong-client"},
142175
"redirect_uri": {cfg.RedirectURI},
@@ -159,7 +192,7 @@ func TestAuthorizationHandler_WrongRedirectURI_ReturnsBadRequest(t *testing.T) {
159192

160193
_, challenge := generatePKCEPair(t)
161194

162-
req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+url.Values{
195+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/oauth/authorize?"+url.Values{
163196
"response_type": {"code"},
164197
"client_id": {cfg.ClientID},
165198
"redirect_uri": {"https://evil.example.com/steal"},
@@ -180,7 +213,7 @@ func TestAuthorizationHandler_NonGetMethod_ReturnsMethodNotAllowed(t *testing.T)
180213
}
181214
handler := auth.NewAuthorizationHandler(cfg, store)
182215

183-
req := httptest.NewRequest(http.MethodPost, "/oauth/authorize", nil)
216+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/oauth/authorize", nil)
184217
w := httptest.NewRecorder()
185218
handler.ServeHTTP(w, req)
186219

@@ -220,7 +253,7 @@ func TestTokenHandler_ExchangesCodeForToken(t *testing.T) {
220253
"client_id": {cfg.ClientID},
221254
"code_verifier": {verifier},
222255
}
223-
req := httptest.NewRequest(http.MethodPost, "/oauth/token",
256+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/oauth/token",
224257
strings.NewReader(form.Encode()))
225258
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
226259
w := httptest.NewRecorder()
@@ -261,7 +294,7 @@ func TestTokenHandler_InvalidVerifier_ReturnsBadRequest(t *testing.T) {
261294
"client_id": {cfg.ClientID},
262295
"code_verifier": {"wrong-verifier-AAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}, // 43+ chars per RFC 7636
263296
}
264-
req := httptest.NewRequest(http.MethodPost, "/oauth/token",
297+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/oauth/token",
265298
strings.NewReader(form.Encode()))
266299
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
267300
w := httptest.NewRecorder()
@@ -298,7 +331,7 @@ func TestTokenHandler_ExpiredCode_ReturnsBadRequest(t *testing.T) {
298331
"client_id": {cfg.ClientID},
299332
"code_verifier": {verifier},
300333
}
301-
req := httptest.NewRequest(http.MethodPost, "/oauth/token",
334+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/oauth/token",
302335
strings.NewReader(form.Encode()))
303336
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
304337
w := httptest.NewRecorder()
@@ -326,7 +359,7 @@ func TestTokenHandler_UnknownCode_ReturnsBadRequest(t *testing.T) {
326359
"client_id": {cfg.ClientID},
327360
"code_verifier": {verifier},
328361
}
329-
req := httptest.NewRequest(http.MethodPost, "/oauth/token",
362+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/oauth/token",
330363
strings.NewReader(form.Encode()))
331364
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
332365
w := httptest.NewRecorder()
@@ -362,7 +395,7 @@ func TestTokenHandler_CodeIsConsumedAfterExchange(t *testing.T) {
362395
"client_id": {cfg.ClientID},
363396
"code_verifier": {verifier},
364397
}
365-
req := httptest.NewRequest(http.MethodPost, "/oauth/token",
398+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/oauth/token",
366399
strings.NewReader(form.Encode()))
367400
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
368401
w := httptest.NewRecorder()
@@ -405,7 +438,7 @@ func TestTokenHandler_MismatchedRedirectURI_ReturnsBadRequest(t *testing.T) {
405438
"client_id": {cfg.ClientID},
406439
"code_verifier": {verifier},
407440
}
408-
req := httptest.NewRequest(http.MethodPost, "/oauth/token",
441+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/oauth/token",
409442
strings.NewReader(form.Encode()))
410443
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
411444
w := httptest.NewRecorder()
@@ -470,7 +503,7 @@ func TestBearerMiddleware_RejectsUnauthenticated(t *testing.T) {
470503
w.WriteHeader(http.StatusOK)
471504
})
472505

473-
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
506+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/mcp", nil)
474507
w := httptest.NewRecorder()
475508
mw.Handler(inner).ServeHTTP(w, req)
476509

@@ -496,7 +529,7 @@ func TestBearerMiddleware_AcceptsValidToken(t *testing.T) {
496529
w.WriteHeader(http.StatusOK)
497530
})
498531

499-
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
532+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/mcp", nil)
500533
req.Header.Set("Authorization", "Bearer valid-token")
501534
w := httptest.NewRecorder()
502535
mw.Handler(inner).ServeHTTP(w, req)

services/mcp-server/internal/auth/oidc_test.go

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package auth_test
22

33
import (
4+
"context"
45
"crypto/sha256"
56
"encoding/base64"
67
"encoding/json"
@@ -182,7 +183,7 @@ func TestOIDCHandler_Authorize_RedirectsToDex(t *testing.T) {
182183

183184
_, challenge := generatePKCEPair(t)
184185

185-
req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+url.Values{
186+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/oauth/authorize?"+url.Values{
186187
"response_type": {"code"},
187188
"client_id": {"meridian-mcp"},
188189
"redirect_uri": {"https://claude.ai/callback"},
@@ -234,7 +235,7 @@ func TestOIDCHandler_Authorize_InvalidClientID(t *testing.T) {
234235

235236
_, challenge := generatePKCEPair(t)
236237

237-
req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+url.Values{
238+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/oauth/authorize?"+url.Values{
238239
"response_type": {"code"},
239240
"client_id": {"wrong-client"},
240241
"redirect_uri": {"https://claude.ai/callback"},
@@ -267,7 +268,7 @@ func TestOIDCHandler_Authorize_MissingPKCE(t *testing.T) {
267268
})
268269
require.NoError(t, err)
269270

270-
req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+url.Values{
271+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/oauth/authorize?"+url.Values{
271272
"response_type": {"code"},
272273
"client_id": {"meridian-mcp"},
273274
"redirect_uri": {"https://claude.ai/callback"},
@@ -294,7 +295,7 @@ func TestOIDCHandler_Authorize_RejectsHTTPRedirect(t *testing.T) {
294295
})
295296
require.NoError(t, err)
296297

297-
req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+url.Values{
298+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/oauth/authorize?"+url.Values{
298299
"response_type": {"code"},
299300
"client_id": {"meridian-mcp"},
300301
"redirect_uri": {"http://evil.example.com/steal"},
@@ -325,7 +326,7 @@ func TestOIDCHandler_Authorize_AllowsLocalhostHTTP(t *testing.T) {
325326
})
326327
require.NoError(t, err)
327328

328-
req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+url.Values{
329+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/oauth/authorize?"+url.Values{
329330
"response_type": {"code"},
330331
"client_id": {"meridian-mcp"},
331332
"redirect_uri": {"http://localhost:3000/callback"},
@@ -381,7 +382,7 @@ func TestOIDCHandler_Callback_ExchangesAndRedirects(t *testing.T) {
381382
require.NoError(t, err)
382383

383384
// Simulate Dex callback
384-
req := httptest.NewRequest(http.MethodGet, "/oauth/callback?"+url.Values{
385+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/oauth/callback?"+url.Values{
385386
"code": {"fake-dex-code"},
386387
"state": {stateKey},
387388
}.Encode(), nil)
@@ -440,7 +441,7 @@ func TestOIDCHandler_Callback_InvalidState(t *testing.T) {
440441
})
441442
require.NoError(t, err)
442443

443-
req := httptest.NewRequest(http.MethodGet, "/oauth/callback?"+url.Values{
444+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/oauth/callback?"+url.Values{
444445
"code": {"fake-dex-code"},
445446
"state": {"invalid-state-key"},
446447
}.Encode(), nil)
@@ -470,7 +471,7 @@ func TestOIDCHandler_Callback_DexError(t *testing.T) {
470471
})
471472
require.NoError(t, err)
472473

473-
req := httptest.NewRequest(http.MethodGet, "/oauth/callback?"+url.Values{
474+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/oauth/callback?"+url.Values{
474475
"error": {"access_denied"},
475476
"error_description": {"user cancelled"},
476477
}.Encode(), nil)
@@ -516,7 +517,7 @@ func TestOIDCFlow_EndToEnd(t *testing.T) {
516517
verifier, challenge := generatePKCEPair(t)
517518

518519
// Step 1: Authorize — get redirect to Dex
519-
authReq := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+url.Values{
520+
authReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/oauth/authorize?"+url.Values{
520521
"response_type": {"code"},
521522
"client_id": {"meridian-mcp"},
522523
"redirect_uri": {"https://claude.ai/callback"},
@@ -536,7 +537,7 @@ func TestOIDCFlow_EndToEnd(t *testing.T) {
536537
require.NotEmpty(t, internalState)
537538

538539
// Step 2: Callback — simulate Dex returning with code
539-
cbReq := httptest.NewRequest(http.MethodGet, "/oauth/callback?"+url.Values{
540+
cbReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/oauth/callback?"+url.Values{
540541
"code": {"fake-dex-code"},
541542
"state": {internalState},
542543
}.Encode(), nil)
@@ -558,7 +559,7 @@ func TestOIDCFlow_EndToEnd(t *testing.T) {
558559
"client_id": {"meridian-mcp"},
559560
"code_verifier": {verifier},
560561
}
561-
tokenReq := httptest.NewRequest(http.MethodPost, "/oauth/token",
562+
tokenReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/oauth/token",
562563
strings.NewReader(form.Encode()))
563564
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
564565
tokenW := httptest.NewRecorder()
@@ -649,7 +650,7 @@ func TestTokenHandler_UsesPreSignedToken(t *testing.T) {
649650
"client_id": {cfg.ClientID},
650651
"code_verifier": {verifier},
651652
}
652-
req := httptest.NewRequest(http.MethodPost, "/oauth/token",
653+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/oauth/token",
653654
strings.NewReader(form.Encode()))
654655
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
655656
w := httptest.NewRecorder()

0 commit comments

Comments
 (0)