From f08c239c772d62a13c400bc03eeb20cc87f90d79 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 14:26:26 +0000 Subject: [PATCH] fix: surface OAuth error_description on token-exchange 4xx The token endpoint at `${POSTHOG_OAUTH_URL}/oauth/token` returns standard OAuth `error` / `error_description` fields on a 4xx, but `exchangeCodeForToken` only ever consumed `response.data` on success. A 400 (e.g. replayed or expired code, PKCE mismatch, redirect_uri mismatch after the multi-port refactor in #400) surfaced as the opaque "Request failed with status code 400" on the very first step of the wizard, leaving users without a path to recovery. This catches axios errors in `exchangeCodeForToken`, parses the OAuth error payload, and rethrows with a clear message that gets rendered by the existing `performOAuthFlow` catch (e.g. "invalid_grant: authorization code expired - please re-run the wizard"). Also logs the resolved `redirect_uri` + port + server response via `logToFile` on the chosen port and on failure, so future 400s can be traced to a specific port iteration. Mirrors the surfacing pattern from #432. Generated-By: PostHog Code Task-Id: 311205bc-0f27-42f4-a2e6-0112a4d64855 --- src/utils/oauth.ts | 75 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 18 deletions(-) diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts index 0dd3e383..1ff256e4 100644 --- a/src/utils/oauth.ts +++ b/src/utils/oauth.ts @@ -1,7 +1,7 @@ import * as crypto from 'node:crypto'; import * as http from 'node:http'; import { execSync } from 'node:child_process'; -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; import { logToFile } from './debug'; import opn from 'opn'; import { z } from 'zod'; @@ -228,6 +228,11 @@ function isPortInUseError(error: unknown): boolean { ); } +const OAuthErrorResponseSchema = z.object({ + error: z.string().optional(), + error_description: z.string().optional(), +}); + async function exchangeCodeForToken( code: string, codeVerifier: string, @@ -235,24 +240,56 @@ async function exchangeCodeForToken( ): Promise { const clientId = IS_DEV ? POSTHOG_DEV_CLIENT_ID : POSTHOG_PROXY_CLIENT_ID; - const response = await axios.post( - `${POSTHOG_OAUTH_URL}/oauth/token`, - { - grant_type: 'authorization_code', - code, - redirect_uri: callbackUrl, - client_id: clientId, - code_verifier: codeVerifier, - }, - { - headers: { - 'Content-Type': 'application/json', - 'User-Agent': WIZARD_USER_AGENT, + try { + const response = await axios.post( + `${POSTHOG_OAUTH_URL}/oauth/token`, + { + grant_type: 'authorization_code', + code, + redirect_uri: callbackUrl, + client_id: clientId, + code_verifier: codeVerifier, }, - }, - ); + { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': WIZARD_USER_AGENT, + }, + }, + ); + + return OAuthTokenResponseSchema.parse(response.data); + } catch (e) { + if (axios.isAxiosError(e)) { + const axiosError = e as AxiosError; + const status = axiosError.response?.status; + const parsed = OAuthErrorResponseSchema.safeParse( + axiosError.response?.data, + ); + const oauthError = parsed.success ? parsed.data.error : undefined; + const oauthDescription = parsed.success + ? parsed.data.error_description + : undefined; + + logToFile( + `[oauth] token exchange failed: status=${String(status)} error=${String( + oauthError, + )} description=${String(oauthDescription)} redirect_uri=${callbackUrl}`, + ); - return OAuthTokenResponseSchema.parse(response.data); + if (status && status >= 400 && status < 500) { + const detail = + oauthDescription || + oauthError || + 'The PostHog OAuth server rejected the authorization code.'; + const label = oauthError ? `${oauthError}: ${detail}` : detail; + throw new Error( + `${label}\n\nThis usually means the authorization code expired, was already used, or the redirect URI didn't match. Please re-run the wizard to start a fresh login.`, + ); + } + } + throw e; + } } export async function performOAuthFlow( @@ -292,7 +329,9 @@ export async function performOAuthFlow( const localLoginUrl = getLocalLoginUrl(port); const urlToOpen = config.signup ? localSignupUrl : localLoginUrl; - logToFile(`[oauth] attempting callback server on port ${port}`); + logToFile( + `[oauth] attempting callback server on port ${port} redirect_uri=${callbackUrl}`, + ); let server: http.Server; let waitForCallback: () => Promise;