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:
Aurélien Sibiril
2026-05-29 12:23:57 +02:00
parent 8f40f460d4
commit cd8ddd8db5
5 changed files with 380 additions and 50 deletions

View File

@@ -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{}

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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"