Skip to content

Commit b250b90

Browse files
authored
Add --remote-auth-scope-param-name for non-standard OAuth scope parameters (#4712)
* Add --remote-auth-scope-param-name flag for non-standard OAuth scope parameters Some OAuth providers use non-standard query parameter names for scopes in the authorization URL. For example, Slack's OAuth v2 requires user-token scopes in "user_scope" instead of the standard "scope" parameter. This causes ToolHive's OAuth flow to fail with invalid_scope errors when connecting to providers like Slack's MCP server. Add a new --remote-auth-scope-param-name flag that allows users to override the query parameter name used for scopes. When set, scopes are sent under the specified parameter name and the standard "scope" parameter is cleared. The oauth2Config.Scopes field is preserved so token refresh requests continue to work correctly. Signed-off-by: Gustavo Gomez <gmogmz@indeed.com> * Address review feedback: fix empty scope= and OIDC fallback path - Replace SetAuthURLParam("scope", "") with temporarily nil-ing oauth2Config.Scopes before AuthCodeURL, then restoring via defer. This omits the scope parameter entirely instead of producing an invalid empty scope= (RFC 6749 §3.3). - Propagate ScopeParamName on the OIDC discovery fallback path in createOAuthConfig, so --remote-auth-scope-param-name works with --remote-auth-issuer as well. - Strengthen test assertion to verify scope parameter is truly absent, not just empty-valued. Signed-off-by: Gustavo Gomez <gmogmz@indeed.com> * Fix missing swagger docs for scope_param_name field Signed-off-by: Gustavo Gomez <gmogmz@indeed.com> --------- Signed-off-by: Gustavo Gomez <gmogmz@indeed.com>
1 parent 0c5213e commit b250b90

15 files changed

Lines changed: 148 additions & 36 deletions

File tree

cmd/thv/app/auth_flags.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ type RemoteAuthFlags struct {
7272
RemoteAuthClientSecret string
7373
RemoteAuthClientSecretFile string
7474
RemoteAuthScopes []string
75+
RemoteAuthScopeParamName string
7576
RemoteAuthSkipBrowser bool
7677
RemoteAuthTimeout time.Duration
7778
RemoteAuthCallbackPort int
@@ -163,6 +164,8 @@ func AddRemoteAuthFlags(cmd *cobra.Command, config *RemoteAuthFlags) {
163164
"authorization server supports dynamic client registration (RFC 7591) or if using PKCE)")
164165
cmd.Flags().StringSliceVar(&config.RemoteAuthScopes, "remote-auth-scopes", []string{},
165166
"OAuth scopes to request for remote server authentication (defaults: OIDC uses 'openid,profile,email')")
167+
cmd.Flags().StringVar(&config.RemoteAuthScopeParamName, "remote-auth-scope-param-name", "",
168+
"Override the query parameter name for scopes in the authorization URL (e.g., 'user_scope' for Slack OAuth)")
166169
cmd.Flags().BoolVar(&config.RemoteAuthSkipBrowser, "remote-auth-skip-browser", false,
167170
"Skip opening browser for remote server OAuth flow (default false)")
168171
cmd.Flags().DurationVar(&config.RemoteAuthTimeout, "remote-auth-timeout", 30*time.Second,

cmd/thv/app/proxy.go

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -360,14 +360,15 @@ func handleOutgoingAuthentication(ctx context.Context) (*discovery.OAuthFlowResu
360360
}
361361

362362
flowConfig := &discovery.OAuthFlowConfig{
363-
ClientID: remoteAuthFlags.RemoteAuthClientID,
364-
ClientSecret: clientSecret,
365-
AuthorizeURL: remoteAuthFlags.RemoteAuthAuthorizeURL,
366-
TokenURL: remoteAuthFlags.RemoteAuthTokenURL,
367-
Scopes: remoteAuthFlags.RemoteAuthScopes,
368-
CallbackPort: remoteAuthFlags.RemoteAuthCallbackPort,
369-
Timeout: remoteAuthFlags.RemoteAuthTimeout,
370-
SkipBrowser: remoteAuthFlags.RemoteAuthSkipBrowser,
363+
ClientID: remoteAuthFlags.RemoteAuthClientID,
364+
ClientSecret: clientSecret,
365+
AuthorizeURL: remoteAuthFlags.RemoteAuthAuthorizeURL,
366+
TokenURL: remoteAuthFlags.RemoteAuthTokenURL,
367+
Scopes: remoteAuthFlags.RemoteAuthScopes,
368+
CallbackPort: remoteAuthFlags.RemoteAuthCallbackPort,
369+
Timeout: remoteAuthFlags.RemoteAuthTimeout,
370+
SkipBrowser: remoteAuthFlags.RemoteAuthSkipBrowser,
371+
ScopeParamName: remoteAuthFlags.RemoteAuthScopeParamName,
371372
}
372373

373374
result, err := discovery.PerformOAuthFlow(ctx, remoteAuthFlags.RemoteAuthIssuer, flowConfig)
@@ -390,14 +391,15 @@ func handleOutgoingAuthentication(ctx context.Context) (*discovery.OAuthFlowResu
390391

391392
// Perform OAuth flow with discovered configuration
392393
flowConfig := &discovery.OAuthFlowConfig{
393-
ClientID: remoteAuthFlags.RemoteAuthClientID,
394-
ClientSecret: clientSecret,
395-
AuthorizeURL: remoteAuthFlags.RemoteAuthAuthorizeURL,
396-
TokenURL: remoteAuthFlags.RemoteAuthTokenURL,
397-
Scopes: remoteAuthFlags.RemoteAuthScopes,
398-
CallbackPort: remoteAuthFlags.RemoteAuthCallbackPort,
399-
Timeout: remoteAuthFlags.RemoteAuthTimeout,
400-
SkipBrowser: remoteAuthFlags.RemoteAuthSkipBrowser,
394+
ClientID: remoteAuthFlags.RemoteAuthClientID,
395+
ClientSecret: clientSecret,
396+
AuthorizeURL: remoteAuthFlags.RemoteAuthAuthorizeURL,
397+
TokenURL: remoteAuthFlags.RemoteAuthTokenURL,
398+
Scopes: remoteAuthFlags.RemoteAuthScopes,
399+
CallbackPort: remoteAuthFlags.RemoteAuthCallbackPort,
400+
Timeout: remoteAuthFlags.RemoteAuthTimeout,
401+
SkipBrowser: remoteAuthFlags.RemoteAuthSkipBrowser,
402+
ScopeParamName: remoteAuthFlags.RemoteAuthScopeParamName,
401403
}
402404

403405
result, err := discovery.PerformOAuthFlow(ctx, authInfo.Realm, flowConfig)

cmd/thv/app/run_flags.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,9 @@ func getRemoteAuthFromRemoteServerMetadata(
962962
authCfg.OAuthParams = oc.OAuthParams
963963
}
964964

965+
// ScopeParamName: from CLI flag only (not yet supported in registry metadata)
966+
authCfg.ScopeParamName = f.RemoteAuthScopeParamName
967+
965968
// Resolve bearer token from multiple sources (flag, file, environment variable)
966969
resolvedBearerToken, err := resolveSecret(
967970
f.RemoteAuthBearerToken,
@@ -1023,6 +1026,7 @@ func getRemoteAuthFromRunFlags(runFlags *RunFlags) (*remote.Config, error) {
10231026
ClientID: runFlags.RemoteAuthFlags.RemoteAuthClientID,
10241027
ClientSecret: clientSecret,
10251028
Scopes: runFlags.RemoteAuthFlags.RemoteAuthScopes,
1029+
ScopeParamName: runFlags.RemoteAuthFlags.RemoteAuthScopeParamName,
10261030
SkipBrowser: runFlags.RemoteAuthFlags.RemoteAuthSkipBrowser,
10271031
Timeout: runFlags.RemoteAuthFlags.RemoteAuthTimeout,
10281032
CallbackPort: runFlags.RemoteAuthFlags.RemoteAuthCallbackPort,

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/server/docs.go

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

docs/server/swagger.json

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

docs/server/swagger.yaml

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

pkg/auth/discovery/discovery.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,7 @@ type OAuthFlowConfig struct {
509509
SkipBrowser bool
510510
Resource string // RFC 8707 resource indicator (optional)
511511
OAuthParams map[string]string
512+
ScopeParamName string // Override scope query parameter name (e.g., "user_scope" for Slack)
512513
}
513514

514515
// OAuthFlowResult contains the result of an OAuth flow
@@ -645,12 +646,13 @@ func createOAuthConfig(ctx context.Context, issuer string, config *OAuthFlowConf
645646
config.CallbackPort,
646647
config.Resource,
647648
config.OAuthParams,
649+
config.ScopeParamName,
648650
)
649651
}
650652

651653
// Fall back to OIDC discovery
652654
slog.Debug("Using OIDC discovery")
653-
return oauth.CreateOAuthConfigFromOIDC(
655+
cfg, err := oauth.CreateOAuthConfigFromOIDC(
654656
ctx,
655657
issuer,
656658
config.ClientID,
@@ -660,6 +662,11 @@ func createOAuthConfig(ctx context.Context, issuer string, config *OAuthFlowConf
660662
config.CallbackPort,
661663
config.Resource,
662664
)
665+
if err != nil {
666+
return nil, err
667+
}
668+
cfg.ScopeParamName = config.ScopeParamName
669+
return cfg, nil
663670
}
664671

665672
func newOAuthFlow(ctx context.Context, oauthConfig *oauth.Config, config *OAuthFlowConfig) (*OAuthFlowResult, error) {

pkg/auth/oauth/flow.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"net/http"
1616
"os"
1717
"os/signal"
18+
"strings"
1819
"syscall"
1920
"time"
2021

@@ -60,6 +61,13 @@ type Config struct {
6061

6162
// OAuthParams are additional parameters to pass to the authorization URL
6263
OAuthParams map[string]string
64+
65+
// ScopeParamName overrides the query parameter name used to send scopes in the
66+
// authorization URL. When empty (default), the standard "scope" parameter is used.
67+
// Some providers use non-standard parameter names (e.g., Slack uses "user_scope"
68+
// for user-token scopes). When set, scopes are sent under this parameter name
69+
// instead of "scope", and the standard "scope" parameter is cleared.
70+
ScopeParamName string
6371
}
6472

6573
// Flow handles the OAuth authentication flow
@@ -267,6 +275,22 @@ func (f *Flow) buildAuthURL() string {
267275
}
268276
}
269277

278+
// When a custom scope parameter name is configured, move scopes from the
279+
// standard "scope" parameter to the custom one. This supports OAuth providers
280+
// that use non-standard parameter names (e.g., Slack's "user_scope").
281+
// We temporarily nil out oauth2Config.Scopes so the library omits the standard
282+
// "scope" parameter entirely (an empty scope= would violate RFC 6749 §3.3).
283+
// Scopes are restored via defer so token refresh requests still work correctly.
284+
if f.config.ScopeParamName != "" && len(f.oauth2Config.Scopes) > 0 {
285+
scopeValue := strings.Join(f.oauth2Config.Scopes, " ")
286+
savedScopes := f.oauth2Config.Scopes
287+
f.oauth2Config.Scopes = nil
288+
defer func() { f.oauth2Config.Scopes = savedScopes }()
289+
opts = append(opts,
290+
oauth2.SetAuthURLParam(f.config.ScopeParamName, scopeValue),
291+
)
292+
}
293+
270294
// Add PKCE parameters if enabled
271295
if f.config.UsePKCE {
272296
opts = append(opts,

0 commit comments

Comments
 (0)