mcp: Add client-side scope accumulation (#944)
## Description This PR implements https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350 Fixes #943 --------- Co-authored-by: Maciej Kisiel <mkisiel@google.com>
This commit is contained in:
parent
1253d17add
commit
bf69179066
7 changed files with 413 additions and 23 deletions
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
@ -128,6 +129,9 @@ type AuthorizationCodeHandler struct {
|
||||||
|
|
||||||
// tokenSource is the token source to use for authorization.
|
// tokenSource is the token source to use for authorization.
|
||||||
tokenSource oauth2.TokenSource
|
tokenSource oauth2.TokenSource
|
||||||
|
|
||||||
|
// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
|
||||||
|
grantedScopes map[string][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ OAuthHandler = (*AuthorizationCodeHandler)(nil)
|
var _ OAuthHandler = (*AuthorizationCodeHandler)(nil)
|
||||||
|
|
@ -187,7 +191,10 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
|
||||||
if config.Client == nil {
|
if config.Client == nil {
|
||||||
config.Client = http.DefaultClient
|
config.Client = http.DefaultClient
|
||||||
}
|
}
|
||||||
return &AuthorizationCodeHandler{config: config}, nil
|
return &AuthorizationCodeHandler{
|
||||||
|
config: config,
|
||||||
|
grantedScopes: make(map[string][]string),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isNonRootHTTPSURL(u string) bool {
|
func isNonRootHTTPSURL(u string) bool {
|
||||||
|
|
@ -275,19 +282,24 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
scps := scopesFromChallenges(wwwChallenges)
|
requestedScopes := scopesFromChallenges(wwwChallenges)
|
||||||
if len(scps) == 0 && len(prm.ScopesSupported) > 0 {
|
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
|
||||||
scps = prm.ScopesSupported
|
requestedScopes = prm.ScopesSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// SEP-2207: when the client desires refresh tokens and the Authorization
|
// SEP-2207: when the client desires refresh tokens and the Authorization
|
||||||
// Server advertises offline_access support, add it to the requested scopes.
|
// Server advertises offline_access support, add it to the requested scopes.
|
||||||
if h.config.RequestRefreshToken &&
|
if h.config.RequestRefreshToken &&
|
||||||
slices.Contains(asm.ScopesSupported, "offline_access") &&
|
slices.Contains(asm.ScopesSupported, "offline_access") &&
|
||||||
!slices.Contains(scps, "offline_access") {
|
!slices.Contains(requestedScopes, "offline_access") {
|
||||||
scps = append(scps, "offline_access")
|
requestedScopes = append(requestedScopes, "offline_access")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accumulate scopes: union previously granted scopes with the newly
|
||||||
|
// challenged scopes so that step-up authorization does not lose
|
||||||
|
// permissions granted in earlier rounds (SEP-2350).
|
||||||
|
requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes)
|
||||||
|
|
||||||
cfg := &oauth2.Config{
|
cfg := &oauth2.Config{
|
||||||
ClientID: resolvedClientConfig.clientID,
|
ClientID: resolvedClientConfig.clientID,
|
||||||
ClientSecret: resolvedClientConfig.clientSecret,
|
ClientSecret: resolvedClientConfig.clientSecret,
|
||||||
|
|
@ -298,7 +310,7 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
||||||
AuthStyle: resolvedClientConfig.authStyle,
|
AuthStyle: resolvedClientConfig.authStyle,
|
||||||
},
|
},
|
||||||
RedirectURL: h.config.RedirectURL,
|
RedirectURL: h.config.RedirectURL,
|
||||||
Scopes: scps,
|
Scopes: requestedScopes,
|
||||||
}
|
}
|
||||||
|
|
||||||
authRes, err := h.getAuthorizationCode(ctx, cfg, prm.Resource)
|
authRes, err := h.getAuthorizationCode(ctx, cfg, prm.Resource)
|
||||||
|
|
@ -307,7 +319,12 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
|
err = h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.updateGrantedScopes(asm.Issuer, requestedScopes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resourceMetadataURLFromChallenges returns a resource metadata URL from the given "WWW-Authenticate" header challenges,
|
// resourceMetadataURLFromChallenges returns a resource metadata URL from the given "WWW-Authenticate" header challenges,
|
||||||
|
|
@ -558,3 +575,20 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context
|
||||||
h.tokenSource = cfg.TokenSource(clientCtx, token)
|
h.tokenSource = cfg.TokenSource(clientCtx, token)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateGrantedScopes updates the granted scopes based on the token source and requested scopes.
|
||||||
|
func (h *AuthorizationCodeHandler) updateGrantedScopes(issuer string, requestedScopes []string) error {
|
||||||
|
if h.tokenSource == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tok, err := h.tokenSource.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil {
|
||||||
|
h.grantedScopes[issuer] = requestedScopes
|
||||||
|
} else {
|
||||||
|
h.grantedScopes[issuer] = tokenScopes
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
|
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
|
||||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
@ -113,6 +114,131 @@ func TestAuthorize(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthorize_ScopeAccumulation(t *testing.T) {
|
||||||
|
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
||||||
|
RegistrationConfig: &oauthtest.RegistrationConfig{
|
||||||
|
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||||
|
"test_client_id": {
|
||||||
|
Secret: "test_client_secret",
|
||||||
|
RedirectURIs: []string{"http://localhost:12345/callback"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TokenScopeFunc: func(requestedScope string) string {
|
||||||
|
// Simulate a server that never grants "write".
|
||||||
|
var granted []string
|
||||||
|
for _, s := range strings.Fields(requestedScope) {
|
||||||
|
if s != "write" {
|
||||||
|
granted = append(granted, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(granted, " ")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
authServer.Start(t)
|
||||||
|
|
||||||
|
resourceMux := http.NewServeMux()
|
||||||
|
resourceServer := httptest.NewServer(resourceMux)
|
||||||
|
t.Cleanup(resourceServer.Close)
|
||||||
|
resourceURL := resourceServer.URL + "/resource"
|
||||||
|
|
||||||
|
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
||||||
|
Resource: resourceURL,
|
||||||
|
AuthorizationServers: []string{authServer.URL()},
|
||||||
|
}))
|
||||||
|
|
||||||
|
var capturedAuthURLs []string
|
||||||
|
noRedirectClient := &http.Client{
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
||||||
|
RedirectURL: "http://localhost:12345/callback",
|
||||||
|
PreregisteredClient: &oauthex.ClientCredentials{
|
||||||
|
ClientID: "test_client_id",
|
||||||
|
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||||
|
ClientSecret: "test_client_secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||||
|
capturedAuthURLs = append(capturedAuthURLs, args.URL)
|
||||||
|
resp, err := noRedirectClient.Get(args.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
loc, err := resp.Location()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &AuthorizationResult{
|
||||||
|
Code: loc.Query().Get("code"),
|
||||||
|
State: loc.Query().Get("state"),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First authorization: 401 with scope="read write".
|
||||||
|
// The token response will only grant "read" (TokenScopeFunc strips "write").
|
||||||
|
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusUnauthorized,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: http.NoBody,
|
||||||
|
}
|
||||||
|
resp.Header.Set("WWW-Authenticate",
|
||||||
|
fmt.Sprintf(`Bearer scope="read write", resource_metadata="%s/.well-known/oauth-protected-resource/resource"`, resourceServer.URL))
|
||||||
|
if err := handler.Authorize(context.Background(), req, resp); err != nil {
|
||||||
|
t.Fatalf("First Authorize failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify first auth URL requested "read" and "write".
|
||||||
|
firstURL, err := url.Parse(capturedAuthURLs[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse first auth URL: %v", err)
|
||||||
|
}
|
||||||
|
firstScopes := strings.Fields(firstURL.Query().Get("scope"))
|
||||||
|
if diff := cmp.Diff([]string{"read", "write"}, firstScopes, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
||||||
|
t.Errorf("First auth scopes mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify only "read" was granted (the token omitted "write").
|
||||||
|
issuer := authServer.URL()
|
||||||
|
if diff := cmp.Diff([]string{"read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
||||||
|
t.Errorf("After first Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second authorization: 403 insufficient_scope with scope="admin".
|
||||||
|
// Accumulated scopes should be "read" (previously granted) + "admin" (new).
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
||||||
|
resp2 := &http.Response{
|
||||||
|
StatusCode: http.StatusForbidden,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: http.NoBody,
|
||||||
|
}
|
||||||
|
resp2.Header.Set("WWW-Authenticate",
|
||||||
|
fmt.Sprintf(`Bearer error="insufficient_scope", scope="admin", resource_metadata="%s/.well-known/oauth-protected-resource/resource"`, resourceServer.URL))
|
||||||
|
if err := handler.Authorize(context.Background(), req2, resp2); err != nil {
|
||||||
|
t.Fatalf("Second Authorize failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify second auth URL accumulated "read" (granted) + "admin" (challenged),
|
||||||
|
// but NOT "write" (requested but never granted).
|
||||||
|
secondURL, err := url.Parse(capturedAuthURLs[1])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse second auth URL: %v", err)
|
||||||
|
}
|
||||||
|
secondScopes := strings.Fields(secondURL.Query().Get("scope"))
|
||||||
|
if diff := cmp.Diff([]string{"admin", "read"}, secondScopes, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
||||||
|
t.Errorf("Second auth scopes mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthorize_ForbiddenUnhandledError(t *testing.T) {
|
func TestAuthorize_ForbiddenUnhandledError(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/resource", nil)
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/resource", nil)
|
||||||
resp := &http.Response{
|
resp := &http.Response{
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
"github.com/modelcontextprotocol/go-sdk/auth"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
|
||||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
"golang.org/x/oauth2/clientcredentials"
|
"golang.org/x/oauth2/clientcredentials"
|
||||||
|
|
@ -47,6 +48,9 @@ type ClientCredentialsHandlerConfig struct {
|
||||||
type ClientCredentialsHandler struct {
|
type ClientCredentialsHandler struct {
|
||||||
config *ClientCredentialsHandlerConfig
|
config *ClientCredentialsHandlerConfig
|
||||||
tokenSource oauth2.TokenSource
|
tokenSource oauth2.TokenSource
|
||||||
|
|
||||||
|
// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
|
||||||
|
grantedScopes map[string][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile-time check that ClientCredentialsHandler implements auth.OAuthHandler.
|
// Compile-time check that ClientCredentialsHandler implements auth.OAuthHandler.
|
||||||
|
|
@ -67,7 +71,10 @@ func NewClientCredentialsHandler(config *ClientCredentialsHandlerConfig) (*Clien
|
||||||
if config.Credentials.ClientSecretAuth == nil {
|
if config.Credentials.ClientSecretAuth == nil {
|
||||||
return nil, fmt.Errorf("clientSecretAuth is required for client credentials grant")
|
return nil, fmt.Errorf("clientSecretAuth is required for client credentials grant")
|
||||||
}
|
}
|
||||||
return &ClientCredentialsHandler{config: config}, nil
|
return &ClientCredentialsHandler{
|
||||||
|
config: config,
|
||||||
|
grantedScopes: make(map[string][]string),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenSource returns the token source for outgoing requests.
|
// TokenSource returns the token source for outgoing requests.
|
||||||
|
|
@ -121,30 +128,47 @@ func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Requ
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine scopes: use PRM's scopes_supported if available.
|
// Determine requestedScopes: use PRM's scopes_supported if available.
|
||||||
scopes := scopesFromChallenges(wwwChallenges)
|
requestedScopes := scopesFromChallenges(wwwChallenges)
|
||||||
if len(scopes) == 0 && len(prm.ScopesSupported) > 0 {
|
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
|
||||||
scopes = prm.ScopesSupported
|
requestedScopes = prm.ScopesSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accumulate scopes: union previously granted scopes with the newly
|
||||||
|
// challenged scopes so that step-up authorization does not lose
|
||||||
|
// permissions granted in earlier rounds (SEP-2350).
|
||||||
|
requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes)
|
||||||
|
|
||||||
// Step 3: Exchange client credentials for an access token.
|
// Step 3: Exchange client credentials for an access token.
|
||||||
creds := h.config.Credentials
|
creds := h.config.Credentials
|
||||||
cfg := &clientcredentials.Config{
|
cfg := &clientcredentials.Config{
|
||||||
ClientID: creds.ClientID,
|
ClientID: creds.ClientID,
|
||||||
ClientSecret: creds.ClientSecretAuth.ClientSecret,
|
ClientSecret: creds.ClientSecretAuth.ClientSecret,
|
||||||
TokenURL: asm.TokenEndpoint,
|
TokenURL: asm.TokenEndpoint,
|
||||||
Scopes: scopes,
|
Scopes: requestedScopes,
|
||||||
AuthStyle: selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported),
|
AuthStyle: selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported),
|
||||||
}
|
}
|
||||||
|
|
||||||
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||||
h.tokenSource = cfg.TokenSource(ctxWithClient)
|
h.tokenSource = cfg.TokenSource(ctxWithClient)
|
||||||
|
|
||||||
// Eagerly fetch a token to surface errors immediately.
|
return h.updateGrantedScopes(asm.Issuer, requestedScopes)
|
||||||
if _, err := h.tokenSource.Token(); err != nil {
|
}
|
||||||
|
|
||||||
|
func (h *ClientCredentialsHandler) updateGrantedScopes(issuer string, requestedScopes []string) error {
|
||||||
|
if h.tokenSource == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tok, err := h.tokenSource.Token()
|
||||||
|
if err != nil {
|
||||||
h.tokenSource = nil
|
h.tokenSource = nil
|
||||||
return fmt.Errorf("client credentials token request failed: %w", err)
|
return fmt.Errorf("client credentials token request failed: %w", err)
|
||||||
}
|
}
|
||||||
|
if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil {
|
||||||
|
h.grantedScopes[issuer] = requestedScopes
|
||||||
|
} else {
|
||||||
|
h.grantedScopes[issuer] = tokenScopes
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
"github.com/modelcontextprotocol/go-sdk/auth"
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
|
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
|
||||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||||
|
|
@ -255,6 +257,86 @@ func TestClientCredentialsHandler_Authorize(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientCredentialsHandler_ScopeAccumulation(t *testing.T) {
|
||||||
|
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
||||||
|
MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{
|
||||||
|
ServeOAuthInsertedEndpoint: true,
|
||||||
|
},
|
||||||
|
RegistrationConfig: &oauthtest.RegistrationConfig{
|
||||||
|
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||||
|
"test-client": {Secret: "test-secret"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ClientCredentialsConfig: &oauthtest.ClientCredentialsConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
TokenScopeFunc: func(requestedScope string) string {
|
||||||
|
// Simulate a server that never grants "write".
|
||||||
|
var granted []string
|
||||||
|
for _, s := range strings.Fields(requestedScope) {
|
||||||
|
if s != "write" {
|
||||||
|
granted = append(granted, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(granted, " ")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
authServer.Start(t)
|
||||||
|
|
||||||
|
resourceMux := http.NewServeMux()
|
||||||
|
resourceServer := httptest.NewServer(resourceMux)
|
||||||
|
t.Cleanup(resourceServer.Close)
|
||||||
|
resourceURL := resourceServer.URL + "/resource"
|
||||||
|
|
||||||
|
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
||||||
|
Resource: resourceURL,
|
||||||
|
AuthorizationServers: []string{authServer.URL()},
|
||||||
|
}))
|
||||||
|
|
||||||
|
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First authorization: 401 with scope="read write".
|
||||||
|
// The token response will only grant "read" (TokenScopeFunc strips "write").
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusUnauthorized,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: http.NoBody,
|
||||||
|
}
|
||||||
|
resp.Header.Set("WWW-Authenticate", `Bearer scope="read write"`)
|
||||||
|
req := httptest.NewRequest("GET", resourceURL, nil)
|
||||||
|
if err := handler.Authorize(t.Context(), req, resp); err != nil {
|
||||||
|
t.Fatalf("First Authorize failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify only "read" was granted (the token omitted "write").
|
||||||
|
issuer := authServer.URL()
|
||||||
|
if diff := cmp.Diff([]string{"read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
||||||
|
t.Errorf("After first Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second authorization: 401 with scope="admin" (simulating step-up).
|
||||||
|
// Accumulated scopes should be "read" (previously granted) + "admin" (new),
|
||||||
|
// but NOT "write" (requested but never granted).
|
||||||
|
resp2 := &http.Response{
|
||||||
|
StatusCode: http.StatusUnauthorized,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: http.NoBody,
|
||||||
|
}
|
||||||
|
resp2.Header.Set("WWW-Authenticate", `Bearer scope="admin"`)
|
||||||
|
req2 := httptest.NewRequest("GET", resourceURL, nil)
|
||||||
|
if err := handler.Authorize(t.Context(), req2, resp2); err != nil {
|
||||||
|
t.Fatalf("Second Authorize failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify accumulated scopes: "read" (granted) + "admin" (new), no "write".
|
||||||
|
if diff := cmp.Diff([]string{"admin", "read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
||||||
|
t.Errorf("After second Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSelectTokenAuthMethod(t *testing.T) {
|
func TestSelectTokenAuthMethod(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
36
internal/authutil/scopes.go
Normal file
36
internal/authutil/scopes.go
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by the license
|
||||||
|
// that can be found in the LICENSE file.
|
||||||
|
package authutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnionScopes returns the union of the existing and challenged scope sets.
|
||||||
|
// It is used during step-up authorization to accumulate scopes across
|
||||||
|
// authorization rounds (SEP-2350).
|
||||||
|
func UnionScopes(existing, challenged []string) []string {
|
||||||
|
combined := make(map[string]struct{})
|
||||||
|
for _, s := range existing {
|
||||||
|
combined[s] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, s := range challenged {
|
||||||
|
combined[s] = struct{}{}
|
||||||
|
}
|
||||||
|
return slices.Collect(maps.Keys(combined))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScopesFromToken extracts the granted scopes from an OAuth2 token response.
|
||||||
|
// Per RFC 6749 §5.1, the scope parameter is optional; returns nil if absent.
|
||||||
|
func ScopesFromToken(token *oauth2.Token) []string {
|
||||||
|
scope, ok := token.Extra("scope").(string)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return strings.Fields(scope)
|
||||||
|
}
|
||||||
72
internal/authutil/scopes_test.go
Normal file
72
internal/authutil/scopes_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by the license
|
||||||
|
// that can be found in the LICENSE file.
|
||||||
|
package authutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnionScopes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
existing []string
|
||||||
|
challenged []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "both empty",
|
||||||
|
existing: nil,
|
||||||
|
challenged: nil,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing only",
|
||||||
|
existing: []string{"read"},
|
||||||
|
challenged: nil,
|
||||||
|
want: []string{"read"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "challenged only",
|
||||||
|
existing: nil,
|
||||||
|
challenged: []string{"write"},
|
||||||
|
want: []string{"write"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disjoint scopes",
|
||||||
|
existing: []string{"read"},
|
||||||
|
challenged: []string{"write"},
|
||||||
|
want: []string{"read", "write"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "overlapping scopes",
|
||||||
|
existing: []string{"read", "write"},
|
||||||
|
challenged: []string{"write", "admin"},
|
||||||
|
want: []string{"read", "write", "admin"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "identical scopes",
|
||||||
|
existing: []string{"read", "write"},
|
||||||
|
challenged: []string{"read", "write"},
|
||||||
|
want: []string{"read", "write"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed scopes",
|
||||||
|
existing: []string{"b", "a"},
|
||||||
|
challenged: []string{"c", "a"},
|
||||||
|
want: []string{"a", "b", "c"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := UnionScopes(tt.existing, tt.challenged)
|
||||||
|
if diff := cmp.Diff(tt.want, got, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
||||||
|
t.Errorf("UnionScopes() mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -85,6 +85,9 @@ type Config struct {
|
||||||
// ScopesSupported is an optional list of scopes to advertise in the
|
// ScopesSupported is an optional list of scopes to advertise in the
|
||||||
// authorization server metadata.
|
// authorization server metadata.
|
||||||
ScopesSupported []string
|
ScopesSupported []string
|
||||||
|
// TokenScopeFunc, if set, is called with the scope from the authorization
|
||||||
|
// request and returns the scope string to include in the token response.
|
||||||
|
TokenScopeFunc func(requestedScope string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
// FakeAuthorizationServer is a fake OAuth 2.0 Authorization Server for testing.
|
// FakeAuthorizationServer is a fake OAuth 2.0 Authorization Server for testing.
|
||||||
|
|
@ -98,6 +101,7 @@ type FakeAuthorizationServer struct {
|
||||||
|
|
||||||
type codeInfo struct {
|
type codeInfo struct {
|
||||||
CodeChallenge string
|
CodeChallenge string
|
||||||
|
Scope string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFakeAuthorizationServer creates a new FakeAuthorizationServer.
|
// NewFakeAuthorizationServer creates a new FakeAuthorizationServer.
|
||||||
|
|
@ -259,6 +263,7 @@ func (s *FakeAuthorizationServer) handleAuthorize(w http.ResponseWriter, r *http
|
||||||
code := rand.Text()
|
code := rand.Text()
|
||||||
s.codes[code] = codeInfo{
|
s.codes[code] = codeInfo{
|
||||||
CodeChallenge: codeChallenge,
|
CodeChallenge: codeChallenge,
|
||||||
|
Scope: r.URL.Query().Get("scope"),
|
||||||
}
|
}
|
||||||
|
|
||||||
state := r.URL.Query().Get("state")
|
state := r.URL.Query().Get("state")
|
||||||
|
|
@ -317,12 +322,18 @@ func (s *FakeAuthorizationServer) handleAuthorizationCodeGrant(w http.ResponseWr
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
resp := map[string]any{
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"access_token": "test_access_token",
|
"access_token": "test_access_token",
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"expires_in": 3600,
|
"expires_in": 3600,
|
||||||
})
|
}
|
||||||
|
if s.config.TokenScopeFunc != nil {
|
||||||
|
if scope := s.config.TokenScopeFunc(codeInfo.Scope); scope != "" {
|
||||||
|
resp["scope"] = scope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FakeAuthorizationServer) handleJWTBearerGrant(w http.ResponseWriter, r *http.Request) {
|
func (s *FakeAuthorizationServer) handleJWTBearerGrant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -355,13 +366,18 @@ func (s *FakeAuthorizationServer) handleClientCredentialsGrant(w http.ResponseWr
|
||||||
http.Error(w, "client_credentials grant not supported", http.StatusBadRequest)
|
http.Error(w, "client_credentials grant not supported", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Client was already authenticated in handleToken.
|
resp := map[string]any{
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"access_token": "test_access_token",
|
"access_token": "test_access_token",
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"expires_in": 3600,
|
"expires_in": 3600,
|
||||||
})
|
}
|
||||||
|
if s.config.TokenScopeFunc != nil {
|
||||||
|
if scope := s.config.TokenScopeFunc(r.Form.Get("scope")); scope != "" {
|
||||||
|
resp["scope"] = scope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FakeAuthorizationServer) authenticateClient(r *http.Request) error {
|
func (s *FakeAuthorizationServer) authenticateClient(r *http.Request) error {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue