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
3 changes: 3 additions & 0 deletions cmd/thv/app/auth_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ type RemoteAuthFlags struct {
RemoteAuthIssuer string
RemoteAuthAuthorizeURL string
RemoteAuthTokenURL string
RemoteAuthResource string

// Token Exchange Configuration
TokenExchangeURL string
Expand Down Expand Up @@ -162,6 +163,8 @@ func AddRemoteAuthFlags(cmd *cobra.Command, config *RemoteAuthFlags) {
"OAuth authorization endpoint URL (alternative to --remote-auth-issuer for non-OIDC OAuth)")
cmd.Flags().StringVar(&config.RemoteAuthTokenURL, "remote-auth-token-url", "",
"OAuth token endpoint URL (alternative to --remote-auth-issuer for non-OIDC OAuth)")
cmd.Flags().StringVar(&config.RemoteAuthResource, "remote-auth-resource", "",
"OAuth 2.0 resource indicator (RFC 8707)")

// Token Exchange flags
cmd.Flags().StringVar(&config.TokenExchangeURL, "token-exchange-url", "",
Expand Down
11 changes: 11 additions & 0 deletions cmd/thv/app/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/stacklok/toolhive/pkg/networking"
"github.com/stacklok/toolhive/pkg/process"
"github.com/stacklok/toolhive/pkg/runner"
"github.com/stacklok/toolhive/pkg/validation"
"github.com/stacklok/toolhive/pkg/workloads"
)

Expand Down Expand Up @@ -461,6 +462,16 @@ func validateRunFlags(cmd *cobra.Command, args []string) error {
return err
}

// Validate --remote-auth-resource flag (RFC 8707)
if resourceFlag := cmd.Flags().Lookup("remote-auth-resource"); resourceFlag != nil && resourceFlag.Changed {
resource := resourceFlag.Value.String()
if resource != "" {
if err := validation.ValidateResourceURI(resource); err != nil {
return fmt.Errorf("invalid --remote-auth-resource: %w", err)
}
}
}

// Validate --from-config flag usage
fromConfigFlag := cmd.Flags().Lookup("from-config")
if fromConfigFlag != nil && fromConfigFlag.Value.String() != "" {
Expand Down
16 changes: 15 additions & 1 deletion cmd/thv/app/run_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -677,11 +677,18 @@ func getRemoteAuthFromRemoteServerMetadata(
authCfg.CallbackPort = runner.DefaultCallbackPort
}

// Issuer / URLs: CLI non-empty wins
// Issuer / URLs / Resource: CLI non-empty wins
authCfg.Issuer = firstNonEmpty(f.RemoteAuthIssuer, oc.Issuer)
authCfg.AuthorizeURL = firstNonEmpty(f.RemoteAuthAuthorizeURL, oc.AuthorizeURL)
authCfg.TokenURL = firstNonEmpty(f.RemoteAuthTokenURL, oc.TokenURL)

resourceIndicator := firstNonEmpty(f.RemoteAuthResource, oc.Resource)
if resourceIndicator != "" {
authCfg.Resource = resourceIndicator
} else {
authCfg.Resource = remote.DefaultResourceIndicator(remoteServerMetadata.URL)
}

// OAuthParams: REPLACE metadata when CLI provides any key/value.
if len(runFlags.OAuthParams) > 0 {
authCfg.OAuthParams = runFlags.OAuthParams
Expand Down Expand Up @@ -715,6 +722,12 @@ func getRemoteAuthFromRunFlags(runFlags *RunFlags) (*remote.Config, error) {
}
}

// Derive the resource parameter (RFC 8707)
resource := runFlags.RemoteAuthFlags.RemoteAuthResource
if resource == "" && runFlags.ResourceURL != "" {
resource = remote.DefaultResourceIndicator(runFlags.RemoteURL)
}

return &remote.Config{
ClientID: runFlags.RemoteAuthFlags.RemoteAuthClientID,
ClientSecret: clientSecret,
Expand All @@ -725,6 +738,7 @@ func getRemoteAuthFromRunFlags(runFlags *RunFlags) (*remote.Config, error) {
Issuer: runFlags.RemoteAuthFlags.RemoteAuthIssuer,
AuthorizeURL: runFlags.RemoteAuthFlags.RemoteAuthAuthorizeURL,
TokenURL: runFlags.RemoteAuthFlags.RemoteAuthTokenURL,
Resource: resource,
OAuthParams: runFlags.OAuthParams,
}, nil
}
Expand Down
1 change: 1 addition & 0 deletions docs/cli/thv_proxy.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/cli/thv_run.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 60 additions & 5 deletions docs/remote-mcp-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,60 @@ When no client credentials are provided ([`pkg/auth/oauth/dynamic_registration.g
4. **Store Credentials**: Use returned client_id (and client_secret if provided)
5. **Proceed with OAuth Flow**: Using registered credentials

## Resource Parameter (RFC 8707) Implementation

ToolHive implements the OAuth 2.0 Resource Indicators (RFC 8707) as required by the MCP specification:

**Location**: [`pkg/auth/remote/handler.go:52-69`](../pkg/auth/remote/handler.go#L52)

### Automatic Defaulting
When no explicit `--remote-auth-resource` flag is provided, ToolHive automatically:
1. Defaults the resource parameter to the remote server URL (the canonical URI of the MCP server)
2. Validates the URI format according to MCP specification requirements
3. Normalizes the URI (lowercase scheme/host, strips fragments, preserves trailing slashes)
4. If the resource parameter cannot be derived, then it will not be sent

### Validation Rules
The resource parameter must conform to MCP canonical URI requirements:
- **Must** include a scheme (http/https)
- **Must** include a host
- **Must not** contain fragments (#)

When the resource parameter is **defaulted** from the remote URL:
- Scheme and host are normalized to lowercase
- Fragments are stripped (not allowed in resource indicators per spec)
- Trailing slashes are preserved (we cannot determine semantic significance)

When the resource parameter is **explicitly provided** by the user:
- Value is validated but **not modified**
- Returns an error if the value is invalid
- User must provide a properly formatted canonical URI

### Examples
```bash
# Automatic resource parameter (defaults and normalizes to remote URL)
thv run https://MCP.Example.COM/api#section
# Resource defaults to: https://mcp.example.com/api (normalized, fragment stripped)

# Explicit resource parameter (not modified, must be valid)
thv run https://mcp.example.com/api \
--remote-auth-resource https://mcp.example.com

# Invalid explicit resource parameter with fragment (returns error)
thv run https://mcp.example.com/api \
--remote-auth-resource https://mcp.example.com#fragment
# Error: invalid resource parameter: resource URI must not contain fragments

# Invalid explicit resource parameter without scheme (returns error)
thv run https://mcp.example.com/api \
--remote-auth-resource mcp.example.com
# Error: invalid resource parameter: resource URI must include a scheme
```

The validated and normalized resource parameter is sent in both:
- Authorization requests (as `resource` query parameter)
- Token exchange requests (as `resource` parameter)

## Security Features

### HTTPS Enforcement
Expand Down Expand Up @@ -277,17 +331,18 @@ The `oauth_config` section supports:
| OAuth 2.1 PKCE | ✅ Compliant | Enabled by default |
| WWW-Authenticate Parsing | ✅ Compliant | Supports Bearer with realm/resource_metadata |
| Multiple Auth Servers | ✅ Compliant | Iterates and validates all servers |
| Resource Parameter (RFC 8707) | ⚠️ Partial | Infrastructure ready, not yet sent in requests |
| Resource Parameter (RFC 8707) | ✅ Compliant | Automatically defaults to remote server URL, validated and normalized |
| Token Audience Validation | ⚠️ Partial | Server-side validation support ready |



## Future Enhancements

While ToolHive is highly compliant with the current MCP specification, potential improvements include:

1. **Resource Parameter**: Add explicit `resource` parameter to OAuth requests (infrastructure exists)
2. **Token Audience Validation**: Enhanced client-side validation of token audience claims
3. **Refresh Token Rotation**: Implement automatic refresh token rotation for long-lived sessions
4. **Client Credential Caching**: Persist dynamically registered clients across sessions
1. **Token Audience Validation**: Enhanced client-side validation of token audience claims
2. **Refresh Token Rotation**: Implement automatic refresh token rotation for long-lived sessions
3. **Client Credential Caching**: Persist dynamically registered clients across sessions

## Conclusion

Expand Down
2 changes: 1 addition & 1 deletion docs/server/docs.go

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/server/swagger.json

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions docs/server/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions pkg/api/v1/workload_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ func (s *WorkloadService) BuildFullRunConfig(ctx context.Context, req *createReq
}
}

// Validate user-provided resource indicator (RFC 8707)
if req.OAuthConfig.Resource != "" {
if err := validation.ValidateResourceURI(req.OAuthConfig.Resource); err != nil {
return nil, fmt.Errorf("%w: invalid resource parameter: %v", retriever.ErrInvalidRunConfig, err)
}
}

// Default group if not specified
groupName := req.Group
if groupName == "" {
Expand Down Expand Up @@ -152,6 +159,15 @@ func (s *WorkloadService) BuildFullRunConfig(ctx context.Context, req *createReq

if remoteServerMetadata, ok := serverMetadata.(*registry.RemoteServerMetadata); ok {
if remoteServerMetadata.OAuthConfig != nil {
// Default resource: user-provided > registry metadata > derived from remote URL
resource := req.OAuthConfig.Resource
if resource == "" {
resource = remoteServerMetadata.OAuthConfig.Resource
}
if resource == "" && remoteServerMetadata.URL != "" {
resource = remote.DefaultResourceIndicator(remoteServerMetadata.URL)
}

remoteAuthConfig = &remote.Config{
ClientID: req.OAuthConfig.ClientID,
Scopes: remoteServerMetadata.OAuthConfig.Scopes,
Expand All @@ -160,6 +176,7 @@ func (s *WorkloadService) BuildFullRunConfig(ctx context.Context, req *createReq
AuthorizeURL: remoteServerMetadata.OAuthConfig.AuthorizeURL,
TokenURL: remoteServerMetadata.OAuthConfig.TokenURL,
UsePKCE: remoteServerMetadata.OAuthConfig.UsePKCE,
Resource: resource,
OAuthParams: remoteServerMetadata.OAuthConfig.OAuthParams,
Headers: remoteServerMetadata.Headers,
EnvVars: remoteServerMetadata.EnvVars,
Expand Down Expand Up @@ -254,6 +271,12 @@ func createRequestToRemoteAuthConfig(
req *createRequest,
) *remote.Config {

// Default resource: user-provided > derived from remote URL
resource := req.OAuthConfig.Resource
if resource == "" && req.URL != "" {
resource = remote.DefaultResourceIndicator(req.URL)
}

// Create RemoteAuthConfig
remoteAuthConfig := &remote.Config{
ClientID: req.OAuthConfig.ClientID,
Expand All @@ -262,6 +285,7 @@ func createRequestToRemoteAuthConfig(
AuthorizeURL: req.OAuthConfig.AuthorizeURL,
TokenURL: req.OAuthConfig.TokenURL,
UsePKCE: req.OAuthConfig.UsePKCE,
Resource: resource,
OAuthParams: req.OAuthConfig.OAuthParams,
CallbackPort: req.OAuthConfig.CallbackPort,
SkipBrowser: req.OAuthConfig.SkipBrowser,
Expand Down
3 changes: 3 additions & 0 deletions pkg/api/v1/workload_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ type remoteOAuthConfig struct {
CallbackPort int `json:"callback_port,omitempty"`
// Whether to skip opening browser for OAuth flow (defaults to false)
SkipBrowser bool `json:"skip_browser,omitempty"`
// OAuth 2.0 resource indicator (RFC 8707)
Resource string `json:"resource,omitempty"`
}

// createRequest represents the request to create a new workload
Expand Down Expand Up @@ -222,6 +224,7 @@ func runConfigToCreateRequest(runConfig *runner.RunConfig) *createRequest {
OAuthParams: runConfig.RemoteAuthConfig.OAuthParams,
CallbackPort: runConfig.RemoteAuthConfig.CallbackPort,
SkipBrowser: runConfig.RemoteAuthConfig.SkipBrowser,
Resource: runConfig.RemoteAuthConfig.Resource,
}
headers = runConfig.RemoteAuthConfig.Headers
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/api/v1/workloads_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ func TestRunConfigToCreateRequest(t *testing.T) {
ClientSecret: "oauth-client-secret,target=oauth_secret",
Scopes: []string{"read", "write"},
UsePKCE: true,
Resource: "https://mcp.example.com",
OAuthParams: map[string]string{"custom": "param"},
CallbackPort: 8081,
},
Expand All @@ -176,6 +177,7 @@ func TestRunConfigToCreateRequest(t *testing.T) {
assert.Equal(t, "test-client", result.OAuthConfig.ClientID)
assert.Equal(t, []string{"read", "write"}, result.OAuthConfig.Scopes)
assert.True(t, result.OAuthConfig.UsePKCE)
assert.Equal(t, "https://mcp.example.com", result.OAuthConfig.Resource)
assert.Equal(t, map[string]string{"custom": "param"}, result.OAuthConfig.OAuthParams)
assert.Equal(t, 8081, result.OAuthConfig.CallbackPort)

Expand Down
3 changes: 3 additions & 0 deletions pkg/auth/discovery/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ type OAuthFlowConfig struct {
CallbackPort int
Timeout time.Duration
SkipBrowser bool
Resource string // RFC 8707 resource indicator (optional)
OAuthParams map[string]string
}

Expand Down Expand Up @@ -494,6 +495,7 @@ func createOAuthConfig(ctx context.Context, issuer string, config *OAuthFlowConf
config.Scopes,
true, // Enable PKCE by default for security
config.CallbackPort,
config.Resource,
config.OAuthParams,
)
}
Expand All @@ -508,6 +510,7 @@ func createOAuthConfig(ctx context.Context, issuer string, config *OAuthFlowConf
config.Scopes,
true, // Enable PKCE by default for security
config.CallbackPort,
config.Resource,
)
}

Expand Down
11 changes: 11 additions & 0 deletions pkg/auth/oauth/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ type Config struct {
// IntrospectionEndpoint is the optional introspection endpoint for validating tokens
IntrospectionEndpoint string

// Resource is the OAuth 2.0 resource indicator (RFC 8707).
Resource string

// OAuthParams are additional parameters to pass to the authorization URL
OAuthParams map[string]string
}
Expand Down Expand Up @@ -244,6 +247,10 @@ func (f *Flow) buildAuthURL() string {
oauth2.SetAuthURLParam("state", f.state),
}

if f.config.Resource != "" {
opts = append(opts, oauth2.SetAuthURLParam("resource", f.config.Resource))
}

if f.config.OAuthParams != nil {
for key, value := range f.config.OAuthParams {
opts = append(opts, oauth2.SetAuthURLParam(key, value))
Expand Down Expand Up @@ -303,6 +310,10 @@ func (f *Flow) handleCallback(tokenChan chan<- *oauth2.Token, errorChan chan<- e
opts = append(opts, oauth2.SetAuthURLParam("code_verifier", f.codeVerifier))
}

if f.config.Resource != "" {
opts = append(opts, oauth2.SetAuthURLParam("resource", f.config.Resource))
}

token, err := f.oauth2Config.Exchange(ctx, code, opts...)
if err != nil {
err = fmt.Errorf("failed to exchange code for token: %w", err)
Expand Down
2 changes: 2 additions & 0 deletions pkg/auth/oauth/manual.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func CreateOAuthConfigManual(
scopes []string,
usePKCE bool,
callbackPort int,
resource string,
oauthParams map[string]string,
) (*Config, error) {
if clientID == "" {
Expand Down Expand Up @@ -47,6 +48,7 @@ func CreateOAuthConfigManual(
Scopes: scopes,
UsePKCE: usePKCE,
CallbackPort: callbackPort,
Resource: resource,
OAuthParams: oauthParams,
}, nil
}
Loading
Loading