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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -34,12 +35,13 @@ import (
|
|||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NOTE: I use client_secret as a salt for the state token, it's an antipattern to
|
// NOTE: the OAuth2 state token (and, for PKCE providers, the code verifier)
|
||||||
// avoid having add configuration key for now. In the future, we should use a random
|
// is keyed by stateSalt(). Public clients (CIMD, no client_secret) set
|
||||||
// string as a salt. It does not compromise security, because the client_secret is
|
// StateSigningKey to a server-side derived key; confidential clients fall
|
||||||
// private to the connector and not exposed to the client but using the same secret for
|
// back to the client_secret, which is private to the connector and not
|
||||||
// two different connectors may not expected by other developers and can lead to confusion
|
// exposed to the client. Reusing the client_secret as the salt is a legacy
|
||||||
// and bugs.
|
// path retained for confidential providers; new public clients always carry
|
||||||
|
// an explicit StateSigningKey.
|
||||||
|
|
||||||
type (
|
type (
|
||||||
OAuth2Connector struct {
|
OAuth2Connector struct {
|
||||||
@@ -65,6 +67,14 @@ type (
|
|||||||
// majority of providers.
|
// majority of providers.
|
||||||
IntegrationSlug string
|
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
|
// HTTPClient is used for the OAuth2 token-exchange request
|
||||||
// issued from CompleteWithState. It must be set by callers;
|
// issued from CompleteWithState. It must be set by callers;
|
||||||
// (*provider.Registry).ApplyOAuth2Defaults assigns an
|
// (*provider.Registry).ApplyOAuth2Defaults assigns an
|
||||||
@@ -79,10 +89,14 @@ type (
|
|||||||
ContinueURL string `json:"continue,omitempty"`
|
ContinueURL string `json:"continue,omitempty"`
|
||||||
ConnectorID string `json:"cid,omitempty"` // Set when reconnecting an existing connector
|
ConnectorID string `json:"cid,omitempty"` // Set when reconnecting an existing connector
|
||||||
RequestedScopes []string `json:"scopes,omitempty"`
|
RequestedScopes []string `json:"scopes,omitempty"`
|
||||||
// CodeVerifier carries the PKCE verifier between Initiate and
|
// PKCENonce carries a random per-flow nonce between Initiate and
|
||||||
// Complete. Set only when the provider requires PKCE
|
// Complete for providers that require PKCE. The actual
|
||||||
// (RequiresPKCE = true on the OAuth2Connector).
|
// code_verifier is DERIVED server-side from the state salt and this
|
||||||
CodeVerifier string `json:"cv,omitempty"`
|
// 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
|
// ProviderMetadata surfaces provider-specific extras parsed
|
||||||
// from the token-exchange response (e.g. PagerDuty's
|
// from the token-exchange response (e.g. PagerDuty's
|
||||||
// `subdomain`). It is populated by CompleteWithState and is
|
// `subdomain`). It is populated by CompleteWithState and is
|
||||||
@@ -163,19 +177,29 @@ func (c *OAuth2Connector) InitiateWithState(
|
|||||||
stateData OAuth2State,
|
stateData OAuth2State,
|
||||||
opts InitiateOptions,
|
opts InitiateOptions,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
// PKCE is generated before the state token so the verifier is
|
// An empty salt would HMAC the state token (and derive the PKCE
|
||||||
// embedded in the signed payload and replayed on the token
|
// verifier) with an empty key, making both forgeable. probod always
|
||||||
// exchange. Providers that do not require PKCE skip this entirely.
|
// sets one, but guard at the type level so a misconfigured connector
|
||||||
if c.RequiresPKCE {
|
// fails loudly instead of issuing a forgeable state.
|
||||||
verifier, err := generatePKCEVerifier()
|
salt := c.stateSalt()
|
||||||
if err != nil {
|
if salt == "" {
|
||||||
return "", fmt.Errorf("cannot generate PKCE verifier: %w", err)
|
return "", fmt.Errorf("cannot create state token: connector has no state signing key or client secret")
|
||||||
}
|
|
||||||
|
|
||||||
stateData.CodeVerifier = verifier
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("cannot create state token: %w", err)
|
return "", fmt.Errorf("cannot create state token: %w", err)
|
||||||
}
|
}
|
||||||
@@ -191,7 +215,8 @@ func (c *OAuth2Connector) InitiateWithState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if c.RequiresPKCE {
|
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")
|
authCodeQuery.Set("code_challenge_method", "S256")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,9 +246,11 @@ func (c *OAuth2Connector) InitiateWithState(
|
|||||||
return u.String(), nil
|
return u.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generatePKCEVerifier produces a 32-byte cryptographically random
|
// generatePKCENonce produces a 32-byte cryptographically random nonce
|
||||||
// PKCE verifier encoded as base64url without padding (RFC 7636 §4.1).
|
// encoded as base64url without padding. The nonce travels in the (signed)
|
||||||
func generatePKCEVerifier() (string, error) {
|
// 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)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
return "", fmt.Errorf("cannot read random bytes: %w", err)
|
return "", fmt.Errorf("cannot read random bytes: %w", err)
|
||||||
@@ -232,6 +259,40 @@ func generatePKCEVerifier() (string, error) {
|
|||||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
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
|
// pkceChallenge derives the S256 PKCE challenge from a verifier: it is
|
||||||
// the base64url-without-padding encoding of SHA-256(verifier) (RFC 7636
|
// the base64url-without-padding encoding of SHA-256(verifier) (RFC 7636
|
||||||
// §4.2).
|
// §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")
|
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 {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("cannot validate state token: %w", err)
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -340,6 +411,22 @@ func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request
|
|||||||
return &oauth2Conn, &payload.Data, nil
|
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 {
|
func basicAuthHeader(clientID, clientSecret string) string {
|
||||||
credentials := clientID + ":" + clientSecret
|
credentials := clientID + ":" + clientSecret
|
||||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
|
return "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
|
||||||
@@ -412,6 +499,36 @@ func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectU
|
|||||||
|
|
||||||
return req, nil
|
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:
|
default:
|
||||||
// "post-form" or empty: credentials in form body (Slack, HubSpot, GitHub, etc.).
|
// "post-form" or empty: credentials in form body (Slack, HubSpot, GitHub, etc.).
|
||||||
formData := url.Values{}
|
formData := url.Values{}
|
||||||
|
|||||||
@@ -561,9 +561,10 @@ func TestCompleteWithState_ScopeFallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestInitiateWithState_PKCE verifies that connectors with RequiresPKCE=true
|
// TestInitiateWithState_PKCE verifies that connectors with RequiresPKCE=true
|
||||||
// generate a PKCE verifier, embed the S256 challenge in the authorization
|
// embed the S256 challenge in the authorization URL (RFC 7636 §4.3) and
|
||||||
// URL (RFC 7636 §4.3), and persist the verifier in the signed state token
|
// persist a random nonce (not the verifier) in the signed state token, so
|
||||||
// so CompleteWithState can replay it on the token exchange.
|
// CompleteWithState can re-derive the verifier and replay it on the token
|
||||||
|
// exchange without ever exposing it in the state parameter.
|
||||||
func TestInitiateWithState_PKCE(t *testing.T) {
|
func TestInitiateWithState_PKCE(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -594,18 +595,22 @@ func TestInitiateWithState_PKCE(t *testing.T) {
|
|||||||
require.NotEmpty(t, challenge, "code_challenge must be present when RequiresPKCE=true")
|
require.NotEmpty(t, challenge, "code_challenge must be present when RequiresPKCE=true")
|
||||||
assert.Equal(t, "S256", parsed.Query().Get("code_challenge_method"))
|
assert.Equal(t, "S256", parsed.Query().Get("code_challenge_method"))
|
||||||
|
|
||||||
// The verifier is persisted in the signed state token. Decode
|
// Only a nonce is persisted in the state token; the verifier is
|
||||||
// the payload (without secret-checking — just inspect) and
|
// derived server-side from the state salt + nonce and must never
|
||||||
// verify that re-deriving the challenge from the verifier
|
// appear in the (signed-but-unencrypted) state. Re-deriving it
|
||||||
// reproduces the URL value.
|
// must reproduce the published challenge.
|
||||||
stateToken := parsed.Query().Get("state")
|
stateToken := parsed.Query().Get("state")
|
||||||
require.NotEmpty(t, stateToken)
|
require.NotEmpty(t, stateToken)
|
||||||
|
|
||||||
payload, err := DecodeOAuth2StatePayload(stateToken)
|
payload, err := DecodeOAuth2StatePayload(stateToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotEmpty(t, payload.Data.CodeVerifier, "verifier must be persisted in state token")
|
require.NotEmpty(t, payload.Data.PKCENonce, "nonce must be persisted in state token")
|
||||||
assert.Equal(t, challenge, pkceChallenge(payload.Data.CodeVerifier),
|
|
||||||
"code_challenge must equal base64url(sha256(verifier))")
|
verifier := derivePKCEVerifier("secret", payload.Data.PKCENonce)
|
||||||
|
require.NotContains(t, stateToken, verifier,
|
||||||
|
"the derived verifier must never appear in the state token")
|
||||||
|
assert.Equal(t, challenge, pkceChallenge(verifier),
|
||||||
|
"code_challenge must equal base64url(sha256(derived verifier))")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("authorize URL omits PKCE params when PKCE is not required", func(t *testing.T) {
|
t.Run("authorize URL omits PKCE params when PKCE is not required", func(t *testing.T) {
|
||||||
@@ -681,8 +686,8 @@ func TestInitiateWithState_PKCE(t *testing.T) {
|
|||||||
payload, err := DecodeOAuth2StatePayload(stateToken)
|
payload, err := DecodeOAuth2StatePayload(stateToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
expectedVerifier := payload.Data.CodeVerifier
|
require.NotEmpty(t, payload.Data.PKCENonce)
|
||||||
require.NotEmpty(t, expectedVerifier)
|
expectedVerifier := derivePKCEVerifier("secret", payload.Data.PKCENonce)
|
||||||
|
|
||||||
// Drive Complete with that same state token + an arbitrary code.
|
// Drive Complete with that same state token + an arbitrary code.
|
||||||
req := httptest.NewRequest(
|
req := httptest.NewRequest(
|
||||||
@@ -699,21 +704,21 @@ func TestInitiateWithState_PKCE(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGeneratePKCEVerifier exercises the verifier generator: each call
|
// TestGeneratePKCENonce exercises the nonce generator: each call must
|
||||||
// must return a fresh value, encoded as RFC 4648 §5 base64url-without-
|
// return a fresh value, encoded as RFC 4648 §5 base64url-without-padding
|
||||||
// padding (RFC 7636 §4.1 mandates 43–128 unreserved chars; 32 bytes
|
// (32 bytes yields 43 chars). The nonce seeds derivePKCEVerifier, so a
|
||||||
// yields 43 chars). Anything outside that contract weakens PKCE.
|
// predictable or short nonce would weaken PKCE.
|
||||||
func TestGeneratePKCEVerifier(t *testing.T) {
|
func TestGeneratePKCENonce(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
v1, err := generatePKCEVerifier()
|
v1, err := generatePKCENonce()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
v2, err := generatePKCEVerifier()
|
v2, err := generatePKCENonce()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.GreaterOrEqual(t, len(v1), 43, "verifier must be at least 43 base64url chars")
|
assert.GreaterOrEqual(t, len(v1), 43, "nonce must be at least 43 base64url chars")
|
||||||
assert.LessOrEqual(t, len(v1), 128, "verifier must be at most 128 chars per RFC 7636")
|
assert.LessOrEqual(t, len(v1), 128, "nonce must be at most 128 chars")
|
||||||
assert.NotEqual(t, v1, v2, "verifier must be unpredictable across calls")
|
assert.NotEqual(t, v1, v2, "nonce must be unpredictable across calls")
|
||||||
|
|
||||||
// Charset: base64url unreserved (RFC 4648 §5) — A-Z a-z 0-9 - _.
|
// Charset: base64url unreserved (RFC 4648 §5) — A-Z a-z 0-9 - _.
|
||||||
for _, c := range v1 {
|
for _, c := range v1 {
|
||||||
@@ -723,11 +728,183 @@ func TestGeneratePKCEVerifier(t *testing.T) {
|
|||||||
case c >= '0' && c <= '9':
|
case c >= '0' && c <= '9':
|
||||||
case c == '-' || c == '_':
|
case c == '-' || c == '_':
|
||||||
default:
|
default:
|
||||||
t.Errorf("verifier contains non-base64url character %q", c)
|
t.Errorf("nonce contains non-base64url character %q", c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStateSalt verifies the OAuth2 state / PKCE salt selection: a public
|
||||||
|
// client's StateSigningKey takes precedence, and a confidential client
|
||||||
|
// falls back to its ClientSecret.
|
||||||
|
func TestStateSalt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(t, "secret", (&OAuth2Connector{ClientSecret: "secret"}).stateSalt())
|
||||||
|
assert.Equal(t, "server-key", (&OAuth2Connector{StateSigningKey: "server-key"}).stateSalt())
|
||||||
|
assert.Equal(t, "server-key",
|
||||||
|
(&OAuth2Connector{ClientSecret: "secret", StateSigningKey: "server-key"}).stateSalt(),
|
||||||
|
"StateSigningKey must win when both are present")
|
||||||
|
assert.Empty(t, (&OAuth2Connector{}).stateSalt(),
|
||||||
|
"both empty yields empty salt (InitiateWithState/CompleteWithState reject this)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeriveConnectorStateKey verifies the connector state-key derivation is
|
||||||
|
// deterministic, hides the raw secret, is sensitive to the secret, and is
|
||||||
|
// domain-separated from the PKCE verifier derived from the same secret.
|
||||||
|
func TestDeriveConnectorStateKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
k1 := DeriveConnectorStateKey("server-secret")
|
||||||
|
|
||||||
|
assert.NotEmpty(t, k1)
|
||||||
|
assert.Equal(t, k1, DeriveConnectorStateKey("server-secret"), "derivation must be deterministic")
|
||||||
|
assert.NotEqual(t, "server-secret", k1, "must not echo the raw secret")
|
||||||
|
assert.NotEqual(t, k1, DeriveConnectorStateKey("other-secret"), "different secrets must yield different keys")
|
||||||
|
assert.NotEqual(t, k1, derivePKCEVerifier("server-secret", "nonce"),
|
||||||
|
"state key must be domain-separated from the PKCE verifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitiateWithState_RejectsEmptySalt confirms a connector with neither a
|
||||||
|
// StateSigningKey nor a ClientSecret cannot mint a state token (an empty HMAC
|
||||||
|
// key would make the token forgeable).
|
||||||
|
func TestInitiateWithState_RejectsEmptySalt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c := &OAuth2Connector{
|
||||||
|
RedirectURI: "https://example.com/cb",
|
||||||
|
AuthURL: "https://provider.example.com/authorize",
|
||||||
|
}
|
||||||
|
|
||||||
|
orgID := gid.New(gid.NewTenantID(), 0)
|
||||||
|
|
||||||
|
_, err := c.InitiateWithState(
|
||||||
|
context.Background(),
|
||||||
|
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
|
||||||
|
InitiateOptions{Scopes: []string{"read:user"}},
|
||||||
|
)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "no state signing key or client secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteWithState_PublicClientCIMD exercises the public-client (CIMD)
|
||||||
|
// flow end to end: there is no client_secret, the state token is signed with
|
||||||
|
// the server-side StateSigningKey (so validation still succeeds), and the
|
||||||
|
// token POST carries client_id + the PKCE code_verifier but NEVER a
|
||||||
|
// client_secret.
|
||||||
|
func TestCompleteWithState_PublicClientCIMD(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
hadSecretField bool
|
||||||
|
capturedClientID string
|
||||||
|
capturedVerifier string
|
||||||
|
)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
form, err := url.ParseQuery(string(body))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, hadSecretField = form["client_secret"]
|
||||||
|
capturedClientID = form.Get("client_id")
|
||||||
|
capturedVerifier = form.Get("code_verifier")
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"live-token","token_type":"Bearer","expires_in":3600}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &OAuth2Connector{
|
||||||
|
ClientID: "https://probo.example.com/api/console/v1/connectors/oauth-client-metadata",
|
||||||
|
ClientSecret: "", // public client: no secret
|
||||||
|
StateSigningKey: "server-side-signing-key",
|
||||||
|
RedirectURI: "https://example.com/cb",
|
||||||
|
AuthURL: "https://provider.example.com/authorize",
|
||||||
|
TokenURL: server.URL,
|
||||||
|
TokenEndpointAuth: "none",
|
||||||
|
RequiresPKCE: true,
|
||||||
|
HTTPClient: httpclient.DefaultClient(httpclient.WithSSRFProtection(), httpclient.WithSSRFAllowLoopback()),
|
||||||
|
}
|
||||||
|
|
||||||
|
orgID := gid.New(gid.NewTenantID(), 0)
|
||||||
|
authURL, err := c.InitiateWithState(
|
||||||
|
context.Background(),
|
||||||
|
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
|
||||||
|
InitiateOptions{Scopes: []string{"organization_member:read"}},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
parsed, err := url.Parse(authURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, parsed.Query().Get("code_challenge"), "public client must use PKCE")
|
||||||
|
|
||||||
|
stateToken := parsed.Query().Get("state")
|
||||||
|
require.NotEmpty(t, stateToken)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"https://example.com/cb?code=the-code&state="+stateToken,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
_, _, err = c.CompleteWithState(context.Background(), req)
|
||||||
|
require.NoError(t, err, "state signed with StateSigningKey must validate")
|
||||||
|
|
||||||
|
assert.False(t, hadSecretField, "public-client token POST must NOT include client_secret")
|
||||||
|
assert.Equal(t, c.ClientID, capturedClientID)
|
||||||
|
assert.NotEmpty(t, capturedVerifier, "public-client token POST must carry the PKCE code_verifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefreshableClient_PublicClientOmitsSecret confirms that refreshing a
|
||||||
|
// public-client (CIMD) token sends client_id but NO client_secret — the
|
||||||
|
// provider advertises token_endpoint_auth_method "none" and would reject an
|
||||||
|
// (empty) secret. This guards the token-refresh path used when an access
|
||||||
|
// token expires mid-campaign.
|
||||||
|
func TestRefreshableClient_PublicClientOmitsSecret(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
hadSecret bool
|
||||||
|
capturedClientID string
|
||||||
|
capturedGrant string
|
||||||
|
)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.NoError(t, r.ParseForm())
|
||||||
|
_, hadSecret = r.Form["client_secret"]
|
||||||
|
capturedClientID = r.Form.Get("client_id")
|
||||||
|
capturedGrant = r.Form.Get("grant_type")
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"refreshed","token_type":"Bearer","expires_in":3600}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
conn := &OAuth2Connection{
|
||||||
|
AccessToken: "stale",
|
||||||
|
RefreshToken: "refresh-tok",
|
||||||
|
ExpiresAt: time.Now().Add(-time.Hour), // expired → force a refresh
|
||||||
|
TokenType: "Bearer",
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := OAuth2RefreshConfig{
|
||||||
|
ClientID: "https://probo.example.com/api/console/v1/connectors/oauth-client-metadata",
|
||||||
|
ClientSecret: "", // public client
|
||||||
|
TokenURL: server.URL,
|
||||||
|
TokenEndpointAuth: "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := conn.RefreshableClient(context.Background(), cfg, httpclient.WithSSRFAllowLoopback())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "refreshed", conn.AccessToken, "refresh must update the access token")
|
||||||
|
assert.Equal(t, "refresh_token", capturedGrant)
|
||||||
|
assert.Equal(t, cfg.ClientID, capturedClientID)
|
||||||
|
assert.False(t, hadSecret, "public-client refresh must NOT send client_secret")
|
||||||
|
}
|
||||||
|
|
||||||
// TestCompleteWithState_PKCEMismatch confirms that a token endpoint
|
// TestCompleteWithState_PKCEMismatch confirms that a token endpoint
|
||||||
// rejecting a stale or mismatched code_verifier (the standard PKCE
|
// rejecting a stale or mismatched code_verifier (the standard PKCE
|
||||||
// failure path) surfaces as an error from CompleteWithState rather
|
// failure path) surfaces as an error from CompleteWithState rather
|
||||||
|
|||||||
@@ -115,6 +115,25 @@ func (r *Registry) All() []*Registration {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublicClients returns every Registration flagged PublicClient (CIMD,
|
||||||
|
// no client_secret). probod uses this to auto-register their OAuth2
|
||||||
|
// connectors with a deployment-derived client_id and state-signing key.
|
||||||
|
// Order is not stable.
|
||||||
|
func (r *Registry) PublicClients() []*Registration {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
var out []*Registration
|
||||||
|
|
||||||
|
for _, reg := range r.providers {
|
||||||
|
if reg.PublicClient {
|
||||||
|
out = append(out, reg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// ProviderDisplayName returns the human-readable label for the
|
// ProviderDisplayName returns the human-readable label for the
|
||||||
// provider, falling back to the raw constant string when no display
|
// provider, falling back to the raw constant string when no display
|
||||||
// name is registered.
|
// name is registered.
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ type Registration struct {
|
|||||||
// request and replays the verifier on the token exchange. Default
|
// request and replays the verifier on the token exchange. Default
|
||||||
// false; non-PKCE providers are unaffected.
|
// false; non-PKCE providers are unaffected.
|
||||||
RequiresPKCE bool
|
RequiresPKCE bool
|
||||||
|
// PublicClient marks an OAuth2 provider that authenticates as a public
|
||||||
|
// client (no client_secret) via PKCE, using the Client ID Metadata
|
||||||
|
// Document (CIMD) flow. probod auto-registers such providers with no
|
||||||
|
// operator credentials: the client_id is the deployment's hosted CIMD
|
||||||
|
// URL (baseURL + connector.CIMDMetadataPath) and the state token is
|
||||||
|
// signed with a server-derived key. Set TokenEndpointAuth to "none"
|
||||||
|
// alongside this.
|
||||||
|
PublicClient bool
|
||||||
// BuildAuthURL derives the authorization URL from an operator-supplied
|
// BuildAuthURL derives the authorization URL from an operator-supplied
|
||||||
// integration slug, for providers (e.g. Vercel) whose AuthURL embeds
|
// integration slug, for providers (e.g. Vercel) whose AuthURL embeds
|
||||||
// it as a path segment. It must construct the URL with net/url and
|
// it as a path segment. It must construct the URL with net/url and
|
||||||
|
|||||||
@@ -16,3 +16,12 @@ package connector
|
|||||||
|
|
||||||
// CallbackPath is the HTTP path for the OAuth2 callback endpoint.
|
// CallbackPath is the HTTP path for the OAuth2 callback endpoint.
|
||||||
const CallbackPath = "/api/console/v1/connectors/complete"
|
const CallbackPath = "/api/console/v1/connectors/complete"
|
||||||
|
|
||||||
|
// CIMDMetadataPath is the HTTP path serving the public OAuth Client ID
|
||||||
|
// Metadata Document (CIMD). For public clients, the deployment's
|
||||||
|
// (baseURL + CIMDMetadataPath) URL IS the OAuth client_id: the provider
|
||||||
|
// (e.g. PostHog) fetches this document server-to-server during the
|
||||||
|
// authorization flow to learn the client's name and redirect URIs, so no
|
||||||
|
// app pre-registration is required. The endpoint must be reachable
|
||||||
|
// unauthenticated from the public internet.
|
||||||
|
const CIMDMetadataPath = "/api/console/v1/connectors/oauth-client-metadata"
|
||||||
|
|||||||
Reference in New Issue
Block a user