The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
888 lines
30 KiB
Go
888 lines
30 KiB
Go
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package connector
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.gearno.de/kit/httpclient"
|
|
"go.probo.inc/probo/pkg/gid"
|
|
"go.probo.inc/probo/pkg/statelesstoken"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// NOTE: the OAuth2 state token (and, for PKCE providers, the code verifier)
|
|
// is keyed by stateSalt(). Public clients (CIMD, no client_secret) set
|
|
// StateSigningKey to a server-side derived key; confidential clients fall
|
|
// back to the client_secret, which is private to the connector and not
|
|
// exposed to the client. Reusing the client_secret as the salt is a legacy
|
|
// path retained for confidential providers; new public clients always carry
|
|
// an explicit StateSigningKey.
|
|
|
|
type (
|
|
OAuth2Connector struct {
|
|
ClientID string
|
|
ClientSecret string
|
|
RedirectURI 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"
|
|
SupportsIncrementalAuth bool
|
|
// RequiresPKCE enables RFC 7636 PKCE (S256). When true,
|
|
// InitiateWithState generates a verifier, persists it in the
|
|
// OAuth2State, and adds code_challenge / code_challenge_method
|
|
// to the authorize URL; CompleteWithState replays the verifier
|
|
// on the token exchange.
|
|
RequiresPKCE bool
|
|
// IntegrationSlug is an operator-supplied identifier used by
|
|
// providers whose authorization URL embeds it as a path segment
|
|
// (Vercel-style integrations). It is consumed by the provider's
|
|
// Registration.BuildAuthURL in
|
|
// (*provider.Registry).ApplyOAuth2Defaults. Empty for the vast
|
|
// majority of providers.
|
|
IntegrationSlug string
|
|
|
|
// StateSigningKey is the HMAC key used to sign the OAuth2 state
|
|
// token and to derive the PKCE verifier. Public clients (CIMD: no
|
|
// client_secret, authenticated by PKCE) MUST set it to a
|
|
// server-side secret; confidential clients leave it empty and fall
|
|
// back to ClientSecret (see stateSalt). It is set by the probod
|
|
// wiring, never serialized.
|
|
StateSigningKey string
|
|
|
|
// BuildAuthURLForSite / BuildTokenURLForDomain / BuildTokenURLForSite
|
|
// are copied from the provider Registration by ApplyOAuth2Defaults.
|
|
// When set, the authorize URL is built per-site at initiate.
|
|
// BuildTokenURLForDomain builds the token URL from a host the provider
|
|
// echoes back on the callback (Datadog's `domain`);
|
|
// BuildTokenURLForSite builds it from the site carried in the signed
|
|
// state (Zendesk's subdomain), for providers that do not echo the host
|
|
// back. A provider sets at most one of the two. Nil for single-site
|
|
// providers.
|
|
BuildAuthURLForSite func(site string) (string, error)
|
|
BuildTokenURLForDomain func(domain string) (string, error)
|
|
BuildTokenURLForSite func(site string) (string, error)
|
|
|
|
// HTTPClient is used for the OAuth2 token-exchange request
|
|
// issued from CompleteWithState. It must be set by callers;
|
|
// (*provider.Registry).ApplyOAuth2Defaults assigns an
|
|
// SSRF-protected client for production use. Tests may inject a
|
|
// loopback-friendly one.
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
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
|
|
RequestedScopes []string `json:"scopes,omitempty"`
|
|
// Site carries the per-customer site/subdomain chosen at initiate
|
|
// (opts.Site) to the callback for multi-site providers whose token
|
|
// host is NOT echoed back by the provider (e.g. Zendesk, whose
|
|
// token endpoint lives at <subdomain>.zendesk.com). It is signed
|
|
// into the state token, so a tampered value is rejected by the HMAC
|
|
// check; the callback still re-validates the format before using it
|
|
// to build a URL host. Empty for single-site providers and for
|
|
// multi-site providers that recover the host from a callback param
|
|
// (e.g. Datadog's `domain`).
|
|
Site string `json:"site,omitempty"`
|
|
// PKCENonce carries a random per-flow nonce between Initiate and
|
|
// Complete for providers that require PKCE. The actual
|
|
// code_verifier is DERIVED server-side from the state salt and this
|
|
// nonce (derivePKCEVerifier), so the verifier never appears in the
|
|
// signed-but-unencrypted state token. The nonce is safe to expose
|
|
// in the state parameter — it is useless without the server-side
|
|
// salt. Set only when RequiresPKCE = true.
|
|
PKCENonce string `json:"pn,omitempty"`
|
|
// ProviderMetadata surfaces provider-specific extras parsed
|
|
// from the token-exchange response (e.g. PagerDuty's
|
|
// `subdomain`). It is populated by CompleteWithState and is
|
|
// NEVER serialized into the state token (the field is for
|
|
// in-process plumbing only). Consumers that need to persist
|
|
// these values (typically the OAuth callback handler) read
|
|
// them off the returned *OAuth2State.
|
|
ProviderMetadata map[string]string `json:"-"`
|
|
}
|
|
|
|
OAuth2Connection struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token,omitempty"`
|
|
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
|
|
TokenEndpointAuth string // "post-form" (default), "basic-form", or "basic-json"
|
|
}
|
|
)
|
|
|
|
var (
|
|
_ Connector = (*OAuth2Connector)(nil)
|
|
_ Connection = (*OAuth2Connection)(nil)
|
|
|
|
OAuth2TokenType = "probo/connector/oauth2"
|
|
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,
|
|
opts InitiateOptions,
|
|
r *http.Request,
|
|
) (string, error) {
|
|
stateData := OAuth2State{
|
|
OrganizationID: organizationID.String(),
|
|
Provider: provider,
|
|
ConnectorID: opts.ConnectorID,
|
|
RequestedScopes: opts.Scopes,
|
|
}
|
|
|
|
if r != nil {
|
|
if continueURL := r.URL.Query().Get("continue"); continueURL != "" {
|
|
stateData.ContinueURL = continueURL
|
|
}
|
|
}
|
|
|
|
return c.InitiateWithState(ctx, stateData, opts)
|
|
}
|
|
|
|
// InitiateWithState generates an OAuth2 authorization URL with a custom state.
|
|
// This allows callers to include additional context (like SCIMBridgeID) in the state.
|
|
func (c *OAuth2Connector) InitiateWithState(
|
|
ctx context.Context,
|
|
stateData OAuth2State,
|
|
opts InitiateOptions,
|
|
) (string, error) {
|
|
// An empty salt would HMAC the state token (and derive the PKCE
|
|
// verifier) with an empty key, making both forgeable. probod always
|
|
// sets one, but guard at the type level so a misconfigured connector
|
|
// fails loudly instead of issuing a forgeable state.
|
|
salt := c.stateSalt()
|
|
if salt == "" {
|
|
return "", fmt.Errorf("cannot create state token: connector has no state signing key or client secret")
|
|
}
|
|
|
|
// Carry the per-customer site/subdomain (if any) into the signed state so
|
|
// it survives the round-trip to the callback. Multi-site providers whose
|
|
// token host the provider does not echo back (e.g. Zendesk) read it from
|
|
// the state at CompleteWithState. No-op (omitempty) when unset.
|
|
stateData.Site = opts.Site
|
|
|
|
// For PKCE providers a per-flow nonce is generated and stored in the
|
|
// signed state; the code verifier itself is DERIVED from salt+nonce
|
|
// (derivePKCEVerifier) and never serialized, so it stays secret even
|
|
// though the state is signed-not-encrypted. Non-PKCE providers skip this.
|
|
if c.RequiresPKCE {
|
|
nonce, err := generatePKCENonce()
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot generate PKCE nonce: %w", err)
|
|
}
|
|
|
|
stateData.PKCENonce = nonce
|
|
}
|
|
|
|
state, err := statelesstoken.NewToken(salt, OAuth2TokenType, OAuth2TokenTTL, stateData)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create state token: %w", err)
|
|
}
|
|
|
|
authCodeQuery := url.Values{}
|
|
authCodeQuery.Set("state", state)
|
|
authCodeQuery.Set("client_id", c.ClientID)
|
|
authCodeQuery.Set("redirect_uri", c.RedirectURI)
|
|
authCodeQuery.Set("response_type", "code")
|
|
|
|
if len(opts.Scopes) > 0 {
|
|
authCodeQuery.Set("scope", strings.Join(opts.Scopes, " "))
|
|
}
|
|
|
|
if c.RequiresPKCE {
|
|
verifier := derivePKCEVerifier(c.stateSalt(), stateData.PKCENonce)
|
|
authCodeQuery.Set("code_challenge", pkceChallenge(verifier))
|
|
authCodeQuery.Set("code_challenge_method", "S256")
|
|
}
|
|
|
|
incrementalAuth := c.SupportsIncrementalAuth && opts.IncludeGrantedScopes
|
|
if incrementalAuth {
|
|
authCodeQuery.Set("include_granted_scopes", "true")
|
|
}
|
|
|
|
// Skip prompt=consent when doing incremental auth so the user sees
|
|
// only the delta, not a full re-consent. First-install flows keep it
|
|
// because IncludeGrantedScopes is false there.
|
|
for k, v := range c.ExtraAuthParams {
|
|
if incrementalAuth && k == "prompt" && v == "consent" {
|
|
continue
|
|
}
|
|
|
|
authCodeQuery.Set(k, v)
|
|
}
|
|
|
|
authURL := c.AuthURL
|
|
if c.BuildAuthURLForSite != nil {
|
|
if opts.Site == "" {
|
|
return "", fmt.Errorf("cannot initiate connector: site is required for multi-site providers")
|
|
}
|
|
|
|
built, err := c.BuildAuthURLForSite(opts.Site)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot build auth URL for site: %w", err)
|
|
}
|
|
|
|
authURL = built
|
|
}
|
|
|
|
u, err := url.Parse(authURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot parse auth URL: %w", err)
|
|
}
|
|
|
|
u.RawQuery = authCodeQuery.Encode()
|
|
|
|
return u.String(), nil
|
|
}
|
|
|
|
// generatePKCENonce produces a 32-byte cryptographically random nonce
|
|
// encoded as base64url without padding. The nonce travels in the (signed)
|
|
// state token and is combined with the server-side state salt by
|
|
// derivePKCEVerifier to produce the actual RFC 7636 code_verifier.
|
|
func generatePKCENonce() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("cannot read random bytes: %w", err)
|
|
}
|
|
|
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
// stateSalt returns the HMAC key used to sign the OAuth2 state token and to
|
|
// derive the PKCE verifier. Public clients (CIMD: no client_secret,
|
|
// authenticated by PKCE) set StateSigningKey to a server-side secret;
|
|
// confidential clients fall back to ClientSecret. It must never be empty
|
|
// for a connector that issues state tokens.
|
|
func (c *OAuth2Connector) stateSalt() string {
|
|
if c.StateSigningKey != "" {
|
|
return c.StateSigningKey
|
|
}
|
|
|
|
return c.ClientSecret
|
|
}
|
|
|
|
// derivePKCEVerifier deterministically derives the RFC 7636 code_verifier
|
|
// from the server-side state salt and a per-flow nonce. Because the verifier
|
|
// is recomputed server-side at both Initiate and Complete — and never placed
|
|
// in the signed-but-unencrypted state token — it stays secret even though
|
|
// the nonce is exposed in the state parameter. This is what makes PKCE
|
|
// meaningful for public clients, whose only secret is the verifier.
|
|
func derivePKCEVerifier(salt, nonce string) string {
|
|
return deriveHMACKey(salt, "pkce:"+nonce)
|
|
}
|
|
|
|
// deriveHMACKey derives a base64url-encoded key from a server-side secret and
|
|
// a domain-separation label via HMAC-SHA256. Distinct labels yield independent
|
|
// keys, so the same secret can safely back several purposes (the PKCE verifier
|
|
// and the connector state-signing key).
|
|
func deriveHMACKey(secret, info string) string {
|
|
h := hmac.New(sha256.New, []byte(secret))
|
|
h.Write([]byte(info))
|
|
|
|
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// pkceChallenge derives the S256 PKCE challenge from a verifier: it is
|
|
// the base64url-without-padding encoding of SHA-256(verifier) (RFC 7636
|
|
// §4.2).
|
|
func pkceChallenge(verifier string) string {
|
|
sum := sha256.Sum256([]byte(verifier))
|
|
return base64.RawURLEncoding.EncodeToString(sum[:])
|
|
}
|
|
|
|
func (c *OAuth2Connector) Complete(ctx context.Context, r *http.Request) (Connection, *gid.GID, string, error) {
|
|
conn, state, err := c.CompleteWithState(ctx, r)
|
|
if err != nil {
|
|
return nil, nil, "", err
|
|
}
|
|
|
|
organizationID, err := gid.ParseGID(state.OrganizationID)
|
|
if err != nil {
|
|
return nil, nil, "", fmt.Errorf("cannot parse organization ID: %w", err)
|
|
}
|
|
|
|
return conn, &organizationID, state.ContinueURL, nil
|
|
}
|
|
|
|
// 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) {
|
|
code := r.URL.Query().Get("code")
|
|
if code == "" {
|
|
return nil, nil, fmt.Errorf("no code in request")
|
|
}
|
|
|
|
stateToken := r.URL.Query().Get("state")
|
|
if stateToken == "" {
|
|
return nil, nil, fmt.Errorf("no state in request")
|
|
}
|
|
|
|
salt := c.stateSalt()
|
|
if salt == "" {
|
|
return nil, nil, fmt.Errorf("cannot validate state token: connector has no state signing key or client secret")
|
|
}
|
|
|
|
payload, err := statelesstoken.ValidateToken[OAuth2State](salt, OAuth2TokenType, stateToken)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("cannot validate state token: %w", err)
|
|
}
|
|
|
|
organizationID, err := gid.ParseGID(payload.Data.OrganizationID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("cannot parse organization ID: %w", err)
|
|
}
|
|
|
|
codeVerifier := ""
|
|
if c.RequiresPKCE {
|
|
codeVerifier = derivePKCEVerifier(c.stateSalt(), payload.Data.PKCENonce)
|
|
}
|
|
|
|
// Multi-site providers build the token host from a per-customer value that
|
|
// reaches the callback by one of two mutually exclusive routes (Register
|
|
// rejects setting both): a host the provider echoes back as a query param
|
|
// (BuildTokenURLForDomain — Datadog's ?domain=), or the site carried in the
|
|
// signed state when the provider echoes nothing (BuildTokenURLForSite —
|
|
// Zendesk's subdomain). They cannot share one closure: a provider's state
|
|
// Site (e.g. Datadog's region key "US3") is not necessarily the string its
|
|
// token host needs (Datadog's API domain "us3.datadoghq.com").
|
|
tokenURL := c.TokenURL
|
|
switch {
|
|
case c.BuildTokenURLForDomain != nil:
|
|
domain := r.URL.Query().Get("domain")
|
|
if domain == "" {
|
|
return nil, nil, fmt.Errorf("cannot complete oauth2 flow: missing domain parameter")
|
|
}
|
|
|
|
built, err := c.BuildTokenURLForDomain(domain)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("cannot build token URL: %w", err)
|
|
}
|
|
|
|
tokenURL = built
|
|
case c.BuildTokenURLForSite != nil:
|
|
// The site/subdomain was carried in the signed state from initiate
|
|
// (the provider does not echo it back on the callback). The HMAC
|
|
// signature already authenticated the value; the closure re-validates
|
|
// the format before using it as a URL host.
|
|
if payload.Data.Site == "" {
|
|
return nil, nil, fmt.Errorf("cannot complete oauth2 flow: missing site in state")
|
|
}
|
|
|
|
built, err := c.BuildTokenURLForSite(payload.Data.Site)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("cannot build token URL: %w", err)
|
|
}
|
|
|
|
tokenURL = built
|
|
}
|
|
|
|
tokenRequest, err := c.buildTokenRequest(ctx, code, c.RedirectURI, codeVerifier, tokenURL)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
tokenResp, err := c.HTTPClient.Do(tokenRequest)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("cannot post token URL: %w", err)
|
|
}
|
|
|
|
defer func() { _ = tokenResp.Body.Close() }()
|
|
|
|
if tokenResp.StatusCode != http.StatusOK {
|
|
return nil, nil, fmt.Errorf("token response status: %d", tokenResp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(tokenResp.Body)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("cannot read token response body: %w", err)
|
|
}
|
|
|
|
// Parse the raw token response (OAuth2 uses expires_in, not expires_at)
|
|
var rawToken struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
TokenType string `json:"token_type"`
|
|
Scope string `json:"scope"`
|
|
}
|
|
if err := json.Unmarshal(body, &rawToken); err != nil {
|
|
return nil, nil, fmt.Errorf("cannot decode token response: %w", err)
|
|
}
|
|
|
|
grantedScope := rawToken.Scope
|
|
if grantedScope == "" {
|
|
// RFC 6749 §5.1: scope is OPTIONAL when identical to the
|
|
// requested scope. Fall back to what we asked for so
|
|
// subsequent reconnect diffs have a meaningful base.
|
|
grantedScope = FormatScopeString(payload.Data.RequestedScopes)
|
|
}
|
|
|
|
oauth2Conn := OAuth2Connection{
|
|
AccessToken: rawToken.AccessToken,
|
|
RefreshToken: rawToken.RefreshToken,
|
|
TokenType: rawToken.TokenType,
|
|
Scope: grantedScope,
|
|
}
|
|
|
|
// Convert expires_in (seconds) to expires_at (absolute time)
|
|
if rawToken.ExpiresIn > 0 {
|
|
oauth2Conn.ExpiresAt = time.Now().Add(time.Duration(rawToken.ExpiresIn) * time.Second)
|
|
}
|
|
|
|
// Persist the per-customer token URL for multi-site providers so
|
|
// token refresh targets the same regional/subdomain host.
|
|
if c.BuildTokenURLForDomain != nil || c.BuildTokenURLForSite != nil {
|
|
oauth2Conn.TokenURL = tokenURL
|
|
}
|
|
|
|
if payload.Data.Provider == SlackProvider {
|
|
conn, _, err := ParseSlackTokenResponse(body, oauth2Conn, organizationID)
|
|
return conn, &payload.Data, err
|
|
}
|
|
|
|
AbsorbPagerDutyTokenResponse(&payload.Data, body)
|
|
|
|
return &oauth2Conn, &payload.Data, nil
|
|
}
|
|
|
|
// DeriveConnectorStateKey derives the HMAC key used to sign connector
|
|
// OAuth2 state tokens (and PKCE verifiers) for public clients, from a
|
|
// server-side secret (the active OAuth2 server signing key). The domain
|
|
// separator avoids reusing the raw server key directly for an unrelated
|
|
// purpose. probod calls this once at startup and assigns the result to
|
|
// each public client's StateSigningKey.
|
|
//
|
|
// NOTE: the key is derived from the single ACTIVE OAuth2 server signing key.
|
|
// Rotating that key changes the derived state key, so connector OAuth flows
|
|
// started within the state token's 10-minute TTL window across a rotation
|
|
// will fail validation and must be retried. A dedicated, independently
|
|
// rotated connector-state key (HMAC key set) is a future improvement.
|
|
func DeriveConnectorStateKey(serverSecret string) string {
|
|
return deriveHMACKey(serverSecret, "probo/connector/oauth2-state-key")
|
|
}
|
|
|
|
func basicAuthHeader(clientID, clientSecret string) string {
|
|
credentials := clientID + ":" + clientSecret
|
|
return "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
|
|
}
|
|
|
|
// newFormTokenRequest builds a form-encoded POST request to the token
|
|
// endpoint with the headers shared by the form-body auth methods
|
|
// ("basic-form", "post-form", and the public-client "none"). Callers set any
|
|
// extra auth header (e.g. Basic) on the returned request.
|
|
func (c *OAuth2Connector) newFormTokenRequest(ctx context.Context, form url.Values, tokenURL string) (*http.Request, error) {
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
tokenURL,
|
|
strings.NewReader(form.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
|
|
}
|
|
|
|
// buildTokenRequest creates the HTTP request for the token exchange, branching
|
|
// on c.TokenEndpointAuth to support different provider requirements. When
|
|
// codeVerifier is non-empty (PKCE-enabled providers), it is replayed as
|
|
// `code_verifier` in the request body.
|
|
func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectURI, codeVerifier, tokenURL 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",
|
|
}
|
|
if codeVerifier != "" {
|
|
body["code_verifier"] = codeVerifier
|
|
}
|
|
|
|
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,
|
|
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")
|
|
|
|
if codeVerifier != "" {
|
|
formData.Set("code_verifier", codeVerifier)
|
|
}
|
|
|
|
req, err := c.newFormTokenRequest(ctx, formData, tokenURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("Authorization", basicAuthHeader(c.ClientID, c.ClientSecret))
|
|
|
|
return req, nil
|
|
|
|
default:
|
|
// "post-form", "none", or empty: credentials in the form body
|
|
// (Slack, HubSpot, GitHub, …). Public clients ("none", CIMD) send
|
|
// client_id only and authenticate with the PKCE verifier;
|
|
// confidential clients also send client_secret.
|
|
formData := url.Values{}
|
|
formData.Set("client_id", c.ClientID)
|
|
|
|
if c.TokenEndpointAuth != "none" {
|
|
formData.Set("client_secret", c.ClientSecret)
|
|
}
|
|
|
|
formData.Set("code", code)
|
|
formData.Set("redirect_uri", redirectURI)
|
|
formData.Set("grant_type", "authorization_code")
|
|
|
|
if codeVerifier != "" {
|
|
formData.Set("code_verifier", codeVerifier)
|
|
}
|
|
|
|
return c.newFormTokenRequest(ctx, formData, tokenURL)
|
|
}
|
|
}
|
|
|
|
func (c *OAuth2Connection) Type() ProtocolType {
|
|
return ProtocolOAuth2
|
|
}
|
|
|
|
func (c *OAuth2Connection) Scopes() []string {
|
|
return ParseScopeString(c.Scope)
|
|
}
|
|
|
|
func (c *OAuth2Connection) Client(ctx context.Context) (*http.Client, error) {
|
|
return c.ClientWithOptions(ctx)
|
|
}
|
|
|
|
// ClientWithOptions returns an HTTP client with the given options.
|
|
// Use this to add logging and tracing to the HTTP client.
|
|
//
|
|
// SSRF protection is always enabled: the underlying connector URL
|
|
// (for example a 1Password SCIM bridge URL) is customer-supplied,
|
|
// so dials to private, loopback, or other reserved address ranges
|
|
// are refused. Hardcoded provider hosts on public IPs are
|
|
// unaffected.
|
|
func (c *OAuth2Connection) ClientWithOptions(ctx context.Context, opts ...httpclient.Option) (*http.Client, error) {
|
|
opts = append(opts, httpclient.WithSSRFProtection())
|
|
transport := &oauth2Transport{
|
|
token: c.AccessToken,
|
|
tokenType: c.TokenType,
|
|
underlying: httpclient.DefaultPooledTransport(opts...),
|
|
}
|
|
client := &http.Client{
|
|
Transport: transport,
|
|
}
|
|
|
|
return client, nil
|
|
}
|
|
|
|
// 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...)
|
|
}
|
|
|
|
// All HTTP traffic on this path (token refresh + API calls)
|
|
// must reject private/loopback/reserved peer IPs because the
|
|
// configured TokenURL or API host can be customer-influenced.
|
|
opts = append(opts, httpclient.WithSSRFProtection())
|
|
|
|
// Determine auth style based on TokenEndpointAuth
|
|
authStyle := oauth2.AuthStyleInParams
|
|
|
|
switch cfg.TokenEndpointAuth {
|
|
case "basic-form", "basic-json":
|
|
authStyle = oauth2.AuthStyleInHeader
|
|
}
|
|
|
|
// Multi-site providers persist a per-customer token URL on the
|
|
// connection; prefer it over the static registration TokenURL so
|
|
// refresh targets the correct regional host.
|
|
tokenURL := cfg.TokenURL
|
|
if c.TokenURL != "" {
|
|
tokenURL = c.TokenURL
|
|
}
|
|
|
|
config := &oauth2.Config{
|
|
ClientID: cfg.ClientID,
|
|
ClientSecret: cfg.ClientSecret,
|
|
Endpoint: oauth2.Endpoint{
|
|
TokenURL: tokenURL,
|
|
AuthStyle: authStyle,
|
|
},
|
|
}
|
|
|
|
// Determine the token expiry
|
|
// If ExpiresAt is zero or in the past, set expiry to force a refresh
|
|
expiry := c.ExpiresAt
|
|
if expiry.IsZero() || expiry.Before(time.Now()) {
|
|
// Set expiry to the past to force oauth2 library to refresh
|
|
expiry = time.Now().Add(-time.Hour)
|
|
}
|
|
|
|
token := &oauth2.Token{
|
|
AccessToken: c.AccessToken,
|
|
RefreshToken: c.RefreshToken,
|
|
Expiry: expiry,
|
|
TokenType: c.TokenType,
|
|
}
|
|
|
|
// Create an HTTP client with telemetry for the oauth2 library to use
|
|
// This ensures token refresh requests are also logged
|
|
baseClient := &http.Client{
|
|
Transport: httpclient.DefaultPooledTransport(opts...),
|
|
}
|
|
ctx = context.WithValue(ctx, oauth2.HTTPClient, baseClient)
|
|
|
|
// Create a token source that will automatically refresh when expired
|
|
tokenSource := config.TokenSource(ctx, token)
|
|
|
|
// Get the current (possibly refreshed) token
|
|
newToken, err := tokenSource.Token()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot refresh token: %w", err)
|
|
}
|
|
|
|
// Update the connection with the potentially refreshed token
|
|
c.AccessToken = newToken.AccessToken
|
|
c.ExpiresAt = newToken.Expiry
|
|
|
|
c.TokenType = newToken.TokenType
|
|
if newToken.RefreshToken != "" {
|
|
c.RefreshToken = newToken.RefreshToken
|
|
}
|
|
|
|
// Return a client with telemetry that uses the refreshed token
|
|
return &http.Client{
|
|
Transport: &oauth2Transport{
|
|
token: newToken.AccessToken,
|
|
tokenType: newToken.TokenType,
|
|
underlying: httpclient.DefaultPooledTransport(opts...),
|
|
},
|
|
}, 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...)
|
|
}
|
|
|
|
// TokenURL is stored from customer-supplied connector settings;
|
|
// reject dials to private/loopback/reserved peer IPs.
|
|
opts = append(opts, httpclient.WithSSRFProtection())
|
|
|
|
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 {
|
|
Type string `json:"type"`
|
|
Alias
|
|
}{
|
|
Type: string(ProtocolOAuth2),
|
|
Alias: Alias(c),
|
|
})
|
|
}
|
|
|
|
func (c *OAuth2Connection) UnmarshalJSON(data []byte) error {
|
|
type Alias OAuth2Connection
|
|
|
|
aux := &struct {
|
|
*Alias
|
|
}{
|
|
Alias: (*Alias)(c),
|
|
}
|
|
|
|
return json.Unmarshal(data, &aux)
|
|
}
|
|
|
|
// OAuth transport for adding authorization header
|
|
type oauth2Transport struct {
|
|
token string
|
|
tokenType string
|
|
underlying http.RoundTripper
|
|
}
|
|
|
|
func (t *oauth2Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
req2 := req.Clone(req.Context())
|
|
// tokenType from the provider's OAuth response is not always a valid HTTP
|
|
// auth scheme (Slack returns "bot" / "user", some providers send an empty
|
|
// string), so we always send "Bearer" -- the only scheme any connector in
|
|
// this codebase actually needs.
|
|
req2.Header.Set("Authorization", "Bearer "+t.token)
|
|
|
|
return t.underlying.RoundTrip(req2)
|
|
}
|