Skip to content

Commit 59e58c3

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 59e58c3

2 files changed

Lines changed: 130 additions & 0 deletions

File tree

auth/authorization_code.go

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

100+
// Scopes optionally restricts the OAuth scopes requested during
101+
// authorization to this allowlist. By default the handler requests every
102+
// scope advertised in the protected resource's metadata (or named in the
103+
// WWW-Authenticate challenge); when Scopes is non-empty the requested set is
104+
// intersected with it, preserving discovery order. This lets a client avoid
105+
// an advertised scope it does not want — for example Gmail's gmail.metadata
106+
// scope, which the Gmail API refuses to combine with the search "q"
107+
// parameter even when gmail.readonly is also granted. An allowlist that
108+
// matches none of the advertised scopes is ignored, so the flow never
109+
// requests an empty scope set; offline_access (see RequestRefreshToken) is
110+
// exempt from filtering.
111+
Scopes []string
112+
100113
// RequestRefreshToken indicates that the client intends to use refresh
101114
// tokens and is capable of storing them securely.
102115
//
@@ -293,6 +306,13 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
293306
requestedScopes = prm.ScopesSupported
294307
}
295308

309+
// Restrict the discovered scopes to the configured allowlist, if any, so a
310+
// client can avoid an advertised scope it does not want. Applied before the
311+
// offline_access and step-up union logic below so neither is affected.
312+
if len(h.config.Scopes) > 0 {
313+
requestedScopes = intersectScopes(requestedScopes, h.config.Scopes)
314+
}
315+
296316
// SEP-2207: when the client desires refresh tokens and the Authorization
297317
// Server advertises offline_access support, add it to the requested scopes.
298318
if h.config.RequestRefreshToken &&
@@ -358,6 +378,23 @@ func scopesFromChallenges(cs []oauthex.Challenge) []string {
358378
return nil
359379
}
360380

381+
// intersectScopes returns the members of scopes that also appear in allow,
382+
// preserving the order of scopes. If nothing matches it returns scopes
383+
// unchanged, so a misconfigured allowlist cannot leave the client requesting no
384+
// scopes at all.
385+
func intersectScopes(scopes, allow []string) []string {
386+
keep := make([]string, 0, len(scopes))
387+
for _, s := range scopes {
388+
if slices.Contains(allow, s) {
389+
keep = append(keep, s)
390+
}
391+
}
392+
if len(keep) == 0 {
393+
return scopes
394+
}
395+
return keep
396+
}
397+
361398
// errorFromChallenges returns the error from the given "WWW-Authenticate" header challenges.
362399
// It only looks at challenges with the "Bearer" scheme.
363400
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)