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
26 changes: 22 additions & 4 deletions docs/en/documentation/configuration/authentication/generic.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,9 @@ When a request is received in this mode, the service will:
- Verifies expiration (`exp`) and audience (`aud`).
- Verifies required scopes in `scope` claim.
4. For **Opaque Tokens**:
- Calls the introspection endpoint (as listed in the `authorizationServer`'s
OIDC configuration).
- Verifies that the token is `active`.
- Verifies expiration (`exp`) and audience (`aud`).
- Calls the introspection endpoint (either configured via `introspectionEndpoint`
or discovered from the `authorizationServer`'s OIDC configuration).
- Verifies expiration (`exp`) and audience (`aud` or `"audience"` fallback).
- Verifies required scopes in `scope` field.

#### Example
Expand All @@ -124,6 +123,22 @@ scopesRequired:
- write
```

#### Google Opaque Access Token Validation Example

To use Google's `tokeninfo` endpoint for validating opaque access tokens, configure the service to use the `GET` method and `access_token` parameter name:

```yaml
kind: authServices
name: google-auth
type: generic
audience: "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com"
authorizationServer: https://accounts.google.com
introspectionEndpoint: https://www.googleapis.com/oauth2/v1/tokeninfo
introspectionMethod: GET
introspectionParamName: access_token
Comment on lines +136 to +138
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we not get these from the initial request?

mcpEnabled: true
```

{{< notice tip >}} Use environment variable replacement with the format
${ENV_NAME} instead of hardcoding your secrets into the configuration file.
{{< /notice >}}
Expand All @@ -142,3 +157,6 @@ ${ENV_NAME} instead of hardcoding your secrets into the configuration file.
| authorizationServer | string | true | The base URL of your OIDC provider. The service will append `/.well-known/openid-configuration` to discover the JWKS URI. HTTP is allowed but logs a warning. |
| mcpEnabled | bool | false | Indicates if MCP endpoint authentication should be applied. Defaults to false. |
| scopesRequired | []string | false | A list of required scopes that must be present in the token's `scope` claim to be considered valid. |
| introspectionEndpoint| string | false | Optional override for the token introspection URL. Useful if the provider does not list it in OIDC discovery (e.g., Google). |
| introspectionMethod | string | false | HTTP method to use for introspection. Defaults to "POST". Set to "GET" for providers like Google. |
| introspectionParamName|string | false | Parameter name for the token in the introspection request. Defaults to "token". Set to "access_token" for Google. |
76 changes: 55 additions & 21 deletions internal/auth/generic/generic.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,15 @@ var _ auth.AuthServiceConfig = Config{}

// Auth service configuration
type Config struct {
Name string `yaml:"name" validate:"required"`
Type string `yaml:"type" validate:"required"`
Audience string `yaml:"audience" validate:"required"`
McpEnabled bool `yaml:"mcpEnabled"`
AuthorizationServer string `yaml:"authorizationServer" validate:"required"`
ScopesRequired []string `yaml:"scopesRequired"`
Name string `yaml:"name" validate:"required"`
Type string `yaml:"type" validate:"required"`
Audience string `yaml:"audience" validate:"required"`
McpEnabled bool `yaml:"mcpEnabled"`
AuthorizationServer string `yaml:"authorizationServer" validate:"required"`
ScopesRequired []string `yaml:"scopesRequired"`
IntrospectionEndpoint string `yaml:"introspectionEndpoint"`
IntrospectionMethod string `yaml:"introspectionMethod"`
IntrospectionParamName string `yaml:"introspectionParamName"`
}

// Returns the auth service type
Expand All @@ -61,6 +64,11 @@ func (cfg Config) Initialize() (auth.AuthService, error) {
return nil, fmt.Errorf("failed to discover OIDC config: %w", err)
}

// Override introspection URL if configured
if cfg.IntrospectionEndpoint != "" {
introspectionURL = cfg.IntrospectionEndpoint
}

// Create the keyfunc to fetch and cache the JWKS in the background
kf, err := keyfunc.NewDefault([]string{jwksURL})
if err != nil {
Expand Down Expand Up @@ -288,14 +296,33 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e
}
}

data := url.Values{}
data.Set("token", tokenStr)
paramName := a.IntrospectionParamName
if paramName == "" {
paramName = "token"
}

req, err := http.NewRequestWithContext(ctx, "POST", introspectionURL, strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("failed to create introspection request: %w", err)
var req *http.Request
if a.IntrospectionMethod == "GET" {
u, err := url.Parse(introspectionURL)
if err != nil {
return fmt.Errorf("failed to parse introspection URL: %w", err)
}
q := u.Query()
q.Set(paramName, tokenStr)
u.RawQuery = q.Encode()
req, err = http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return fmt.Errorf("failed to create introspection request: %w", err)
}
} else {
data := url.Values{}
data.Set(paramName, tokenStr)
req, err = http.NewRequestWithContext(ctx, "POST", introspectionURL, strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("failed to create introspection request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")

// Send request to auth server's introspection endpoint
Expand All @@ -317,17 +344,18 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e
}

var introspectResp struct {
Active bool `json:"active"`
Scope string `json:"scope"`
Aud json.RawMessage `json:"aud"`
Exp int64 `json:"exp"`
Active *bool `json:"active"`
Scope string `json:"scope"`
Aud json.RawMessage `json:"aud"`
Audience json.RawMessage `json:"audience"`
Exp int64 `json:"exp"`
}

if err := json.Unmarshal(body, &introspectResp); err != nil {
return fmt.Errorf("failed to parse introspection response: %w", err)
}
Comment thread
duwenxin99 marked this conversation as resolved.

if !introspectResp.Active {
if introspectResp.Active != nil && !*introspectResp.Active {
logger.InfoContext(ctx, "token is not active")
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "token is not active", ScopesRequired: a.ScopesRequired}
}
Expand All @@ -341,16 +369,22 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e

// Extract audience
// According to RFC 7662, the aud claim can be a string or an array of strings
// Fallback to "audience" for Google tokeninfo
audData := introspectResp.Aud
if len(audData) == 0 {
audData = introspectResp.Audience
}

var aud []string
if len(introspectResp.Aud) > 0 {
if len(audData) > 0 {
var audStr string
var audArr []string
if err := json.Unmarshal(introspectResp.Aud, &audStr); err == nil {
if err := json.Unmarshal(audData, &audStr); err == nil {
aud = []string{audStr}
} else if err := json.Unmarshal(introspectResp.Aud, &audArr); err == nil {
} else if err := json.Unmarshal(audData, &audArr); err == nil {
aud = audArr
} else {
logger.WarnContext(ctx, "failed to parse aud claim in introspection response")
logger.WarnContext(ctx, "failed to parse aud or audience claim in introspection response")
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid aud claim", ScopesRequired: a.ScopesRequired}
}
}
Expand Down
84 changes: 84 additions & 0 deletions tests/auth/auth_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (

"github.com/MicahParks/jwkset"
"github.com/golang-jwt/jwt/v5"
"github.com/googleapis/mcp-toolbox/internal/sources"
"github.com/googleapis/mcp-toolbox/internal/testutils"
"github.com/googleapis/mcp-toolbox/tests"
)
Expand Down Expand Up @@ -191,3 +192,86 @@ func TestMcpAuth(t *testing.T) {
})
}
}

// TestGoogleTokenValidation tests validation of Google access token
func TestGoogleTokenValidation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()

// Get access token
accessToken, err := sources.GetIAMAccessToken(ctx)
if err != nil {
t.Errorf("error getting access token from ADC: %s", err)
}

// Call tokeninfo to get audience
resp, err := http.Get("https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=" + accessToken)
if err != nil {
t.Fatalf("failed to call tokeninfo: %v", err)
}
defer resp.Body.Close()
Comment thread
duwenxin99 marked this conversation as resolved.

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("tokeninfo returned non-200 status %d: %s", resp.StatusCode, string(body))
}

var tokenInfo struct {
Audience string `json:"audience"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenInfo); err != nil {
t.Fatalf("failed to decode tokeninfo response: %v", err)
}

aud := tokenInfo.Audience
if aud == "" {
t.Fatalf("audience is empty in tokeninfo response")
}

toolsFile := map[string]any{
"sources": map[string]any{},
"authServices": map[string]any{
"google-auth": map[string]any{
"type": "generic",
"audience": aud,
"authorizationServer": "https://accounts.google.com",
"introspectionEndpoint": "https://www.googleapis.com/oauth2/v1/tokeninfo",
"introspectionMethod": "GET",
"introspectionParamName": "access_token",
"mcpEnabled": true,
},
},
"tools": map[string]any{},
}

args := []string{"--enable-api", "--toolbox-url=http://127.0.0.1:5005", "--port=5005"}
cmd, cleanup, err := tests.StartCmd(ctx, toolsFile, args...)
if err != nil {
t.Fatalf("command initialization returned an error: %s", err)
}
defer cleanup()

waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out)
if err != nil {
t.Logf("toolbox command logs: \n%s", out)
t.Fatalf("toolbox didn't start successfully: %s", err)
}

api := "http://127.0.0.1:5005/mcp/sse"

req, _ := http.NewRequest(http.MethodGet, api, nil)
req.Header.Add("Authorization", "Bearer "+accessToken)

resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unable to send request: %s", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
}
Loading