Skip to content
Merged
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
70 changes: 53 additions & 17 deletions docs/en/documentation/configuration/authentication/generic.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ compliant identity provider (IDP). It discovers the JWKS (JSON Web Key Set) URL
either through the provider's `/.well-known/openid-configuration` endpoint or
directly via the provided `authorizationServer`.

To configure this auth service, you need to provide the `audience` (typically
your client ID or the intended audience for the token), the
`authorizationServer` of your identity provider, and optionally a list of
`scopesRequired` that must be present in the token's claims.
To configure this auth service, you need to provide the `audience` (the expected `aud` claim in the token), the `authorizationServer` of your identity provider, and optionally a list of `scopesRequired` that must be present in the token's claims.

> [!NOTE]
> The only time the `aud` claim matches the `client_id` is inside an ID Token (a concept from OpenID Connect used to verify a user's identity). Because an ID token is intended to be consumed by the client application itself, the client is the audience.

## Usage Modes

Expand Down 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,21 +123,58 @@ scopesRequired:
- write
```

#### Google Opaque Access Token Validation Example
Comment thread
duwenxin99 marked this conversation as resolved.

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"
Comment thread
duwenxin99 marked this conversation as resolved.
authorizationServer: https://accounts.google.com
introspectionEndpoint: https://www.googleapis.com/oauth2/v3/tokeninfo
introspectionMethod: GET
introspectionParamName: access_token
mcpEnabled: true
```

#### Okta OIDC Configuration Example

To secure your MCP server or tools using Okta as the identity provider:

```yaml
kind: authServices
name: okta-auth
type: generic
audience: api://default # Or your custom Okta audience
authorizationServer: https://your-subdomain.okta.com/oauth2/default
mcpEnabled: true
scopesRequired:
- openid
- profile
```

> [!NOTE]
> 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`.

{{< notice tip >}} Use environment variable replacement with the format
${ENV_NAME} instead of hardcoding your secrets into the configuration file.
{{< /notice >}}

[auth-invoke]: ../tools/_index.md#authorized-invocations
[auth-params]: ../tools/_index.md#authenticated-parameters
[mcp-auth]:
https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
[mcp-auth]: https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization

## Reference

| **field** | **type** | **required** | **description** |
| ------------------- | :------: | :----------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| type | string | true | Must be "generic". |
| audience | string | true | The expected audience (`aud` claim) in the JWT token. This ensures the token was minted specifically for your application. |
| 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. |
| **field** | **type** | **required** | **description** |
| ---------------------- | :------: | :----------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| type | string | true | Must be "generic". |
| audience | string | true | The expected audience (`aud` claim) in the token. This ensures the token was minted specifically for your application. See [Getting Started](#getting-started) for details on OIDC audience matching. |
| 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/v3/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