diff --git a/src/__tests__/mcp-cli.test.ts b/src/__tests__/mcp-cli.test.ts index fe1b3762a..c539ef86f 100644 --- a/src/__tests__/mcp-cli.test.ts +++ b/src/__tests__/mcp-cli.test.ts @@ -90,6 +90,22 @@ describe('mcp add handler', () => { ); }); + test('passes --base-url and --oauth-client-id through to buildSession', async () => { + mcpAddCommand.handler!( + makeArgv({ + baseUrl: 'https://posthog.example.com', + oauthClientId: 'own-client', + }), + ); + await flush(); + expect(mockBuildSessionMcp).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'https://posthog.example.com', + oauthClientId: 'own-client', + }), + ); + }); + test('parses --features into a trimmed array', async () => { mcpAddCommand.handler!(makeArgv({ features: 'flags, errors , logs' })); await flush(); diff --git a/src/commands/basic-integration/ci-install.ts b/src/commands/basic-integration/ci-install.ts index 2efcb1c8b..c90663ac1 100644 --- a/src/commands/basic-integration/ci-install.ts +++ b/src/commands/basic-integration/ci-install.ts @@ -11,6 +11,7 @@ import { emitWizardError } from '@lib/errors'; type Options = Arguments & { region?: string; baseUrl?: string; + oauthClientId?: string; installDir?: string; apiKey?: string; signup?: boolean; @@ -167,7 +168,7 @@ async function provisionForSignup( options.email as string, options.name ?? '', signupRegion, - { baseUrl: options.baseUrl }, + { baseUrl: options.baseUrl, oauthClientId: options.oauthClientId }, ); } catch (error) { const msg = error instanceof Error ? error.message : String(error); diff --git a/src/commands/mcp/add.ts b/src/commands/mcp/add.ts index f1bf00116..d7011a6a3 100644 --- a/src/commands/mcp/add.ts +++ b/src/commands/mcp/add.ts @@ -59,6 +59,7 @@ function runMcpAdd(argv: Arguments): void { mcpFeatures: features, apiKey, baseUrl: argv.baseUrl as string | undefined, + oauthClientId: argv.oauthClientId as string | undefined, }); } catch (error) { if (!isTUIUnavailable(error)) throw error; diff --git a/src/commands/mcp/tutorial.ts b/src/commands/mcp/tutorial.ts index 66e2443fe..1ed81b791 100644 --- a/src/commands/mcp/tutorial.ts +++ b/src/commands/mcp/tutorial.ts @@ -34,6 +34,7 @@ function runMcpTutorial(argv: Arguments): void { debug, localMcp, baseUrl: argv.baseUrl as string | undefined, + oauthClientId: argv.oauthClientId as string | undefined, }); } catch (err) { // TUI unavailable — the tutorial has no headless fallback. diff --git a/src/commands/provision.ts b/src/commands/provision.ts index aa60fd1f4..2a53a2cd9 100644 --- a/src/commands/provision.ts +++ b/src/commands/provision.ts @@ -46,6 +46,7 @@ function runProvision(argv: Arguments): void { region: (argv.region as string).toUpperCase() as 'US' | 'EU', name: (argv.name as string) ?? '', baseUrl: argv.baseUrl as string | undefined, + oauthClientId: argv.oauthClientId as string | undefined, jsonMode, }); } @@ -55,6 +56,7 @@ type ProvisionArgs = { region: 'US' | 'EU'; name: string; baseUrl?: string; + oauthClientId?: string; jsonMode: boolean; }; @@ -63,6 +65,7 @@ async function provision({ region, name, baseUrl, + oauthClientId, jsonMode, }: ProvisionArgs): Promise { try { @@ -70,7 +73,10 @@ async function provision({ if (!jsonMode) { getUI().log.info(`Provisioning account for ${email} in ${region}...`); } - const result = await provisionNewAccount(email, name, region, { baseUrl }); + const result = await provisionNewAccount(email, name, region, { + baseUrl, + oauthClientId, + }); emitResult(result, jsonMode); process.exit(0); } catch (error) { diff --git a/src/commands/slack.ts b/src/commands/slack.ts index 4f0bf7743..1ed45bc8f 100644 --- a/src/commands/slack.ts +++ b/src/commands/slack.ts @@ -33,6 +33,7 @@ function runSlackConnect(argv: Arguments): void { tui.store.session = buildSession({ debug, baseUrl: argv.baseUrl as string | undefined, + oauthClientId: argv.oauthClientId as string | undefined, }); } catch (err) { // TUI unavailable — connecting Slack has no headless fallback. diff --git a/src/lib/agent/runner/shared/authenticate.ts b/src/lib/agent/runner/shared/authenticate.ts index 6fe0a9ba8..967d24ccc 100644 --- a/src/lib/agent/runner/shared/authenticate.ts +++ b/src/lib/agent/runner/shared/authenticate.ts @@ -41,6 +41,7 @@ export async function authenticate( email: session.email, region: session.region, baseUrl: session.baseUrl, + oauthClientId: session.oauthClientId, localMcp: session.localMcp, programId, }); diff --git a/src/lib/runners/run-non-interactive.ts b/src/lib/runners/run-non-interactive.ts index a26263dbb..07d350054 100644 --- a/src/lib/runners/run-non-interactive.ts +++ b/src/lib/runners/run-non-interactive.ts @@ -128,6 +128,7 @@ export function runNonInteractive( email: options.email as string | undefined, projectId: options.projectId as string | undefined, baseUrl: options.baseUrl as string | undefined, + oauthClientId: options.oauthClientId as string | undefined, benchmark: options.benchmark as boolean | undefined, yaraReport: options.yaraReport as boolean | undefined, noTelemetry: resolveNoTelemetry(options), diff --git a/src/lib/runners/run-wizard.ts b/src/lib/runners/run-wizard.ts index 262c5f560..0f07a1735 100644 --- a/src/lib/runners/run-wizard.ts +++ b/src/lib/runners/run-wizard.ts @@ -117,6 +117,7 @@ export function runWizard( projectId: options.projectId as string | undefined, email: options.email as string | undefined, baseUrl: options.baseUrl as string | undefined, + oauthClientId: options.oauthClientId as string | undefined, benchmark: options.benchmark as boolean | undefined, yaraReport: options.yaraReport as boolean | undefined, noTelemetry: resolveNoTelemetry(options), @@ -212,6 +213,7 @@ export function runWizard( apiKey: session.apiKey, projectId: session.projectId, baseUrl: session.baseUrl, + oauthClientId: session.oauthClientId, programId: config.id, }); activeTui.store.setCredentials({ diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index e7df9fc2d..a38b56803 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -244,6 +244,13 @@ export interface WizardSession { * `@utils/urls`. Empty/unset → region-based resolution. */ baseUrl?: string; + /** + * Explicit OAuth client ID (`--oauth-client-id`). Overrides the client ID the + * login and provisioning flows would otherwise pick from `baseUrl`, so a + * pinned self-hosted instance can present a client its own OAuth app + * registers. Threaded into `@utils/oauth` and `@utils/provisioning`. + */ + oauthClientId?: string; benchmark: boolean; yaraReport: boolean; projectId?: number; @@ -428,6 +435,7 @@ export function buildSession(args: { email?: string; region?: CloudRegion; baseUrl?: string; + oauthClientId?: string; integration?: Integration; benchmark?: boolean; yaraReport?: boolean; @@ -455,6 +463,7 @@ export function buildSession(args: { // helper already honours. An explicit `--base-url` is more specific, so it wins. baseUrl: args.baseUrl ?? (local.localPosthog ? POSTHOG_LOCAL_URL : undefined), + oauthClientId: args.oauthClientId, benchmark: args.benchmark ?? false, yaraReport: args.yaraReport ?? false, projectId: parseProjectIdArg(args.projectId), diff --git a/src/ui/tui/screens/SlackConnectScreen.tsx b/src/ui/tui/screens/SlackConnectScreen.tsx index f0f4d99c8..f3c40354e 100644 --- a/src/ui/tui/screens/SlackConnectScreen.tsx +++ b/src/ui/tui/screens/SlackConnectScreen.tsx @@ -230,6 +230,8 @@ export const SlackConnectScreen = ({ store }: SlackConnectScreenProps) => { ci: false, apiKey: undefined, projectId: undefined, + baseUrl: store.session.baseUrl, + oauthClientId: store.session.oauthClientId, programId: Program.SlackConnect, }); if (cancelled) return; diff --git a/src/ui/tui/services/mcp-suggested-prompts-services.ts b/src/ui/tui/services/mcp-suggested-prompts-services.ts index 8b41505ca..3481ec717 100644 --- a/src/ui/tui/services/mcp-suggested-prompts-services.ts +++ b/src/ui/tui/services/mcp-suggested-prompts-services.ts @@ -115,6 +115,7 @@ export function createMcpSuggestedPromptsServices( email: undefined, region: undefined, baseUrl: store.session.baseUrl, + oauthClientId: store.session.oauthClientId, // Widens the OAuth scope grant: base `WIZARD_OAUTH_SCOPES` plus // read on every product surface (flags, experiments, surveys, // replays, errors, web/LLM analytics, cohorts, persons) plus diff --git a/src/utils/__tests__/oauth-errors.test.ts b/src/utils/__tests__/oauth-errors.test.ts index 5e0068245..1235745c1 100644 --- a/src/utils/__tests__/oauth-errors.test.ts +++ b/src/utils/__tests__/oauth-errors.test.ts @@ -240,6 +240,7 @@ describe('buildOAuthFailureMessage', () => { 'PostHog at http://localhost:8010 does not recognize', ); expect(message).toContain('--base-url'); + expect(message).toContain('--oauth-client-id'); expect(message).toContain('What to do:'); }); diff --git a/src/utils/__tests__/provisioning.test.ts b/src/utils/__tests__/provisioning.test.ts index a239df5f9..70874b80d 100644 --- a/src/utils/__tests__/provisioning.test.ts +++ b/src/utils/__tests__/provisioning.test.ts @@ -344,6 +344,43 @@ describe('provisionNewAccount', () => { ); }); + it('sends an explicit oauthClientId instead of the region client', async () => { + mockedAxios.post + .mockResolvedValueOnce({ + data: { id: 'req_cid', type: 'oauth', oauth: { code: 'code_cid' } }, + }) + .mockResolvedValueOnce({ + data: { + token_type: 'bearer', + access_token: 'pha_cid', + refresh_token: 'phr_cid', + expires_in: 3600, + }, + }) + .mockResolvedValueOnce({ + data: { + status: 'complete', + id: '8', + complete: { + access_configuration: { + api_key: 'phc_cid', + host: 'https://selfhosted.example.com', + }, + }, + }, + }); + + await provisionNewAccount('self@example.com', '', 'US', { + baseUrl: 'https://selfhosted.example.com', + oauthClientId: 'my-instance-client-id', + }); + + const accountCall = mockedAxios.post.mock.calls[0]; + expect((accountCall[1] as Record).client_id).toBe( + 'my-instance-client-id', + ); + }); + it('sends project name in resources configuration', async () => { mockedAxios.post .mockResolvedValueOnce({ diff --git a/src/utils/oauth-errors.ts b/src/utils/oauth-errors.ts index e95f7f751..617403e3e 100644 --- a/src/utils/oauth-errors.ts +++ b/src/utils/oauth-errors.ts @@ -179,7 +179,7 @@ function headlineAndRemediation(params: FailureMessageParams): { return { headline: `PostHog at ${oauthUrl} does not recognize the wizard's OAuth client (invalid_client).`, whatToDo: - 'The client ID below is not registered on the target instance. If you pointed the wizard at a local or self-hosted stack (--base-url), seed its wizard OAuth app first; otherwise re-run without --base-url to use PostHog Cloud.', + 'The client ID below is not registered on the target instance. If you pointed the wizard at a local or self-hosted stack (--base-url), either seed its wizard OAuth app or pass --oauth-client-id with a client the instance registers; otherwise re-run without --base-url to use PostHog Cloud.', }; case 'invalid_grant': return { diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts index 1f7f8ed75..5080f952f 100644 --- a/src/utils/oauth.ts +++ b/src/utils/oauth.ts @@ -115,19 +115,22 @@ interface OAuthConfig { * OAuth server and selects the matching client ID. */ baseUrl?: string; + /** + * Explicit OAuth client ID (`--oauth-client-id`, from + * `session.oauthClientId`). Wins over the base-URL heuristic below. + */ + oauthClientId?: string; } /** - * OAuth client ID for the current target. A pinned base URL (`--base-url`, or - * IS_DEV's implicit localhost) means we're talking to a dev-seeded stack, which - * registers the dev client; prod uses the proxy client. - * - * TODO: this assumes any pinned base URL is a dev-seeded instance that - * registers POSTHOG_DEV_CLIENT_ID. If we ever point `--base-url` at a non-dev - * instance with its own OAuth app, make the client ID configurable (e.g. a - * `--oauth-client-id` flag) instead of always falling back to the dev client. + * OAuth client ID for the current target. An explicit `--oauth-client-id` wins, + * so a pinned instance with its own OAuth app can present a client it registers. + * Otherwise a pinned base URL (`--base-url`, or IS_DEV's implicit localhost) + * means a dev-seeded stack, which registers the dev client; prod uses the proxy + * client. */ -function getOAuthClientId(baseUrl?: string): string { +function getOAuthClientId(baseUrl?: string, oauthClientId?: string): string { + if (oauthClientId) return oauthClientId; return resolveBaseUrl(baseUrl) ? POSTHOG_DEV_CLIENT_ID : POSTHOG_PROXY_CLIENT_ID; @@ -351,8 +354,9 @@ async function exchangeCodeForToken( codeVerifier: string, callbackUrl: string, baseUrl?: string, + oauthClientId?: string, ): Promise { - const clientId = getOAuthClientId(baseUrl); + const clientId = getOAuthClientId(baseUrl, oauthClientId); const oauthUrl = getOAuthUrl(baseUrl); logToFile(`[oauth] exchanging code for token at ${oauthUrl}/oauth/token`); @@ -456,7 +460,7 @@ function reportNarrowedGrant( export async function performOAuthFlow( config: OAuthConfig, ): Promise { - const clientId = getOAuthClientId(config.baseUrl); + const clientId = getOAuthClientId(config.baseUrl, config.oauthClientId); const oauthUrl = getOAuthUrl(config.baseUrl); const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); @@ -559,6 +563,7 @@ export async function performOAuthFlow( codeVerifier, callbackUrl, config.baseUrl, + config.oauthClientId, ); server.close(); diff --git a/src/utils/provisioning.ts b/src/utils/provisioning.ts index ab38173d5..440e574e7 100644 --- a/src/utils/provisioning.ts +++ b/src/utils/provisioning.ts @@ -42,18 +42,18 @@ const getProvisioningBaseUrl = ( }; /** - * OAuth client ID for provisioning. A pinned base URL means a dev-seeded stack - * that registers the dev client; prod uses the client registered for the target - * region (the wizard OAuth app is registered separately per region). - * - * TODO: same assumption as `getOAuthClientId` in oauth.ts — a pinned base URL is - * treated as a dev-seeded instance. Make configurable if we ever point - * `--base-url` at a non-dev instance with its own OAuth app. + * OAuth client ID for provisioning. An explicit `--oauth-client-id` wins, so a + * pinned instance with its own OAuth app can present a client it registers. + * Otherwise a pinned base URL means a dev-seeded stack that registers the dev + * client; prod uses the client registered for the target region (the wizard + * OAuth app is registered separately per region). */ const getProvisioningClientId = ( region: 'US' | 'EU', baseUrl?: string, + oauthClientId?: string, ): string => { + if (oauthClientId) return oauthClientId; if (resolveBaseUrl(baseUrl)) return POSTHOG_DEV_CLIENT_ID; return region === 'EU' ? POSTHOG_EU_CLIENT_ID : POSTHOG_US_CLIENT_ID; }; @@ -290,6 +290,8 @@ export async function provisionNewAccount( orgName?: string; projectName?: string; baseUrl?: string; + /** Explicit OAuth client ID (`--oauth-client-id`); wins over the base-URL heuristic. */ + oauthClientId?: string; /** Scope list to request; defaults to `WIZARD_PROVISIONING_SCOPES`. */ scopes?: readonly string[]; }, @@ -307,7 +309,11 @@ export async function provisionNewAccount( id: crypto.randomUUID(), email, name, - client_id: getProvisioningClientId(region, opts?.baseUrl), + client_id: getProvisioningClientId( + region, + opts?.baseUrl, + opts?.oauthClientId, + ), code_challenge: codeChallenge, code_challenge_method: 'S256', scopes: opts?.scopes ?? WIZARD_PROVISIONING_SCOPES, diff --git a/src/utils/setup-utils.ts b/src/utils/setup-utils.ts index 0767932f6..f5b5d68fe 100644 --- a/src/utils/setup-utils.ts +++ b/src/utils/setup-utils.ts @@ -414,6 +414,9 @@ export async function getOrAskForProjectData( /** Explicit base URL override (`--base-url`, from `session.baseUrl`). When * set, pins every PostHog origin and bypasses region resolution. */ baseUrl?: string; + /** Explicit OAuth client ID (`--oauth-client-id`, from + * `session.oauthClientId`). Threaded into the login/provisioning flow. */ + oauthClientId?: string; /** `--local-mcp`: forwarded into the resolved host so `host.mcpUrl` is local. */ localMcp?: boolean; /** Optional — picks the OAuth scope set via @@ -506,6 +509,7 @@ export async function getOrAskForProjectData( email: _options.email, region: _options.region, baseUrl: _options.baseUrl, + oauthClientId: _options.oauthClientId, programId: _options.programId, projectId: _options.projectId, localMcp: _options.localMcp, @@ -577,6 +581,8 @@ async function askForWizardLogin(options: { region?: CloudRegion; /** Explicit base URL override (`--base-url`); pins every PostHog origin. */ baseUrl?: string; + /** Explicit OAuth client ID (`--oauth-client-id`); overrides the base-URL heuristic. */ + oauthClientId?: string; /** Used to pick the right scope set via `getOAuthScopesForProgram`. * Omitted → default `WIZARD_OAUTH_SCOPES`. */ programId?: ProgramId | null; @@ -593,6 +599,7 @@ async function askForWizardLogin(options: { options.baseUrl, options.localMcp, options.programId, + options.oauthClientId, ); } @@ -602,6 +609,7 @@ async function askForWizardLogin(options: { signup: false, projectId: options.projectId, baseUrl: options.baseUrl, + oauthClientId: options.oauthClientId, }); try { @@ -712,6 +720,7 @@ async function askForProvisioningSignup( baseUrl?: string, localMcp?: boolean, programId?: ProgramId | null, + oauthClientId?: string, ): Promise { if (!email || !email.includes('@')) { getUI().log.error( @@ -731,6 +740,7 @@ async function askForProvisioningSignup( orgName, projectName, baseUrl, + oauthClientId, scopes: getProvisioningScopesForProgram(programId), }); @@ -758,7 +768,12 @@ async function askForProvisioningSignup( getUI().log.warn(message); getUI().log.info('Signing you in to your new account instead...'); - return askForWizardLogin({ signup: false, baseUrl, localMcp }); + return askForWizardLogin({ + signup: false, + baseUrl, + oauthClientId, + localMcp, + }); } spinner.stop('Account creation failed.'); @@ -768,7 +783,12 @@ async function askForProvisioningSignup( 'This email already has a PostHog account. Switching to login flow...', ); - return askForWizardLogin({ signup: false, baseUrl, localMcp }); + return askForWizardLogin({ + signup: false, + baseUrl, + oauthClientId, + localMcp, + }); } getUI().log.error(`Failed to create account: ${message}`); diff --git a/src/wizard.ts b/src/wizard.ts index 680808a77..3cd9ae6d3 100644 --- a/src/wizard.ts +++ b/src/wizard.ts @@ -61,6 +61,12 @@ export const GLOBAL_OPTIONS = { type: 'string' as const, hidden: true, }, + 'oauth-client-id': { + describe: + 'Override the OAuth client ID sent during login. Use with --base-url when the target instance registers its own wizard OAuth app instead of the dev-seeded one.\nenv: POSTHOG_WIZARD_OAUTH_CLIENT_ID', + type: 'string' as const, + hidden: true, + }, benchmark: { default: false, describe: