Skip to content

Commit 06621c6

Browse files
toabctlclaude
andcommitted
auth: add Scopes allowlist to AuthorizationCodeHandlerConfig
The authorization-code handler requests every scope advertised in the protected resource's metadata (or named in the WWW-Authenticate challenge), with no way for a client to narrow that set. Some servers advertise a scope a client should not request: Gmail's MCP server lists gmail.metadata, and the Gmail API refuses the search "q" parameter on any token carrying gmail.metadata even when gmail.readonly is also granted. Add an optional Scopes field that, when non-empty, intersects the discovered scopes with the allowlist (order preserved). An empty intersection is ignored so a misconfigured allowlist never leaves the client requesting no scopes; offline_access is applied after filtering and so is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 65ece59 commit 06621c6

2 files changed

Lines changed: 124 additions & 0 deletions

File tree

auth/authorization_code.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,15 @@ type AuthorizationCodeHandlerConfig struct {
9797
// See [AuthorizationCodeFetcher] for details.
9898
AuthorizationCodeFetcher AuthorizationCodeFetcher
9999

100+
// Scopes optionally restricts the requested scopes to this allowlist,
101+
// intersecting it with the scopes discovered from metadata/challenge
102+
// (preserving order). This lets a client drop an advertised scope it does
103+
// not want — e.g. Gmail's gmail.metadata, which the Gmail API refuses to
104+
// combine with the search "q" parameter even alongside gmail.readonly. An
105+
// allowlist matching nothing is ignored so the flow never requests an empty
106+
// set; offline_access is exempt.
107+
Scopes []string
108+
100109
// RequestRefreshToken indicates that the client intends to use refresh
101110
// tokens and is capable of storing them securely.
102111
//
@@ -293,6 +302,12 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
293302
requestedScopes = prm.ScopesSupported
294303
}
295304

305+
// Apply the configured allowlist before offline_access and the step-up
306+
// union below so neither is affected.
307+
if len(h.config.Scopes) > 0 {
308+
requestedScopes = intersectScopes(requestedScopes, h.config.Scopes)
309+
}
310+
296311
// SEP-2207: when the client desires refresh tokens and the Authorization
297312
// Server advertises offline_access support, add it to the requested scopes.
298313
if h.config.RequestRefreshToken &&
@@ -358,6 +373,22 @@ func scopesFromChallenges(cs []oauthex.Challenge) []string {
358373
return nil
359374
}
360375

376+
// intersectScopes returns the members of scopes also in allow, preserving order.
377+
// If nothing matches it returns scopes unchanged, so a bad allowlist cannot
378+
// leave the client requesting no scopes at all.
379+
func intersectScopes(scopes, allow []string) []string {
380+
keep := make([]string, 0, len(scopes))
381+
for _, s := range scopes {
382+
if slices.Contains(allow, s) {
383+
keep = append(keep, s)
384+
}
385+
}
386+
if len(keep) == 0 {
387+
return scopes
388+
}
389+
return keep
390+
}
391+
361392
// errorFromChallenges returns the error from the given "WWW-Authenticate" header challenges.
362393
// It only looks at challenges with the "Bearer" scheme.
363394
func errorFromChallenges(cs []oauthex.Challenge) string {

auth/authorization_code_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,6 +1222,99 @@ func TestAuthorize_OfflineAccessScope(t *testing.T) {
12221222
}
12231223
}
12241224

1225+
func TestAuthorize_ScopesAllowlist(t *testing.T) {
1226+
// advertised is delivered via the WWW-Authenticate "scope" challenge, which
1227+
// the handler treats as the requested scopes before applying the allowlist.
1228+
const advertised = "gmail.metadata gmail.readonly gmail.compose"
1229+
tests := []struct {
1230+
name string
1231+
allowlist []string
1232+
wantScopes string
1233+
}{
1234+
{
1235+
name: "FiltersToAllowlistPreservingOrder",
1236+
allowlist: []string{"gmail.compose", "gmail.readonly"},
1237+
wantScopes: "gmail.readonly gmail.compose",
1238+
},
1239+
{
1240+
name: "EmptyIntersectionFailsOpen",
1241+
allowlist: []string{"drive.readonly"},
1242+
wantScopes: advertised,
1243+
},
1244+
{
1245+
name: "NoAllowlistLeavesScopesUnchanged",
1246+
allowlist: nil,
1247+
wantScopes: advertised,
1248+
},
1249+
}
1250+
1251+
for _, tt := range tests {
1252+
t.Run(tt.name, func(t *testing.T) {
1253+
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
1254+
RegistrationConfig: &oauthtest.RegistrationConfig{
1255+
PreregisteredClients: map[string]oauthtest.ClientInfo{
1256+
"test_client_id": {
1257+
Secret: "test_client_secret",
1258+
RedirectURIs: []string{"http://localhost:12345/callback"},
1259+
},
1260+
},
1261+
},
1262+
})
1263+
authServer.Start(t)
1264+
1265+
resourceMux := http.NewServeMux()
1266+
resourceServer := httptest.NewServer(resourceMux)
1267+
t.Cleanup(resourceServer.Close)
1268+
resourceURL := resourceServer.URL + "/resource"
1269+
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
1270+
Resource: resourceURL,
1271+
AuthorizationServers: []string{authServer.URL()},
1272+
}))
1273+
1274+
var capturedAuthURL string
1275+
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
1276+
RedirectURL: "http://localhost:12345/callback",
1277+
PreregisteredClient: &oauthex.ClientCredentials{
1278+
ClientID: "test_client_id",
1279+
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test_client_secret"},
1280+
},
1281+
Scopes: tt.allowlist,
1282+
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
1283+
capturedAuthURL = args.URL
1284+
return nil, fmt.Errorf("stop after capturing URL")
1285+
},
1286+
})
1287+
if err != nil {
1288+
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
1289+
}
1290+
1291+
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
1292+
resp := &http.Response{
1293+
StatusCode: http.StatusUnauthorized,
1294+
Header: make(http.Header),
1295+
Body: http.NoBody,
1296+
Request: req,
1297+
}
1298+
resp.Header.Set("WWW-Authenticate", fmt.Sprintf(
1299+
"Bearer resource_metadata=%s/.well-known/oauth-protected-resource/resource, scope=%q",
1300+
resourceServer.URL, advertised))
1301+
1302+
handler.Authorize(context.Background(), req, resp)
1303+
1304+
if capturedAuthURL == "" {
1305+
t.Fatal("AuthorizationCodeFetcher was not called")
1306+
}
1307+
u, err := url.Parse(capturedAuthURL)
1308+
if err != nil {
1309+
t.Fatalf("failed to parse captured auth URL: %v", err)
1310+
}
1311+
if got := u.Query().Get("scope"); got != tt.wantScopes {
1312+
t.Errorf("requested scope = %q, want %q", got, tt.wantScopes)
1313+
}
1314+
})
1315+
}
1316+
}
1317+
12251318
// validConfig for test to create an AuthorizationCodeHandler using its constructor.
12261319
// Values that are relevant to the test should be set explicitly.
12271320
func validConfig() *AuthorizationCodeHandlerConfig {

0 commit comments

Comments
 (0)