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