Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 57 additions & 18 deletions src/utils/oauth.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -228,31 +228,68 @@ 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,
callbackUrl: string,
): Promise<OAuthTokenResponse> {
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<unknown>;
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(
Expand Down Expand Up @@ -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<string>;
Expand Down
Loading