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