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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user