@@ -16,6 +16,7 @@ import (
1616 "testing"
1717
1818 "github.com/google/go-cmp/cmp"
19+ "github.com/google/go-cmp/cmp/cmpopts"
1920 "github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
2021 "github.com/modelcontextprotocol/go-sdk/oauthex"
2122 "golang.org/x/oauth2"
@@ -113,6 +114,131 @@ func TestAuthorize(t *testing.T) {
113114 }
114115}
115116
117+ func TestAuthorize_ScopeAccumulation (t * testing.T ) {
118+ authServer := oauthtest .NewFakeAuthorizationServer (oauthtest.Config {
119+ RegistrationConfig : & oauthtest.RegistrationConfig {
120+ PreregisteredClients : map [string ]oauthtest.ClientInfo {
121+ "test_client_id" : {
122+ Secret : "test_client_secret" ,
123+ RedirectURIs : []string {"http://localhost:12345/callback" },
124+ },
125+ },
126+ },
127+ TokenScopeFunc : func (requestedScope string ) string {
128+ // Simulate a server that never grants "write".
129+ var granted []string
130+ for _ , s := range strings .Fields (requestedScope ) {
131+ if s != "write" {
132+ granted = append (granted , s )
133+ }
134+ }
135+ return strings .Join (granted , " " )
136+ },
137+ })
138+ authServer .Start (t )
139+
140+ resourceMux := http .NewServeMux ()
141+ resourceServer := httptest .NewServer (resourceMux )
142+ t .Cleanup (resourceServer .Close )
143+ resourceURL := resourceServer .URL + "/resource"
144+
145+ resourceMux .Handle ("/.well-known/oauth-protected-resource/resource" , ProtectedResourceMetadataHandler (& oauthex.ProtectedResourceMetadata {
146+ Resource : resourceURL ,
147+ AuthorizationServers : []string {authServer .URL ()},
148+ }))
149+
150+ var capturedAuthURLs []string
151+ noRedirectClient := & http.Client {
152+ CheckRedirect : func (req * http.Request , via []* http.Request ) error {
153+ return http .ErrUseLastResponse
154+ },
155+ }
156+
157+ handler , err := NewAuthorizationCodeHandler (& AuthorizationCodeHandlerConfig {
158+ RedirectURL : "http://localhost:12345/callback" ,
159+ PreregisteredClient : & oauthex.ClientCredentials {
160+ ClientID : "test_client_id" ,
161+ ClientSecretAuth : & oauthex.ClientSecretAuth {
162+ ClientSecret : "test_client_secret" ,
163+ },
164+ },
165+ AuthorizationCodeFetcher : func (ctx context.Context , args * AuthorizationArgs ) (* AuthorizationResult , error ) {
166+ capturedAuthURLs = append (capturedAuthURLs , args .URL )
167+ resp , err := noRedirectClient .Get (args .URL )
168+ if err != nil {
169+ return nil , err
170+ }
171+ defer resp .Body .Close ()
172+ loc , err := resp .Location ()
173+ if err != nil {
174+ return nil , err
175+ }
176+ return & AuthorizationResult {
177+ Code : loc .Query ().Get ("code" ),
178+ State : loc .Query ().Get ("state" ),
179+ }, nil
180+ },
181+ })
182+ if err != nil {
183+ t .Fatalf ("NewAuthorizationCodeHandler failed: %v" , err )
184+ }
185+
186+ // First authorization: 401 with scope="read write".
187+ // The token response will only grant "read" (TokenScopeFunc strips "write").
188+ req := httptest .NewRequest (http .MethodGet , resourceURL , nil )
189+ resp := & http.Response {
190+ StatusCode : http .StatusUnauthorized ,
191+ Header : make (http.Header ),
192+ Body : http .NoBody ,
193+ }
194+ resp .Header .Set ("WWW-Authenticate" ,
195+ fmt .Sprintf (`Bearer scope="read write", resource_metadata="%s/.well-known/oauth-protected-resource/resource"` , resourceServer .URL ))
196+ if err := handler .Authorize (context .Background (), req , resp ); err != nil {
197+ t .Fatalf ("First Authorize failed: %v" , err )
198+ }
199+
200+ // Verify first auth URL requested "read" and "write".
201+ firstURL , err := url .Parse (capturedAuthURLs [0 ])
202+ if err != nil {
203+ t .Fatalf ("Failed to parse first auth URL: %v" , err )
204+ }
205+ firstScopes := strings .Fields (firstURL .Query ().Get ("scope" ))
206+ if diff := cmp .Diff ([]string {"read" , "write" }, firstScopes , cmpopts .SortSlices (func (a , b string ) bool { return a < b })); diff != "" {
207+ t .Errorf ("First auth scopes mismatch (-want +got):\n %s" , diff )
208+ }
209+
210+ // Verify only "read" was granted (the token omitted "write").
211+ issuer := authServer .URL ()
212+ if diff := cmp .Diff ([]string {"read" }, handler .grantedScopes [issuer ], cmpopts .SortSlices (func (a , b string ) bool { return a < b })); diff != "" {
213+ t .Errorf ("After first Authorize, grantedScopes mismatch (-want +got):\n %s" , diff )
214+ }
215+
216+ // Second authorization: 403 insufficient_scope with scope="admin".
217+ // Accumulated scopes should be "read" (previously granted) + "admin" (new).
218+ req2 := httptest .NewRequest (http .MethodGet , resourceURL , nil )
219+ resp2 := & http.Response {
220+ StatusCode : http .StatusForbidden ,
221+ Header : make (http.Header ),
222+ Body : http .NoBody ,
223+ }
224+ resp2 .Header .Set ("WWW-Authenticate" ,
225+ fmt .Sprintf (`Bearer error="insufficient_scope", scope="admin", resource_metadata="%s/.well-known/oauth-protected-resource/resource"` , resourceServer .URL ))
226+ if err := handler .Authorize (context .Background (), req2 , resp2 ); err != nil {
227+ t .Fatalf ("Second Authorize failed: %v" , err )
228+ }
229+
230+ // Verify second auth URL accumulated "read" (granted) + "admin" (challenged),
231+ // but NOT "write" (requested but never granted).
232+ secondURL , err := url .Parse (capturedAuthURLs [1 ])
233+ if err != nil {
234+ t .Fatalf ("Failed to parse second auth URL: %v" , err )
235+ }
236+ secondScopes := strings .Fields (secondURL .Query ().Get ("scope" ))
237+ if diff := cmp .Diff ([]string {"admin" , "read" }, secondScopes , cmpopts .SortSlices (func (a , b string ) bool { return a < b })); diff != "" {
238+ t .Errorf ("Second auth scopes mismatch (-want +got):\n %s" , diff )
239+ }
240+ }
241+
116242func TestAuthorize_ForbiddenUnhandledError (t * testing.T ) {
117243 req := httptest .NewRequest (http .MethodGet , "http://example.com/resource" , nil )
118244 resp := & http.Response {
0 commit comments