diff --git a/cmd/thv/app/auth_flags.go b/cmd/thv/app/auth_flags.go index 2a1a8a1bb6..d192b7379a 100644 --- a/cmd/thv/app/auth_flags.go +++ b/cmd/thv/app/auth_flags.go @@ -72,6 +72,7 @@ type RemoteAuthFlags struct { RemoteAuthClientSecret string RemoteAuthClientSecretFile string RemoteAuthScopes []string + RemoteAuthScopeParamName string RemoteAuthSkipBrowser bool RemoteAuthTimeout time.Duration RemoteAuthCallbackPort int @@ -163,6 +164,8 @@ func AddRemoteAuthFlags(cmd *cobra.Command, config *RemoteAuthFlags) { "authorization server supports dynamic client registration (RFC 7591) or if using PKCE)") cmd.Flags().StringSliceVar(&config.RemoteAuthScopes, "remote-auth-scopes", []string{}, "OAuth scopes to request for remote server authentication (defaults: OIDC uses 'openid,profile,email')") + cmd.Flags().StringVar(&config.RemoteAuthScopeParamName, "remote-auth-scope-param-name", "", + "Override the query parameter name for scopes in the authorization URL (e.g., 'user_scope' for Slack OAuth)") cmd.Flags().BoolVar(&config.RemoteAuthSkipBrowser, "remote-auth-skip-browser", false, "Skip opening browser for remote server OAuth flow (default false)") cmd.Flags().DurationVar(&config.RemoteAuthTimeout, "remote-auth-timeout", 30*time.Second, diff --git a/cmd/thv/app/proxy.go b/cmd/thv/app/proxy.go index b768d22ab5..de33227bcf 100644 --- a/cmd/thv/app/proxy.go +++ b/cmd/thv/app/proxy.go @@ -360,14 +360,15 @@ func handleOutgoingAuthentication(ctx context.Context) (*discovery.OAuthFlowResu } flowConfig := &discovery.OAuthFlowConfig{ - ClientID: remoteAuthFlags.RemoteAuthClientID, - ClientSecret: clientSecret, - AuthorizeURL: remoteAuthFlags.RemoteAuthAuthorizeURL, - TokenURL: remoteAuthFlags.RemoteAuthTokenURL, - Scopes: remoteAuthFlags.RemoteAuthScopes, - CallbackPort: remoteAuthFlags.RemoteAuthCallbackPort, - Timeout: remoteAuthFlags.RemoteAuthTimeout, - SkipBrowser: remoteAuthFlags.RemoteAuthSkipBrowser, + ClientID: remoteAuthFlags.RemoteAuthClientID, + ClientSecret: clientSecret, + AuthorizeURL: remoteAuthFlags.RemoteAuthAuthorizeURL, + TokenURL: remoteAuthFlags.RemoteAuthTokenURL, + Scopes: remoteAuthFlags.RemoteAuthScopes, + CallbackPort: remoteAuthFlags.RemoteAuthCallbackPort, + Timeout: remoteAuthFlags.RemoteAuthTimeout, + SkipBrowser: remoteAuthFlags.RemoteAuthSkipBrowser, + ScopeParamName: remoteAuthFlags.RemoteAuthScopeParamName, } result, err := discovery.PerformOAuthFlow(ctx, remoteAuthFlags.RemoteAuthIssuer, flowConfig) @@ -390,14 +391,15 @@ func handleOutgoingAuthentication(ctx context.Context) (*discovery.OAuthFlowResu // Perform OAuth flow with discovered configuration flowConfig := &discovery.OAuthFlowConfig{ - ClientID: remoteAuthFlags.RemoteAuthClientID, - ClientSecret: clientSecret, - AuthorizeURL: remoteAuthFlags.RemoteAuthAuthorizeURL, - TokenURL: remoteAuthFlags.RemoteAuthTokenURL, - Scopes: remoteAuthFlags.RemoteAuthScopes, - CallbackPort: remoteAuthFlags.RemoteAuthCallbackPort, - Timeout: remoteAuthFlags.RemoteAuthTimeout, - SkipBrowser: remoteAuthFlags.RemoteAuthSkipBrowser, + ClientID: remoteAuthFlags.RemoteAuthClientID, + ClientSecret: clientSecret, + AuthorizeURL: remoteAuthFlags.RemoteAuthAuthorizeURL, + TokenURL: remoteAuthFlags.RemoteAuthTokenURL, + Scopes: remoteAuthFlags.RemoteAuthScopes, + CallbackPort: remoteAuthFlags.RemoteAuthCallbackPort, + Timeout: remoteAuthFlags.RemoteAuthTimeout, + SkipBrowser: remoteAuthFlags.RemoteAuthSkipBrowser, + ScopeParamName: remoteAuthFlags.RemoteAuthScopeParamName, } result, err := discovery.PerformOAuthFlow(ctx, authInfo.Realm, flowConfig) diff --git a/cmd/thv/app/run_flags.go b/cmd/thv/app/run_flags.go index d17e310f8e..94c159af8a 100644 --- a/cmd/thv/app/run_flags.go +++ b/cmd/thv/app/run_flags.go @@ -962,6 +962,9 @@ func getRemoteAuthFromRemoteServerMetadata( authCfg.OAuthParams = oc.OAuthParams } + // ScopeParamName: from CLI flag only (not yet supported in registry metadata) + authCfg.ScopeParamName = f.RemoteAuthScopeParamName + // Resolve bearer token from multiple sources (flag, file, environment variable) resolvedBearerToken, err := resolveSecret( f.RemoteAuthBearerToken, @@ -1023,6 +1026,7 @@ func getRemoteAuthFromRunFlags(runFlags *RunFlags) (*remote.Config, error) { ClientID: runFlags.RemoteAuthFlags.RemoteAuthClientID, ClientSecret: clientSecret, Scopes: runFlags.RemoteAuthFlags.RemoteAuthScopes, + ScopeParamName: runFlags.RemoteAuthFlags.RemoteAuthScopeParamName, SkipBrowser: runFlags.RemoteAuthFlags.RemoteAuthSkipBrowser, Timeout: runFlags.RemoteAuthFlags.RemoteAuthTimeout, CallbackPort: runFlags.RemoteAuthFlags.RemoteAuthCallbackPort, diff --git a/docs/cli/thv_proxy.md b/docs/cli/thv_proxy.md index d93a8ed72e..be2e8d92d2 100644 --- a/docs/cli/thv_proxy.md +++ b/docs/cli/thv_proxy.md @@ -117,6 +117,7 @@ thv proxy [flags] SERVER_NAME --remote-auth-client-secret-file string Path to file containing OAuth client secret (alternative to --remote-auth-client-secret) (optional if the authorization server supports dynamic client registration (RFC 7591) or if using PKCE) --remote-auth-issuer string OAuth/OIDC issuer URL for remote server authentication (e.g., https://accounts.google.com) --remote-auth-resource string OAuth 2.0 resource indicator (RFC 8707) + --remote-auth-scope-param-name string Override the query parameter name for scopes in the authorization URL (e.g., 'user_scope' for Slack OAuth) --remote-auth-scopes strings OAuth scopes to request for remote server authentication (defaults: OIDC uses 'openid,profile,email') --remote-auth-skip-browser Skip opening browser for remote server OAuth flow (default false) --remote-auth-timeout duration Timeout for OAuth authentication flow (e.g., 30s, 1m, 2m30s) (default 30s) diff --git a/docs/cli/thv_run.md b/docs/cli/thv_run.md index 257705f733..d3c38dc4cb 100644 --- a/docs/cli/thv_run.md +++ b/docs/cli/thv_run.md @@ -167,6 +167,7 @@ thv run [flags] SERVER_OR_IMAGE_OR_PROTOCOL [-- ARGS...] --remote-auth-client-secret-file string Path to file containing OAuth client secret (alternative to --remote-auth-client-secret) (optional if the authorization server supports dynamic client registration (RFC 7591) or if using PKCE) --remote-auth-issuer string OAuth/OIDC issuer URL for remote server authentication (e.g., https://accounts.google.com) --remote-auth-resource string OAuth 2.0 resource indicator (RFC 8707) + --remote-auth-scope-param-name string Override the query parameter name for scopes in the authorization URL (e.g., 'user_scope' for Slack OAuth) --remote-auth-scopes strings OAuth scopes to request for remote server authentication (defaults: OIDC uses 'openid,profile,email') --remote-auth-skip-browser Skip opening browser for remote server OAuth flow (default false) --remote-auth-timeout duration Timeout for OAuth authentication flow (e.g., 30s, 1m, 2m30s) (default 30s) diff --git a/docs/server/docs.go b/docs/server/docs.go index df70eb0529..86da675929 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -316,6 +316,10 @@ const docTemplate = `{ "description": "Resource is the OAuth 2.0 resource indicator (RFC 8707).", "type": "string" }, + "scope_param_name": { + "description": "ScopeParamName overrides the query parameter name used to send scopes in the\nauthorization URL. When empty, the standard \"scope\" parameter is used.\nSome providers require a non-standard name (e.g., Slack uses \"user_scope\").", + "type": "string" + }, "scopes": { "items": { "type": "string" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 4008970275..405f04dcd8 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -309,6 +309,10 @@ "description": "Resource is the OAuth 2.0 resource indicator (RFC 8707).", "type": "string" }, + "scope_param_name": { + "description": "ScopeParamName overrides the query parameter name used to send scopes in the\nauthorization URL. When empty, the standard \"scope\" parameter is used.\nSome providers require a non-standard name (e.g., Slack uses \"user_scope\").", + "type": "string" + }, "scopes": { "items": { "type": "string" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index ac859bc102..81d5eb3c9b 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -310,6 +310,12 @@ components: resource: description: Resource is the OAuth 2.0 resource indicator (RFC 8707). type: string + scope_param_name: + description: |- + ScopeParamName overrides the query parameter name used to send scopes in the + authorization URL. When empty, the standard "scope" parameter is used. + Some providers require a non-standard name (e.g., Slack uses "user_scope"). + type: string scopes: items: type: string diff --git a/pkg/auth/discovery/discovery.go b/pkg/auth/discovery/discovery.go index 6a69fc6a15..b3ef713b73 100644 --- a/pkg/auth/discovery/discovery.go +++ b/pkg/auth/discovery/discovery.go @@ -509,6 +509,7 @@ type OAuthFlowConfig struct { SkipBrowser bool Resource string // RFC 8707 resource indicator (optional) OAuthParams map[string]string + ScopeParamName string // Override scope query parameter name (e.g., "user_scope" for Slack) } // OAuthFlowResult contains the result of an OAuth flow @@ -645,12 +646,13 @@ func createOAuthConfig(ctx context.Context, issuer string, config *OAuthFlowConf config.CallbackPort, config.Resource, config.OAuthParams, + config.ScopeParamName, ) } // Fall back to OIDC discovery slog.Debug("Using OIDC discovery") - return oauth.CreateOAuthConfigFromOIDC( + cfg, err := oauth.CreateOAuthConfigFromOIDC( ctx, issuer, config.ClientID, @@ -660,6 +662,11 @@ func createOAuthConfig(ctx context.Context, issuer string, config *OAuthFlowConf config.CallbackPort, config.Resource, ) + if err != nil { + return nil, err + } + cfg.ScopeParamName = config.ScopeParamName + return cfg, nil } func newOAuthFlow(ctx context.Context, oauthConfig *oauth.Config, config *OAuthFlowConfig) (*OAuthFlowResult, error) { diff --git a/pkg/auth/oauth/flow.go b/pkg/auth/oauth/flow.go index da9a9249df..bb17dd3060 100644 --- a/pkg/auth/oauth/flow.go +++ b/pkg/auth/oauth/flow.go @@ -15,6 +15,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -60,6 +61,13 @@ type Config struct { // OAuthParams are additional parameters to pass to the authorization URL OAuthParams map[string]string + + // ScopeParamName overrides the query parameter name used to send scopes in the + // authorization URL. When empty (default), the standard "scope" parameter is used. + // Some providers use non-standard parameter names (e.g., Slack uses "user_scope" + // for user-token scopes). When set, scopes are sent under this parameter name + // instead of "scope", and the standard "scope" parameter is cleared. + ScopeParamName string } // Flow handles the OAuth authentication flow @@ -267,6 +275,22 @@ func (f *Flow) buildAuthURL() string { } } + // When a custom scope parameter name is configured, move scopes from the + // standard "scope" parameter to the custom one. This supports OAuth providers + // that use non-standard parameter names (e.g., Slack's "user_scope"). + // We temporarily nil out oauth2Config.Scopes so the library omits the standard + // "scope" parameter entirely (an empty scope= would violate RFC 6749 ยง3.3). + // Scopes are restored via defer so token refresh requests still work correctly. + if f.config.ScopeParamName != "" && len(f.oauth2Config.Scopes) > 0 { + scopeValue := strings.Join(f.oauth2Config.Scopes, " ") + savedScopes := f.oauth2Config.Scopes + f.oauth2Config.Scopes = nil + defer func() { f.oauth2Config.Scopes = savedScopes }() + opts = append(opts, + oauth2.SetAuthURLParam(f.config.ScopeParamName, scopeValue), + ) + } + // Add PKCE parameters if enabled if f.config.UsePKCE { opts = append(opts, diff --git a/pkg/auth/oauth/flow_test.go b/pkg/auth/oauth/flow_test.go index 713e7c649f..6a410a054b 100644 --- a/pkg/auth/oauth/flow_test.go +++ b/pkg/auth/oauth/flow_test.go @@ -255,6 +255,49 @@ func TestBuildAuthURL(t *testing.T) { assert.Equal(t, "S256", query.Get("code_challenge_method")) }, }, + { + name: "auth URL with custom scope parameter name", + config: &Config{ + ClientID: "test-client", + AuthURL: "https://example.com/auth", + TokenURL: "https://example.com/token", + Scopes: []string{"search:read", "chat:write"}, + ScopeParamName: "user_scope", + }, + validate: func(t *testing.T, authURL string, _ *Flow) { + t.Helper() + parsedURL, err := url.Parse(authURL) + require.NoError(t, err) + + query := parsedURL.Query() + // Standard "scope" parameter should be absent, not empty + _, hasScope := query["scope"] + assert.False(t, hasScope, "scope parameter should be absent, not empty") + // Scopes should appear under the custom parameter name + assert.Contains(t, query.Get("user_scope"), "search:read") + assert.Contains(t, query.Get("user_scope"), "chat:write") + }, + }, + { + name: "auth URL with scope param name but no scopes", + config: &Config{ + ClientID: "test-client", + AuthURL: "https://example.com/auth", + TokenURL: "https://example.com/token", + Scopes: []string{}, + ScopeParamName: "user_scope", + }, + validate: func(t *testing.T, authURL string, _ *Flow) { + t.Helper() + parsedURL, err := url.Parse(authURL) + require.NoError(t, err) + + query := parsedURL.Query() + // Neither scope nor user_scope should be present + assert.Empty(t, query.Get("scope")) + assert.Empty(t, query.Get("user_scope")) + }, + }, } for _, tt := range tests { diff --git a/pkg/auth/oauth/manual.go b/pkg/auth/oauth/manual.go index d6abeaf882..40a8ca7054 100644 --- a/pkg/auth/oauth/manual.go +++ b/pkg/auth/oauth/manual.go @@ -19,6 +19,7 @@ func CreateOAuthConfigManual( callbackPort int, resource string, oauthParams map[string]string, + scopeParamName string, ) (*Config, error) { if clientID == "" { return nil, fmt.Errorf("client ID is required") @@ -44,14 +45,15 @@ func CreateOAuthConfigManual( } return &Config{ - ClientID: clientID, - ClientSecret: clientSecret, - AuthURL: authURL, - TokenURL: tokenURL, - Scopes: scopes, - UsePKCE: usePKCE, - CallbackPort: callbackPort, - Resource: resource, - OAuthParams: oauthParams, + ClientID: clientID, + ClientSecret: clientSecret, + AuthURL: authURL, + TokenURL: tokenURL, + Scopes: scopes, + UsePKCE: usePKCE, + CallbackPort: callbackPort, + Resource: resource, + OAuthParams: oauthParams, + ScopeParamName: scopeParamName, }, nil } diff --git a/pkg/auth/oauth/manual_test.go b/pkg/auth/oauth/manual_test.go index 5333b4d265..7eb8925afd 100644 --- a/pkg/auth/oauth/manual_test.go +++ b/pkg/auth/oauth/manual_test.go @@ -269,6 +269,7 @@ func TestCreateOAuthConfigManual(t *testing.T) { tt.callbackPort, tt.resource, oauthParams, + "", ) if tt.expectError { @@ -335,6 +336,7 @@ func TestCreateOAuthConfigManual_ScopeDefaultBehavior(t *testing.T) { 8080, "", nil, // No OAuth params for basic tests + "", // No scope param name override ) require.NoError(t, err) @@ -378,6 +380,7 @@ func TestCreateOAuthConfigManual_PKCEBehavior(t *testing.T) { 8080, "", nil, // No OAuth params for basic tests + "", // No scope param name override ) require.NoError(t, err) @@ -426,6 +429,7 @@ func TestCreateOAuthConfigManual_CallbackPortBehavior(t *testing.T) { tt.port, "", nil, // No OAuth params for basic tests + "", // No scope param name override ) require.NoError(t, err) @@ -491,6 +495,7 @@ func TestCreateOAuthConfigManual_OAuthParamsBehavior(t *testing.T) { 8080, "", tt.oauthParams, + "", ) require.NoError(t, err) diff --git a/pkg/auth/remote/config.go b/pkg/auth/remote/config.go index 049e5ab6b2..98c8fecd2f 100644 --- a/pkg/auth/remote/config.go +++ b/pkg/auth/remote/config.go @@ -44,6 +44,11 @@ type Config struct { // OAuth parameters for server-specific customization OAuthParams map[string]string `json:"oauth_params,omitempty" yaml:"oauth_params,omitempty"` + // ScopeParamName overrides the query parameter name used to send scopes in the + // authorization URL. When empty, the standard "scope" parameter is used. + // Some providers require a non-standard name (e.g., Slack uses "user_scope"). + ScopeParamName string `json:"scope_param_name,omitempty" yaml:"scope_param_name,omitempty"` + // Bearer token configuration (alternative to OAuth) BearerToken string `json:"bearer_token,omitempty" yaml:"bearer_token,omitempty"` //nolint:gosec // G117 BearerTokenFile string `json:"bearer_token_file,omitempty" yaml:"bearer_token_file,omitempty"` diff --git a/pkg/auth/remote/handler.go b/pkg/auth/remote/handler.go index a798698831..ccce3fb17b 100644 --- a/pkg/auth/remote/handler.go +++ b/pkg/auth/remote/handler.go @@ -146,16 +146,17 @@ func (h *Handler) performOAuthFlow( // buildOAuthFlowConfig creates the OAuth flow configuration func (h *Handler) buildOAuthFlowConfig(scopes []string, authServerInfo *discovery.AuthServerInfo) *discovery.OAuthFlowConfig { flowConfig := &discovery.OAuthFlowConfig{ - ClientID: h.config.ClientID, - ClientSecret: h.config.ClientSecret, - AuthorizeURL: h.config.AuthorizeURL, - TokenURL: h.config.TokenURL, - Scopes: scopes, - CallbackPort: h.config.CallbackPort, - Timeout: h.config.Timeout, - SkipBrowser: h.config.SkipBrowser, - Resource: h.config.Resource, - OAuthParams: h.config.OAuthParams, + ClientID: h.config.ClientID, + ClientSecret: h.config.ClientSecret, + AuthorizeURL: h.config.AuthorizeURL, + TokenURL: h.config.TokenURL, + Scopes: scopes, + CallbackPort: h.config.CallbackPort, + Timeout: h.config.Timeout, + SkipBrowser: h.config.SkipBrowser, + Resource: h.config.Resource, + OAuthParams: h.config.OAuthParams, + ScopeParamName: h.config.ScopeParamName, } // If we have discovered endpoints from the authorization server metadata,