Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion docs-website/router/mcp/oauth/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ icon: 'sliders-up'
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `oauth.enabled` | Enable OAuth 2.1 / JWKS-based authentication for the MCP server | `false` |
| `oauth.authorization_server_url` | Base URL of the OAuth 2.0 authorization server. Advertised via the [RFC 9728 metadata endpoint](#rfc-9728-protected-resource-metadata) so clients can discover authorization endpoints. | - |
| `oauth.authorization_server_urls` | List of base URLs of OAuth 2.0 authorization servers. Use this option to trust more than one authorization server. See [Multiple Authorization Servers](#multiple-authorization-servers). | `[]` |
| `oauth.scope_challenge_include_token_scopes` | When `true`, includes the token's existing scopes in the `scope` parameter of 403 responses. Works around MCP SDK scope accumulation bugs. See [Scope Challenge Behavior](/router/mcp/oauth/scopes#scope-challenge-behavior). | `false` |
| `oauth.max_scope_combinations` | Maximum scope combinations computed per operation. Raise for schemas with many overlapping `@requiresScopes`. | `2048` |
| `oauth.scopes.initialize` | Scopes required for **all** HTTP requests (checked before JSON-RPC parsing). This is the baseline scope needed to establish an MCP connection. | `[]` |
Expand Down Expand Up @@ -59,12 +60,34 @@ oauth:
header_key_id: 'my-key-id'
```

## Multiple Authorization Servers

The router can trust more than one OAuth 2.0 authorization server. Use `authorization_server_urls` to list all trusted servers. Configure a JWKS entry for each issuer so the router can validate its tokens.

```yaml
oauth:
enabled: true
authorization_server_urls:
- 'https://auth-a.example.com'
- 'https://auth-b.example.com'
jwks:
- url: 'https://auth-a.example.com/.well-known/jwks.json'
- url: 'https://auth-b.example.com/.well-known/jwks.json'
```

The router validates an incoming token against every configured JWKS provider. A token is accepted when any provider validates it.

All configured servers are advertised in the `authorization_servers` field of the [RFC 9728 metadata endpoint](#rfc-9728-protected-resource-metadata). MCP clients pick one of the advertised servers for authorization.

You can combine `authorization_server_url` and `authorization_server_urls`. The router merges both into one list, with the single URL first, and removes duplicates. Existing configurations with only `authorization_server_url` continue to work unchanged.

## Environment Variables

| Environment Variable | Configuration Path |
| ------------------------------------------------ | ------------------------------------------------ |
| `MCP_OAUTH_ENABLED` | `mcp.oauth.enabled` |
| `MCP_OAUTH_AUTHORIZATION_SERVER_URL` | `mcp.oauth.authorization_server_url` |
| `MCP_OAUTH_AUTHORIZATION_SERVER_URLS` | `mcp.oauth.authorization_server_urls` (comma-separated) |
| `MCP_OAUTH_SCOPE_CHALLENGE_INCLUDE_TOKEN_SCOPES` | `mcp.oauth.scope_challenge_include_token_scopes` |
| `MCP_OAUTH_MAX_SCOPE_COMBINATIONS` | `mcp.oauth.max_scope_combinations` |

Expand Down Expand Up @@ -116,7 +139,7 @@ The `scope` parameter always contains only the scopes needed for the specific op

## RFC 9728 Protected Resource Metadata

When OAuth is enabled and `authorization_server_url` is configured, the MCP server exposes a public (unauthenticated) metadata endpoint at:
When OAuth is enabled and at least one authorization server is configured (`authorization_server_url` or `authorization_server_urls`), the MCP server exposes a public (unauthenticated) metadata endpoint at:

```
GET /.well-known/oauth-protected-resource/mcp
Expand Down
2 changes: 2 additions & 0 deletions docs-website/router/mcp/oauth/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ storage_providers:
`resource_metadata` in `WWW-Authenticate` headers. Set this to your externally-reachable URL.
</Info>

To trust more than one authorization server, use `authorization_server_urls`. See [Multiple Authorization Servers](/router/mcp/oauth/configuration#multiple-authorization-servers).

## Step 2: Add Scope Requirements

Define which scopes are required at each level:
Expand Down
90 changes: 90 additions & 0 deletions router-tests/protocol/mcp_oauth_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package integration

import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -193,3 +195,91 @@ func TestMCPOAuthPerToolScopes(t *testing.T) {
})
})
}

func TestMCPOAuthMultipleAuthorizationServers(t *testing.T) {
oauthServerA, err := testutil.NewOAuthTestServer(t, &testutil.OAuthTestServerOptions{KeyID: "server_a_rsa"})
require.NoError(t, err, "failed to start OAuth server A")
defer oauthServerA.Close() //nolint:errcheck

oauthServerB, err := testutil.NewOAuthTestServer(t, &testutil.OAuthTestServerOptions{KeyID: "server_b_rsa"})
require.NoError(t, err, "failed to start OAuth server B")
defer oauthServerB.Close() //nolint:errcheck

oauthServerUnknown, err := testutil.NewOAuthTestServer(t, &testutil.OAuthTestServerOptions{KeyID: "server_unknown_rsa"})
require.NoError(t, err, "failed to start unknown OAuth server")
defer oauthServerUnknown.Close() //nolint:errcheck

tokenFromA, err := oauthServerA.CreateTokenWithScopes("test-user", []string{"mcp:tools:read"})
require.NoError(t, err, "failed to create token on server A")

testenv.Run(t, &testenv.Config{
MCP: config.MCPConfiguration{
Enabled: true,
OAuth: config.MCPOAuthConfiguration{
Enabled: true,
JWKS: []config.JWKSConfiguration{
{URL: oauthServerA.JWKSURL()},
{URL: oauthServerB.JWKSURL()},
},
AuthorizationServerURLs: []string{
oauthServerA.Issuer(),
oauthServerB.Issuer(),
},
},
},
MCPAuthToken: tokenFromA,
MCPOperationsPath: "testdata/mcp_operations",
}, func(t *testing.T, xEnv *testenv.Environment) {
ctx := context.Background()
Comment thread
asoorm marked this conversation as resolved.
Outdated

t.Run("metadata endpoint advertises all authorization servers", func(t *testing.T) {
metadataURL := strings.TrimSuffix(xEnv.GetMCPServerAddr(), "/mcp") + "/.well-known/oauth-protected-resource/mcp"

resp, err := http.Get(metadataURL)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck

require.Equal(t, http.StatusOK, resp.StatusCode)

var metadata struct {
AuthorizationServers []string `json:"authorization_servers"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&metadata))
assert.Equal(t, []string{oauthServerA.Issuer(), oauthServerB.Issuer()}, metadata.AuthorizationServers)
})

t.Run("accepts tokens from the first authorization server", func(t *testing.T) {
client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), tokenFromA)

require.NoError(t, client.Connect(ctx), "should connect with a token from server A")
defer client.Close() //nolint:errcheck
})

t.Run("accepts tokens from the second authorization server", func(t *testing.T) {
tokenFromB, err := oauthServerB.CreateTokenWithScopes("test-user", []string{"mcp:tools:read"})
Comment thread
asoorm marked this conversation as resolved.
require.NoError(t, err, "failed to create token on server B")

client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), tokenFromB)

require.NoError(t, client.Connect(ctx), "should connect with a token from server B")
defer client.Close() //nolint:errcheck
})

t.Run("rejects tokens from an unknown authorization server", func(t *testing.T) {
// Trust is anchored in the configured JWKS signing keys, not in the
// token's iss claim. The unknown server signs with a key that is in
// no configured JWKS, so signature verification fails with 401.
tokenFromUnknown, err := oauthServerUnknown.CreateTokenWithScopes("test-user", []string{"mcp:tools:read"})
require.NoError(t, err, "failed to create token on unknown server")

client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), tokenFromUnknown)

err = client.Connect(ctx)
require.Error(t, err, "should fail to connect with a token from an unknown issuer")

authErr, ok := err.(*AuthError)
require.True(t, ok, "expected *AuthError but got %T: %v", err, err)
assert.Equal(t, http.StatusUnauthorized, authErr.StatusCode, "should return HTTP 401")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
})
}
12 changes: 10 additions & 2 deletions router-tests/testutil/oauth_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ type OAuthTestServer struct {
type OAuthTestServerOptions struct {
DefaultScopes string
PreRegisteredClients []*OAuthClient
// KeyID sets the JWKS key ID for the signing key. Defaults to "test_rsa".
// Set a unique value per server when a test runs multiple OAuth servers.
KeyID string
}

// NewOAuthTestServer creates and starts a minimal OAuth 2.1 AS on a random port.
Expand All @@ -72,7 +75,12 @@ func NewOAuthTestServer(t *testing.T, opts *OAuthTestServerOptions) (*OAuthTestS
opts = &OAuthTestServerOptions{}
}

cryptoProvider, err := jwks.NewRSACrypto("test_rsa", jwkset.AlgRS256, 2048)
keyID := opts.KeyID
if keyID == "" {
keyID = "test_rsa"
}

cryptoProvider, err := jwks.NewRSACrypto(keyID, jwkset.AlgRS256, 2048)
if err != nil {
return nil, fmt.Errorf("failed to create RSA crypto: %w", err)
}
Expand All @@ -89,7 +97,7 @@ func NewOAuthTestServer(t *testing.T, opts *OAuthTestServerOptions) (*OAuthTestS
s := &OAuthTestServer{
t: t,
provider: cryptoProvider,
keyID: "test_rsa",
keyID: keyID,
audience: "test-audience",
storage: jwkStorage,
clients: make(map[string]*OAuthClient),
Expand Down
23 changes: 23 additions & 0 deletions router/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1364,6 +1364,10 @@ type MCPOAuthConfiguration struct {
Enabled bool `yaml:"enabled" envDefault:"false" env:"ENABLED"`
JWKS []JWKSConfiguration `yaml:"jwks"`
AuthorizationServerURL string `yaml:"authorization_server_url,omitempty" env:"AUTHORIZATION_SERVER_URL"`
// AuthorizationServerURLs configures multiple OAuth 2.0 authorization servers.
// All entries are advertised in the RFC 9728 Protected Resource Metadata.
// Use AuthorizationServers to read the merged list of both fields.
AuthorizationServerURLs []string `yaml:"authorization_server_urls,omitempty" env:"AUTHORIZATION_SERVER_URLS"`
// Scopes configures which OAuth scopes are required for different MCP operations.
Scopes MCPOAuthScopesConfiguration `yaml:"scopes,omitempty" envPrefix:"SCOPES_"`
// ScopeChallengeIncludeTokenScopes controls whether the server includes the token's existing scopes
Expand All @@ -1378,6 +1382,25 @@ type MCPOAuthConfiguration struct {
MaxScopeCombinations int `yaml:"max_scope_combinations" envDefault:"2048" env:"MAX_SCOPE_COMBINATIONS"`
}

// AuthorizationServers returns all configured authorization server URLs.
// It merges AuthorizationServerURL with AuthorizationServerURLs.
// The single URL comes first. Empty and duplicate entries are removed.
func (c MCPOAuthConfiguration) AuthorizationServers() []string {
var servers []string
seen := make(map[string]struct{}, len(c.AuthorizationServerURLs)+1)
for _, url := range append([]string{c.AuthorizationServerURL}, c.AuthorizationServerURLs...) {
if url == "" {
continue
}
if _, ok := seen[url]; ok {
continue
}
seen[url] = struct{}{}
servers = append(servers, url)
}
return servers
}

// MCPOAuthScopesConfiguration defines which scopes are required for different MCP operations.
// All configured scopes are automatically unioned into scopes_supported for OAuth metadata discovery.
type MCPOAuthScopesConfiguration struct {
Expand Down
8 changes: 8 additions & 0 deletions router/pkg/config/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2776,6 +2776,14 @@
"description": "The base URL of the OAuth 2.0 authorization server. This URL is advertised to MCP clients via the Protected Resource Metadata endpoint (RFC 9728) to enable automatic discovery of OAuth endpoints. Clients will append '/.well-known/oauth-authorization-server' to this URL to discover token, authorization, and registration endpoints. Example: 'https://auth.example.com'",
"format": "http-url"
},
"authorization_server_urls": {
"type": "array",
"description": "A list of base URLs of OAuth 2.0 authorization servers. All URLs are advertised to MCP clients via the Protected Resource Metadata endpoint (RFC 9728). Use this field to trust more than one authorization server. Entries are merged with 'authorization_server_url' and duplicates are removed. Configure a JWKS entry for each issuer under 'jwks' so the router can validate its tokens.",
"items": {
"type": "string",
"format": "http-url"
}
},
"scopes": {
"type": "object",
"description": "Configures which OAuth scopes are required for different MCP operations. All configured scopes are automatically unioned into 'scopes_supported' for OAuth metadata discovery.",
Expand Down
128 changes: 128 additions & 0 deletions router/pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2412,3 +2412,131 @@ mcp:
require.Equal(t, "Query products, orders and customers.", cfg.Config.MCP.Server.Description)
})
}

func TestMCPOAuthAuthorizationServerURLs(t *testing.T) {
t.Run("reads multiple authorization server urls from yaml", func(t *testing.T) {

f := createTempFileFromFixture(t, `
version: "1"

graph:
token: "token"

mcp:
enabled: true
server:
base_url: "https://router.example.com"
oauth:
enabled: true
jwks:
- url: "https://auth-a.example.com/.well-known/jwks.json"
authorization_server_urls:
- "https://auth-a.example.com"
- "https://auth-b.example.com"
`)
cfg, err := LoadConfig([]string{f})
require.NoError(t, err)

require.Equal(t, []string{
"https://auth-a.example.com",
"https://auth-b.example.com",
}, cfg.Config.MCP.OAuth.AuthorizationServerURLs)
})

t.Run("reads multiple authorization server urls from the environment", func(t *testing.T) {
t.Setenv("MCP_OAUTH_AUTHORIZATION_SERVER_URLS", "https://auth-a.example.com,https://auth-b.example.com")

f := createTempFileFromFixture(t, `
version: "1"

graph:
token: "token"

mcp:
enabled: true
server:
base_url: "https://router.example.com"
oauth:
enabled: true
jwks:
- url: "https://auth-a.example.com/.well-known/jwks.json"
`)
cfg, err := LoadConfig([]string{f})
require.NoError(t, err)

require.Equal(t, []string{
"https://auth-a.example.com",
"https://auth-b.example.com",
}, cfg.Config.MCP.OAuth.AuthorizationServerURLs)
})

t.Run("keeps the single authorization server url working", func(t *testing.T) {
f := createTempFileFromFixture(t, `
version: "1"

graph:
token: "token"

mcp:
enabled: true
server:
base_url: "https://router.example.com"
oauth:
enabled: true
jwks:
- url: "https://auth-a.example.com/.well-known/jwks.json"
authorization_server_url: "https://auth-a.example.com"
`)
cfg, err := LoadConfig([]string{f})
require.NoError(t, err)

require.Equal(t, "https://auth-a.example.com", cfg.Config.MCP.OAuth.AuthorizationServerURL)
require.Equal(t, []string{"https://auth-a.example.com"}, cfg.Config.MCP.OAuth.AuthorizationServers())
})
}

func TestMCPOAuthAuthorizationServersAccessor(t *testing.T) {
t.Parallel()

testCases := []struct {
name string
config MCPOAuthConfiguration
expected []string
}{
{
name: "empty config returns nil",
config: MCPOAuthConfiguration{},
expected: nil,
},
{
name: "single url only",
config: MCPOAuthConfiguration{
AuthorizationServerURL: "https://auth-a.example.com",
},
expected: []string{"https://auth-a.example.com"},
},
{
name: "multiple urls only",
config: MCPOAuthConfiguration{
AuthorizationServerURLs: []string{"https://auth-a.example.com", "https://auth-b.example.com"},
},
expected: []string{"https://auth-a.example.com", "https://auth-b.example.com"},
},
{
name: "single url comes first and duplicates are removed",
config: MCPOAuthConfiguration{
AuthorizationServerURL: "https://auth-a.example.com",
AuthorizationServerURLs: []string{"https://auth-b.example.com", "https://auth-a.example.com"},
},
expected: []string{"https://auth-a.example.com", "https://auth-b.example.com"},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

require.Equal(t, tc.expected, tc.config.AuthorizationServers())
})
}
}
1 change: 1 addition & 0 deletions router/pkg/config/testdata/config_defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@
"Enabled": false,
"JWKS": null,
"AuthorizationServerURL": "",
"AuthorizationServerURLs": null,
"Scopes": {
"Initialize": null,
"ToolsList": null,
Expand Down
1 change: 1 addition & 0 deletions router/pkg/config/testdata/config_full.json
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
"Enabled": false,
"JWKS": null,
"AuthorizationServerURL": "",
"AuthorizationServerURLs": null,
"Scopes": {
"Initialize": null,
"ToolsList": null,
Expand Down
Loading
Loading