Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
32 changes: 28 additions & 4 deletions docs-website/router/mcp/oauth/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ icon: 'sliders-up'
| Option | Description | Default |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `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. Advertised via the [RFC 9728 metadata endpoint](#rfc-9728-protected-resource-metadata) so clients can discover authorization endpoints. See [Multiple Authorization Servers](#multiple-authorization-servers). | `[]` |
| `oauth.authorization_server_url` | **Deprecated.** Use `authorization_server_urls` instead. Base URL of a single OAuth 2.0 authorization server. The router merges this value into `authorization_server_urls`. | - |
| `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.

The option `authorization_server_url` is deprecated. Use `authorization_server_urls` for new configurations. Existing configurations with `authorization_server_url` continue to work. When both options are set, the router merges them into one list, with the single URL first, and removes duplicates.

## 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_AUTHORIZATION_SERVER_URL` | `mcp.oauth.authorization_server_url` (deprecated) |
| `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 Expand Up @@ -170,7 +193,8 @@ mcp:
expose_schema: true # Enables get_schema tool
oauth:
enabled: true
authorization_server_url: 'https://auth.example.com'
authorization_server_urls:
- 'https://auth.example.com'
scope_challenge_include_token_scopes: false # Set to true for MCP clients with scope accumulation bugs
scopes:
initialize:
Expand Down
9 changes: 6 additions & 3 deletions docs-website/router/mcp/oauth/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ mcp:
base_url: 'https://mcp.example.com' # Required when OAuth is enabled
oauth:
enabled: true
authorization_server_url: 'https://auth.example.com'
authorization_server_urls:
- 'https://auth.example.com'
jwks:
- url: 'https://auth.example.com/.well-known/jwks.json'
refresh_interval: '1m'
Expand All @@ -48,7 +49,8 @@ Define which scopes are required at each level:
```yaml
oauth:
enabled: true
authorization_server_url: 'https://auth.example.com'
authorization_server_urls:
- 'https://auth.example.com'
scopes:
initialize:
- 'mcp:connect' # Required for all MCP requests
Expand Down Expand Up @@ -116,7 +118,8 @@ For local development, you can use a symmetric secret instead of a remote JWKS e
```yaml
oauth:
enabled: true
authorization_server_url: 'https://auth.example.com'
authorization_server_urls:
- 'https://auth.example.com'
jwks:
- secret: 'your-shared-secret'
symmetric_algorithm: 'HS256'
Expand Down
94 changes: 94 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,95 @@ 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) {
// The subtests run sequentially on purpose. Concurrent MCP sessions keep
// the MCP HTTP server busy, and its graceful shutdown then exceeds the
// 5 second budget. See the flake analysis on PR 3148.
t.Run("metadata endpoint advertises all authorization servers", func(t *testing.T) {
metadataURL := strings.TrimSuffix(xEnv.GetMCPServerAddr(), "/mcp") + "/.well-known/oauth-protected-resource/mcp"

req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, metadataURL, nil)
require.NoError(t, err)

resp, err := http.DefaultClient.Do(req)
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(t.Context()), "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(t.Context()), "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(t.Context())
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
30 changes: 27 additions & 3 deletions router/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1361,9 +1361,14 @@ type MCPConfiguration struct {
}

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"`
Enabled bool `yaml:"enabled" envDefault:"false" env:"ENABLED"`
JWKS []JWKSConfiguration `yaml:"jwks"`
// Deprecated: AuthorizationServerURL is deprecated, use AuthorizationServerURLs instead.
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 +1383,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
12 changes: 11 additions & 1 deletion router/pkg/config/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2774,7 +2774,17 @@
"authorization_server_url": {
"type": "string",
"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"
"format": "http-url",
"deprecated": true,
"deprecationMessage": "The authorization_server_url is deprecated. Please use the authorization_server_urls configuration instead."
},
"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",
Expand Down
Loading
Loading