Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
969af25
feat: implement scope accumulation in AuthorizationCode and ClientCre…
guglielmo-san May 4, 2026
0115c95
refactor: replace map-based deduplication with slices.Contains in aut…
guglielmo-san May 6, 2026
eeedb71
refactor: replace manual map-based deduplication with slices.Contains…
guglielmo-san May 6, 2026
49928b6
Merge branch 'main' into guglielmoc/SEP-2350_clientside_scope_accumul…
guglielmo-san May 6, 2026
1002d49
feat: ensure offline_access scope is added before unioning requested …
guglielmo-san May 6, 2026
370e55f
test: update authorization code tests to terminate early after captur…
guglielmo-san May 6, 2026
1675344
refactor: track granted scopes per issuer and remove mutexes in OAuth…
guglielmo-san May 7, 2026
26e906a
refactor: consolidate scope union and update logic in authorization h…
guglielmo-san May 8, 2026
e54ffd6
Merge branch 'main' into guglielmoc/SEP-2350_clientside_scope_accumul…
guglielmo-san May 8, 2026
f2d1d4b
fix: improve scope accumulation logic to exclude ungranted scopes and…
guglielmo-san May 8, 2026
d484636
refactor: consolidate UnionScopes and ScopesFromToken into shared aut…
guglielmo-san May 8, 2026
3ea872c
docs: clarify comment to reference previously granted instead of requ…
guglielmo-san May 8, 2026
6044f47
refactor: rename scopes variable to requestedScopes for clarity in cl…
guglielmo-san May 8, 2026
5259065
refactor: initialize grantedScopes map in AuthorizationCodeHandler an…
guglielmo-san May 8, 2026
1147187
fix: allow empty strings as valid scope values in ScopesFromToken
guglielmo-san May 8, 2026
8b3ea38
Update auth/authorization_code.go
guglielmo-san May 8, 2026
c272fd1
refactor: move scope utility functions to internal/authutil package
guglielmo-san May 8, 2026
43ed310
Merge branch 'main' into guglielmoc/SEP-2350_clientside_scope_accumul…
guglielmo-san May 8, 2026
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
50 changes: 42 additions & 8 deletions auth/authorization_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"slices"
"strings"

"github.com/modelcontextprotocol/go-sdk/internal/authutil"
"github.com/modelcontextprotocol/go-sdk/internal/util"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
Expand Down Expand Up @@ -128,6 +129,9 @@ type AuthorizationCodeHandler struct {

// tokenSource is the token source to use for authorization.
tokenSource oauth2.TokenSource

// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
grantedScopes map[string][]string
Comment thread
guglielmo-san marked this conversation as resolved.
}

var _ OAuthHandler = (*AuthorizationCodeHandler)(nil)
Expand Down Expand Up @@ -187,7 +191,10 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
if config.Client == nil {
config.Client = http.DefaultClient
}
return &AuthorizationCodeHandler{config: config}, nil
return &AuthorizationCodeHandler{
config: config,
grantedScopes: make(map[string][]string),
}, nil
}

func isNonRootHTTPSURL(u string) bool {
Expand Down Expand Up @@ -275,19 +282,24 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
return err
}

scps := scopesFromChallenges(wwwChallenges)
if len(scps) == 0 && len(prm.ScopesSupported) > 0 {
scps = prm.ScopesSupported
requestedScopes := scopesFromChallenges(wwwChallenges)
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
requestedScopes = prm.ScopesSupported
}

// SEP-2207: when the client desires refresh tokens and the Authorization
// Server advertises offline_access support, add it to the requested scopes.
if h.config.RequestRefreshToken &&
slices.Contains(asm.ScopesSupported, "offline_access") &&
!slices.Contains(scps, "offline_access") {
scps = append(scps, "offline_access")
!slices.Contains(requestedScopes, "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{
ClientID: resolvedClientConfig.clientID,
ClientSecret: resolvedClientConfig.clientSecret,
Expand All @@ -298,7 +310,7 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
AuthStyle: resolvedClientConfig.authStyle,
},
RedirectURL: h.config.RedirectURL,
Scopes: scps,
Scopes: requestedScopes,
}

authRes, err := h.getAuthorizationCode(ctx, cfg, prm.Resource)
Expand All @@ -307,7 +319,12 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
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,
Expand Down Expand Up @@ -558,3 +575,20 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context
h.tokenSource = cfg.TokenSource(clientCtx, token)
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
}
126 changes: 126 additions & 0 deletions auth/authorization_code_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"testing"

"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/oauthex"
"golang.org/x/oauth2"
Expand Down Expand Up @@ -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) {
req := httptest.NewRequest(http.MethodGet, "http://example.com/resource", nil)
resp := &http.Response{
Expand Down
40 changes: 32 additions & 8 deletions auth/extauth/client_credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"strings"

"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
Expand Down Expand Up @@ -47,6 +48,9 @@ type ClientCredentialsHandlerConfig struct {
type ClientCredentialsHandler struct {
config *ClientCredentialsHandlerConfig
tokenSource oauth2.TokenSource

// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
grantedScopes map[string][]string
Comment thread
guglielmo-san marked this conversation as resolved.
}

// Compile-time check that ClientCredentialsHandler implements auth.OAuthHandler.
Expand All @@ -67,7 +71,10 @@ func NewClientCredentialsHandler(config *ClientCredentialsHandlerConfig) (*Clien
if config.Credentials.ClientSecretAuth == nil {
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.
Expand Down Expand Up @@ -121,30 +128,47 @@ func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Requ
}
}

// Determine scopes: use PRM's scopes_supported if available.
scopes := scopesFromChallenges(wwwChallenges)
if len(scopes) == 0 && len(prm.ScopesSupported) > 0 {
scopes = prm.ScopesSupported
// Determine requestedScopes: use PRM's scopes_supported if available.
requestedScopes := scopesFromChallenges(wwwChallenges)
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
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.
creds := h.config.Credentials
cfg := &clientcredentials.Config{
ClientID: creds.ClientID,
ClientSecret: creds.ClientSecretAuth.ClientSecret,
TokenURL: asm.TokenEndpoint,
Scopes: scopes,
Scopes: requestedScopes,
AuthStyle: selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported),
}

ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
h.tokenSource = cfg.TokenSource(ctxWithClient)

// Eagerly fetch a token to surface errors immediately.
if _, err := h.tokenSource.Token(); err != nil {
return h.updateGrantedScopes(asm.Issuer, requestedScopes)
}

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
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
}

Expand Down
Loading
Loading