From cd8ddd8db58c1476c33be68f0c45f8a38149aa4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Fri, 29 May 2026 12:23:57 +0200 Subject: [PATCH] Add public-client (CIMD) OAuth support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- pkg/connector/oauth2.go | 171 ++++++++++++++++++---- pkg/connector/oauth2_test.go | 223 ++++++++++++++++++++++++++--- pkg/connector/provider/registry.go | 19 +++ pkg/connector/provider/types.go | 8 ++ pkg/connector/providers.go | 9 ++ 5 files changed, 380 insertions(+), 50 deletions(-) diff --git a/pkg/connector/oauth2.go b/pkg/connector/oauth2.go index 283433ea5..7ecc2e035 100644 --- a/pkg/connector/oauth2.go +++ b/pkg/connector/oauth2.go @@ -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{} diff --git a/pkg/connector/oauth2_test.go b/pkg/connector/oauth2_test.go index 1311594bc..bf270bf28 100644 --- a/pkg/connector/oauth2_test.go +++ b/pkg/connector/oauth2_test.go @@ -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 diff --git a/pkg/connector/provider/registry.go b/pkg/connector/provider/registry.go index 85e2039cb..223a6d817 100644 --- a/pkg/connector/provider/registry.go +++ b/pkg/connector/provider/registry.go @@ -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. diff --git a/pkg/connector/provider/types.go b/pkg/connector/provider/types.go index 08f022927..44a65ad89 100644 --- a/pkg/connector/provider/types.go +++ b/pkg/connector/provider/types.go @@ -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 diff --git a/pkg/connector/providers.go b/pkg/connector/providers.go index 59bf44698..a61dd70a3 100644 --- a/pkg/connector/providers.go +++ b/pkg/connector/providers.go @@ -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"