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 (
"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{}

View File

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

View File

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

View File

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

View File

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