Pass OAuth2 scopes to connector at initiate time

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>
This commit is contained in:
Aurélien Sibiril
2026-04-07 14:53:09 +02:00
parent 4cc0e31214
commit e006f335b2
6 changed files with 111 additions and 20 deletions

View File

@@ -26,8 +26,16 @@ import (
type ( type (
ProtocolType string 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 { 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 Complete(ctx context.Context, r *http.Request) (Connection, *gid.GID, string, error) // returns: connection, organizationID, continueURL, error
} }

View File

@@ -44,7 +44,6 @@ type (
ClientID string ClientID string
ClientSecret string ClientSecret string
RedirectURI string RedirectURI string
Scopes []string
AuthURL string AuthURL string
TokenURL string TokenURL string
ExtraAuthParams map[string]string // Optional: extra params for auth URL (e.g., access_type=offline for Google) 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) 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{ stateData := OAuth2State{
OrganizationID: organizationID.String(), OrganizationID: organizationID.String(),
Provider: provider, Provider: provider,
@@ -111,12 +116,17 @@ func (c *OAuth2Connector) Initiate(ctx context.Context, provider string, organiz
stateData.ConnectorID = connectorID 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. // InitiateWithState generates an OAuth2 authorization URL with a custom state.
// This allows callers to include additional context (like SCIMBridgeID) in the 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) state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL, stateData)
if err != nil { if err != nil {
return "", fmt.Errorf("cannot create state token: %w", err) 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("client_id", c.ClientID)
authCodeQuery.Set("redirect_uri", c.RedirectURI) authCodeQuery.Set("redirect_uri", c.RedirectURI)
authCodeQuery.Set("response_type", "code") 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) // Add any extra auth params (e.g., access_type=offline, prompt=consent for Google)
for k, v := range c.ExtraAuthParams { for k, v := range c.ExtraAuthParams {

View File

@@ -27,6 +27,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/gid"
) )
func TestBuildTokenRequest_PostForm(t *testing.T) { func TestBuildTokenRequest_PostForm(t *testing.T) {
@@ -256,3 +257,57 @@ func TestClientCredentialsClient_ReusesValidToken(t *testing.T) {
assert.Equal(t, "existing-token", conn.AccessToken) 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")
})
}

View File

@@ -22,10 +22,12 @@ const (
type ( type (
// providerDefinition holds the static OAuth2 properties for a provider. // providerDefinition holds the static OAuth2 properties for a provider.
// These are intrinsic to the provider and do not vary between deployments. // 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 { providerDefinition struct {
AuthURL string AuthURL string
TokenURL string TokenURL string
Scopes []string
ExtraAuthParams map[string]string ExtraAuthParams map[string]string
TokenEndpointAuth string // "post-form" (default), "basic-form", or "basic-json" TokenEndpointAuth string // "post-form" (default), "basic-form", or "basic-json"
} }
@@ -38,17 +40,14 @@ var (
"SLACK": { "SLACK": {
AuthURL: "https://slack.com/oauth/v2/authorize", AuthURL: "https://slack.com/oauth/v2/authorize",
TokenURL: "https://slack.com/api/oauth.v2.access", TokenURL: "https://slack.com/api/oauth.v2.access",
Scopes: []string{"chat:write", "channels:join", "incoming-webhook"},
}, },
"HUBSPOT": { "HUBSPOT": {
AuthURL: "https://app.hubspot.com/oauth/authorize", AuthURL: "https://app.hubspot.com/oauth/authorize",
TokenURL: "https://api.hubapi.com/oauth/v1/token", TokenURL: "https://api.hubapi.com/oauth/v1/token",
Scopes: []string{"settings.users.read"},
}, },
"DOCUSIGN": { "DOCUSIGN": {
AuthURL: "https://account.docusign.com/oauth/auth", AuthURL: "https://account.docusign.com/oauth/auth",
TokenURL: "https://account.docusign.com/oauth/token", TokenURL: "https://account.docusign.com/oauth/token",
Scopes: []string{"signature"},
TokenEndpointAuth: "basic-form", TokenEndpointAuth: "basic-form",
}, },
"NOTION": { "NOTION": {
@@ -60,36 +59,43 @@ var (
"GITHUB": { "GITHUB": {
AuthURL: "https://github.com/login/oauth/authorize", AuthURL: "https://github.com/login/oauth/authorize",
TokenURL: "https://github.com/login/oauth/access_token", TokenURL: "https://github.com/login/oauth/access_token",
Scopes: []string{"read:org"},
}, },
"SENTRY": { "SENTRY": {
AuthURL: "https://sentry.io/oauth/authorize/", AuthURL: "https://sentry.io/oauth/authorize/",
TokenURL: "https://sentry.io/oauth/token/", TokenURL: "https://sentry.io/oauth/token/",
Scopes: []string{"org:read", "member:read"},
}, },
"INTERCOM": { "INTERCOM": {
AuthURL: "https://app.intercom.com/oauth", AuthURL: "https://app.intercom.com/oauth",
TokenURL: "https://api.intercom.io/auth/eagle/token", TokenURL: "https://api.intercom.io/auth/eagle/token",
// Scopes configured at app level in Intercom Developer Hub.
}, },
"BREX": { "BREX": {
AuthURL: "https://accounts-api.brex.com/oauth2/default/v1/authorize", AuthURL: "https://accounts-api.brex.com/oauth2/default/v1/authorize",
TokenURL: "https://accounts-api.brex.com/oauth2/default/v1/token", 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 // ApplyProviderDefaults sets the redirect URI and applies static provider
// defaults (auth URL, token URL, scopes, extra params, token endpoint auth) // defaults (auth URL, token URL, extra params, token endpoint auth) onto
// onto an OAuth2Connector. Call this before registering the connector. // an OAuth2Connector. Call this before registering the connector.
func ApplyProviderDefaults(provider string, redirectURI string, c *OAuth2Connector) { func ApplyProviderDefaults(provider string, redirectURI string, c *OAuth2Connector) {
c.RedirectURI = redirectURI c.RedirectURI = redirectURI
if def, ok := providerDefinitions[provider]; ok { if def, ok := providerDefinitions[provider]; ok {
c.AuthURL = def.AuthURL c.AuthURL = def.AuthURL
c.TokenURL = def.TokenURL c.TokenURL = def.TokenURL
c.Scopes = def.Scopes
c.ExtraAuthParams = def.ExtraAuthParams c.ExtraAuthParams = def.ExtraAuthParams
c.TokenEndpointAuth = def.TokenEndpointAuth c.TokenEndpointAuth = def.TokenEndpointAuth
} }

View File

@@ -57,13 +57,19 @@ func (r *ConnectorRegistry) Get(provider string) (Connector, error) {
return c, nil 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) c, err := r.Get(provider)
if err != nil { if err != nil {
return "", fmt.Errorf("cannot initiate connector: %w", err) 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 // ExtractProviderFromState decodes the OAuth2 state token without

View File

@@ -141,7 +141,11 @@ func NewMux(
return 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 { if err != nil {
panic(fmt.Errorf("cannot initiate connector: %w", err)) panic(fmt.Errorf("cannot initiate connector: %w", err))
} }