From 46dee27bcfabc60224a2aab86395a54ee59ad362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:51:33 +0200 Subject: [PATCH] Add connector infrastructure for access review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add API key connector protocol, OAuth2 client credentials grant, token refresh config, provider info endpoint, ConnectorProviders helper, and bootstrap configs for all OAuth providers. Move OAuth2 state decode near type. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/bootstrap/builder.go | 163 ++++++++++++++-- pkg/connector/apikey.go | 63 +++++++ pkg/connector/connector.go | 8 + pkg/connector/oauth2.go | 273 +++++++++++++++++++++------ pkg/connector/oauth2_grant_type.go | 30 +++ pkg/connector/oauth2_test.go | 258 +++++++++++++++++++++++++ pkg/connector/registry.go | 68 ++++++- pkg/probod/connector_config.go | 30 +-- pkg/statelesstoken/statelesstoken.go | 23 +++ 9 files changed, 826 insertions(+), 90 deletions(-) create mode 100644 pkg/connector/apikey.go create mode 100644 pkg/connector/oauth2_grant_type.go create mode 100644 pkg/connector/oauth2_test.go diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index cf8f64010..8af776943 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -212,23 +212,129 @@ func (b *Builder) Build() (*probod.FullConfig, error) { } if slackClientID := b.getEnv("CONNECTOR_SLACK_CLIENT_ID"); slackClientID != "" { - cfg.Probod.Connectors = []probod.ConnectorConfig{ - { - Provider: "SLACK", - Protocol: "oauth2", - RawConfig: probod.ConnectorConfigOAuth2{ - ClientID: slackClientID, - ClientSecret: b.getEnv("CONNECTOR_SLACK_CLIENT_SECRET"), - RedirectURI: b.getEnv("CONNECTOR_SLACK_REDIRECT_URI"), - AuthURL: b.getEnvOrDefault("CONNECTOR_SLACK_AUTH_URL", "https://slack.com/oauth/v2/authorize"), - TokenURL: b.getEnvOrDefault("CONNECTOR_SLACK_TOKEN_URL", "https://slack.com/api/oauth.v2.access"), - Scopes: []string{"chat:write", "channels:join", "incoming-webhook"}, - }, - RawSettings: map[string]any{ - "signing-secret": b.getEnv("CONNECTOR_SLACK_SIGNING_SECRET"), - }, + cfg.Probod.Connectors = append(cfg.Probod.Connectors, probod.ConnectorConfig{ + Provider: "SLACK", + Protocol: "oauth2", + RawConfig: probod.ConnectorConfigOAuth2{ + ClientID: slackClientID, + ClientSecret: b.getEnv("CONNECTOR_SLACK_CLIENT_SECRET"), + RedirectURI: b.getEnv("CONNECTOR_SLACK_REDIRECT_URI"), + AuthURL: b.getEnvOrDefault("CONNECTOR_SLACK_AUTH_URL", "https://slack.com/oauth/v2/authorize"), + TokenURL: b.getEnvOrDefault("CONNECTOR_SLACK_TOKEN_URL", "https://slack.com/api/oauth.v2.access"), + Scopes: []string{"chat:write", "channels:join", "incoming-webhook"}, }, - } + RawSettings: map[string]any{ + "signing-secret": b.getEnv("CONNECTOR_SLACK_SIGNING_SECRET"), + }, + }) + } + + if hubspotClientID := b.getEnv("CONNECTOR_HUBSPOT_CLIENT_ID"); hubspotClientID != "" { + cfg.Probod.Connectors = append(cfg.Probod.Connectors, probod.ConnectorConfig{ + Provider: "HUBSPOT", + Protocol: "oauth2", + RawConfig: probod.ConnectorConfigOAuth2{ + ClientID: hubspotClientID, + ClientSecret: b.getEnv("CONNECTOR_HUBSPOT_CLIENT_SECRET"), + RedirectURI: b.getEnv("CONNECTOR_HUBSPOT_REDIRECT_URI"), + AuthURL: b.getEnvOrDefault("CONNECTOR_HUBSPOT_AUTH_URL", "https://app.hubspot.com/oauth/authorize"), + TokenURL: b.getEnvOrDefault("CONNECTOR_HUBSPOT_TOKEN_URL", "https://api.hubapi.com/oauth/v1/token"), + Scopes: []string{"settings.users.read"}, + }, + }) + } + + if docusignClientID := b.getEnv("CONNECTOR_DOCUSIGN_CLIENT_ID"); docusignClientID != "" { + cfg.Probod.Connectors = append(cfg.Probod.Connectors, probod.ConnectorConfig{ + Provider: "DOCUSIGN", + Protocol: "oauth2", + RawConfig: probod.ConnectorConfigOAuth2{ + ClientID: docusignClientID, + ClientSecret: b.getEnv("CONNECTOR_DOCUSIGN_CLIENT_SECRET"), + RedirectURI: b.getEnv("CONNECTOR_DOCUSIGN_REDIRECT_URI"), + AuthURL: b.getEnvOrDefault("CONNECTOR_DOCUSIGN_AUTH_URL", "https://account.docusign.com/oauth/auth"), + TokenURL: b.getEnvOrDefault("CONNECTOR_DOCUSIGN_TOKEN_URL", "https://account.docusign.com/oauth/token"), + Scopes: []string{"signature"}, + TokenEndpointAuth: "basic-form", + }, + }) + } + + if notionClientID := b.getEnv("CONNECTOR_NOTION_CLIENT_ID"); notionClientID != "" { + cfg.Probod.Connectors = append(cfg.Probod.Connectors, probod.ConnectorConfig{ + Provider: "NOTION", + Protocol: "oauth2", + RawConfig: probod.ConnectorConfigOAuth2{ + ClientID: notionClientID, + ClientSecret: b.getEnv("CONNECTOR_NOTION_CLIENT_SECRET"), + RedirectURI: b.getEnv("CONNECTOR_NOTION_REDIRECT_URI"), + AuthURL: b.getEnvOrDefault("CONNECTOR_NOTION_AUTH_URL", "https://api.notion.com/v1/oauth/authorize"), + TokenURL: b.getEnvOrDefault("CONNECTOR_NOTION_TOKEN_URL", "https://api.notion.com/v1/oauth/token"), + // Notion does not use scopes in OAuth URL; permissions are granted via page-picker UI during authorization. + ExtraAuthParams: map[string]string{"owner": "user"}, + TokenEndpointAuth: "basic-json", + }, + }) + } + + if githubClientID := b.getEnv("CONNECTOR_GITHUB_CLIENT_ID"); githubClientID != "" { + cfg.Probod.Connectors = append(cfg.Probod.Connectors, probod.ConnectorConfig{ + Provider: "GITHUB", + Protocol: "oauth2", + RawConfig: probod.ConnectorConfigOAuth2{ + ClientID: githubClientID, + ClientSecret: b.getEnv("CONNECTOR_GITHUB_CLIENT_SECRET"), + RedirectURI: b.getEnv("CONNECTOR_GITHUB_REDIRECT_URI"), + AuthURL: b.getEnvOrDefault("CONNECTOR_GITHUB_AUTH_URL", "https://github.com/login/oauth/authorize"), + TokenURL: b.getEnvOrDefault("CONNECTOR_GITHUB_TOKEN_URL", "https://github.com/login/oauth/access_token"), + Scopes: []string{"read:org"}, + }, + }) + } + + if sentryClientID := b.getEnv("CONNECTOR_SENTRY_CLIENT_ID"); sentryClientID != "" { + cfg.Probod.Connectors = append(cfg.Probod.Connectors, probod.ConnectorConfig{ + Provider: "SENTRY", + Protocol: "oauth2", + RawConfig: probod.ConnectorConfigOAuth2{ + ClientID: sentryClientID, + ClientSecret: b.getEnv("CONNECTOR_SENTRY_CLIENT_SECRET"), + RedirectURI: b.getEnv("CONNECTOR_SENTRY_REDIRECT_URI"), + AuthURL: b.getEnvOrDefault("CONNECTOR_SENTRY_AUTH_URL", "https://sentry.io/oauth/authorize/"), + TokenURL: b.getEnvOrDefault("CONNECTOR_SENTRY_TOKEN_URL", "https://sentry.io/oauth/token/"), + Scopes: []string{"org:read", "member:read"}, + }, + }) + } + + if intercomClientID := b.getEnv("CONNECTOR_INTERCOM_CLIENT_ID"); intercomClientID != "" { + cfg.Probod.Connectors = append(cfg.Probod.Connectors, probod.ConnectorConfig{ + Provider: "INTERCOM", + Protocol: "oauth2", + RawConfig: probod.ConnectorConfigOAuth2{ + ClientID: intercomClientID, + ClientSecret: b.getEnv("CONNECTOR_INTERCOM_CLIENT_SECRET"), + RedirectURI: b.getEnv("CONNECTOR_INTERCOM_REDIRECT_URI"), + AuthURL: b.getEnvOrDefault("CONNECTOR_INTERCOM_AUTH_URL", "https://app.intercom.com/oauth"), + TokenURL: b.getEnvOrDefault("CONNECTOR_INTERCOM_TOKEN_URL", "https://api.intercom.io/auth/eagle/token"), + // Intercom scopes are configured at app level in Developer Hub, not in the OAuth URL. + }, + }) + } + + if brexClientID := b.getEnv("CONNECTOR_BREX_CLIENT_ID"); brexClientID != "" { + cfg.Probod.Connectors = append(cfg.Probod.Connectors, probod.ConnectorConfig{ + Provider: "BREX", + Protocol: "oauth2", + RawConfig: probod.ConnectorConfigOAuth2{ + ClientID: brexClientID, + ClientSecret: b.getEnv("CONNECTOR_BREX_CLIENT_SECRET"), + RedirectURI: b.getEnv("CONNECTOR_BREX_REDIRECT_URI"), + AuthURL: b.getEnvOrDefault("CONNECTOR_BREX_AUTH_URL", "https://accounts-api.brex.com/oauth2/default/v1/authorize"), + TokenURL: b.getEnvOrDefault("CONNECTOR_BREX_TOKEN_URL", "https://accounts-api.brex.com/oauth2/default/v1/token"), + Scopes: []string{"openid", "offline_access"}, + }, + }) } return cfg, nil @@ -262,6 +368,31 @@ func (b *Builder) validateRequired() error { } } + oauthProviders := []struct { + envPrefix string + required []string + }{ + {"CONNECTOR_HUBSPOT", []string{"CLIENT_SECRET", "REDIRECT_URI"}}, + {"CONNECTOR_DOCUSIGN", []string{"CLIENT_SECRET", "REDIRECT_URI"}}, + {"CONNECTOR_NOTION", []string{"CLIENT_SECRET", "REDIRECT_URI"}}, + {"CONNECTOR_GITHUB", []string{"CLIENT_SECRET", "REDIRECT_URI"}}, + {"CONNECTOR_SENTRY", []string{"CLIENT_SECRET", "REDIRECT_URI"}}, + {"CONNECTOR_INTERCOM", []string{"CLIENT_SECRET", "REDIRECT_URI"}}, + {"CONNECTOR_BREX", []string{"CLIENT_SECRET", "REDIRECT_URI"}}, + } + + for _, p := range oauthProviders { + clientIDKey := p.envPrefix + "_CLIENT_ID" + if b.getEnv(clientIDKey) != "" { + for _, suffix := range p.required { + key := p.envPrefix + "_" + suffix + if b.getEnv(key) == "" { + missing = append(missing, key+" (required when "+clientIDKey+" is set)") + } + } + } + } + if len(missing) > 0 { return fmt.Errorf("missing required environment variables:\n - %s", strings.Join(missing, "\n - ")) } diff --git a/pkg/connector/apikey.go b/pkg/connector/apikey.go new file mode 100644 index 000000000..4ba1ec85b --- /dev/null +++ b/pkg/connector/apikey.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package connector + +import ( + "context" + "encoding/json" + "net/http" + + "go.gearno.de/kit/httpclient" +) + +type APIKeyConnection struct { + APIKey string `json:"api_key"` +} + +var _ Connection = (*APIKeyConnection)(nil) + +func (c *APIKeyConnection) Type() ProtocolType { + return ProtocolAPIKey +} + +func (c *APIKeyConnection) Client(ctx context.Context) (*http.Client, error) { + transport := &oauth2Transport{ + token: c.APIKey, + tokenType: "Bearer", + underlying: httpclient.DefaultPooledTransport(), + } + return &http.Client{Transport: transport}, nil +} + +func (c APIKeyConnection) MarshalJSON() ([]byte, error) { + type Alias APIKeyConnection + return json.Marshal(&struct { + Type string `json:"type"` + Alias + }{ + Type: string(ProtocolAPIKey), + Alias: Alias(c), + }) +} + +func (c *APIKeyConnection) UnmarshalJSON(data []byte) error { + type Alias APIKeyConnection + aux := &struct { + *Alias + }{ + Alias: (*Alias)(c), + } + return json.Unmarshal(data, &aux) +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index b2039388f..a0dfdf850 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -42,6 +42,7 @@ type ( const ( ProtocolOAuth2 ProtocolType = "OAUTH2" + ProtocolAPIKey ProtocolType = "API_KEY" ) func UnmarshalConnection(protocol string, provider string, data []byte) (Connection, error) { @@ -62,6 +63,13 @@ func UnmarshalConnection(protocol string, provider string, data []byte) (Connect } return &conn, nil } + + case string(ProtocolAPIKey): + var conn APIKeyConnection + if err := json.Unmarshal(data, &conn); err != nil { + return nil, fmt.Errorf("cannot unmarshal api key connection: %w", err) + } + return &conn, nil } return nil, fmt.Errorf("unknown connection protocol: %s", protocol) diff --git a/pkg/connector/oauth2.go b/pkg/connector/oauth2.go index c4f101aa4..3a8a447ed 100644 --- a/pkg/connector/oauth2.go +++ b/pkg/connector/oauth2.go @@ -15,7 +15,9 @@ package connector import ( + "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -39,19 +41,21 @@ import ( type ( OAuth2Connector struct { - 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) + 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) + TokenEndpointAuth string // "post-form" (default), "basic-form", or "basic-json" } OAuth2State struct { OrganizationID string `json:"oid"` Provider string `json:"provider"` ContinueURL string `json:"continue,omitempty"` + ConnectorID string `json:"cid,omitempty"` // Set when reconnecting an existing connector } OAuth2Connection struct { @@ -60,13 +64,20 @@ type ( ExpiresAt time.Time `json:"expires_at"` TokenType string `json:"token_type"` Scope string `json:"scope,omitempty"` + + // Client Credentials fields (only set when GrantType == "client_credentials"): + GrantType OAuth2GrantType `json:"grant_type,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + TokenURL string `json:"token_url,omitempty"` } // OAuth2RefreshConfig contains the OAuth2 credentials needed for token refresh. OAuth2RefreshConfig struct { - ClientID string - ClientSecret string - TokenURL string + ClientID string + ClientSecret string + TokenURL string + TokenEndpointAuth string // "post-form" (default), "basic-form", or "basic-json" } ) @@ -78,6 +89,15 @@ var ( OAuth2TokenTTL = 10 * time.Minute ) +// DecodeOAuth2StatePayload decodes the OAuth2 state token payload without +// verifying the signature. This is useful when you need to inspect the +// payload to determine which secret to use for full validation (e.g., +// extracting the provider from the state token to look up the correct +// connector). +func DecodeOAuth2StatePayload(tokenString string) (*statelesstoken.Payload[OAuth2State], error) { + return statelesstoken.DecodePayload[OAuth2State](tokenString) +} + func (c *OAuth2Connector) Initiate(ctx context.Context, provider string, organizationID gid.GID, r *http.Request) (string, error) { stateData := OAuth2State{ OrganizationID: organizationID.String(), @@ -87,6 +107,9 @@ func (c *OAuth2Connector) Initiate(ctx context.Context, provider string, organiz if continueURL := r.URL.Query().Get("continue"); continueURL != "" { stateData.ContinueURL = continueURL } + if connectorID := r.URL.Query().Get("connector_id"); connectorID != "" { + stateData.ConnectorID = connectorID + } } return c.InitiateWithState(ctx, stateData, r) } @@ -99,21 +122,10 @@ func (c *OAuth2Connector) InitiateWithState(ctx context.Context, stateData OAuth return "", fmt.Errorf("cannot create state token: %w", err) } - // Build redirect URI with provider (fixed per provider, so can be registered in OAuth console) - redirectURI := c.RedirectURI - redirectURIParsed, err := url.Parse(redirectURI) - if err != nil { - return "", fmt.Errorf("cannot parse redirect URI: %w", err) - } - q := redirectURIParsed.Query() - q.Set("provider", stateData.Provider) - redirectURIParsed.RawQuery = q.Encode() - redirectURI = redirectURIParsed.String() - authCodeQuery := url.Values{} authCodeQuery.Set("state", state) authCodeQuery.Set("client_id", c.ClientID) - authCodeQuery.Set("redirect_uri", redirectURI) + authCodeQuery.Set("redirect_uri", c.RedirectURI) authCodeQuery.Set("response_type", "code") authCodeQuery.Set("scope", strings.Join(c.Scopes, " ")) @@ -149,11 +161,6 @@ func (c *OAuth2Connector) Complete(ctx context.Context, r *http.Request) (Connec // CompleteWithState completes the OAuth2 flow and returns the full state. // This allows callers to access additional context (like SCIMBridgeID) from the state. func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request) (Connection, *OAuth2State, error) { - provider := r.URL.Query().Get("provider") - if provider == "" { - return nil, nil, fmt.Errorf("missing provider in query parameters") - } - code := r.URL.Query().Get("code") if code == "" { return nil, nil, fmt.Errorf("no code in request") @@ -169,41 +176,15 @@ func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request return nil, nil, fmt.Errorf("cannot validate state token: %w", err) } - if payload.Data.Provider != provider { - return nil, nil, fmt.Errorf("provider mismatch: state has %q, query has %q", payload.Data.Provider, provider) - } - organizationID, err := gid.ParseGID(payload.Data.OrganizationID) if err != nil { return nil, nil, fmt.Errorf("cannot parse organization ID: %w", err) } - // Build redirect URI with provider (must match what was sent to auth endpoint) - redirectURI := c.RedirectURI - redirectURIParsed, err := url.Parse(redirectURI) + tokenRequest, err := c.buildTokenRequest(ctx, code, c.RedirectURI) if err != nil { - return nil, nil, fmt.Errorf("cannot parse redirect URI: %w", err) + return nil, nil, err } - q := redirectURIParsed.Query() - q.Set("provider", provider) - redirectURIParsed.RawQuery = q.Encode() - redirectURI = redirectURIParsed.String() - - tokenRequestData := url.Values{} - tokenRequestData.Set("client_id", c.ClientID) - tokenRequestData.Set("client_secret", c.ClientSecret) - tokenRequestData.Set("code", code) - tokenRequestData.Set("redirect_uri", redirectURI) - tokenRequestData.Set("grant_type", "authorization_code") - - tokenRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.TokenURL, strings.NewReader(tokenRequestData.Encode())) - if err != nil { - return nil, nil, fmt.Errorf("cannot create token request: %w", err) - } - - tokenRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") - tokenRequest.Header.Set("Accept", "application/json") - tokenRequest.Header.Set("User-Agent", "Probo Connector") tokenResp, err := http.DefaultClient.Do(tokenRequest) if err != nil { @@ -244,7 +225,7 @@ func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request oauth2Conn.ExpiresAt = time.Now().Add(time.Duration(rawToken.ExpiresIn) * time.Second) } - if provider == SlackProvider { + if payload.Data.Provider == SlackProvider { conn, _, err := ParseSlackTokenResponse(body, oauth2Conn, organizationID) return conn, &payload.Data, err } @@ -252,6 +233,92 @@ func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request return &oauth2Conn, &payload.Data, nil } +func basicAuthHeader(clientID, clientSecret string) string { + credentials := clientID + ":" + clientSecret + return "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials)) +} + +// buildTokenRequest creates the HTTP request for the token exchange, branching +// on c.TokenEndpointAuth to support different provider requirements. +func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectURI string) (*http.Request, error) { + switch c.TokenEndpointAuth { + case "basic-json": + // JSON body with Basic auth header (Notion). + body := map[string]string{ + "code": code, + "redirect_uri": redirectURI, + "grant_type": "authorization_code", + } + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("cannot marshal token request body: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + c.TokenURL, + bytes.NewReader(jsonBody), + ) + if err != nil { + return nil, fmt.Errorf("cannot create token request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Probo Connector") + req.Header.Set("Authorization", basicAuthHeader(c.ClientID, c.ClientSecret)) + return req, nil + + case "basic-form": + // Form-encoded body with Basic auth header (DocuSign). + formData := url.Values{} + formData.Set("code", code) + formData.Set("redirect_uri", redirectURI) + formData.Set("grant_type", "authorization_code") + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + c.TokenURL, + strings.NewReader(formData.Encode()), + ) + if err != nil { + return nil, fmt.Errorf("cannot create token request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Probo Connector") + req.Header.Set("Authorization", basicAuthHeader(c.ClientID, c.ClientSecret)) + return req, nil + + default: + // "post-form" or empty: credentials in form body (Slack, HubSpot, GitHub, etc.). + formData := url.Values{} + formData.Set("client_id", c.ClientID) + formData.Set("client_secret", c.ClientSecret) + formData.Set("code", code) + formData.Set("redirect_uri", redirectURI) + formData.Set("grant_type", "authorization_code") + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + c.TokenURL, + strings.NewReader(formData.Encode()), + ) + if err != nil { + return nil, fmt.Errorf("cannot create token request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Probo Connector") + return req, nil + } +} + func (c *OAuth2Connection) Type() ProtocolType { return ProtocolOAuth2 } @@ -276,16 +343,31 @@ func (c *OAuth2Connection) ClientWithOptions(ctx context.Context, opts ...httpcl // RefreshableClient returns an HTTP client that automatically refreshes the token when expired. // It also updates the connection's token fields if a refresh occurs. +// +// For client_credentials grant type, it uses the connection's own credentials +// to obtain a new token instead of refreshing via a refresh token. func (c *OAuth2Connection) RefreshableClient(ctx context.Context, cfg OAuth2RefreshConfig, opts ...httpclient.Option) (*http.Client, error) { + if c.GrantType == OAuth2GrantTypeClientCredentials { + return c.clientCredentialsClient(ctx, opts...) + } + if c.RefreshToken == "" { return c.ClientWithOptions(ctx, opts...) } + // Determine auth style based on TokenEndpointAuth + authStyle := oauth2.AuthStyleInParams + switch cfg.TokenEndpointAuth { + case "basic-form", "basic-json": + authStyle = oauth2.AuthStyleInHeader + } + config := &oauth2.Config{ ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, Endpoint: oauth2.Endpoint{ - TokenURL: cfg.TokenURL, + TokenURL: cfg.TokenURL, + AuthStyle: authStyle, }, } @@ -338,6 +420,83 @@ func (c *OAuth2Connection) RefreshableClient(ctx context.Context, cfg OAuth2Refr }, nil } +// clientCredentialsClient obtains a new access token using the client_credentials +// grant type, using the connection's own ClientID, ClientSecret, and TokenURL. +func (c *OAuth2Connection) clientCredentialsClient(ctx context.Context, opts ...httpclient.Option) (*http.Client, error) { + // If we have a valid token that hasn't expired, reuse it + if c.AccessToken != "" && !c.ExpiresAt.IsZero() && c.ExpiresAt.After(time.Now()) { + return c.ClientWithOptions(ctx, opts...) + } + + formData := url.Values{} + formData.Set("grant_type", "client_credentials") + if c.Scope != "" { + formData.Set("scope", c.Scope) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + c.TokenURL, + strings.NewReader(formData.Encode()), + ) + if err != nil { + return nil, fmt.Errorf("cannot create client credentials token request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Probo Connector") + req.Header.Set("Authorization", basicAuthHeader(c.ClientID, c.ClientSecret)) + + httpClient := &http.Client{ + Transport: httpclient.DefaultPooledTransport(opts...), + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot post client credentials token URL: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("client credentials token response status: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("cannot read client credentials token response body: %w", err) + } + + var rawToken struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + TokenType string `json:"token_type"` + } + if err := json.Unmarshal(body, &rawToken); err != nil { + return nil, fmt.Errorf("cannot decode client credentials token response: %w", err) + } + + c.AccessToken = rawToken.AccessToken + if rawToken.TokenType != "" { + c.TokenType = rawToken.TokenType + } + if c.TokenType == "" { + c.TokenType = "Bearer" + } + if rawToken.ExpiresIn > 0 { + c.ExpiresAt = time.Now().Add(time.Duration(rawToken.ExpiresIn) * time.Second) + } + + return &http.Client{ + Transport: &oauth2Transport{ + token: c.AccessToken, + tokenType: c.TokenType, + underlying: httpclient.DefaultPooledTransport(opts...), + }, + }, nil +} + func (c OAuth2Connection) MarshalJSON() ([]byte, error) { type Alias OAuth2Connection return json.Marshal(&struct { diff --git a/pkg/connector/oauth2_grant_type.go b/pkg/connector/oauth2_grant_type.go new file mode 100644 index 000000000..b269af166 --- /dev/null +++ b/pkg/connector/oauth2_grant_type.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package connector + +type OAuth2GrantType string + +const ( + OAuth2GrantTypeAuthorizationCode OAuth2GrantType = "authorization_code" + OAuth2GrantTypeClientCredentials OAuth2GrantType = "client_credentials" +) + +func (g OAuth2GrantType) IsValid() bool { + switch g { + case OAuth2GrantTypeAuthorizationCode, OAuth2GrantTypeClientCredentials: + return true + } + return false +} diff --git a/pkg/connector/oauth2_test.go b/pkg/connector/oauth2_test.go new file mode 100644 index 000000000..0103c0379 --- /dev/null +++ b/pkg/connector/oauth2_test.go @@ -0,0 +1,258 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package connector + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildTokenRequest_PostForm(t *testing.T) { + t.Parallel() + + t.Run("empty token endpoint auth", func(t *testing.T) { + t.Parallel() + + connector := &OAuth2Connector{ + ClientID: "my-client-id", + ClientSecret: "my-client-secret", + TokenURL: "https://provider.example.com/oauth/token", + TokenEndpointAuth: "", + } + + req, err := connector.buildTokenRequest( + context.Background(), + "test-code", + "https://example.com/callback", + ) + require.NoError(t, err) + + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, "https://provider.example.com/oauth/token", req.URL.String()) + assert.Equal(t, "application/x-www-form-urlencoded; charset=utf-8", req.Header.Get("Content-Type")) + assert.Empty(t, req.Header.Get("Authorization")) + + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + + formValues, err := url.ParseQuery(string(body)) + require.NoError(t, err) + + assert.Equal(t, "my-client-id", formValues.Get("client_id")) + assert.Equal(t, "my-client-secret", formValues.Get("client_secret")) + assert.Equal(t, "test-code", formValues.Get("code")) + assert.Equal(t, "https://example.com/callback", formValues.Get("redirect_uri")) + assert.Equal(t, "authorization_code", formValues.Get("grant_type")) + }) + + t.Run("explicit post-form token endpoint auth", func(t *testing.T) { + t.Parallel() + + connector := &OAuth2Connector{ + ClientID: "my-client-id", + ClientSecret: "my-client-secret", + TokenURL: "https://provider.example.com/oauth/token", + TokenEndpointAuth: "post-form", + } + + req, err := connector.buildTokenRequest( + context.Background(), + "test-code", + "https://example.com/callback", + ) + require.NoError(t, err) + + assert.Equal(t, http.MethodPost, req.Method) + assert.Empty(t, req.Header.Get("Authorization")) + + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + + formValues, err := url.ParseQuery(string(body)) + require.NoError(t, err) + + assert.Equal(t, "my-client-id", formValues.Get("client_id")) + assert.Equal(t, "my-client-secret", formValues.Get("client_secret")) + assert.Equal(t, "test-code", formValues.Get("code")) + assert.Equal(t, "https://example.com/callback", formValues.Get("redirect_uri")) + assert.Equal(t, "authorization_code", formValues.Get("grant_type")) + }) +} + +func TestBuildTokenRequest_BasicForm(t *testing.T) { + t.Parallel() + + connector := &OAuth2Connector{ + ClientID: "my-client-id", + ClientSecret: "my-client-secret", + TokenURL: "https://provider.example.com/oauth/token", + TokenEndpointAuth: "basic-form", + } + + req, err := connector.buildTokenRequest( + context.Background(), + "test-code", + "https://example.com/callback", + ) + require.NoError(t, err) + + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, "https://provider.example.com/oauth/token", req.URL.String()) + assert.Equal(t, "application/x-www-form-urlencoded; charset=utf-8", req.Header.Get("Content-Type")) + + // Verify Basic auth header + authHeader := req.Header.Get("Authorization") + require.NotEmpty(t, authHeader) + + expectedCredentials := base64.StdEncoding.EncodeToString([]byte("my-client-id:my-client-secret")) + assert.Equal(t, "Basic "+expectedCredentials, authHeader) + + // Verify body does NOT contain client credentials + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + + formValues, err := url.ParseQuery(string(body)) + require.NoError(t, err) + + assert.Empty(t, formValues.Get("client_id")) + assert.Empty(t, formValues.Get("client_secret")) + assert.Equal(t, "test-code", formValues.Get("code")) + assert.Equal(t, "https://example.com/callback", formValues.Get("redirect_uri")) + assert.Equal(t, "authorization_code", formValues.Get("grant_type")) +} + +func TestBuildTokenRequest_BasicJSON(t *testing.T) { + t.Parallel() + + connector := &OAuth2Connector{ + ClientID: "my-client-id", + ClientSecret: "my-client-secret", + TokenURL: "https://provider.example.com/oauth/token", + TokenEndpointAuth: "basic-json", + } + + req, err := connector.buildTokenRequest( + context.Background(), + "test-code", + "https://example.com/callback", + ) + require.NoError(t, err) + + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, "https://provider.example.com/oauth/token", req.URL.String()) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + + // Verify Basic auth header + authHeader := req.Header.Get("Authorization") + require.NotEmpty(t, authHeader) + + expectedCredentials := base64.StdEncoding.EncodeToString([]byte("my-client-id:my-client-secret")) + assert.Equal(t, "Basic "+expectedCredentials, authHeader) + + // Verify body is valid JSON + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + + var jsonBody map[string]string + err = json.Unmarshal(body, &jsonBody) + require.NoError(t, err) + + assert.Equal(t, "test-code", jsonBody["code"]) + assert.Equal(t, "https://example.com/callback", jsonBody["redirect_uri"]) + assert.Equal(t, "authorization_code", jsonBody["grant_type"]) + + // JSON body must NOT contain client credentials + _, hasClientID := jsonBody["client_id"] + _, hasClientSecret := jsonBody["client_secret"] + assert.False(t, hasClientID, "JSON body should not contain client_id") + assert.False(t, hasClientSecret, "JSON body should not contain client_secret") +} + +func TestClientCredentialsClient(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + + // Verify Basic auth header is present + authHeader := r.Header.Get("Authorization") + assert.NotEmpty(t, authHeader) + + decoded, err := base64.StdEncoding.DecodeString(authHeader[len("Basic "):]) + require.NoError(t, err) + assert.Equal(t, "cc-client-id:cc-client-secret", string(decoded)) + + // Verify form body + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + + formValues, err := url.ParseQuery(string(body)) + require.NoError(t, err) + assert.Equal(t, "client_credentials", formValues.Get("grant_type")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token": "test-token", "expires_in": 3600, "token_type": "Bearer"}`)) + })) + defer server.Close() + + beforeRequest := time.Now() + + conn := &OAuth2Connection{ + GrantType: OAuth2GrantTypeClientCredentials, + ClientID: "cc-client-id", + ClientSecret: "cc-client-secret", + TokenURL: server.URL, + } + + client, err := conn.clientCredentialsClient(context.Background()) + require.NoError(t, err) + require.NotNil(t, client) + + assert.Equal(t, "test-token", conn.AccessToken) + assert.Equal(t, "Bearer", conn.TokenType) + + // ExpiresAt should be approximately now + 1 hour + expectedExpiry := beforeRequest.Add(1 * time.Hour) + assert.WithinDuration(t, expectedExpiry, conn.ExpiresAt, 5*time.Second) +} + +func TestClientCredentialsClient_ReusesValidToken(t *testing.T) { + t.Parallel() + + conn := &OAuth2Connection{ + GrantType: OAuth2GrantTypeClientCredentials, + AccessToken: "existing-token", + TokenType: "Bearer", + ExpiresAt: time.Now().Add(1 * time.Hour), + } + + // No test server -- calling clientCredentialsClient should not make any HTTP request + // because the token is still valid. + client, err := conn.clientCredentialsClient(context.Background()) + require.NoError(t, err) + require.NotNil(t, client) + + assert.Equal(t, "existing-token", conn.AccessToken) +} diff --git a/pkg/connector/registry.go b/pkg/connector/registry.go index 8b9ad588e..3bc033b8e 100644 --- a/pkg/connector/registry.go +++ b/pkg/connector/registry.go @@ -65,6 +65,24 @@ func (cr *ConnectorRegistry) Initiate(ctx context.Context, provider string, orga return connector.Initiate(ctx, provider, organizationID, r) } +// ExtractProviderFromState decodes the OAuth2 state token without +// verifying its signature and returns the provider name. This allows +// the callback handler to determine which connector to use for +// completing the OAuth2 flow, removing the need for a ?provider= +// query parameter on the redirect URI. +func ExtractProviderFromState(stateToken string) (string, error) { + payload, err := DecodeOAuth2StatePayload(stateToken) + if err != nil { + return "", fmt.Errorf("cannot decode state token: %w", err) + } + + if payload.Data.Provider == "" { + return "", fmt.Errorf("state token has no provider") + } + + return payload.Data.Provider, nil +} + func (cr *ConnectorRegistry) Complete(ctx context.Context, provider string, r *http.Request) (Connection, *gid.GID, string, error) { connector, err := cr.Get(provider) if err != nil { @@ -74,6 +92,49 @@ func (cr *ConnectorRegistry) Complete(ctx context.Context, provider string, r *h return connector.Complete(ctx, r) } +// CompleteWithState completes the OAuth2 flow and returns the full state +// including any reconnection context (ConnectorID). +func (cr *ConnectorRegistry) CompleteWithState(ctx context.Context, provider string, r *http.Request) (Connection, *OAuth2State, error) { + connector, err := cr.Get(provider) + if err != nil { + return nil, nil, fmt.Errorf("cannot complete connector: %w", err) + } + + oauth2Connector, ok := connector.(*OAuth2Connector) + if !ok { + return nil, nil, fmt.Errorf("connector %q is not an OAuth2 connector", provider) + } + + return oauth2Connector.CompleteWithState(ctx, r) +} + +// providerProbeURLs maps provider names to lightweight API endpoints +// used to verify OAuth token validity. Each URL must accept a GET +// request with a Bearer token and return 401/403 for invalid tokens. +var providerProbeURLs = map[string]string{ + "SLACK": "https://slack.com/api/users.list?limit=1", + "GOOGLE_WORKSPACE": "https://admin.googleapis.com/admin/directory/v1/users?customer=my_customer&maxResults=1", + "LINEAR": "https://api.linear.app/graphql", + "BREX": "https://platform.brexapis.com/v2/users/me", + "HUBSPOT": "https://api.hubapi.com/account-info/v3/details", + "DOCUSIGN": "https://account-d.docusign.com/oauth/userinfo", + "NOTION": "https://api.notion.com/v1/users/me", + "GITHUB": "https://api.github.com/user", + "SENTRY": "https://sentry.io/api/0/organizations/", + "INTERCOM": "https://api.intercom.io/me", + "CLOUDFLARE": "https://api.cloudflare.com/client/v4/user/tokens/verify", + "OPENAI": "https://api.openai.com/v1/models", + "SUPABASE": "https://api.supabase.com/v1/organizations", + "TALLY": "https://api.tally.so/me", + "RESEND": "https://api.resend.com/domains", + "ONE_PASSWORD": "https://events.1password.com/api/v1/auditevents", +} + +// GetProbeURL returns the probe URL for a provider. +func (cr *ConnectorRegistry) GetProbeURL(provider string) string { + return providerProbeURLs[provider] +} + // GetOAuth2RefreshConfig returns the OAuth2 refresh configuration for a provider. // Returns nil if the provider is not found or is not an OAuth2 connector. func (cr *ConnectorRegistry) GetOAuth2RefreshConfig(provider string) *OAuth2RefreshConfig { @@ -91,8 +152,9 @@ func (cr *ConnectorRegistry) GetOAuth2RefreshConfig(provider string) *OAuth2Refr } return &OAuth2RefreshConfig{ - ClientID: oauth2Connector.ClientID, - ClientSecret: oauth2Connector.ClientSecret, - TokenURL: oauth2Connector.TokenURL, + ClientID: oauth2Connector.ClientID, + ClientSecret: oauth2Connector.ClientSecret, + TokenURL: oauth2Connector.TokenURL, + TokenEndpointAuth: oauth2Connector.TokenEndpointAuth, } } diff --git a/pkg/probod/connector_config.go b/pkg/probod/connector_config.go index 9a16fd5f8..737a07fd1 100644 --- a/pkg/probod/connector_config.go +++ b/pkg/probod/connector_config.go @@ -33,13 +33,14 @@ type ConnectorConfig struct { } type ConnectorConfigOAuth2 struct { - ClientID string `json:"client-id"` - ClientSecret string `json:"client-secret"` - RedirectURI string `json:"redirect-uri"` - AuthURL string `json:"auth-url"` - TokenURL string `json:"token-url"` - Scopes []string `json:"scopes"` - ExtraAuthParams map[string]string `json:"extra-auth-params,omitempty"` + ClientID string `json:"client-id"` + ClientSecret string `json:"client-secret"` + RedirectURI string `json:"redirect-uri"` + AuthURL string `json:"auth-url"` + TokenURL string `json:"token-url"` + Scopes []string `json:"scopes"` + ExtraAuthParams map[string]string `json:"extra-auth-params,omitempty"` + TokenEndpointAuth string `json:"token-endpoint-auth,omitempty"` } func (c *Config) GetSlackSigningSecret() string { @@ -90,13 +91,14 @@ func (c *ConnectorConfig) UnmarshalJSON(data []byte) error { } oauth2Connector := connector.OAuth2Connector{ - ClientID: config.ClientID, - ClientSecret: config.ClientSecret, - RedirectURI: config.RedirectURI, - AuthURL: config.AuthURL, - TokenURL: config.TokenURL, - Scopes: config.Scopes, - ExtraAuthParams: config.ExtraAuthParams, + ClientID: config.ClientID, + ClientSecret: config.ClientSecret, + RedirectURI: config.RedirectURI, + AuthURL: config.AuthURL, + TokenURL: config.TokenURL, + Scopes: config.Scopes, + ExtraAuthParams: config.ExtraAuthParams, + TokenEndpointAuth: config.TokenEndpointAuth, } c.Config = &oauth2Connector diff --git a/pkg/statelesstoken/statelesstoken.go b/pkg/statelesstoken/statelesstoken.go index 754f9d3e3..e7ae2a749 100644 --- a/pkg/statelesstoken/statelesstoken.go +++ b/pkg/statelesstoken/statelesstoken.go @@ -95,6 +95,29 @@ func NewDeterministicToken[T any](secret string, tokenType string, expiresAt tim return tokenString, nil } +// DecodePayload decodes the token payload without verifying the signature. +// This is useful when you need to inspect the payload to determine which +// secret to use for full validation (e.g., extracting the provider from +// an OAuth2 state token to look up the correct connector). +func DecodePayload[T any](tokenString string) (*Payload[T], error) { + parts := strings.Split(tokenString, ".") + if len(parts) != 2 { + return nil, &ErrInvalidToken{message: "invalid token format"} + } + + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, fmt.Errorf("cannot decode token payload: %w", err) + } + + var payload Payload[T] + if err := json.Unmarshal(payloadBytes, &payload); err != nil { + return nil, fmt.Errorf("cannot unmarshal token payload: %w", err) + } + + return &payload, nil +} + // ValidateToken validates a token and unmarshals the payload // It returns an error if the token is invalid or expired func ValidateToken[T any](secret string, tokenType string, tokenString string) (*Payload[T], error) {