Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 12 additions & 17 deletions docs/en/documentation/configuration/authentication/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,18 @@ description: >
AuthServices represent services that handle authentication and authorization.
---

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

- [**Authorized Invocation**][auth-invoke] is when a tool
is validated by the auth service before the call can be invoked. Toolbox
will reject any calls that fail to validate or have an invalid token.
- [**Authenticated Parameters**][auth-params] replace 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.
### 1. Toolbox Native Authorization
Used for specific tools to enforce authorization or resolve parameters:
- [**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.
- [**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.

### 2. MCP Authorization
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.**

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

## Example
Expand Down Expand Up @@ -48,13 +47,9 @@ Use environment variable replacement with the format ${ENV_NAME}
instead of hardcoding your secrets into the configuration file.
{{< /notice >}}

After you've configured an `authService` you'll, need to reference it in the
configuration for each tool that should use it:

- **Authorized Invocations** for authorizing a tool call, [use the
`authRequired` field in a tool config][auth-invoke]
- **Authenticated Parameters** for using the value from a OIDC claim, [use the
`authService` field in a parameter config][auth-params]
After you've configured an `authService`, you can use it:
- For **Toolbox Native Authorization** by referencing it in your tool configuration (using `authRequired` or `authService` in parameters).
- For **MCP Authorization** by setting `mcpEnabled: true` in the auth service configuration to secure the entire server.

## Specifying ID Tokens from Clients

Expand Down
23 changes: 23 additions & 0 deletions docs/en/documentation/configuration/authentication/generic.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,29 @@ scopesRequired:
- write
```

#### Tool-Level Scopes

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.

This ensures that a client can only invoke the tool if their authorization token contains all the specified scopes.

```yaml
kind: tool
name: update_flight_status
type: postgres-sql
source: my-pg-instance
statement: |
UPDATE flights SET status = $1 WHERE flight_number = $2
description: Update flight status
authRequired:
- my-generic-auth
scopesRequired:
- execute:sql
- write:flights
```

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.

{{< notice tip >}} Use environment variable replacement with the format
${ENV_NAME} instead of hardcoding your secrets into the configuration file.
{{< /notice >}}
Expand Down
9 changes: 7 additions & 2 deletions docs/en/documentation/configuration/tools/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,13 @@ templateParameters:
| excludedValues | []string | false | Input value will be checked against this field. Regex is also supported. |
| items | parameter object | true (if array) | Specify a Parameter object for the type of the values in the array (string only). |

## Authorized Invocations
## Tool-Level Scopes (MCP Authorization)

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.

For detailed information on how to configure this and examples, please see the [Generic OIDC Auth](../authentication/generic.md#tool-level-scopes) documentation.

## Authorized Invocations (Toolbox Native Authorization)

You can require an authorization check for any Tool invocation request by
specifying an `authRequired` field. Specify a list of
Expand All @@ -279,7 +285,6 @@ authRequired:
- other-auth-service
```


## Tool Annotations

Tool annotations provide semantic metadata that helps MCP clients understand tool
Expand Down
54 changes: 34 additions & 20 deletions internal/auth/generic/generic.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,15 +227,15 @@ type MCPAuthError struct {
func (e *MCPAuthError) Error() string { return e.Message }

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

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

tokenStr := headerParts[1]
Expand All @@ -251,40 +251,44 @@ func isJWTFormat(token string) bool {
}

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

claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid JWT claims format", ScopesRequired: a.ScopesRequired}
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid JWT claims format", ScopesRequired: a.ScopesRequired}
}

// Validate audience
aud, err := claims.GetAudience()
if err != nil {
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "could not parse audience from token", ScopesRequired: a.ScopesRequired}
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "could not parse audience from token", ScopesRequired: a.ScopesRequired}
}

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

return a.validateClaims(ctx, aud, scopeClaim)
err = a.validateClaims(ctx, aud, scopeClaim)
if err != nil {
return nil, err
}
return claims, nil
}

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

introspectionURL := a.introspectionURL
if introspectionURL == "" {
introspectionURL, err = url.JoinPath(a.AuthorizationServer, "introspect")
if err != nil {
return fmt.Errorf("failed to construct introspection URL: %w", err)
return nil, fmt.Errorf("failed to construct introspection URL: %w", err)
}
}

Expand All @@ -293,7 +297,7 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e

req, err := http.NewRequestWithContext(ctx, "POST", introspectionURL, strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("failed to create introspection request: %w", err)
return nil, fmt.Errorf("failed to create introspection request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
Expand All @@ -302,18 +306,18 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e
resp, err := a.client.Do(req)
if err != nil {
logger.ErrorContext(ctx, "failed to call introspection endpoint: %v", err)
return &MCPAuthError{Code: http.StatusInternalServerError, Message: fmt.Sprintf("failed to call introspection endpoint: %v", err), ScopesRequired: a.ScopesRequired}
return nil, &MCPAuthError{Code: http.StatusInternalServerError, Message: fmt.Sprintf("failed to call introspection endpoint: %v", err), ScopesRequired: a.ScopesRequired}
}
defer resp.Body.Close()

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

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

var introspectResp struct {
Expand All @@ -324,19 +328,19 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e
}

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

if !introspectResp.Active {
logger.InfoContext(ctx, "token is not active")
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "token is not active", ScopesRequired: a.ScopesRequired}
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "token is not active", ScopesRequired: a.ScopesRequired}
}

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

// Extract audience
Expand All @@ -351,11 +355,21 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e
aud = audArr
} else {
logger.WarnContext(ctx, "failed to parse aud claim in introspection response")
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid aud claim", ScopesRequired: a.ScopesRequired}
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid aud claim", ScopesRequired: a.ScopesRequired}
}
}

return a.validateClaims(ctx, aud, introspectResp.Scope)
err = a.validateClaims(ctx, aud, introspectResp.Scope)
if err != nil {
return nil, err
}
claims := map[string]any{
"active": introspectResp.Active,
"scope": introspectResp.Scope,
"aud": aud,
"exp": introspectResp.Exp,
}
return claims, nil
}

// validateClaims validates the audience and scopes of a token
Expand Down
6 changes: 3 additions & 3 deletions internal/auth/generic/generic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ func TestValidateMCPAuth_Opaque(t *testing.T) {
header := http.Header{}
header.Set("Authorization", "Bearer "+tc.token)

err = genericAuth.ValidateMCPAuth(ctx, header)
_, err = genericAuth.ValidateMCPAuth(ctx, header)

if tc.wantError {
if err == nil {
Expand Down Expand Up @@ -486,7 +486,7 @@ func TestValidateJwtToken(t *testing.T) {
t.Fatalf("failed to create logger: %v", err)
}
ctx := util.WithLogger(context.Background(), logger)
err = genericAuth.validateJwtToken(ctx, tc.token)
_, err = genericAuth.validateJwtToken(ctx, tc.token)
if tc.wantError {
if err == nil {
t.Fatalf("expected error, got nil")
Expand Down Expand Up @@ -649,7 +649,7 @@ func TestValidateOpaqueToken(t *testing.T) {
}
ctx := util.WithLogger(context.Background(), logger)

err = genericAuth.validateOpaqueToken(ctx, tc.token)
_, err = genericAuth.validateOpaqueToken(ctx, tc.token)

if tc.wantError {
if err == nil {
Expand Down
15 changes: 15 additions & 0 deletions internal/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,21 @@ func UnmarshalYAMLToolConfig(ctx context.Context, name string, r map[string]any)
r["authRequired"] = []string{}
}

// Parse scopesRequired if present
if rawScopes, ok := r["scopesRequired"]; ok {
if scopesList, ok := rawScopes.([]any); ok {
var scopes []string
for _, s := range scopesList {
if str, ok := s.(string); ok {
scopes = append(scopes, str)
}
}
r["scopesRequired"] = scopes
} else {
return nil, fmt.Errorf("scopesRequired must be a list of strings")
}
}

// validify parameter references
if rawParams, ok := r["parameters"]; ok {
if paramsList, ok := rawParams.([]any); ok {
Expand Down
24 changes: 23 additions & 1 deletion internal/server/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -333,6 +334,13 @@ func mcpRouter(s *Server) (chi.Router, error) {
r.Use(middleware.AllowContentType("application/json", "application/json-rpc", "application/jsonrequest"))
r.Use(middleware.StripSlashes)
r.Use(render.SetContentType(render.ContentTypeJSON))
// Inject logger into ctx
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := util.WithLogger(r.Context(), s.logger)
next.ServeHTTP(w, r.WithContext(ctx))
})
})
r.Use(mcpAuthMiddleware(s))

r.Get("/sse", func(w http.ResponseWriter, r *http.Request) { sseHandler(s, w, r) })
Expand Down Expand Up @@ -463,7 +471,6 @@ func httpHandler(s *Server, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

ctx := r.Context()
ctx = util.WithLogger(ctx, s.logger)

// Read body first so we can extract trace context
body, err := io.ReadAll(r.Body)
Expand Down Expand Up @@ -578,6 +585,21 @@ func httpHandler(s *Server, w http.ResponseWriter, r *http.Request) {
if errors.As(err, &clientServerErr) {
w.WriteHeader(clientServerErr.Code)
}
var mcpErr *generic.MCPAuthError
if errors.As(err, &mcpErr) {
switch mcpErr.Code {
case http.StatusForbidden:
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer error="insufficient_scope", scope="%s", resource_metadata="%s", error_description="%s"`, strings.Join(mcpErr.ScopesRequired, " "), s.toolboxUrl+"/.well-known/oauth-protected-resource", mcpErr.Message))
w.WriteHeader(http.StatusForbidden)
case http.StatusUnauthorized:
scopesArg := ""
if len(mcpErr.ScopesRequired) > 0 {
scopesArg = fmt.Sprintf(`, scope="%s"`, strings.Join(mcpErr.ScopesRequired, " "))
}
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata="%s"%s`, s.toolboxUrl+"/.well-known/oauth-protected-resource", scopesArg))
w.WriteHeader(http.StatusUnauthorized)
}
}
}
}

Expand Down
Loading
Loading