Add public-client (CIMD) OAuth support
Public clients authenticate with PKCE and no client secret, using a hosted Client ID Metadata Document (CIMD) as the client_id. Add a no-secret token-endpoint mode, derive the state-token salt and the PKCE verifier from a server-side key so the verifier never appears in the signed-but-unencrypted state, and expose Registration.PublicClient, Registry.PublicClients and the CIMD metadata path for provider wiring. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
@@ -17,6 +17,7 @@ package connector
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
@@ -34,12 +35,13 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// NOTE: I use client_secret as a salt for the state token, it's an antipattern to
|
||||
// avoid having add configuration key for now. In the future, we should use a random
|
||||
// string as a salt. It does not compromise security, because the client_secret is
|
||||
// private to the connector and not exposed to the client but using the same secret for
|
||||
// two different connectors may not expected by other developers and can lead to confusion
|
||||
// and bugs.
|
||||
// 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 {
|
||||
@@ -65,6 +67,14 @@ type (
|
||||
// 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
|
||||
|
||||
// HTTPClient is used for the OAuth2 token-exchange request
|
||||
// issued from CompleteWithState. It must be set by callers;
|
||||
// (*provider.Registry).ApplyOAuth2Defaults assigns an
|
||||
@@ -79,10 +89,14 @@ 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"`
|
||||
// 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
|
||||
@@ -163,19 +177,29 @@ 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
|
||||
// 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")
|
||||
}
|
||||
|
||||
state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL, stateData)
|
||||
// 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)
|
||||
}
|
||||
@@ -191,7 +215,8 @@ func (c *OAuth2Connector) InitiateWithState(
|
||||
}
|
||||
|
||||
if c.RequiresPKCE {
|
||||
authCodeQuery.Set("code_challenge", pkceChallenge(stateData.CodeVerifier))
|
||||
verifier := derivePKCEVerifier(c.stateSalt(), stateData.PKCENonce)
|
||||
authCodeQuery.Set("code_challenge", pkceChallenge(verifier))
|
||||
authCodeQuery.Set("code_challenge_method", "S256")
|
||||
}
|
||||
|
||||
@@ -221,9 +246,11 @@ 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) {
|
||||
// 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)
|
||||
@@ -232,6 +259,40 @@ func generatePKCEVerifier() (string, error) {
|
||||
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).
|
||||
@@ -267,7 +328,12 @@ func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request
|
||||
return nil, nil, fmt.Errorf("no state in request")
|
||||
}
|
||||
|
||||
payload, err := statelesstoken.ValidateToken[OAuth2State](c.ClientSecret, OAuth2TokenType, stateToken)
|
||||
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)
|
||||
}
|
||||
@@ -277,7 +343,12 @@ 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, payload.Data.CodeVerifier)
|
||||
codeVerifier := ""
|
||||
if c.RequiresPKCE {
|
||||
codeVerifier = derivePKCEVerifier(c.stateSalt(), payload.Data.PKCENonce)
|
||||
}
|
||||
|
||||
tokenRequest, err := c.buildTokenRequest(ctx, code, c.RedirectURI, codeVerifier)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -340,6 +411,22 @@ func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request
|
||||
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))
|
||||
@@ -412,6 +499,36 @@ func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectU
|
||||
|
||||
return req, nil
|
||||
|
||||
case "none":
|
||||
// Public client (CIMD): client_id in the body, authenticated by
|
||||
// the PKCE code_verifier. No client_secret is sent — the provider
|
||||
// advertises token_endpoint_auth_method "none".
|
||||
formData := url.Values{}
|
||||
formData.Set("client_id", c.ClientID)
|
||||
formData.Set("code", code)
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
|
||||
if codeVerifier != "" {
|
||||
formData.Set("code_verifier", codeVerifier)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
default:
|
||||
// "post-form" or empty: credentials in form body (Slack, HubSpot, GitHub, etc.).
|
||||
formData := url.Values{}
|
||||
|
||||
Reference in New Issue
Block a user