Skip to content

Commit 141b5cf

Browse files
committed
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>
1 parent a388093 commit 141b5cf

File tree

10 files changed

+112
-26
lines changed

10 files changed

+112
-26
lines changed

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
@@ -899,6 +899,9 @@ func getRemoteAuthFromRemoteServerMetadata(
899899
authCfg.OAuthParams = oc.OAuthParams
900900
}
901901

902+
// ScopeParamName: from CLI flag only (not yet supported in registry metadata)
903+
authCfg.ScopeParamName = f.RemoteAuthScopeParamName
904+
902905
// Resolve bearer token from multiple sources (flag, file, environment variable)
903906
resolvedBearerToken, err := resolveSecret(
904907
f.RemoteAuthBearerToken,
@@ -960,6 +963,7 @@ func getRemoteAuthFromRunFlags(runFlags *RunFlags) (*remote.Config, error) {
960963
ClientID: runFlags.RemoteAuthFlags.RemoteAuthClientID,
961964
ClientSecret: clientSecret,
962965
Scopes: runFlags.RemoteAuthFlags.RemoteAuthScopes,
966+
ScopeParamName: runFlags.RemoteAuthFlags.RemoteAuthScopeParamName,
963967
SkipBrowser: runFlags.RemoteAuthFlags.RemoteAuthSkipBrowser,
964968
Timeout: runFlags.RemoteAuthFlags.RemoteAuthTimeout,
965969
CallbackPort: runFlags.RemoteAuthFlags.RemoteAuthCallbackPort,

pkg/auth/discovery/discovery.go

Lines changed: 2 additions & 0 deletions
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,6 +646,7 @@ func createOAuthConfig(ctx context.Context, issuer string, config *OAuthFlowConf
645646
config.CallbackPort,
646647
config.Resource,
647648
config.OAuthParams,
649+
config.ScopeParamName,
648650
)
649651
}
650652

pkg/auth/oauth/flow.go

Lines changed: 20 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,18 @@ 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+
// The standard "scope" is cleared by setting it to empty; oauth2Config.Scopes
282+
// is preserved so token refresh requests still include scopes correctly.
283+
if f.config.ScopeParamName != "" && len(f.oauth2Config.Scopes) > 0 {
284+
opts = append(opts,
285+
oauth2.SetAuthURLParam("scope", ""),
286+
oauth2.SetAuthURLParam(f.config.ScopeParamName, strings.Join(f.oauth2Config.Scopes, " ")),
287+
)
288+
}
289+
270290
// Add PKCE parameters if enabled
271291
if f.config.UsePKCE {
272292
opts = append(opts,

pkg/auth/oauth/flow_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,48 @@ func TestBuildAuthURL(t *testing.T) {
255255
assert.Equal(t, "S256", query.Get("code_challenge_method"))
256256
},
257257
},
258+
{
259+
name: "auth URL with custom scope parameter name",
260+
config: &Config{
261+
ClientID: "test-client",
262+
AuthURL: "https://example.com/auth",
263+
TokenURL: "https://example.com/token",
264+
Scopes: []string{"search:read", "chat:write"},
265+
ScopeParamName: "user_scope",
266+
},
267+
validate: func(t *testing.T, authURL string, flow *Flow) {
268+
t.Helper()
269+
parsedURL, err := url.Parse(authURL)
270+
require.NoError(t, err)
271+
272+
query := parsedURL.Query()
273+
// Standard "scope" should be cleared
274+
assert.Empty(t, query.Get("scope"))
275+
// Scopes should appear under the custom parameter name
276+
assert.Contains(t, query.Get("user_scope"), "search:read")
277+
assert.Contains(t, query.Get("user_scope"), "chat:write")
278+
},
279+
},
280+
{
281+
name: "auth URL with scope param name but no scopes",
282+
config: &Config{
283+
ClientID: "test-client",
284+
AuthURL: "https://example.com/auth",
285+
TokenURL: "https://example.com/token",
286+
Scopes: []string{},
287+
ScopeParamName: "user_scope",
288+
},
289+
validate: func(t *testing.T, authURL string, flow *Flow) {
290+
t.Helper()
291+
parsedURL, err := url.Parse(authURL)
292+
require.NoError(t, err)
293+
294+
query := parsedURL.Query()
295+
// Neither scope nor user_scope should be present
296+
assert.Empty(t, query.Get("scope"))
297+
assert.Empty(t, query.Get("user_scope"))
298+
},
299+
},
258300
}
259301

260302
for _, tt := range tests {

pkg/auth/oauth/manual.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func CreateOAuthConfigManual(
1919
callbackPort int,
2020
resource string,
2121
oauthParams map[string]string,
22+
scopeParamName string,
2223
) (*Config, error) {
2324
if clientID == "" {
2425
return nil, fmt.Errorf("client ID is required")
@@ -44,14 +45,15 @@ func CreateOAuthConfigManual(
4445
}
4546

4647
return &Config{
47-
ClientID: clientID,
48-
ClientSecret: clientSecret,
49-
AuthURL: authURL,
50-
TokenURL: tokenURL,
51-
Scopes: scopes,
52-
UsePKCE: usePKCE,
53-
CallbackPort: callbackPort,
54-
Resource: resource,
55-
OAuthParams: oauthParams,
48+
ClientID: clientID,
49+
ClientSecret: clientSecret,
50+
AuthURL: authURL,
51+
TokenURL: tokenURL,
52+
Scopes: scopes,
53+
UsePKCE: usePKCE,
54+
CallbackPort: callbackPort,
55+
Resource: resource,
56+
OAuthParams: oauthParams,
57+
ScopeParamName: scopeParamName,
5658
}, nil
5759
}

pkg/auth/oauth/manual_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ func TestCreateOAuthConfigManual(t *testing.T) {
269269
tt.callbackPort,
270270
tt.resource,
271271
oauthParams,
272+
"",
272273
)
273274

274275
if tt.expectError {
@@ -335,6 +336,7 @@ func TestCreateOAuthConfigManual_ScopeDefaultBehavior(t *testing.T) {
335336
8080,
336337
"",
337338
nil, // No OAuth params for basic tests
339+
"", // No scope param name override
338340
)
339341

340342
require.NoError(t, err)
@@ -378,6 +380,7 @@ func TestCreateOAuthConfigManual_PKCEBehavior(t *testing.T) {
378380
8080,
379381
"",
380382
nil, // No OAuth params for basic tests
383+
"", // No scope param name override
381384
)
382385

383386
require.NoError(t, err)
@@ -426,6 +429,7 @@ func TestCreateOAuthConfigManual_CallbackPortBehavior(t *testing.T) {
426429
tt.port,
427430
"",
428431
nil, // No OAuth params for basic tests
432+
"", // No scope param name override
429433
)
430434

431435
require.NoError(t, err)
@@ -491,6 +495,7 @@ func TestCreateOAuthConfigManual_OAuthParamsBehavior(t *testing.T) {
491495
8080,
492496
"",
493497
tt.oauthParams,
498+
"",
494499
)
495500

496501
require.NoError(t, err)

pkg/auth/remote/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ type Config struct {
4444
// OAuth parameters for server-specific customization
4545
OAuthParams map[string]string `json:"oauth_params,omitempty" yaml:"oauth_params,omitempty"`
4646

47+
// ScopeParamName overrides the query parameter name used to send scopes in the
48+
// authorization URL. When empty, the standard "scope" parameter is used.
49+
// Some providers require a non-standard name (e.g., Slack uses "user_scope").
50+
ScopeParamName string `json:"scope_param_name,omitempty" yaml:"scope_param_name,omitempty"`
51+
4752
// Bearer token configuration (alternative to OAuth)
4853
BearerToken string `json:"bearer_token,omitempty" yaml:"bearer_token,omitempty"` //nolint:gosec // G117
4954
BearerTokenFile string `json:"bearer_token_file,omitempty" yaml:"bearer_token_file,omitempty"`

pkg/auth/remote/handler.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,8 @@ func (h *Handler) buildOAuthFlowConfig(scopes []string, authServerInfo *discover
155155
Timeout: h.config.Timeout,
156156
SkipBrowser: h.config.SkipBrowser,
157157
Resource: h.config.Resource,
158-
OAuthParams: h.config.OAuthParams,
158+
OAuthParams: h.config.OAuthParams,
159+
ScopeParamName: h.config.ScopeParamName,
159160
}
160161

161162
// If we have discovered endpoints from the authorization server metadata,

0 commit comments

Comments
 (0)