Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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. |
87 changes: 58 additions & 29 deletions internal/auth/generic/generic.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,15 @@

// 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 @@ -56,11 +59,16 @@
httpClient := newSecureHTTPClient()

// Discover OIDC endpoints
jwksURL, introspectionURL, err := discoverOIDCConfig(httpClient, cfg.AuthorizationServer)
jwksURL, introspectionUrl, err := discoverOIDCConfig(httpClient, cfg.AuthorizationServer)
if err != nil {
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 All @@ -71,7 +79,7 @@
Config: cfg,
kf: kf,
client: httpClient,
introspectionURL: introspectionURL,
introspectionUrl: introspectionUrl,
}
return a, nil
}
Expand Down Expand Up @@ -153,7 +161,7 @@
Config
kf keyfunc.Keyfunc
client *http.Client
introspectionURL string
introspectionUrl string
Comment thread
duwenxin99 marked this conversation as resolved.
Outdated
}

// Returns the auth service type
Expand Down Expand Up @@ -280,22 +288,41 @@
return fmt.Errorf("failed to get logger from context: %w", err)
}

introspectionURL := a.introspectionURL
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)
}
}

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

Check failure on line 299 in internal/auth/generic/generic.go

View workflow job for this annotation

GitHub Actions / lint

QF1008: could remove embedded field "Config" from selector (staticcheck)
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.Config.IntrospectionMethod == "GET" {

Check failure on line 305 in internal/auth/generic/generic.go

View workflow job for this annotation

GitHub Actions / lint

QF1008: could remove embedded field "Config" from selector (staticcheck)
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,21 +344,17 @@
}

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 {
logger.InfoContext(ctx, "token is not active")
return &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) {
Expand All @@ -341,16 +364,22 @@

// 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
79 changes: 79 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,81 @@ 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.

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