Skip to content

Commit 19a9241

Browse files
authored
feat(auth): support for VSCode auth flow (#258)
Adds DisableDynamicClientRegistration and OAuthScopes to be able to override the values proxied from the configured authorization server. DisableDynamicClientRegistration removes the registration_endpoint field from the well-known authorization resource metadata. This forces VSCode to show a for to input the Client ID and Client Secret since these can't be discovered. The OAuthScopes allows to override the scopes_supported field. VSCode automatically makes an auth request for all of the supported scopes. In many cases, this is not supported by the auth server. By providing this configuration, the user (MCP Server administrator) is able to set which scopes are effectively supported and force VSCode to only request these. Signed-off-by: Marc Nuri <[email protected]>
1 parent 90d4bb0 commit 19a9241

File tree

4 files changed

+137
-6
lines changed

4 files changed

+137
-6
lines changed

pkg/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ type StaticConfig struct {
3333
// AuthorizationURL is the URL of the OIDC authorization server.
3434
// It is used for token validation and for STS token exchange.
3535
AuthorizationURL string `toml:"authorization_url,omitempty"`
36+
// DisableDynamicClientRegistration indicates whether dynamic client registration is disabled.
37+
// If true, the .well-known endpoints will not expose the registration endpoint.
38+
DisableDynamicClientRegistration bool `toml:"disable_dynamic_client_registration,omitempty"`
39+
// OAuthScopes are the supported **client** scopes requested during the **client/frontend** OAuth flow.
40+
OAuthScopes []string `toml:"oauth_scopes,omitempty"`
3641
// StsClientId is the OAuth client ID used for backend token exchange
3742
StsClientId string `toml:"sts_client_id,omitempty"`
3843
// StsClientSecret is the OAuth client secret used for backend token exchange

pkg/http/authorization.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ func AuthorizationMiddleware(staticConfig *config.StaticConfig, oidcProvider *oi
111111
}
112112
// Token exchange with OIDC provider
113113
sts := NewFromConfig(staticConfig, oidcProvider)
114+
// TODO: Maybe the token had already been exchanged, if it has the right audience and scopes, we can skip this step.
114115
if err == nil && sts.IsEnabled() {
115116
var exchangedToken *oauth2.Token
116117
// If the token is valid, we can exchange it for a new token with the specified audience and scopes.

pkg/http/http_test.go

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"crypto/rsa"
99
"flag"
1010
"fmt"
11+
"io"
1112
"net"
1213
"net/http"
1314
"net/http/httptest"
@@ -334,7 +335,28 @@ func TestWellKnownReverseProxy(t *testing.T) {
334335
})
335336
}
336337
})
337-
// With Authorization URL configured
338+
// With Authorization URL configured but invalid payload
339+
invalidPayloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
340+
w.Header().Set("Content-Type", "application/json")
341+
_, _ = w.Write([]byte(`NOT A JSON PAYLOAD`))
342+
}))
343+
t.Cleanup(invalidPayloadServer.Close)
344+
invalidPayloadConfig := &config.StaticConfig{AuthorizationURL: invalidPayloadServer.URL, RequireOAuth: true, ValidateToken: true}
345+
testCaseWithContext(t, &httpContext{StaticConfig: invalidPayloadConfig}, func(ctx *httpContext) {
346+
for _, path := range cases {
347+
resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
348+
t.Cleanup(func() { _ = resp.Body.Close() })
349+
t.Run("Protected resource '"+path+"' with invalid Authorization URL payload returns 500 - Internal Server Error", func(t *testing.T) {
350+
if err != nil {
351+
t.Fatalf("Failed to get %s endpoint: %v", path, err)
352+
}
353+
if resp.StatusCode != http.StatusInternalServerError {
354+
t.Errorf("Expected HTTP 500 Internal Server Error, got %d", resp.StatusCode)
355+
}
356+
})
357+
}
358+
})
359+
// With Authorization URL configured and valid payload
338360
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
339361
if !strings.HasPrefix(r.URL.EscapedPath(), "/.well-known/") {
340362
http.NotFound(w, r)
@@ -344,7 +366,8 @@ func TestWellKnownReverseProxy(t *testing.T) {
344366
_, _ = w.Write([]byte(`{"issuer": "https://example.com","scopes_supported":["mcp-server"]}`))
345367
}))
346368
t.Cleanup(testServer.Close)
347-
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{AuthorizationURL: testServer.URL, RequireOAuth: true, ValidateToken: true}}, func(ctx *httpContext) {
369+
staticConfig := &config.StaticConfig{AuthorizationURL: testServer.URL, RequireOAuth: true, ValidateToken: true}
370+
testCaseWithContext(t, &httpContext{StaticConfig: staticConfig}, func(ctx *httpContext) {
348371
for _, path := range cases {
349372
resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
350373
t.Cleanup(func() { _ = resp.Body.Close() })
@@ -365,6 +388,87 @@ func TestWellKnownReverseProxy(t *testing.T) {
365388
})
366389
}
367390

391+
func TestWellKnownOverrides(t *testing.T) {
392+
cases := []string{
393+
".well-known/oauth-authorization-server",
394+
".well-known/oauth-protected-resource",
395+
".well-known/openid-configuration",
396+
}
397+
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
398+
if !strings.HasPrefix(r.URL.EscapedPath(), "/.well-known/") {
399+
http.NotFound(w, r)
400+
return
401+
}
402+
w.Header().Set("Content-Type", "application/json")
403+
_, _ = w.Write([]byte(`
404+
{
405+
"issuer": "https://localhost",
406+
"registration_endpoint": "https://localhost/clients-registrations/openid-connect",
407+
"require_request_uri_registration": true,
408+
"scopes_supported":["scope-1", "scope-2"]
409+
}`))
410+
}))
411+
t.Cleanup(testServer.Close)
412+
baseConfig := config.StaticConfig{AuthorizationURL: testServer.URL, RequireOAuth: true, ValidateToken: true}
413+
// With Dynamic Client Registration disabled
414+
disableDynamicRegistrationConfig := baseConfig
415+
disableDynamicRegistrationConfig.DisableDynamicClientRegistration = true
416+
testCaseWithContext(t, &httpContext{StaticConfig: &disableDynamicRegistrationConfig}, func(ctx *httpContext) {
417+
for _, path := range cases {
418+
resp, _ := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
419+
t.Cleanup(func() { _ = resp.Body.Close() })
420+
body, err := io.ReadAll(resp.Body)
421+
if err != nil {
422+
t.Fatalf("Failed to read response body: %v", err)
423+
}
424+
t.Run("DisableDynamicClientRegistration removes registration_endpoint field", func(t *testing.T) {
425+
if strings.Contains(string(body), "registration_endpoint") {
426+
t.Error("Expected registration_endpoint to be removed, but it was found in the response")
427+
}
428+
})
429+
t.Run("DisableDynamicClientRegistration sets require_request_uri_registration = false", func(t *testing.T) {
430+
if !strings.Contains(string(body), `"require_request_uri_registration":false`) {
431+
t.Error("Expected require_request_uri_registration to be false, but it was not found in the response")
432+
}
433+
})
434+
t.Run("DisableDynamicClientRegistration includes/preserves scopes_supported", func(t *testing.T) {
435+
if !strings.Contains(string(body), `"scopes_supported":["scope-1","scope-2"]`) {
436+
t.Error("Expected scopes_supported to be present, but it was not found in the response")
437+
}
438+
})
439+
}
440+
})
441+
// With overrides for OAuth scopes (client/frontend)
442+
oAuthScopesConfig := baseConfig
443+
oAuthScopesConfig.OAuthScopes = []string{"openid", "mcp-server"}
444+
testCaseWithContext(t, &httpContext{StaticConfig: &oAuthScopesConfig}, func(ctx *httpContext) {
445+
for _, path := range cases {
446+
resp, _ := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
447+
t.Cleanup(func() { _ = resp.Body.Close() })
448+
body, err := io.ReadAll(resp.Body)
449+
if err != nil {
450+
t.Fatalf("Failed to read response body: %v", err)
451+
}
452+
t.Run("OAuthScopes overrides scopes_supported", func(t *testing.T) {
453+
if !strings.Contains(string(body), `"scopes_supported":["openid","mcp-server"]`) {
454+
t.Errorf("Expected scopes_supported to be overridden, but original was preserved, response: %s", string(body))
455+
}
456+
})
457+
t.Run("OAuthScopes preserves other fields", func(t *testing.T) {
458+
if !strings.Contains(string(body), `"issuer":"https://localhost"`) {
459+
t.Errorf("Expected issuer to be preserved, but got: %s", string(body))
460+
}
461+
if !strings.Contains(string(body), `"registration_endpoint":"https://localhost`) {
462+
t.Errorf("Expected registration_endpoint to be preserved, but got: %s", string(body))
463+
}
464+
if !strings.Contains(string(body), `"require_request_uri_registration":true`) {
465+
t.Error("Expected require_request_uri_registration to be true, but it was not found in the response")
466+
}
467+
})
468+
}
469+
})
470+
}
471+
368472
func TestMiddlewareLogging(t *testing.T) {
369473
testCase(t, func(ctx *httpContext) {
370474
_, _ = http.Get(fmt.Sprintf("http://%s/.well-known/oauth-protected-resource", ctx.HttpAddress))

pkg/http/wellknown.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package http
22

33
import (
4-
"io"
4+
"encoding/json"
5+
"fmt"
56
"net/http"
67
"strings"
78

@@ -21,7 +22,9 @@ var WellKnownEndpoints = []string{
2122
}
2223

2324
type WellKnown struct {
24-
authorizationUrl string
25+
authorizationUrl string
26+
scopesSupported []string
27+
disableDynamicClientRegistration bool
2528
}
2629

2730
var _ http.Handler = &WellKnown{}
@@ -31,7 +34,11 @@ func WellKnownHandler(staticConfig *config.StaticConfig) http.Handler {
3134
if authorizationUrl != "" && strings.HasSuffix("authorizationUrl", "/") {
3235
authorizationUrl = strings.TrimSuffix(authorizationUrl, "/")
3336
}
34-
return &WellKnown{authorizationUrl}
37+
return &WellKnown{
38+
authorizationUrl: authorizationUrl,
39+
disableDynamicClientRegistration: staticConfig.DisableDynamicClientRegistration,
40+
scopesSupported: staticConfig.OAuthScopes,
41+
}
3542
}
3643

3744
func (w WellKnown) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
@@ -50,16 +57,30 @@ func (w WellKnown) ServeHTTP(writer http.ResponseWriter, request *http.Request)
5057
return
5158
}
5259
defer func() { _ = resp.Body.Close() }()
53-
body, err := io.ReadAll(resp.Body)
60+
var resourceMetadata map[string]interface{}
61+
err = json.NewDecoder(resp.Body).Decode(&resourceMetadata)
5462
if err != nil {
5563
http.Error(writer, "Failed to read response body: "+err.Error(), http.StatusInternalServerError)
5664
return
5765
}
66+
if w.disableDynamicClientRegistration {
67+
delete(resourceMetadata, "registration_endpoint")
68+
resourceMetadata["require_request_uri_registration"] = false
69+
}
70+
if len(w.scopesSupported) > 0 {
71+
resourceMetadata["scopes_supported"] = w.scopesSupported
72+
}
73+
body, err := json.Marshal(resourceMetadata)
74+
if err != nil {
75+
http.Error(writer, "Failed to marshal response body: "+err.Error(), http.StatusInternalServerError)
76+
return
77+
}
5878
for key, values := range resp.Header {
5979
for _, value := range values {
6080
writer.Header().Add(key, value)
6181
}
6282
}
83+
writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
6384
writer.WriteHeader(resp.StatusCode)
6485
_, _ = writer.Write(body)
6586
}

0 commit comments

Comments
 (0)