Skip to content

Commit 769d846

Browse files
SriganeshNkclaude
authored andcommitted
Wire OAuth resource-server support behind OAUTH_ENABLED (#71)
* Wire OAuth resource-server support behind OAUTH_ENABLED When OAUTH_ENABLED is unset (the default) nothing changes: the middleware is an identity function and the metadata paths serve 404. When enabled: - /mcp requires either an OAuth bearer token introspected against the authorization server with a matching audience, or a Render API key (passed through unchanged; OAUTH_API_KEY_PASSTHROUGH=false rejects API keys for strict OAuth-only deployments). - The RFC 9728 protected-resource metadata document is served at the path-insertion well-known URI derived from the resource URI, plus the root form. Configuration is validated at startup: OAUTH_AUTHORIZATION_SERVER_URL and OAUTH_CANONICAL_RESOURCE_URI are required when enabled; OAUTH_INTROSPECTION_SERVICE_TOKEN is optional. Route assembly is extracted into newHTTPMux and covered by tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Declare OAUTH_INTROSPECTION_SERVICE_TOKEN in the blueprint sync: false keeps the value dashboard-managed and out of the (public) blueprint, matching OPENAI_VERIFICATION_TOKEN. Not load-bearing yet: introspection is unauthenticated until service-token auth ships. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> GitOrigin-RevId: 92f274e12bf8bb670e7fbf2b976e03721863d225
1 parent e337a6f commit 769d846

3 files changed

Lines changed: 113 additions & 8 deletions

File tree

cmd/server.go

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/render-oss/render-mcp-server/pkg/logs"
1919
"github.com/render-oss/render-mcp-server/pkg/metrics"
2020
"github.com/render-oss/render-mcp-server/pkg/multicontext"
21+
"github.com/render-oss/render-mcp-server/pkg/oauth"
2122
"github.com/render-oss/render-mcp-server/pkg/owner"
2223
"github.com/render-oss/render-mcp-server/pkg/postgres"
2324
"github.com/render-oss/render-mcp-server/pkg/service"
@@ -72,21 +73,20 @@ func Serve(transport string) *server.MCPServer {
7273
)),
7374
)
7475

75-
mux := http.NewServeMux()
76-
mux.Handle("/mcp", streamableServer)
77-
if token := os.Getenv("OPENAI_VERIFICATION_TOKEN"); token != "" {
78-
mux.HandleFunc("/.well-known/openai-apps-challenge", func(w http.ResponseWriter, r *http.Request) {
79-
w.Header().Set("Content-Type", "text/plain")
80-
w.Write([]byte(token))
81-
})
76+
// OAuth resource-server support is opt-in via OAUTH_ENABLED;
77+
// pkg/oauth owns the gate. Fail at boot on misconfiguration.
78+
oauthCfg, err := oauth.FromEnv()
79+
if err != nil {
80+
log.Fatalf("OAuth configuration: %v", err)
8281
}
82+
mux := newHTTPMux(streamableServer, oauthCfg, os.Getenv("OPENAI_VERIFICATION_TOKEN"))
8383

8484
httpServer := &http.Server{
8585
Addr: ":10000",
8686
Handler: logging.HTTPMiddleware(mux),
8787
ReadTimeout: 5 * time.Second,
8888
}
89-
err := httpServer.ListenAndServe()
89+
err = httpServer.ListenAndServe()
9090
if err != nil {
9191
log.Fatalf("Starting Streamable server: %v\n:", err)
9292
}
@@ -102,3 +102,28 @@ func Serve(transport string) *server.MCPServer {
102102

103103
return s
104104
}
105+
106+
// newHTTPMux serves /mcp behind the OAuth middleware plus the RFC 9728 metadata
107+
// routes. When OAuth is disabled the middleware is identity and metadata 404s,
108+
// so /mcp is unchanged. openAIToken, when set, serves the OpenAI app challenge.
109+
func newHTTPMux(mcpHandler http.Handler, oauthCfg oauth.Config, openAIToken string) *http.ServeMux {
110+
oauthMiddleware := oauth.Middleware(oauthCfg, oauth.NewIntrospector(
111+
oauthCfg.AuthorizationServerURL,
112+
oauthCfg.IntrospectionServiceToken,
113+
oauth.DefaultIntrospectionCacheTTL,
114+
))
115+
116+
mux := http.NewServeMux()
117+
mux.Handle("/mcp", oauthMiddleware(mcpHandler))
118+
metadata := oauth.HandleProtectedResourceMetadata(oauthCfg)
119+
for _, path := range oauthCfg.MetadataPaths() {
120+
mux.HandleFunc(path, metadata)
121+
}
122+
if openAIToken != "" {
123+
mux.HandleFunc("/.well-known/openai-apps-challenge", func(w http.ResponseWriter, _ *http.Request) {
124+
w.Header().Set("Content-Type", "text/plain")
125+
_, _ = w.Write([]byte(openAIToken))
126+
})
127+
}
128+
return mux
129+
}

cmd/server_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package cmd
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/render-oss/render-mcp-server/pkg/oauth"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// recordingHandler stands in for the MCP server and reports whether it was reached.
13+
func recordingHandler() (http.Handler, *bool) {
14+
called := false
15+
h := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
16+
called = true
17+
w.WriteHeader(http.StatusOK)
18+
})
19+
return h, &called
20+
}
21+
22+
func TestNewHTTPMux_OAuthDisabledIsPassthrough(t *testing.T) {
23+
mcp, called := recordingHandler()
24+
mux := newHTTPMux(mcp, oauth.Config{}, "")
25+
26+
// /mcp reaches the MCP handler with no challenge — unchanged from pre-OAuth.
27+
rec := httptest.NewRecorder()
28+
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil))
29+
require.True(t, *called)
30+
require.Equal(t, http.StatusOK, rec.Code)
31+
require.Empty(t, rec.Header().Get("WWW-Authenticate"))
32+
33+
// Metadata is not advertised when disabled.
34+
rec = httptest.NewRecorder()
35+
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource", nil))
36+
require.Equal(t, http.StatusNotFound, rec.Code)
37+
}
38+
39+
func TestNewHTTPMux_OAuthEnabled(t *testing.T) {
40+
cfg := oauth.Config{
41+
Enabled: true,
42+
AuthorizationServerURL: "https://as.example.com",
43+
CanonicalResourceURI: "https://mcp.example.com/mcp",
44+
APIKeyPassthrough: true,
45+
}
46+
mcp, called := recordingHandler()
47+
mux := newHTTPMux(mcp, cfg, "")
48+
49+
// Path-aware RFC 9728 metadata is served.
50+
rec := httptest.NewRecorder()
51+
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource/mcp", nil))
52+
require.Equal(t, http.StatusOK, rec.Code)
53+
require.Contains(t, rec.Body.String(), cfg.CanonicalResourceURI)
54+
55+
// /mcp without credentials is challenged and the MCP handler is not reached.
56+
rec = httptest.NewRecorder()
57+
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil))
58+
require.Equal(t, http.StatusUnauthorized, rec.Code)
59+
require.Contains(t, rec.Header().Get("WWW-Authenticate"), "resource_metadata=")
60+
require.False(t, *called)
61+
}
62+
63+
func TestNewHTTPMux_OpenAIChallenge(t *testing.T) {
64+
mcp, _ := recordingHandler()
65+
66+
// Served only when the token is configured.
67+
mux := newHTTPMux(mcp, oauth.Config{}, "verify-token")
68+
rec := httptest.NewRecorder()
69+
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/.well-known/openai-apps-challenge", nil))
70+
require.Equal(t, http.StatusOK, rec.Code)
71+
require.Equal(t, "verify-token", rec.Body.String())
72+
73+
// Absent token: the route is not registered.
74+
mux = newHTTPMux(mcp, oauth.Config{}, "")
75+
rec = httptest.NewRecorder()
76+
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/.well-known/openai-apps-challenge", nil))
77+
require.Equal(t, http.StatusNotFound, rec.Code)
78+
}

render.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ services:
1313
envVars:
1414
- key: OPENAI_VERIFICATION_TOKEN
1515
sync: false
16+
- key: OAUTH_INTROSPECTION_SERVICE_TOKEN
17+
sync: false
1618
- key: REDIS_URL
1719
fromService:
1820
name: mcp-kv

0 commit comments

Comments
 (0)