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;