Skip to content

Commit c528985

Browse files
authored
feat(auth): implement MCP auth tool-level scopes validation (#3049)
1 parent 80a6602 commit c528985

13 files changed

Lines changed: 350 additions & 63 deletions

File tree

docs/en/documentation/configuration/authentication/_index.md

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,18 @@ description: >
66
AuthServices represent services that handle authentication and authorization.
77
---
88

9-
AuthServices represent services that handle authentication and authorization. It
10-
can primarily be used by [Tools](../tools/_index.md) in two different ways:
9+
AuthServices represent services that handle authentication and authorization. They support two distinct modes of operation:
1110

12-
- [**Authorized Invocation**][auth-invoke] is when a tool
13-
is validated by the auth service before the call can be invoked. Toolbox
14-
will reject any calls that fail to validate or have an invalid token.
15-
- [**Authenticated Parameters**][auth-params] replace the value of a parameter
16-
with a field from an [OIDC][openid-claims] claim. Toolbox will automatically
17-
resolve the ID token provided by the client and replace the parameter in the
18-
tool call.
11+
### 1. Toolbox Native Authorization
12+
Used for specific tools to enforce authorization or resolve parameters:
13+
- [**Authorized Invocation**][auth-invoke]: A tool is validated by the auth service before it can be invoked. Toolbox will reject any calls that fail to validate or have an invalid token.
14+
- [**Authenticated Parameters**][auth-params]: Replaces the value of a parameter with a field from an [OIDC][openid-claims] claim. Toolbox will automatically resolve the ID token provided by the client and replace the parameter in the tool call.
15+
16+
### 2. MCP Authorization
17+
Used to secure the entire MCP server. The Model Context Protocol supports [MCP Authorization](https://modelcontextprotocol.io/docs/tutorials/security/authorization) to secure interactions between clients and servers. When enabled, all MCP endpoints require a valid token, and you can enforce granular tool-level scope authorization. **Note that this mode is currently only supported when using the `generic` auth service type.**
1918

2019
[openid-claims]: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
21-
[auth-invoke]: ../tools/_index.md#authorized-invocations
20+
[auth-invoke]: ../tools/_index.md#authorized-invocations-toolbox-native-authorization
2221
[auth-params]: ../tools/_index.md#authenticated-parameters
2322

2423
## Example
@@ -48,13 +47,9 @@ Use environment variable replacement with the format ${ENV_NAME}
4847
instead of hardcoding your secrets into the configuration file.
4948
{{< /notice >}}
5049
51-
After you've configured an `authService` you'll, need to reference it in the
52-
configuration for each tool that should use it:
53-
54-
- **Authorized Invocations** for authorizing a tool call, [use the
55-
`authRequired` field in a tool config][auth-invoke]
56-
- **Authenticated Parameters** for using the value from a OIDC claim, [use the
57-
`authService` field in a parameter config][auth-params]
50+
After you've configured an `authService`, you can use it:
51+
- For **Toolbox Native Authorization** by referencing it in your tool configuration (using `authRequired` or `authService` in parameters).
52+
- For **MCP Authorization** by setting `mcpEnabled: true` in the auth service configuration to secure the entire server.
5853

5954
## Specifying ID Tokens from Clients
6055

docs/en/documentation/configuration/authentication/generic.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,29 @@ scopesRequired:
158158
> [!NOTE]
159159
> If you are using Okta's Org Authorization Server (instead of a Custom Authorization Server), your `authorizationServer` URL will be `https://your-subdomain.okta.com`.
160160

161+
#### Tool-Level Scopes
162+
163+
When using MCP Authorization (with `mcpEnabled: true` in the auth service), you can enforce granular tool-level scope authorization by specifying the `scopesRequired` field in the tool configuration.
164+
165+
This ensures that a client can only invoke the tool if their authorization token contains all the specified scopes.
166+
167+
```yaml
168+
kind: tool
169+
name: update_flight_status
170+
type: postgres-sql
171+
source: my-pg-instance
172+
statement: |
173+
UPDATE flights SET status = $1 WHERE flight_number = $2
174+
description: Update flight status
175+
authRequired:
176+
- my-generic-auth
177+
scopesRequired:
178+
- execute:sql
179+
- write:flights
180+
```
181+
182+
If a client attempts to invoke this tool without the required scopes, the server will return an HTTP 403 Forbidden response with a `WWW-Authenticate` header challenge indicating the missing scopes, as per the MCP Auth specification.
183+
161184
{{< notice tip >}} Use environment variable replacement with the format
162185
${ENV_NAME} instead of hardcoding your secrets into the configuration file.
163186
{{< /notice >}}

docs/en/documentation/configuration/tools/_index.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,13 @@ templateParameters:
260260
| excludedValues | []string | false | Input value will be checked against this field. Regex is also supported. |
261261
| items | parameter object | true (if array) | Specify a Parameter object for the type of the values in the array (string only). |
262262

263-
## Authorized Invocations
263+
## Tool-Level Scopes (MCP Authorization)
264+
265+
The Model Context Protocol supports [MCP Authorization](https://modelcontextprotocol.io/docs/tutorials/security/authorization) to secure interactions between clients and servers. When using MCP Authorization in Toolbox, you can enforce granular tool-level scope authorization by specifying the `scopesRequired` field in the tool configuration.
266+
267+
For detailed information on how to configure this and examples, please see the [Generic OIDC Auth](../authentication/generic.md#tool-level-scopes) documentation.
268+
269+
## Authorized Invocations (Toolbox Native Authorization)
264270

265271
You can require an authorization check for any Tool invocation request by
266272
specifying an `authRequired` field. Specify a list of
@@ -279,7 +285,6 @@ authRequired:
279285
- other-auth-service
280286
```
281287
282-
283288
## Tool Annotations
284289
285290
Tool annotations provide semantic metadata that helps MCP clients understand tool

internal/auth/generic/generic.go

Lines changed: 70 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func (cfg Config) Initialize() (auth.AuthService, error) {
5959
httpClient := newSecureHTTPClient()
6060

6161
// Discover OIDC endpoints
62-
jwksURL, introspectionURL, err := discoverOIDCConfig(httpClient, cfg.AuthorizationServer)
62+
jwksURL, introspectionURL, issuer, err := discoverOIDCConfig(httpClient, cfg.AuthorizationServer)
6363
if err != nil {
6464
return nil, fmt.Errorf("failed to discover OIDC config: %w", err)
6565
}
@@ -80,6 +80,7 @@ func (cfg Config) Initialize() (auth.AuthService, error) {
8080
kf: kf,
8181
client: httpClient,
8282
introspectionURL: introspectionURL,
83+
issuer: issuer,
8384
}
8485
return a, nil
8586
}
@@ -100,58 +101,59 @@ func newSecureHTTPClient() *http.Client {
100101
}
101102
}
102103

103-
func discoverOIDCConfig(client *http.Client, AuthorizationServer string) (jwksURI string, introspectionEndpoint string, err error) {
104+
func discoverOIDCConfig(client *http.Client, AuthorizationServer string) (jwksURI string, introspectionEndpoint string, issuer string, err error) {
104105
u, err := url.Parse(AuthorizationServer)
105106
if err != nil {
106-
return "", "", fmt.Errorf("invalid auth URL")
107+
return "", "", "", fmt.Errorf("invalid auth URL")
107108
}
108109
if u.Scheme != "https" {
109110
log.Printf("WARNING: HTTP instead of HTTPS is being used for AuthorizationServer: %s", AuthorizationServer)
110111
}
111112

112113
oidcConfigURL, err := url.JoinPath(AuthorizationServer, ".well-known/openid-configuration")
113114
if err != nil {
114-
return "", "", err
115+
return "", "", "", err
115116
}
116117

117118
resp, err := client.Get(oidcConfigURL)
118119
if err != nil {
119-
return "", "", fmt.Errorf("failed to fetch OIDC config: %w", err)
120+
return "", "", "", fmt.Errorf("failed to fetch OIDC config: %w", err)
120121
}
121122
defer resp.Body.Close()
122123

123124
if resp.StatusCode != http.StatusOK {
124-
return "", "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
125+
return "", "", "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
125126
}
126127

127128
// Limit read size to 1MB to prevent memory exhaustion
128129
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
129130
if err != nil {
130-
return "", "", err
131+
return "", "", "", err
131132
}
132133

133134
var config struct {
135+
Issuer string `json:"issuer"`
134136
JwksUri string `json:"jwks_uri"`
135137
IntrospectionEndpoint string `json:"introspection_endpoint"`
136138
}
137139
if err := json.Unmarshal(body, &config); err != nil {
138-
return "", "", err
140+
return "", "", "", err
139141
}
140142

141143
if config.JwksUri == "" {
142-
return "", "", fmt.Errorf("jwks_uri not found in config")
144+
return "", "", "", fmt.Errorf("jwks_uri not found in config")
143145
}
144146

145147
// Sanitize the resulting JWKS URI before returning it
146148
parsedJWKS, err := url.Parse(config.JwksUri)
147149
if err != nil {
148-
return "", "", fmt.Errorf("invalid jwks_uri detected")
150+
return "", "", "", fmt.Errorf("invalid jwks_uri detected")
149151
}
150152
if parsedJWKS.Scheme != "https" {
151153
log.Printf("WARNING: HTTP instead of HTTPS is being used for JWKS URI: %s", config.JwksUri)
152154
}
153155

154-
return config.JwksUri, config.IntrospectionEndpoint, nil
156+
return config.JwksUri, config.IntrospectionEndpoint, config.Issuer, nil
155157
}
156158

157159
var _ auth.AuthService = AuthService{}
@@ -162,6 +164,7 @@ type AuthService struct {
162164
kf keyfunc.Keyfunc
163165
client *http.Client
164166
introspectionURL string
167+
issuer string
165168
}
166169

167170
// Returns the auth service type
@@ -235,15 +238,15 @@ type MCPAuthError struct {
235238
func (e *MCPAuthError) Error() string { return e.Message }
236239

237240
// ValidateMCPAuth handles MCP auth token validation
238-
func (a AuthService) ValidateMCPAuth(ctx context.Context, h http.Header) error {
241+
func (a AuthService) ValidateMCPAuth(ctx context.Context, h http.Header) (map[string]any, error) {
239242
tokenString := h.Get("Authorization")
240243
if tokenString == "" {
241-
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "missing access token", ScopesRequired: a.ScopesRequired}
244+
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "missing access token", ScopesRequired: a.ScopesRequired}
242245
}
243246

244247
headerParts := strings.Split(tokenString, " ")
245248
if len(headerParts) != 2 || strings.ToLower(headerParts[0]) != "bearer" {
246-
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "authorization header must be in the format 'Bearer <token>'", ScopesRequired: a.ScopesRequired}
249+
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "authorization header must be in the format 'Bearer <token>'", ScopesRequired: a.ScopesRequired}
247250
}
248251

249252
tokenStr := headerParts[1]
@@ -259,40 +262,53 @@ func isJWTFormat(token string) bool {
259262
}
260263

261264
// validateJwtToken validates a JWT token locally
262-
func (a AuthService) validateJwtToken(ctx context.Context, tokenStr string) error {
265+
func (a AuthService) validateJwtToken(ctx context.Context, tokenStr string) (map[string]any, error) {
263266
token, err := jwt.Parse(tokenStr, a.kf.Keyfunc)
264267
if err != nil || !token.Valid {
265-
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid or expired token", ScopesRequired: a.ScopesRequired}
268+
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid or expired token", ScopesRequired: a.ScopesRequired}
266269
}
267270

268271
claims, ok := token.Claims.(jwt.MapClaims)
269272
if !ok {
270-
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid JWT claims format", ScopesRequired: a.ScopesRequired}
273+
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid JWT claims format", ScopesRequired: a.ScopesRequired}
274+
}
275+
276+
// Validate issuer
277+
iss, err := claims.GetIssuer()
278+
if err != nil {
279+
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "could not parse issuer from token", ScopesRequired: a.ScopesRequired}
280+
}
281+
if iss == "" {
282+
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "missing issuer claim in token", ScopesRequired: a.ScopesRequired}
271283
}
272284

273285
// Validate audience
274286
aud, err := claims.GetAudience()
275287
if err != nil {
276-
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "could not parse audience from token", ScopesRequired: a.ScopesRequired}
288+
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "could not parse audience from token", ScopesRequired: a.ScopesRequired}
277289
}
278290

279291
scopeClaim, _ := claims["scope"].(string)
280292

281-
return a.validateClaims(ctx, aud, scopeClaim)
293+
err = a.validateClaims(ctx, iss, aud, scopeClaim)
294+
if err != nil {
295+
return nil, err
296+
}
297+
return claims, nil
282298
}
283299

284300
// validateOpaqueToken validates an opaque token by calling the introspection endpoint
285-
func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) error {
301+
func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) (map[string]any, error) {
286302
logger, err := util.LoggerFromContext(ctx)
287303
if err != nil {
288-
return fmt.Errorf("failed to get logger from context: %w", err)
304+
return nil, fmt.Errorf("failed to get logger from context: %w", err)
289305
}
290306

291307
introspectionURL := a.introspectionURL
292308
if introspectionURL == "" {
293309
introspectionURL, err = url.JoinPath(a.AuthorizationServer, "introspect")
294310
if err != nil {
295-
return fmt.Errorf("failed to construct introspection URL: %w", err)
311+
return nil, fmt.Errorf("failed to construct introspection URL: %w", err)
296312
}
297313
}
298314

@@ -305,21 +321,21 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e
305321
if a.IntrospectionMethod == "GET" {
306322
u, err := url.Parse(introspectionURL)
307323
if err != nil {
308-
return fmt.Errorf("failed to parse introspection URL: %w", err)
324+
return nil, fmt.Errorf("failed to parse introspection URL: %w", err)
309325
}
310326
q := u.Query()
311327
q.Set(paramName, tokenStr)
312328
u.RawQuery = q.Encode()
313329
req, err = http.NewRequestWithContext(ctx, "GET", u.String(), nil)
314330
if err != nil {
315-
return fmt.Errorf("failed to create introspection request: %w", err)
331+
return nil, fmt.Errorf("failed to create introspection request: %w", err)
316332
}
317333
} else {
318334
data := url.Values{}
319335
data.Set(paramName, tokenStr)
320336
req, err = http.NewRequestWithContext(ctx, "POST", introspectionURL, strings.NewReader(data.Encode()))
321337
if err != nil {
322-
return fmt.Errorf("failed to create introspection request: %w", err)
338+
return nil, fmt.Errorf("failed to create introspection request: %w", err)
323339
}
324340
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
325341
}
@@ -329,18 +345,18 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e
329345
resp, err := a.client.Do(req)
330346
if err != nil {
331347
logger.ErrorContext(ctx, "failed to call introspection endpoint: %v", err)
332-
return &MCPAuthError{Code: http.StatusInternalServerError, Message: fmt.Sprintf("failed to call introspection endpoint: %v", err), ScopesRequired: a.ScopesRequired}
348+
return nil, &MCPAuthError{Code: http.StatusInternalServerError, Message: fmt.Sprintf("failed to call introspection endpoint: %v", err), ScopesRequired: a.ScopesRequired}
333349
}
334350
defer resp.Body.Close()
335351

336352
if resp.StatusCode != http.StatusOK {
337353
logger.WarnContext(ctx, "introspection failed with status: %d", resp.StatusCode)
338-
return &MCPAuthError{Code: http.StatusUnauthorized, Message: fmt.Sprintf("introspection failed with status: %d", resp.StatusCode), ScopesRequired: a.ScopesRequired}
354+
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: fmt.Sprintf("introspection failed with status: %d", resp.StatusCode), ScopesRequired: a.ScopesRequired}
339355
}
340356

341357
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
342358
if err != nil {
343-
return fmt.Errorf("failed to read introspection response: %w", err)
359+
return nil, fmt.Errorf("failed to read introspection response: %w", err)
344360
}
345361

346362
var introspectResp struct {
@@ -349,22 +365,23 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e
349365
Aud json.RawMessage `json:"aud"`
350366
Audience json.RawMessage `json:"audience"`
351367
Exp int64 `json:"exp"`
368+
Iss string `json:"iss"`
352369
}
353370

354371
if err := json.Unmarshal(body, &introspectResp); err != nil {
355-
return fmt.Errorf("failed to parse introspection response: %w", err)
372+
return nil, fmt.Errorf("failed to parse introspection response: %w", err)
356373
}
357374

358375
if introspectResp.Active != nil && !*introspectResp.Active {
359376
logger.InfoContext(ctx, "token is not active")
360-
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "token is not active", ScopesRequired: a.ScopesRequired}
377+
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "token is not active", ScopesRequired: a.ScopesRequired}
361378
}
362379

363380
// Verify expiration (with 1 minute leeway)
364381
const leeway = 60
365382
if introspectResp.Exp > 0 && time.Now().Unix() > (introspectResp.Exp+leeway) {
366383
logger.WarnContext(ctx, "token has expired: exp=%d, now=%d", introspectResp.Exp, time.Now().Unix())
367-
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "token has expired", ScopesRequired: a.ScopesRequired}
384+
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "token has expired", ScopesRequired: a.ScopesRequired}
368385
}
369386

370387
// Extract audience
@@ -385,20 +402,39 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e
385402
aud = audArr
386403
} else {
387404
logger.WarnContext(ctx, "failed to parse aud or audience claim in introspection response")
388-
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid aud claim", ScopesRequired: a.ScopesRequired}
405+
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid aud claim", ScopesRequired: a.ScopesRequired}
389406
}
390407
}
391408

392-
return a.validateClaims(ctx, aud, introspectResp.Scope)
409+
err = a.validateClaims(ctx, introspectResp.Iss, aud, introspectResp.Scope)
410+
if err != nil {
411+
return nil, err
412+
}
413+
claims := map[string]any{
414+
"active": introspectResp.Active,
415+
"scope": introspectResp.Scope,
416+
"aud": aud,
417+
"exp": introspectResp.Exp,
418+
"iss": introspectResp.Iss,
419+
}
420+
return claims, nil
393421
}
394422

395423
// validateClaims validates the audience and scopes of a token
396-
func (a AuthService) validateClaims(ctx context.Context, aud []string, scopeStr string) error {
424+
func (a AuthService) validateClaims(ctx context.Context, iss string, aud []string, scopeStr string) error {
397425
logger, err := util.LoggerFromContext(ctx)
398426
if err != nil {
399427
return fmt.Errorf("failed to get logger from context: %w", err)
400428
}
401429

430+
// Validate issuer
431+
if a.issuer != "" && iss != "" {
432+
if iss != a.issuer {
433+
logger.WarnContext(ctx, "issuer validation failed: expected %s, got %s", a.issuer, iss)
434+
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "issuer validation failed", ScopesRequired: a.ScopesRequired}
435+
}
436+
}
437+
402438
// Validate audience
403439
if a.Audience != "" {
404440
isAudValid := false

0 commit comments

Comments
 (0)