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{}
|
||||
|
||||
@@ -561,9 +561,10 @@ func TestCompleteWithState_ScopeFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestInitiateWithState_PKCE verifies that connectors with RequiresPKCE=true
|
||||
// generate a PKCE verifier, embed the S256 challenge in the authorization
|
||||
// URL (RFC 7636 §4.3), and persist the verifier in the signed state token
|
||||
// so CompleteWithState can replay it on the token exchange.
|
||||
// embed the S256 challenge in the authorization URL (RFC 7636 §4.3) and
|
||||
// persist a random nonce (not the verifier) in the signed state token, so
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -594,18 +595,22 @@ func TestInitiateWithState_PKCE(t *testing.T) {
|
||||
require.NotEmpty(t, challenge, "code_challenge must be present when RequiresPKCE=true")
|
||||
assert.Equal(t, "S256", parsed.Query().Get("code_challenge_method"))
|
||||
|
||||
// The verifier is persisted in the signed state token. Decode
|
||||
// the payload (without secret-checking — just inspect) and
|
||||
// verify that re-deriving the challenge from the verifier
|
||||
// reproduces the URL value.
|
||||
// Only a nonce is persisted in the state token; the verifier is
|
||||
// derived server-side from the state salt + nonce and must never
|
||||
// appear in the (signed-but-unencrypted) state. Re-deriving it
|
||||
// must reproduce the published challenge.
|
||||
stateToken := parsed.Query().Get("state")
|
||||
require.NotEmpty(t, stateToken)
|
||||
|
||||
payload, err := DecodeOAuth2StatePayload(stateToken)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, payload.Data.CodeVerifier, "verifier must be persisted in state token")
|
||||
assert.Equal(t, challenge, pkceChallenge(payload.Data.CodeVerifier),
|
||||
"code_challenge must equal base64url(sha256(verifier))")
|
||||
require.NotEmpty(t, payload.Data.PKCENonce, "nonce must be persisted in state token")
|
||||
|
||||
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) {
|
||||
@@ -681,8 +686,8 @@ func TestInitiateWithState_PKCE(t *testing.T) {
|
||||
payload, err := DecodeOAuth2StatePayload(stateToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedVerifier := payload.Data.CodeVerifier
|
||||
require.NotEmpty(t, expectedVerifier)
|
||||
require.NotEmpty(t, payload.Data.PKCENonce)
|
||||
expectedVerifier := derivePKCEVerifier("secret", payload.Data.PKCENonce)
|
||||
|
||||
// Drive Complete with that same state token + an arbitrary code.
|
||||
req := httptest.NewRequest(
|
||||
@@ -699,21 +704,21 @@ func TestInitiateWithState_PKCE(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestGeneratePKCEVerifier exercises the verifier generator: each call
|
||||
// must return a fresh value, encoded as RFC 4648 §5 base64url-without-
|
||||
// padding (RFC 7636 §4.1 mandates 43–128 unreserved chars; 32 bytes
|
||||
// yields 43 chars). Anything outside that contract weakens PKCE.
|
||||
func TestGeneratePKCEVerifier(t *testing.T) {
|
||||
// TestGeneratePKCENonce exercises the nonce generator: each call must
|
||||
// return a fresh value, encoded as RFC 4648 §5 base64url-without-padding
|
||||
// (32 bytes yields 43 chars). The nonce seeds derivePKCEVerifier, so a
|
||||
// predictable or short nonce would weaken PKCE.
|
||||
func TestGeneratePKCENonce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v1, err := generatePKCEVerifier()
|
||||
v1, err := generatePKCENonce()
|
||||
require.NoError(t, err)
|
||||
v2, err := generatePKCEVerifier()
|
||||
v2, err := generatePKCENonce()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.GreaterOrEqual(t, len(v1), 43, "verifier must be at least 43 base64url chars")
|
||||
assert.LessOrEqual(t, len(v1), 128, "verifier must be at most 128 chars per RFC 7636")
|
||||
assert.NotEqual(t, v1, v2, "verifier must be unpredictable across calls")
|
||||
assert.GreaterOrEqual(t, len(v1), 43, "nonce must be at least 43 base64url chars")
|
||||
assert.LessOrEqual(t, len(v1), 128, "nonce must be at most 128 chars")
|
||||
assert.NotEqual(t, v1, v2, "nonce must be unpredictable across calls")
|
||||
|
||||
// Charset: base64url unreserved (RFC 4648 §5) — A-Z a-z 0-9 - _.
|
||||
for _, c := range v1 {
|
||||
@@ -723,11 +728,183 @@ func TestGeneratePKCEVerifier(t *testing.T) {
|
||||
case c >= '0' && c <= '9':
|
||||
case c == '-' || c == '_':
|
||||
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
|
||||
// rejecting a stale or mismatched code_verifier (the standard PKCE
|
||||
// failure path) surfaces as an error from CompleteWithState rather
|
||||
|
||||
@@ -115,6 +115,25 @@ func (r *Registry) All() []*Registration {
|
||||
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
|
||||
// provider, falling back to the raw constant string when no display
|
||||
// name is registered.
|
||||
|
||||
@@ -46,6 +46,14 @@ type Registration struct {
|
||||
// request and replays the verifier on the token exchange. Default
|
||||
// false; non-PKCE providers are unaffected.
|
||||
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
|
||||
// integration slug, for providers (e.g. Vercel) whose AuthURL embeds
|
||||
// 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.
|
||||
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