Skip to content

Commit 43f4333

Browse files
DavidS-ovmactions-user
authored andcommitted
refactor: consolidate MCP OAuth handlers and add Area51 DCR support (#4679)
Deduplicate OAuth metadata (RFC 8414), Dynamic Client Registration (RFC 7591), and Protected Resource Metadata (RFC 9728) handlers from customermcp into shared go/auth package. Add Area51-specific OAuth metadata and DCR endpoints so MCP clients auto-discover the client_id without hardcoded auth blocks in .cursor/mcp.json. Redirect URI filtering in DCR restricts echoed URIs to localhost-only per RFC 8252, preventing open-redirect via registration responses. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds new OAuth metadata and Dynamic Client Registration endpoints for Area51 and refactors MCP discovery responses, which affects OAuth client discovery flows and could break MCP authentication if misconfigured. > > **Overview** > Enables MCP clients (e.g. Cursor) to **auto-discover OAuth configuration and `client_id`** for the Area51 MCP endpoint by adding RFC 8414 Authorization Server Metadata and RFC 7591 Dynamic Client Registration routes, and wiring them into `api-server` startup and routing. > > Consolidates MCP OAuth/PRM handler implementations into shared `go/auth` helpers (`NewMCPOAuthMetadataHandler`, `NewMCPDCRHandler`, `NewMCPPRMHandler`) and updates both Area51 and customer MCP code to use them; PRM responses no longer include `client_id` and DCR responses now *filter redirect URIs to localhost only*. > > Updates local dev proxying and config: nginx now forwards `/.well-known/oauth-authorization-server/area51/oauth` to the api-server, devcontainer envs add `API_SERVER_MCP_CLIENT_ID`, and `.cursor/mcp.json` removes hardcoded Area51 OAuth `CLIENT_ID` blocks. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit bcb15bc9d0cddc8a8b43923abf2d2d3b1a898421. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> GitOrigin-RevId: 86ea4467c7f28631dfde437f2fe22c3c58cc78ca
1 parent 03ba78a commit 43f4333

2 files changed

Lines changed: 343 additions & 0 deletions

File tree

go/auth/mcpoauth.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package auth
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/url"
8+
)
9+
10+
// NewMCPOAuthMetadataHandler returns an HTTP handler that serves OAuth 2.0
11+
// Authorization Server Metadata (RFC 8414) for an MCP endpoint.
12+
//
13+
// Instead of proxying Auth0's metadata at runtime, it constructs a static
14+
// document that points authorization_endpoint and token_endpoint to Auth0
15+
// while advertising our own registration_endpoint for Dynamic Client
16+
// Registration (RFC 7591). This lets MCP clients like Cursor discover
17+
// the client_id automatically without any user configuration.
18+
//
19+
// scopes should include both the standard OIDC scopes and any
20+
// application-specific scopes (e.g. "admin:read", "changes:read").
21+
func NewMCPOAuthMetadataHandler(auth0Domain, issuerURL, registrationEndpointURL string, scopes []string) http.Handler {
22+
metadata := map[string]any{
23+
"issuer": issuerURL,
24+
"authorization_endpoint": fmt.Sprintf("https://%s/authorize", auth0Domain),
25+
"token_endpoint": fmt.Sprintf("https://%s/oauth/token", auth0Domain),
26+
"registration_endpoint": registrationEndpointURL,
27+
28+
"jwks_uri": fmt.Sprintf("https://%s/.well-known/jwks.json", auth0Domain),
29+
"userinfo_endpoint": fmt.Sprintf("https://%s/userinfo", auth0Domain),
30+
"revocation_endpoint": fmt.Sprintf("https://%s/oauth/revoke", auth0Domain),
31+
32+
"response_types_supported": []string{"code"},
33+
"grant_types_supported": []string{"authorization_code", "refresh_token"},
34+
"code_challenge_methods_supported": []string{"S256"},
35+
"token_endpoint_auth_methods_supported": []string{"none"},
36+
"scopes_supported": scopes,
37+
}
38+
39+
body, _ := json.Marshal(metadata) //nolint:errchkjson // static map of strings/slices, cannot fail
40+
41+
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
42+
w.Header().Set("Content-Type", "application/json")
43+
_, _ = w.Write(body)
44+
})
45+
}
46+
47+
// NewMCPDCRHandler returns an HTTP handler that implements a minimal OAuth 2.0
48+
// Dynamic Client Registration (RFC 7591) endpoint. It always returns the
49+
// same pre-configured Auth0 client_id since all MCP clients share a single
50+
// public OAuth application.
51+
//
52+
// Per RFC 7591 Section 3.2, the response echoes back the registered client
53+
// metadata including redirect_uris from the request.
54+
func NewMCPDCRHandler(clientID string) http.Handler {
55+
type dcrRequest struct {
56+
RedirectURIs []string `json:"redirect_uris"`
57+
ClientName string `json:"client_name"`
58+
}
59+
60+
type dcrResponse struct {
61+
ClientID string `json:"client_id"`
62+
RedirectURIs []string `json:"redirect_uris"`
63+
ClientName string `json:"client_name,omitempty"`
64+
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
65+
}
66+
67+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
68+
if r.Method != http.MethodPost {
69+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
70+
return
71+
}
72+
73+
limited := http.MaxBytesReader(w, r.Body, 64<<10)
74+
var req dcrRequest
75+
if err := json.NewDecoder(limited).Decode(&req); err != nil {
76+
req = dcrRequest{}
77+
}
78+
_ = limited.Close()
79+
80+
// Don't echo back arbitrary redirect_uris; Auth0 enforces the
81+
// registered set during token exchange, but echoing unchecked URIs
82+
// could mislead clients that trust the DCR response blindly.
83+
// Instead, return only localhost URIs which are the standard
84+
// callback for native/public OAuth clients per RFC 8252.
85+
safeURIs := make([]string, 0, len(req.RedirectURIs))
86+
for _, uri := range req.RedirectURIs {
87+
if IsLocalhostRedirect(uri) {
88+
safeURIs = append(safeURIs, uri)
89+
}
90+
}
91+
92+
resp := dcrResponse{
93+
ClientID: clientID,
94+
RedirectURIs: safeURIs,
95+
ClientName: req.ClientName,
96+
TokenEndpointAuthMethod: "none",
97+
}
98+
99+
w.Header().Set("Content-Type", "application/json")
100+
w.WriteHeader(http.StatusCreated)
101+
_ = json.NewEncoder(w).Encode(resp)
102+
})
103+
}
104+
105+
// NewMCPPRMHandler returns an http.Handler that serves the OAuth 2.0 Protected
106+
// Resource Metadata (RFC 9728) JSON document for an MCP endpoint. No
107+
// authentication is required.
108+
//
109+
// authorizationServerURL is the issuer URL of the OAuth metadata endpoint (not
110+
// the raw Auth0 domain). MCP clients use this to discover the authorization
111+
// and token endpoints, as well as the Dynamic Client Registration endpoint.
112+
func NewMCPPRMHandler(authorizationServerURL, resourceURL string, scopes []string) http.Handler {
113+
type prmResponse struct {
114+
Resource string `json:"resource"`
115+
AuthorizationServers []string `json:"authorization_servers"`
116+
ScopesSupported []string `json:"scopes_supported"`
117+
BearerMethodsSupported []string `json:"bearer_methods_supported"`
118+
}
119+
120+
resp := prmResponse{
121+
Resource: resourceURL,
122+
AuthorizationServers: []string{authorizationServerURL},
123+
ScopesSupported: scopes,
124+
BearerMethodsSupported: []string{"header"},
125+
}
126+
127+
body, _ := json.Marshal(resp) //nolint:errchkjson // static struct of strings, cannot fail
128+
129+
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
130+
w.Header().Set("Content-Type", "application/json")
131+
w.WriteHeader(http.StatusOK)
132+
_, _ = w.Write(body)
133+
})
134+
}
135+
136+
// IsLocalhostRedirect returns true if the URI is a loopback redirect, which is
137+
// the standard callback for native/public OAuth clients (RFC 8252 Section 7.3).
138+
func IsLocalhostRedirect(raw string) bool {
139+
u, err := url.Parse(raw)
140+
if err != nil {
141+
return false
142+
}
143+
host := u.Hostname()
144+
return host == "127.0.0.1" || host == "::1" || host == "localhost"
145+
}

go/auth/mcpoauth_test.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package auth
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestNewMCPOAuthMetadataHandler(t *testing.T) {
12+
scopes := []string{"openid", "profile", "email", "offline_access", "admin:read"}
13+
handler := NewMCPOAuthMetadataHandler(
14+
"auth.example.com",
15+
"https://api.example.com/area51/oauth",
16+
"https://api.example.com/area51/oauth/register",
17+
scopes,
18+
)
19+
20+
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/.well-known/oauth-authorization-server/area51/oauth", nil)
21+
rec := httptest.NewRecorder()
22+
handler.ServeHTTP(rec, req)
23+
24+
if rec.Code != http.StatusOK {
25+
t.Fatalf("expected 200, got %d", rec.Code)
26+
}
27+
28+
var body map[string]any
29+
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
30+
t.Fatalf("failed to decode metadata: %v", err)
31+
}
32+
33+
if body["issuer"] != "https://api.example.com/area51/oauth" {
34+
t.Errorf("unexpected issuer: %v", body["issuer"])
35+
}
36+
if body["authorization_endpoint"] != "https://auth.example.com/authorize" {
37+
t.Errorf("unexpected authorization_endpoint: %v", body["authorization_endpoint"])
38+
}
39+
if body["token_endpoint"] != "https://auth.example.com/oauth/token" {
40+
t.Errorf("unexpected token_endpoint: %v", body["token_endpoint"])
41+
}
42+
if body["registration_endpoint"] != "https://api.example.com/area51/oauth/register" {
43+
t.Errorf("unexpected registration_endpoint: %v", body["registration_endpoint"])
44+
}
45+
if body["jwks_uri"] != "https://auth.example.com/.well-known/jwks.json" {
46+
t.Errorf("unexpected jwks_uri: %v", body["jwks_uri"])
47+
}
48+
49+
scopesAny, ok := body["scopes_supported"].([]any)
50+
if !ok {
51+
t.Fatalf("scopes_supported is not an array: %T", body["scopes_supported"])
52+
}
53+
if len(scopesAny) != len(scopes) {
54+
t.Errorf("expected %d scopes, got %d", len(scopes), len(scopesAny))
55+
}
56+
}
57+
58+
func TestNewMCPDCRHandler(t *testing.T) {
59+
handler := NewMCPDCRHandler("test-client-id")
60+
61+
reqBody := `{"redirect_uris":["http://127.0.0.1/callback"],"client_name":"Test Client"}`
62+
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/area51/oauth/register", strings.NewReader(reqBody))
63+
req.Header.Set("Content-Type", "application/json")
64+
rec := httptest.NewRecorder()
65+
handler.ServeHTTP(rec, req)
66+
67+
if rec.Code != http.StatusCreated {
68+
t.Fatalf("expected 201, got %d", rec.Code)
69+
}
70+
71+
var body struct {
72+
ClientID string `json:"client_id"`
73+
RedirectURIs []string `json:"redirect_uris"`
74+
ClientName string `json:"client_name"`
75+
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
76+
}
77+
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
78+
t.Fatalf("failed to decode DCR response: %v", err)
79+
}
80+
81+
if body.ClientID != "test-client-id" {
82+
t.Errorf("unexpected client_id: %q", body.ClientID)
83+
}
84+
if body.TokenEndpointAuthMethod != "none" {
85+
t.Errorf("unexpected token_endpoint_auth_method: %q", body.TokenEndpointAuthMethod)
86+
}
87+
if len(body.RedirectURIs) != 1 || body.RedirectURIs[0] != "http://127.0.0.1/callback" {
88+
t.Errorf("unexpected redirect_uris: %v", body.RedirectURIs)
89+
}
90+
if body.ClientName != "Test Client" {
91+
t.Errorf("unexpected client_name: %q", body.ClientName)
92+
}
93+
}
94+
95+
func TestNewMCPDCRHandler_MethodNotAllowed(t *testing.T) {
96+
handler := NewMCPDCRHandler("test-client-id")
97+
98+
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/area51/oauth/register", nil)
99+
rec := httptest.NewRecorder()
100+
handler.ServeHTTP(rec, req)
101+
102+
if rec.Code != http.StatusMethodNotAllowed {
103+
t.Fatalf("expected 405, got %d", rec.Code)
104+
}
105+
}
106+
107+
func TestNewMCPDCRHandler_FiltersNonLocalhostRedirects(t *testing.T) {
108+
handler := NewMCPDCRHandler("test-client-id")
109+
110+
reqBody := `{"redirect_uris":["http://127.0.0.1/callback","https://evil.com/callback","http://localhost:3000/callback"]}`
111+
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/register", strings.NewReader(reqBody))
112+
req.Header.Set("Content-Type", "application/json")
113+
rec := httptest.NewRecorder()
114+
handler.ServeHTTP(rec, req)
115+
116+
if rec.Code != http.StatusCreated {
117+
t.Fatalf("expected 201, got %d", rec.Code)
118+
}
119+
120+
var body struct {
121+
RedirectURIs []string `json:"redirect_uris"`
122+
}
123+
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
124+
t.Fatalf("failed to decode: %v", err)
125+
}
126+
127+
if len(body.RedirectURIs) != 2 {
128+
t.Fatalf("expected 2 safe redirect URIs, got %d: %v", len(body.RedirectURIs), body.RedirectURIs)
129+
}
130+
}
131+
132+
func TestNewMCPPRMHandler(t *testing.T) {
133+
handler := NewMCPPRMHandler(
134+
"https://api.example.com/area51/oauth",
135+
"https://api.example.com/area51/mcp",
136+
[]string{"admin:read"},
137+
)
138+
139+
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/.well-known/oauth-protected-resource/area51/mcp", nil)
140+
rec := httptest.NewRecorder()
141+
handler.ServeHTTP(rec, req)
142+
143+
if rec.Code != http.StatusOK {
144+
t.Fatalf("expected 200, got %d", rec.Code)
145+
}
146+
147+
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
148+
t.Errorf("expected Content-Type application/json, got %q", ct)
149+
}
150+
151+
var body struct {
152+
Resource string `json:"resource"`
153+
AuthorizationServers []string `json:"authorization_servers"`
154+
ScopesSupported []string `json:"scopes_supported"`
155+
BearerMethodsSupported []string `json:"bearer_methods_supported"`
156+
ClientID string `json:"client_id"`
157+
}
158+
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
159+
t.Fatalf("failed to decode PRM response: %v", err)
160+
}
161+
162+
if body.Resource != "https://api.example.com/area51/mcp" {
163+
t.Errorf("unexpected resource: %q", body.Resource)
164+
}
165+
if len(body.AuthorizationServers) != 1 || body.AuthorizationServers[0] != "https://api.example.com/area51/oauth" {
166+
t.Errorf("unexpected authorization_servers: %v", body.AuthorizationServers)
167+
}
168+
if len(body.ScopesSupported) != 1 || body.ScopesSupported[0] != "admin:read" {
169+
t.Errorf("unexpected scopes_supported: %v", body.ScopesSupported)
170+
}
171+
if len(body.BearerMethodsSupported) != 1 || body.BearerMethodsSupported[0] != "header" {
172+
t.Errorf("unexpected bearer_methods_supported: %v", body.BearerMethodsSupported)
173+
}
174+
if body.ClientID != "" {
175+
t.Errorf("expected no client_id in PRM, got %q", body.ClientID)
176+
}
177+
}
178+
179+
func TestIsLocalhostRedirect(t *testing.T) {
180+
tests := []struct {
181+
uri string
182+
want bool
183+
}{
184+
{"http://127.0.0.1/callback", true},
185+
{"http://localhost:3000/callback", true},
186+
{"http://[::1]:8080/callback", true},
187+
{"https://evil.com/callback", false},
188+
{"https://example.com", false},
189+
{"not-a-url", false},
190+
}
191+
192+
for _, tt := range tests {
193+
got := IsLocalhostRedirect(tt.uri)
194+
if got != tt.want {
195+
t.Errorf("IsLocalhostRedirect(%q) = %v, want %v", tt.uri, got, tt.want)
196+
}
197+
}
198+
}

0 commit comments

Comments
 (0)