Add PostHog Cloud OAuth and self-hosted support

PostHog Cloud authenticates via CIMD OAuth (public client, PKCE)
through the region-agnostic oauth.posthog.com gateway, with an API-key
fallback. PostHog Self-Hosted is a separate provider using an API key
and an instance URL.

The shared driver discovers the data region by probing us/eu for OAuth
connections, since the gateway does not serve the data API, and pins
pagination to the resolved host.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-05-29 12:24:21 +02:00
parent 6e65c56235
commit 3888ff95cd
10 changed files with 406 additions and 55 deletions

View File

@@ -29,14 +29,24 @@ import (
type PostHogDriver struct {
httpClient *http.Client
baseURL string
}
var _ Driver = (*PostHogDriver)(nil)
const (
posthogMembersEndpoint = "https://app.posthog.com/api/organizations/@current/members/"
posthogOrganizationEndpoint = "https://app.posthog.com/api/organizations/@current/"
posthogMembersPageSize = 100
posthogMembersPath = "/api/organizations/@current/members/"
posthogOrganizationPath = "/api/organizations/@current/"
posthogMembersPageSize = 100
// PostHog Cloud regional data hosts. OAuth connections carry no region
// (empty baseURL): the region-agnostic oauth.posthog.com gateway used
// for the OAuth handshake does NOT serve the data API, so the driver
// discovers the region by probing these hosts with the connection's
// token. API-key (us/eu) and self-hosted connections always carry an
// explicit host instead.
posthogUSBaseURL = "https://us.posthog.com"
posthogEUBaseURL = "https://eu.posthog.com"
posthogMembershipLevelMember = 1
posthogMembershipLevelAdmin = 8
@@ -67,12 +77,39 @@ type (
}
)
func NewPostHogDriver(httpClient *http.Client) *PostHogDriver {
return &PostHogDriver{httpClient: httpClient}
// NewPostHogDriver builds a driver against baseURL (e.g. https://us.posthog.com
// or a self-hosted instance URL). An empty baseURL marks a cloud OAuth
// connection whose region is discovered lazily on first use (see resolveBaseURL).
func NewPostHogDriver(httpClient *http.Client, baseURL string) *PostHogDriver {
return &PostHogDriver{httpClient: httpClient, baseURL: baseURL}
}
// resolveBaseURL ensures the driver has a concrete data host. Explicit hosts
// (API-key region / self-hosted) are used as-is; an empty baseURL (cloud
// OAuth) is resolved by probing the PostHog Cloud regions with the
// connection's token, since the oauth.posthog.com gateway does not serve /api.
// The result is cached on the driver for subsequent pages.
func (d *PostHogDriver) resolveBaseURL(ctx context.Context) error {
if d.baseURL != "" {
return nil
}
host, err := resolvePostHogRegion(ctx, d.httpClient)
if err != nil {
return err
}
d.baseURL = host
return nil
}
func (d *PostHogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
nextURL, err := buildPostHogMembersURL()
if err := d.resolveBaseURL(ctx); err != nil {
return nil, err
}
nextURL, err := d.membersURL()
if err != nil {
return nil, err
}
@@ -98,7 +135,7 @@ func (d *PostHogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
return records, nil
}
nextURL, err = resolvePostHogNextURL(resp.Next)
nextURL, err = d.resolveNextURL(resp.Next)
if err != nil {
return nil, err
}
@@ -107,8 +144,13 @@ func (d *PostHogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
return nil, fmt.Errorf("cannot list all posthog accounts: %w", ErrPaginationLimitReached)
}
func buildPostHogMembersURL() (string, error) {
u, err := url.Parse(posthogMembersEndpoint)
func (d *PostHogDriver) membersURL() (string, error) {
endpoint, err := url.JoinPath(d.baseURL, posthogMembersPath)
if err != nil {
return "", fmt.Errorf("cannot build posthog members URL: %w", err)
}
u, err := url.Parse(endpoint)
if err != nil {
return "", fmt.Errorf("cannot parse posthog members URL: %w", err)
}
@@ -121,17 +163,36 @@ func buildPostHogMembersURL() (string, error) {
return u.String(), nil
}
func resolvePostHogNextURL(next string) (string, error) {
func (d *PostHogDriver) resolveNextURL(next string) (string, error) {
nextURL, err := url.Parse(next)
if err != nil {
return "", fmt.Errorf("cannot parse posthog next page URL: %w", err)
}
base, err := url.Parse(d.baseURL)
if err != nil {
return "", fmt.Errorf("cannot parse posthog base URL: %w", err)
}
if nextURL.IsAbs() {
// Pin pagination to the resolved data host. The connection's bearer
// token is attached to every request, so an absolute `next` pointing
// at a different host (a compromised or spoofed API response) would
// forward the token off-host. Refuse cross-host pagination; the
// error is static so it never echoes an attacker-controlled host.
if !strings.EqualFold(nextURL.Host, base.Host) {
return "", fmt.Errorf("cannot follow posthog next page URL: cross-host pagination is not allowed")
}
return nextURL.String(), nil
}
baseURL, err := url.Parse(posthogMembersEndpoint)
endpoint, err := url.JoinPath(d.baseURL, posthogMembersPath)
if err != nil {
return "", fmt.Errorf("cannot build posthog members base URL: %w", err)
}
baseURL, err := url.Parse(endpoint)
if err != nil {
return "", fmt.Errorf("cannot parse posthog members base URL: %w", err)
}
@@ -139,6 +200,47 @@ func resolvePostHogNextURL(next string) (string, error) {
return baseURL.ResolveReference(nextURL).String(), nil
}
// resolvePostHogRegion probes the PostHog Cloud region hosts with the given
// token-bearing client and returns the first that answers 2xx on the @current
// organization endpoint. OAuth connections authenticate via the region-agnostic
// oauth.posthog.com gateway, which does not serve /api, so the actual data
// region (us/eu) must be discovered against the regional hosts directly.
func resolvePostHogRegion(ctx context.Context, client *http.Client) (string, error) {
for _, host := range []string{posthogUSBaseURL, posthogEUBaseURL} {
endpoint, err := url.JoinPath(host, posthogOrganizationPath)
if err != nil {
continue
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
continue
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
// Surface a cancelled/expired context as the real cause rather
// than masking it behind "no region accepted the connection".
if ctx.Err() != nil {
return "", fmt.Errorf("cannot resolve posthog region: %w", ctx.Err())
}
continue
}
status := resp.StatusCode
_ = resp.Body.Close()
if status >= http.StatusOK && status < http.StatusMultipleChoices {
return host, nil
}
}
return "", fmt.Errorf("cannot resolve posthog region: no region accepted the connection")
}
func (d *PostHogDriver) fetchMembers(
ctx context.Context,
nextURL string,
@@ -159,7 +261,7 @@ func (d *PostHogDriver) fetchMembers(
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("cannot fetch posthog members: unexpected status %d", httpResp.StatusCode)
}
@@ -234,22 +336,44 @@ func posthogMFAStatus(twoFAEnabled *bool) coredata.MFAStatus {
}
// posthogNameResolver resolves the PostHog organization name from the
// current organization endpoint, which returns the org an API key belongs to.
// current organization endpoint, which returns the org the connection
// belongs to.
type posthogNameResolver struct {
httpClient *http.Client
baseURL string
}
var _ NameResolver = (*posthogNameResolver)(nil)
func NewPostHogNameResolver(httpClient *http.Client) NameResolver {
return &posthogNameResolver{httpClient: httpClient}
// NewPostHogNameResolver resolves the org name against baseURL. An empty
// baseURL marks a cloud OAuth connection whose region is discovered lazily.
func NewPostHogNameResolver(httpClient *http.Client, baseURL string) NameResolver {
return &posthogNameResolver{httpClient: httpClient, baseURL: baseURL}
}
func (r *posthogNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
baseURL := r.baseURL
if baseURL == "" {
host, err := resolvePostHogRegion(ctx, r.httpClient)
if err != nil {
// Terminal: cannot determine the region (e.g. revoked token).
// Keep the generic source name rather than making the
// source-name worker retry forever.
return "", nil
}
baseURL = host
}
endpoint, err := url.JoinPath(baseURL, posthogOrganizationPath)
if err != nil {
return "", fmt.Errorf("cannot build posthog organization URL: %w", err)
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
posthogOrganizationEndpoint,
endpoint,
nil,
)
if err != nil {
@@ -268,7 +392,7 @@ func (r *posthogNameResolver) ResolveInstanceName(ctx context.Context) (string,
// Best-effort: a non-2xx (e.g. a revoked key) must not make the
// source-name worker retry forever. Give up gracefully and keep the
// generic source name; a dead key surfaces on the next ListAccounts.
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
return "", nil
}

View File

@@ -16,9 +16,11 @@ package drivers
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -32,7 +34,7 @@ func TestPostHogDriverListAccounts(t *testing.T) {
rec := newRecorder(t, "testdata/posthog", "POSTHOG_PERSONAL_API_KEY")
client := newVCRClient(rec, bearerAuth(os.Getenv("POSTHOG_PERSONAL_API_KEY")))
records, err := NewPostHogDriver(client).ListAccounts(context.Background())
records, err := NewPostHogDriver(client, "https://app.posthog.com").ListAccounts(context.Background())
require.NoError(t, err)
require.Len(t, records, 3)
@@ -63,6 +65,61 @@ func TestPostHogDriverListAccounts(t *testing.T) {
require.NotNil(t, admin.CreatedAt)
}
// TestPostHogDriverResolvesRegionLazily covers the OAuth path: an empty
// baseURL means the region-agnostic gateway was used for the handshake, so
// the driver must discover the data region (us/eu) by probing before listing.
func TestPostHogDriverResolvesRegionLazily(t *testing.T) {
t.Parallel()
resp := func(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}
const euMembers = `{"count":1,"next":"","results":[{"id":"m1","user":{"uuid":"u1","first_name":"A","last_name":"B","email":"a@b.com"},"level":1,"joined_at":"2025-01-01T00:00:00Z"}]}`
t.Run("empty base URL probes US then falls back to EU", func(t *testing.T) {
t.Parallel()
var usHits, euHits int
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Host {
case "us.posthog.com":
usHits++
return resp(http.StatusUnauthorized, `{"detail":"unauthorized"}`), nil
case "eu.posthog.com":
euHits++
return resp(http.StatusOK, euMembers), nil
default:
return resp(http.StatusNotFound, ""), nil
}
})}
records, err := NewPostHogDriver(client, "").ListAccounts(context.Background())
require.NoError(t, err)
require.Len(t, records, 1)
assert.Equal(t, "a@b.com", records[0].Email)
assert.Positive(t, usHits, "US region must be probed")
assert.Positive(t, euHits, "EU region must be used after US refuses")
})
t.Run("no region accepts the token returns an error", func(t *testing.T) {
t.Parallel()
client := &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return resp(http.StatusForbidden, `{"detail":"forbidden"}`), nil
})}
_, err := NewPostHogDriver(client, "").ListAccounts(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot resolve posthog region")
})
}
func TestPostHogNameResolver(t *testing.T) {
t.Parallel()
@@ -108,7 +165,7 @@ func TestPostHogNameResolver(t *testing.T) {
client := &http.Client{Transport: &hostRewriter{target: srv.URL}}
got, err := NewPostHogNameResolver(client).ResolveInstanceName(context.Background())
got, err := NewPostHogNameResolver(client, "https://app.posthog.com").ResolveInstanceName(context.Background())
if tc.wantErr {
require.Error(t, err)
return