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