diff --git a/pkg/server/api/console/v1/connector_oauth_client_metadata.go b/pkg/server/api/console/v1/connector_oauth_client_metadata.go new file mode 100644 index 000000000..28ef8f1b9 --- /dev/null +++ b/pkg/server/api/console/v1/connector_oauth_client_metadata.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package console_v1 + +import ( + "encoding/json" + "net/http" + + "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/connector" +) + +// oauthClientMetadata is the OAuth Client ID Metadata Document (CIMD) +// published for public-client connectors. The deployment's +// (baseURL + CIMDMetadataPath) URL is the OAuth client_id; providers such as +// PostHog fetch this document server-to-server during authorization to learn +// the client's identity and allowed redirect URIs, so no app pre-registration +// is needed. Public clients authenticate with PKCE (token_endpoint_auth_method +// "none") rather than a client secret. +// Probo brand fields shown to the end user on the provider's consent screen. +// These describe the Probo product itself (not the per-tenant deployment), so +// they are the canonical brand homepage and logo rather than the baseURL. +const ( + proboBrandURI = "https://www.getprobo.com" + proboLogoURI = "https://www.getprobo.com/probo-logo-only.svg" +) + +type oauthClientMetadata struct { + ClientID string `json:"client_id"` + ClientName string `json:"client_name"` + ClientURI string `json:"client_uri"` + LogoURI string `json:"logo_uri"` + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` +} + +// handleConnectorOAuthClientMetadata serves the public, unauthenticated CIMD +// document. It is intentionally outside the auth middleware group: the OAuth +// provider fetches it without any Probo credentials. +func handleConnectorOAuthClientMetadata(baseURL *baseurl.BaseURL) http.HandlerFunc { + doc := oauthClientMetadata{ + ClientID: baseURL.WithPath(connector.CIMDMetadataPath).MustString(), + ClientName: "Probo", + ClientURI: proboBrandURI, + LogoURI: proboLogoURI, + RedirectURIs: []string{baseURL.WithPath(connector.CallbackPath).MustString()}, + TokenEndpointAuthMethod: "none", + GrantTypes: []string{"authorization_code", "refresh_token"}, + ResponseTypes: []string{"code"}, + } + + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=300") + _ = json.NewEncoder(w).Encode(doc) + } +} diff --git a/pkg/server/api/console/v1/connector_oauth_client_metadata_test.go b/pkg/server/api/console/v1/connector_oauth_client_metadata_test.go new file mode 100644 index 000000000..09aba4f5d --- /dev/null +++ b/pkg/server/api/console/v1/connector_oauth_client_metadata_test.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package console_v1 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/baseurl" +) + +// TestHandleConnectorOAuthClientMetadata verifies the public CIMD document: +// PostHog fetches it server-to-server during authorization, so client_id, +// redirect_uris (derived from the deployment base URL) and the public-client +// token_endpoint_auth_method must be exactly right or the OAuth flow breaks. +func TestHandleConnectorOAuthClientMetadata(t *testing.T) { + t.Parallel() + + base, err := baseurl.Parse("https://probo.example.com") + require.NoError(t, err) + + rec := httptest.NewRecorder() + handleConnectorOAuthClientMetadata(base)( + rec, + httptest.NewRequest(http.MethodGet, "/api/console/v1/connectors/oauth-client-metadata", nil), + ) + + res := rec.Result() + defer func() { _ = res.Body.Close() }() + + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, "application/json", res.Header.Get("Content-Type")) + + var doc struct { + ClientID string `json:"client_id"` + ClientName string `json:"client_name"` + ClientURI string `json:"client_uri"` + LogoURI string `json:"logo_uri"` + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + } + require.NoError(t, json.NewDecoder(res.Body).Decode(&doc)) + + // Functional fields are deployment-derived (must match where the OAuth + // flow actually runs)... + assert.Equal(t, "https://probo.example.com/api/console/v1/connectors/oauth-client-metadata", doc.ClientID) + assert.Equal(t, []string{"https://probo.example.com/api/console/v1/connectors/complete"}, doc.RedirectURIs) + // ...while the brand fields shown on the consent screen are the canonical + // Probo product identity, NOT the per-tenant deployment URL. + assert.Equal(t, "Probo", doc.ClientName) + assert.Equal(t, "https://www.getprobo.com", doc.ClientURI) + assert.Equal(t, "https://www.getprobo.com/probo-logo-only.svg", doc.LogoURI) + assert.Equal(t, "none", doc.TokenEndpointAuthMethod, "public client must advertise token_endpoint_auth_method none") + assert.Contains(t, doc.GrantTypes, "authorization_code") + assert.Contains(t, doc.GrantTypes, "refresh_token") + assert.Equal(t, []string{"code"}, doc.ResponseTypes) +} diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index 943ee556d..b05877118 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -128,6 +128,12 @@ func NewMux( ) }) + // Public, unauthenticated: the OAuth Client ID Metadata Document (CIMD) + // is fetched server-to-server by public-client providers (PostHog) + // during authorization, with no Probo credentials. Mounted outside the + // auth group above. + r.Get("/connectors/oauth-client-metadata", handleConnectorOAuthClientMetadata(baseURL)) + return r }