Add connector infrastructure for access review

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>
This commit is contained in:
Aurélien Sibiril
2026-04-02 11:51:33 +02:00
parent 314984aa98
commit 46dee27bcf
9 changed files with 826 additions and 90 deletions

View File

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

63
pkg/connector/apikey.go Normal file
View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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)
}

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,258 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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)
}

View File

@@ -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,
}
}

View File

@@ -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

View File

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