Add PKCE, token-body extras, AuthURL templating to OAuth2 → Add settings structs for Pattern-2 connector providers

- Add PKCE, token-body extras, AuthURL templating to OAuth2
- Add 13 connector provider enum values
- Add scopes, display names, name resolvers for 13 providers
- Add settings structs for Pattern-2 connector providers

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-05-17 17:22:48 +02:00
parent 3ff66757ad
commit 0147acd9f0
10 changed files with 1334 additions and 5 deletions

View File

@@ -17,10 +17,13 @@ package connector
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"strings"
@@ -49,6 +52,22 @@ type (
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
// TokenExtraParams are merged into the token-exchange request
// body (form-encoded for post-form / basic-form, JSON for
// basic-json). Used for provider-specific extras such as
// Lever's `audience` parameter.
TokenExtraParams map[string]string
// AuthURLParams are operator-supplied placeholders substituted
// into the static provider AuthURL by ApplyProviderDefaults
// (for example Vercel's "{integration_slug}"). Empty for the
// vast majority of providers.
AuthURLParams map[string]string
// HTTPClient is used for the OAuth2 token-exchange request
// issued from CompleteWithState. It must be set by callers;
@@ -63,6 +82,18 @@ type (
ContinueURL string `json:"continue,omitempty"`
ConnectorID string `json:"cid,omitempty"` // Set when reconnecting an existing connector
RequestedScopes []string `json:"scopes,omitempty"`
// CodeVerifier carries the PKCE verifier between Initiate and
// Complete. Set only when the provider requires PKCE
// (RequiresPKCE = true on the OAuth2Connector).
CodeVerifier string `json:"cv,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 {
@@ -133,6 +164,17 @@ func (c *OAuth2Connector) InitiateWithState(
stateData OAuth2State,
opts InitiateOptions,
) (string, error) {
// PKCE is generated before the state token so the verifier is
// embedded in the signed payload and replayed on the token
// exchange. Providers that do not require PKCE skip this entirely.
if c.RequiresPKCE {
verifier, err := generatePKCEVerifier()
if err != nil {
return "", fmt.Errorf("cannot generate PKCE verifier: %w", err)
}
stateData.CodeVerifier = verifier
}
state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL, stateData)
if err != nil {
return "", fmt.Errorf("cannot create state token: %w", err)
@@ -147,6 +189,11 @@ func (c *OAuth2Connector) InitiateWithState(
authCodeQuery.Set("scope", strings.Join(opts.Scopes, " "))
}
if c.RequiresPKCE {
authCodeQuery.Set("code_challenge", pkceChallenge(stateData.CodeVerifier))
authCodeQuery.Set("code_challenge_method", "S256")
}
incrementalAuth := c.SupportsIncrementalAuth && opts.IncludeGrantedScopes
if incrementalAuth {
authCodeQuery.Set("include_granted_scopes", "true")
@@ -172,6 +219,24 @@ func (c *OAuth2Connector) InitiateWithState(
return u.String(), nil
}
// generatePKCEVerifier produces a 32-byte cryptographically random
// PKCE verifier encoded as base64url without padding (RFC 7636 §4.1).
func generatePKCEVerifier() (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
}
// 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 {
@@ -209,7 +274,7 @@ func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request
return nil, nil, fmt.Errorf("cannot parse organization ID: %w", err)
}
tokenRequest, err := c.buildTokenRequest(ctx, code, c.RedirectURI)
tokenRequest, err := c.buildTokenRequest(ctx, code, c.RedirectURI, payload.Data.CodeVerifier)
if err != nil {
return nil, nil, err
}
@@ -266,17 +331,40 @@ func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request
return conn, &payload.Data, err
}
// PagerDuty Scoped OAuth includes the customer's subdomain in the
// token response body. We parse it here so the OAuth callback
// handler can write it to PagerDutyConnectorSettings without
// having to issue a second decode against a now-closed body.
if payload.Data.Provider == PagerDutyProvider {
var pd struct {
Subdomain string `json:"subdomain"`
}
if err := json.Unmarshal(body, &pd); err == nil && pd.Subdomain != "" {
if payload.Data.ProviderMetadata == nil {
payload.Data.ProviderMetadata = map[string]string{}
}
payload.Data.ProviderMetadata["subdomain"] = pd.Subdomain
}
}
return &oauth2Conn, &payload.Data, nil
}
// PagerDutyProvider is the canonical string used by PagerDuty in the
// state token's `provider` field.
const PagerDutyProvider = "PAGERDUTY"
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) {
// 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. TokenExtraParams are merged into the
// body in every branch.
func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectURI, codeVerifier string) (*http.Request, error) {
switch c.TokenEndpointAuth {
case "basic-json":
// JSON body with Basic auth header (Notion).
@@ -285,6 +373,10 @@ func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectU
"redirect_uri": redirectURI,
"grant_type": "authorization_code",
}
if codeVerifier != "" {
body["code_verifier"] = codeVerifier
}
maps.Copy(body, c.TokenExtraParams)
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("cannot marshal token request body: %w", err)
@@ -312,6 +404,12 @@ func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectU
formData.Set("code", code)
formData.Set("redirect_uri", redirectURI)
formData.Set("grant_type", "authorization_code")
if codeVerifier != "" {
formData.Set("code_verifier", codeVerifier)
}
for k, v := range c.TokenExtraParams {
formData.Set(k, v)
}
req, err := http.NewRequestWithContext(
ctx,
@@ -337,6 +435,12 @@ func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectU
formData.Set("code", code)
formData.Set("redirect_uri", redirectURI)
formData.Set("grant_type", "authorization_code")
if codeVerifier != "" {
formData.Set("code_verifier", codeVerifier)
}
for k, v := range c.TokenExtraParams {
formData.Set(k, v)
}
req, err := http.NewRequestWithContext(
ctx,