From e006f335b2bac6e8e407c5fbb119bc4125654f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:53:09 +0200 Subject: [PATCH] Pass OAuth2 scopes to connector at initiate time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an InitiateOptions struct to the Connector interface so each caller can declare the scopes it needs instead of having them baked into the connector at registration. The HTTP handler reads repeated ?scope= query parameters from /connectors/initiate and forwards them. Also restore GOOGLE_WORKSPACE and LINEAR provider definitions which were silently dropped from the bootstrap config refactor. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/connector/connector.go | 10 ++++- pkg/connector/oauth2.go | 22 ++++++++--- pkg/connector/oauth2_test.go | 55 +++++++++++++++++++++++++++ pkg/connector/providers.go | 28 ++++++++------ pkg/connector/registry.go | 10 ++++- pkg/server/api/console/v1/resolver.go | 6 ++- 6 files changed, 111 insertions(+), 20 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index a0dfdf850..f3f92bdd8 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -26,8 +26,16 @@ import ( type ( ProtocolType string + // InitiateOptions holds per-call options passed by the caller initiating + // a connector flow. Different callers may need different configurations + // for the same provider — for OAuth2, the most common case is requesting + // a different set of scopes (e.g. SCIM bridge vs access review). + InitiateOptions struct { + Scopes []string + } + Connector interface { - Initiate(ctx context.Context, provider string, organizationID gid.GID, r *http.Request) (string, error) + Initiate(ctx context.Context, provider string, organizationID gid.GID, opts InitiateOptions, r *http.Request) (string, error) Complete(ctx context.Context, r *http.Request) (Connection, *gid.GID, string, error) // returns: connection, organizationID, continueURL, error } diff --git a/pkg/connector/oauth2.go b/pkg/connector/oauth2.go index 3a8a447ed..a95b5b8ac 100644 --- a/pkg/connector/oauth2.go +++ b/pkg/connector/oauth2.go @@ -44,7 +44,6 @@ type ( ClientID string ClientSecret string RedirectURI string - Scopes []string AuthURL string TokenURL string ExtraAuthParams map[string]string // Optional: extra params for auth URL (e.g., access_type=offline for Google) @@ -98,7 +97,13 @@ func DecodeOAuth2StatePayload(tokenString string) (*statelesstoken.Payload[OAuth return statelesstoken.DecodePayload[OAuth2State](tokenString) } -func (c *OAuth2Connector) Initiate(ctx context.Context, provider string, organizationID gid.GID, r *http.Request) (string, error) { +func (c *OAuth2Connector) Initiate( + ctx context.Context, + provider string, + organizationID gid.GID, + opts InitiateOptions, + r *http.Request, +) (string, error) { stateData := OAuth2State{ OrganizationID: organizationID.String(), Provider: provider, @@ -111,12 +116,17 @@ func (c *OAuth2Connector) Initiate(ctx context.Context, provider string, organiz stateData.ConnectorID = connectorID } } - return c.InitiateWithState(ctx, stateData, r) + return c.InitiateWithState(ctx, stateData, opts, r) } // InitiateWithState generates an OAuth2 authorization URL with a custom state. // This allows callers to include additional context (like SCIMBridgeID) in the state. -func (c *OAuth2Connector) InitiateWithState(ctx context.Context, stateData OAuth2State, r *http.Request) (string, error) { +func (c *OAuth2Connector) InitiateWithState( + ctx context.Context, + stateData OAuth2State, + opts InitiateOptions, + r *http.Request, +) (string, error) { state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL, stateData) if err != nil { return "", fmt.Errorf("cannot create state token: %w", err) @@ -127,7 +137,9 @@ func (c *OAuth2Connector) InitiateWithState(ctx context.Context, stateData OAuth authCodeQuery.Set("client_id", c.ClientID) authCodeQuery.Set("redirect_uri", c.RedirectURI) authCodeQuery.Set("response_type", "code") - authCodeQuery.Set("scope", strings.Join(c.Scopes, " ")) + if len(opts.Scopes) > 0 { + authCodeQuery.Set("scope", strings.Join(opts.Scopes, " ")) + } // Add any extra auth params (e.g., access_type=offline, prompt=consent for Google) for k, v := range c.ExtraAuthParams { diff --git a/pkg/connector/oauth2_test.go b/pkg/connector/oauth2_test.go index 0103c0379..47e2fb74a 100644 --- a/pkg/connector/oauth2_test.go +++ b/pkg/connector/oauth2_test.go @@ -27,6 +27,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/gid" ) func TestBuildTokenRequest_PostForm(t *testing.T) { @@ -256,3 +257,57 @@ func TestClientCredentialsClient_ReusesValidToken(t *testing.T) { assert.Equal(t, "existing-token", conn.AccessToken) } + +func TestInitiateWithState_Scopes(t *testing.T) { + t.Parallel() + + t.Run("scopes are joined and set on auth URL", func(t *testing.T) { + t.Parallel() + + c := &OAuth2Connector{ + ClientID: "id", + ClientSecret: "secret", + RedirectURI: "https://example.com/cb", + AuthURL: "https://provider.example.com/authorize", + } + + orgID := gid.New(gid.NewTenantID(), 0) + + u, err := c.InitiateWithState( + context.Background(), + OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"}, + InitiateOptions{Scopes: []string{"read:user", "write:user"}}, + nil, + ) + require.NoError(t, err) + + parsed, err := url.Parse(u) + require.NoError(t, err) + assert.Equal(t, "read:user write:user", parsed.Query().Get("scope")) + }) + + t.Run("empty scopes omits scope parameter", func(t *testing.T) { + t.Parallel() + + c := &OAuth2Connector{ + ClientID: "id", + ClientSecret: "secret", + RedirectURI: "https://example.com/cb", + AuthURL: "https://provider.example.com/authorize", + } + + orgID := gid.New(gid.NewTenantID(), 0) + + u, err := c.InitiateWithState( + context.Background(), + OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"}, + InitiateOptions{}, + nil, + ) + require.NoError(t, err) + + parsed, err := url.Parse(u) + require.NoError(t, err) + assert.False(t, parsed.Query().Has("scope"), "scope param should be absent when no scopes provided") + }) +} diff --git a/pkg/connector/providers.go b/pkg/connector/providers.go index 4d1a12aef..c0b0efa0e 100644 --- a/pkg/connector/providers.go +++ b/pkg/connector/providers.go @@ -22,10 +22,12 @@ const ( type ( // providerDefinition holds the static OAuth2 properties for a provider. // These are intrinsic to the provider and do not vary between deployments. + // Scopes are not part of this — they are passed by the caller at + // initiate time via InitiateOptions, since the same provider may be used + // in multiple contexts requiring different scope sets. providerDefinition struct { AuthURL string TokenURL string - Scopes []string ExtraAuthParams map[string]string TokenEndpointAuth string // "post-form" (default), "basic-form", or "basic-json" } @@ -38,17 +40,14 @@ var ( "SLACK": { AuthURL: "https://slack.com/oauth/v2/authorize", TokenURL: "https://slack.com/api/oauth.v2.access", - Scopes: []string{"chat:write", "channels:join", "incoming-webhook"}, }, "HUBSPOT": { AuthURL: "https://app.hubspot.com/oauth/authorize", TokenURL: "https://api.hubapi.com/oauth/v1/token", - Scopes: []string{"settings.users.read"}, }, "DOCUSIGN": { AuthURL: "https://account.docusign.com/oauth/auth", TokenURL: "https://account.docusign.com/oauth/token", - Scopes: []string{"signature"}, TokenEndpointAuth: "basic-form", }, "NOTION": { @@ -60,36 +59,43 @@ var ( "GITHUB": { AuthURL: "https://github.com/login/oauth/authorize", TokenURL: "https://github.com/login/oauth/access_token", - Scopes: []string{"read:org"}, }, "SENTRY": { AuthURL: "https://sentry.io/oauth/authorize/", TokenURL: "https://sentry.io/oauth/token/", - Scopes: []string{"org:read", "member:read"}, }, "INTERCOM": { AuthURL: "https://app.intercom.com/oauth", TokenURL: "https://api.intercom.io/auth/eagle/token", - // Scopes configured at app level in Intercom Developer Hub. }, "BREX": { AuthURL: "https://accounts-api.brex.com/oauth2/default/v1/authorize", TokenURL: "https://accounts-api.brex.com/oauth2/default/v1/token", - Scopes: []string{"openid", "offline_access"}, + }, + "GOOGLE_WORKSPACE": { + AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", + TokenURL: "https://oauth2.googleapis.com/token", + ExtraAuthParams: map[string]string{ + "access_type": "offline", + "prompt": "consent", + }, + }, + "LINEAR": { + AuthURL: "https://linear.app/oauth/authorize", + TokenURL: "https://api.linear.app/oauth/token", }, } ) // ApplyProviderDefaults sets the redirect URI and applies static provider -// defaults (auth URL, token URL, scopes, extra params, token endpoint auth) -// onto an OAuth2Connector. Call this before registering the connector. +// defaults (auth URL, token URL, extra params, token endpoint auth) onto +// an OAuth2Connector. Call this before registering the connector. func ApplyProviderDefaults(provider string, redirectURI string, c *OAuth2Connector) { c.RedirectURI = redirectURI if def, ok := providerDefinitions[provider]; ok { c.AuthURL = def.AuthURL c.TokenURL = def.TokenURL - c.Scopes = def.Scopes c.ExtraAuthParams = def.ExtraAuthParams c.TokenEndpointAuth = def.TokenEndpointAuth } diff --git a/pkg/connector/registry.go b/pkg/connector/registry.go index 6cda258ae..d001e523a 100644 --- a/pkg/connector/registry.go +++ b/pkg/connector/registry.go @@ -57,13 +57,19 @@ func (r *ConnectorRegistry) Get(provider string) (Connector, error) { return c, nil } -func (r *ConnectorRegistry) Initiate(ctx context.Context, provider string, organizationID gid.GID, req *http.Request) (string, error) { +func (r *ConnectorRegistry) Initiate( + ctx context.Context, + provider string, + organizationID gid.GID, + opts InitiateOptions, + req *http.Request, +) (string, error) { c, err := r.Get(provider) if err != nil { return "", fmt.Errorf("cannot initiate connector: %w", err) } - return c.Initiate(ctx, provider, organizationID, req) + return c.Initiate(ctx, provider, organizationID, opts, req) } // ExtractProviderFromState decodes the OAuth2 state token without diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index fae400bd9..03a87874b 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -141,7 +141,11 @@ func NewMux( return } - redirectURL, err := connectorRegistry.Initiate(r.Context(), provider, organizationID, r) + opts := connector.InitiateOptions{ + Scopes: r.URL.Query()["scope"], + } + + redirectURL, err := connectorRegistry.Initiate(r.Context(), provider, organizationID, opts, r) if err != nil { panic(fmt.Errorf("cannot initiate connector: %w", err)) }