Skip to content

Commit 9598136

Browse files
authored
Support resource indicator in remote auth (#2497)
Fix #1192
1 parent 1ef827d commit 9598136

24 files changed

+413
-16
lines changed

cmd/thv/app/auth_flags.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ type RemoteAuthFlags struct {
7575
RemoteAuthIssuer string
7676
RemoteAuthAuthorizeURL string
7777
RemoteAuthTokenURL string
78+
RemoteAuthResource string
7879

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

166169
// Token Exchange flags
167170
cmd.Flags().StringVar(&config.TokenExchangeURL, "token-exchange-url", "",

cmd/thv/app/run.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/stacklok/toolhive/pkg/networking"
2323
"github.com/stacklok/toolhive/pkg/process"
2424
"github.com/stacklok/toolhive/pkg/runner"
25+
"github.com/stacklok/toolhive/pkg/validation"
2526
"github.com/stacklok/toolhive/pkg/workloads"
2627
)
2728

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

465+
// Validate --remote-auth-resource flag (RFC 8707)
466+
if resourceFlag := cmd.Flags().Lookup("remote-auth-resource"); resourceFlag != nil && resourceFlag.Changed {
467+
resource := resourceFlag.Value.String()
468+
if resource != "" {
469+
if err := validation.ValidateResourceURI(resource); err != nil {
470+
return fmt.Errorf("invalid --remote-auth-resource: %w", err)
471+
}
472+
}
473+
}
474+
464475
// Validate --from-config flag usage
465476
fromConfigFlag := cmd.Flags().Lookup("from-config")
466477
if fromConfigFlag != nil && fromConfigFlag.Value.String() != "" {

cmd/thv/app/run_flags.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,11 +677,18 @@ func getRemoteAuthFromRemoteServerMetadata(
677677
authCfg.CallbackPort = runner.DefaultCallbackPort
678678
}
679679

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

685+
resourceIndicator := firstNonEmpty(f.RemoteAuthResource, oc.Resource)
686+
if resourceIndicator != "" {
687+
authCfg.Resource = resourceIndicator
688+
} else {
689+
authCfg.Resource = remote.DefaultResourceIndicator(remoteServerMetadata.URL)
690+
}
691+
685692
// OAuthParams: REPLACE metadata when CLI provides any key/value.
686693
if len(runFlags.OAuthParams) > 0 {
687694
authCfg.OAuthParams = runFlags.OAuthParams
@@ -715,6 +722,12 @@ func getRemoteAuthFromRunFlags(runFlags *RunFlags) (*remote.Config, error) {
715722
}
716723
}
717724

725+
// Derive the resource parameter (RFC 8707)
726+
resource := runFlags.RemoteAuthFlags.RemoteAuthResource
727+
if resource == "" && runFlags.ResourceURL != "" {
728+
resource = remote.DefaultResourceIndicator(runFlags.RemoteURL)
729+
}
730+
718731
return &remote.Config{
719732
ClientID: runFlags.RemoteAuthFlags.RemoteAuthClientID,
720733
ClientSecret: clientSecret,
@@ -725,6 +738,7 @@ func getRemoteAuthFromRunFlags(runFlags *RunFlags) (*remote.Config, error) {
725738
Issuer: runFlags.RemoteAuthFlags.RemoteAuthIssuer,
726739
AuthorizeURL: runFlags.RemoteAuthFlags.RemoteAuthAuthorizeURL,
727740
TokenURL: runFlags.RemoteAuthFlags.RemoteAuthTokenURL,
741+
Resource: resource,
728742
OAuthParams: runFlags.OAuthParams,
729743
}, nil
730744
}

docs/cli/thv_proxy.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli/thv_run.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/remote-mcp-authentication.md

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,60 @@ When no client credentials are provided ([`pkg/auth/oauth/dynamic_registration.g
150150
4. **Store Credentials**: Use returned client_id (and client_secret if provided)
151151
5. **Proceed with OAuth Flow**: Using registered credentials
152152

153+
## Resource Parameter (RFC 8707) Implementation
154+
155+
ToolHive implements the OAuth 2.0 Resource Indicators (RFC 8707) as required by the MCP specification:
156+
157+
**Location**: [`pkg/auth/remote/handler.go:52-69`](../pkg/auth/remote/handler.go#L52)
158+
159+
### Automatic Defaulting
160+
When no explicit `--remote-auth-resource` flag is provided, ToolHive automatically:
161+
1. Defaults the resource parameter to the remote server URL (the canonical URI of the MCP server)
162+
2. Validates the URI format according to MCP specification requirements
163+
3. Normalizes the URI (lowercase scheme/host, strips fragments, preserves trailing slashes)
164+
4. If the resource parameter cannot be derived, then it will not be sent
165+
166+
### Validation Rules
167+
The resource parameter must conform to MCP canonical URI requirements:
168+
- **Must** include a scheme (http/https)
169+
- **Must** include a host
170+
- **Must not** contain fragments (#)
171+
172+
When the resource parameter is **defaulted** from the remote URL:
173+
- Scheme and host are normalized to lowercase
174+
- Fragments are stripped (not allowed in resource indicators per spec)
175+
- Trailing slashes are preserved (we cannot determine semantic significance)
176+
177+
When the resource parameter is **explicitly provided** by the user:
178+
- Value is validated but **not modified**
179+
- Returns an error if the value is invalid
180+
- User must provide a properly formatted canonical URI
181+
182+
### Examples
183+
```bash
184+
# Automatic resource parameter (defaults and normalizes to remote URL)
185+
thv run https://MCP.Example.COM/api#section
186+
# Resource defaults to: https://mcp.example.com/api (normalized, fragment stripped)
187+
188+
# Explicit resource parameter (not modified, must be valid)
189+
thv run https://mcp.example.com/api \
190+
--remote-auth-resource https://mcp.example.com
191+
192+
# Invalid explicit resource parameter with fragment (returns error)
193+
thv run https://mcp.example.com/api \
194+
--remote-auth-resource https://mcp.example.com#fragment
195+
# Error: invalid resource parameter: resource URI must not contain fragments
196+
197+
# Invalid explicit resource parameter without scheme (returns error)
198+
thv run https://mcp.example.com/api \
199+
--remote-auth-resource mcp.example.com
200+
# Error: invalid resource parameter: resource URI must include a scheme
201+
```
202+
203+
The validated and normalized resource parameter is sent in both:
204+
- Authorization requests (as `resource` query parameter)
205+
- Token exchange requests (as `resource` parameter)
206+
153207
## Security Features
154208

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

337+
338+
283339
## Future Enhancements
284340

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

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

292347
## Conclusion
293348

docs/server/docs.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/api/v1/workload_service.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@ func (s *WorkloadService) BuildFullRunConfig(ctx context.Context, req *createReq
102102
}
103103
}
104104

105+
// Validate user-provided resource indicator (RFC 8707)
106+
if req.OAuthConfig.Resource != "" {
107+
if err := validation.ValidateResourceURI(req.OAuthConfig.Resource); err != nil {
108+
return nil, fmt.Errorf("%w: invalid resource parameter: %v", retriever.ErrInvalidRunConfig, err)
109+
}
110+
}
111+
105112
// Default group if not specified
106113
groupName := req.Group
107114
if groupName == "" {
@@ -152,6 +159,15 @@ func (s *WorkloadService) BuildFullRunConfig(ctx context.Context, req *createReq
152159

153160
if remoteServerMetadata, ok := serverMetadata.(*registry.RemoteServerMetadata); ok {
154161
if remoteServerMetadata.OAuthConfig != nil {
162+
// Default resource: user-provided > registry metadata > derived from remote URL
163+
resource := req.OAuthConfig.Resource
164+
if resource == "" {
165+
resource = remoteServerMetadata.OAuthConfig.Resource
166+
}
167+
if resource == "" && remoteServerMetadata.URL != "" {
168+
resource = remote.DefaultResourceIndicator(remoteServerMetadata.URL)
169+
}
170+
155171
remoteAuthConfig = &remote.Config{
156172
ClientID: req.OAuthConfig.ClientID,
157173
Scopes: remoteServerMetadata.OAuthConfig.Scopes,
@@ -160,6 +176,7 @@ func (s *WorkloadService) BuildFullRunConfig(ctx context.Context, req *createReq
160176
AuthorizeURL: remoteServerMetadata.OAuthConfig.AuthorizeURL,
161177
TokenURL: remoteServerMetadata.OAuthConfig.TokenURL,
162178
UsePKCE: remoteServerMetadata.OAuthConfig.UsePKCE,
179+
Resource: resource,
163180
OAuthParams: remoteServerMetadata.OAuthConfig.OAuthParams,
164181
Headers: remoteServerMetadata.Headers,
165182
EnvVars: remoteServerMetadata.EnvVars,
@@ -254,6 +271,12 @@ func createRequestToRemoteAuthConfig(
254271
req *createRequest,
255272
) *remote.Config {
256273

274+
// Default resource: user-provided > derived from remote URL
275+
resource := req.OAuthConfig.Resource
276+
if resource == "" && req.URL != "" {
277+
resource = remote.DefaultResourceIndicator(req.URL)
278+
}
279+
257280
// Create RemoteAuthConfig
258281
remoteAuthConfig := &remote.Config{
259282
ClientID: req.OAuthConfig.ClientID,
@@ -262,6 +285,7 @@ func createRequestToRemoteAuthConfig(
262285
AuthorizeURL: req.OAuthConfig.AuthorizeURL,
263286
TokenURL: req.OAuthConfig.TokenURL,
264287
UsePKCE: req.OAuthConfig.UsePKCE,
288+
Resource: resource,
265289
OAuthParams: req.OAuthConfig.OAuthParams,
266290
CallbackPort: req.OAuthConfig.CallbackPort,
267291
SkipBrowser: req.OAuthConfig.SkipBrowser,

0 commit comments

Comments
 (0)