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"
|
||||
"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"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
var _ OAuthHandler = (*AuthorizationCodeHandler)(nil)
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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,
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
// Compile-time check that ClientCredentialsHandler implements auth.OAuthHandler.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import (
|
|||
"strings"
|
||||
"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/internal/oauthtest"
|
||||
"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) {
|
||||
tests := []struct {
|
||||
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
|
||||
// authorization server metadata.
|
||||
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.
|
||||
|
|
@ -98,6 +101,7 @@ type FakeAuthorizationServer struct {
|
|||
|
||||
type codeInfo struct {
|
||||
CodeChallenge string
|
||||
Scope string
|
||||
}
|
||||
|
||||
// NewFakeAuthorizationServer creates a new FakeAuthorizationServer.
|
||||
|
|
@ -259,6 +263,7 @@ func (s *FakeAuthorizationServer) handleAuthorize(w http.ResponseWriter, r *http
|
|||
code := rand.Text()
|
||||
s.codes[code] = codeInfo{
|
||||
CodeChallenge: codeChallenge,
|
||||
Scope: r.URL.Query().Get("scope"),
|
||||
}
|
||||
|
||||
state := r.URL.Query().Get("state")
|
||||
|
|
@ -317,12 +322,18 @@ func (s *FakeAuthorizationServer) handleAuthorizationCodeGrant(w http.ResponseWr
|
|||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
resp := map[string]any{
|
||||
"access_token": "test_access_token",
|
||||
"token_type": "Bearer",
|
||||
"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) {
|
||||
|
|
@ -355,13 +366,18 @@ func (s *FakeAuthorizationServer) handleClientCredentialsGrant(w http.ResponseWr
|
|||
http.Error(w, "client_credentials grant not supported", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Client was already authenticated in handleToken.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
resp := map[string]any{
|
||||
"access_token": "test_access_token",
|
||||
"token_type": "Bearer",
|
||||
"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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue