Skip to content

Commit 3eae891

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 be40131 commit 3eae891

12 files changed

Lines changed: 123 additions & 35 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.

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) {
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) {
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)

0 commit comments

Comments
 (0)