feat: add manual OAuth callback fallback

This commit is contained in:
2026-05-01 07:51:09 +09:00
parent e9496f2c4a
commit 06a6c6a000
9 changed files with 212 additions and 36 deletions

View File

@@ -2,6 +2,7 @@ import {
buildCodexAuthorizeUrl,
createPkcePair,
extractCodexIdentity,
parseCodexCallbackParams,
renderCodexOauthCallbackHtml,
} from './codex-oauth';
@@ -67,6 +68,19 @@ describe('codex-oauth helpers', () => {
});
});
it('parses a pasted localhost callback URL for manual completion', () => {
expect(
parseCodexCallbackParams(
'http://localhost:1455/auth/callback?code=abc123&state=state-123',
),
).toEqual({
code: 'abc123',
oauthError: null,
oauthErrorDescription: null,
state: 'state-123',
});
});
it('renders callback html that reports completion back to the frontend window', () => {
const html = renderCodexOauthCallbackHtml({
attemptId: 'attempt_123',

View File

@@ -77,6 +77,20 @@ export function extractCodexIdentity(idToken: string): CodexIdentity {
};
}
export function parseCodexCallbackParams(
rawUrl: string,
redirectUri = CODEX_OAUTH_DEFAULT_REDIRECT_URI,
) {
const callbackUrl = new URL(rawUrl, redirectUri);
return {
code: callbackUrl.searchParams.get('code'),
state: callbackUrl.searchParams.get('state'),
oauthError: callbackUrl.searchParams.get('error'),
oauthErrorDescription: callbackUrl.searchParams.get('error_description'),
};
}
export function renderCodexOauthCallbackHtml(input: {
attemptId: string;
status: 'success' | 'error';

View File

@@ -16,6 +16,7 @@ import {
} from '../common/current-user.decorator';
import { JwtAuthGuard } from '../common/jwt-auth.guard';
import { CodexService } from './codex.service';
import { CompleteCodexManualLoginDto } from './dto/complete-codex-manual-login.dto';
import { StartCodexLoginDto } from './dto/start-codex-login.dto';
@Controller('codex')
@@ -46,6 +47,20 @@ export class CodexController {
return this.codexService.getLoginAttempt(user.sub, attemptId);
}
@UseGuards(JwtAuthGuard)
@Post('accounts/login/:attemptId/manual-complete')
completeManualLoginAttempt(
@CurrentUser() user: AuthenticatedUser,
@Param('attemptId') attemptId: string,
@Body() dto: CompleteCodexManualLoginDto,
) {
return this.codexService.completeManualLoginAttempt(
user.sub,
attemptId,
dto.callbackUrl,
);
}
@UseGuards(JwtAuthGuard)
@Post('accounts/login/:attemptId/cancel')
cancelLoginAttempt(

View File

@@ -22,6 +22,7 @@ import {
CODEX_OAUTH_TOKEN_URL,
createPkcePair,
extractCodexIdentity,
parseCodexCallbackParams,
renderCodexOauthCallbackHtml,
} from './codex-oauth';
import { aggregateUsagePayloads } from './usage-aggregator';
@@ -107,6 +108,34 @@ export class CodexService {
return this.toLoginAttemptView(attempt);
}
async completeManualLoginAttempt(
userId: string,
attemptId: string,
rawUrl: string,
) {
const attempt = await this.prisma.openAiLoginAttempt.findFirst({
where: { id: attemptId, userId },
});
if (!attempt) {
throw new NotFoundException('Login attempt not found');
}
const params = parseCodexCallbackParams(rawUrl, this.getOauthRedirectUri());
if (!params.state) {
throw new BadRequestException(
'Missing OAuth state in the pasted callback URL.',
);
}
if (params.state !== attempt.state) {
throw new BadRequestException(
'This callback URL belongs to a different login attempt.',
);
}
await this.completeOauthAttempt(attempt, params);
return this.getLoginAttempt(userId, attemptId);
}
async cancelLoginAttempt(userId: string, attemptId: string) {
const attempt = await this.prisma.openAiLoginAttempt.findFirst({
where: { id: attemptId, userId },
@@ -205,14 +234,9 @@ export class CodexService {
}
async handleOauthCallbackRequest(rawUrl: string) {
const callbackUrl = new URL(rawUrl, this.getOauthRedirectUri());
const code = callbackUrl.searchParams.get('code');
const state = callbackUrl.searchParams.get('state');
const oauthError = callbackUrl.searchParams.get('error');
const oauthErrorDescription =
callbackUrl.searchParams.get('error_description');
const params = parseCodexCallbackParams(rawUrl, this.getOauthRedirectUri());
if (!state) {
if (!params.state) {
return this.renderCallbackPage({
attemptId: 'unknown',
status: 'error',
@@ -221,7 +245,7 @@ export class CodexService {
}
const attempt = await this.prisma.openAiLoginAttempt.findUnique({
where: { state },
where: { state: params.state },
});
if (!attempt) {
return this.renderCallbackPage({
@@ -231,15 +255,23 @@ export class CodexService {
});
}
const result = await this.completeOauthAttempt(attempt, params);
return this.renderCallbackPage(result);
}
private async completeOauthAttempt(
attempt: PendingLoginAttemptRecord,
params: ReturnType<typeof parseCodexCallbackParams>,
) {
if (attempt.status !== 'pending') {
return this.renderCallbackPage({
return {
attemptId: attempt.id,
status: attempt.status === 'completed' ? 'success' : 'error',
message:
attempt.status === 'completed'
? 'This OpenAI account is already connected.'
: attempt.lastError || 'This login attempt is no longer active.',
});
} as const;
}
if (attempt.expiresAt.getTime() < Date.now()) {
@@ -247,29 +279,29 @@ export class CodexService {
where: { id: attempt.id },
data: { status: 'expired', lastError: 'Login window expired.' },
});
return this.renderCallbackPage({
return {
attemptId: attempt.id,
status: 'error',
status: 'error' as const,
message: 'This login window has expired. Please start again.',
});
};
}
if (oauthError) {
const message = oauthErrorDescription
? `${oauthError}: ${oauthErrorDescription}`
: oauthError;
if (params.oauthError) {
const message = params.oauthErrorDescription
? `${params.oauthError}: ${params.oauthErrorDescription}`
: params.oauthError;
await this.prisma.openAiLoginAttempt.update({
where: { id: attempt.id },
data: { status: 'error', lastError: message },
});
return this.renderCallbackPage({
return {
attemptId: attempt.id,
status: 'error',
status: 'error' as const,
message,
});
};
}
if (!code) {
if (!params.code) {
await this.prisma.openAiLoginAttempt.update({
where: { id: attempt.id },
data: {
@@ -277,11 +309,11 @@ export class CodexService {
lastError: 'Missing authorization code from OpenAI.',
},
});
return this.renderCallbackPage({
return {
attemptId: attempt.id,
status: 'error',
status: 'error' as const,
message: 'Missing authorization code from OpenAI.',
});
};
}
try {
@@ -290,7 +322,7 @@ export class CodexService {
this.getEncryptionSecret(),
);
const tokenResponse = await this.exchangeAuthorizationCode(
code,
params.code,
verifier,
);
const identity = extractCodexIdentity(tokenResponse.id_token ?? '');
@@ -363,11 +395,11 @@ export class CodexService {
},
});
return this.renderCallbackPage({
return {
attemptId: attempt.id,
status: 'success',
status: 'success' as const,
message: `Connected ${sessionIdentity.email ?? attempt.label} to CodexDash.`,
});
};
} catch (error) {
const message =
error instanceof Error ? error.message : 'OpenAI login failed.';
@@ -375,11 +407,11 @@ export class CodexService {
where: { id: attempt.id },
data: { status: 'error', lastError: message },
});
return this.renderCallbackPage({
return {
attemptId: attempt.id,
status: 'error',
status: 'error' as const,
message,
});
};
}
}
@@ -655,3 +687,15 @@ type LoginAttemptRecord = {
lastError: string | null;
account: AccountRecord | null;
};
type PendingLoginAttemptRecord = {
id: string;
userId: string;
label: string;
emailHint: string | null;
status: string;
state: string;
encryptedCodeVerifier: string;
expiresAt: Date;
lastError: string | null;
};

View File

@@ -0,0 +1,8 @@
import { IsString, MinLength } from 'class-validator';
import { CompleteCodexManualLoginInput } from '@codexdash/shared-types';
export class CompleteCodexManualLoginDto implements CompleteCodexManualLoginInput {
@IsString()
@MinLength(10)
callbackUrl!: string;
}

View File

@@ -9,6 +9,7 @@ import {
} from '@tanstack/react-query';
import type {
AuthResponse,
CompleteCodexManualLoginInput,
LoginInput,
RegisterInput,
StartCodexLoginInput,
@@ -70,6 +71,10 @@ const connectSchema = z.object({
emailHint: z.string().optional(),
});
const manualCallbackSchema = z.object({
callbackUrl: z.string().min(10),
});
function AuthShell({
onAuthenticated,
}: {
@@ -230,7 +235,7 @@ function AuthShell({
<div className="space-y-3 text-sm text-slate-400">
<p>
OpenAI account connection now uses a real sign-in flow based on the Codex client OAuth pattern.
After you click connect, CodexDash opens OpenAI login in a popup and receives the callback locally.
After you click connect, CodexDash opens OpenAI login in a popup and can also finish from a pasted callback URL if localhost is unavailable.
</p>
<Button
type="button"
@@ -263,12 +268,17 @@ function ConnectAccountDialog() {
resolver: zodResolver(connectSchema),
defaultValues: { label: '', emailHint: '' },
});
const manualCallbackForm = useForm<z.infer<typeof manualCallbackSchema>>({
resolver: zodResolver(manualCallbackSchema),
defaultValues: { callbackUrl: '' },
});
const startMutation = useMutation({
mutationFn: api.startCodexLogin,
onSuccess: (response) => {
setAttemptId(response.attemptId);
setAuthorizeUrl(response.authorizeUrl);
manualCallbackForm.reset();
popupRef.current = window.open(
response.authorizeUrl,
'codexdash-openai-login',
@@ -283,6 +293,19 @@ function ConnectAccountDialog() {
onError: (error: Error) => toast.error(error.message),
});
const manualCompleteMutation = useMutation({
mutationFn: ({
callbackUrl,
currentAttemptId,
}: CompleteCodexManualLoginInput & { currentAttemptId: string }) =>
api.completeCodexManualLogin(currentAttemptId, { callbackUrl }),
onSuccess: () => {
toast.success('Processing the pasted OpenAI callback URL…');
void attemptQuery.refetch();
},
onError: (error: Error) => toast.error(error.message),
});
const attemptQuery = useQuery({
enabled: Boolean(attemptId),
queryKey: ['codex-login-attempt', attemptId],
@@ -297,6 +320,7 @@ function ConnectAccountDialog() {
toast.success('Login attempt cancelled.');
setAttemptId(null);
setAuthorizeUrl(null);
manualCallbackForm.reset();
popupRef.current?.close();
},
onError: (error: Error) => toast.error(error.message),
@@ -321,6 +345,7 @@ function ConnectAccountDialog() {
setAttemptId(null);
setAuthorizeUrl(null);
form.reset();
manualCallbackForm.reset();
popupRef.current?.close();
void queryClient.invalidateQueries({ queryKey: ['usage-summary'] });
}, 0);
@@ -331,7 +356,7 @@ function ConnectAccountDialog() {
handledAttemptStatusRef.current = statusKey;
toast.error(attempt.lastError || 'OpenAI login failed.');
}
}, [attemptQuery.data, form, queryClient]);
}, [attemptQuery.data, form, manualCallbackForm, queryClient]);
useEffect(() => {
function onMessage(event: MessageEvent) {
@@ -424,6 +449,45 @@ function ConnectAccountDialog() {
</div>
) : null}
<div className="rounded-2xl border border-white/10 bg-white/4 p-4 text-sm text-slate-300">
<div className="font-medium text-white">Manual fallback</div>
<p className="mt-2 leading-6 text-slate-400">
If localhost:1455 is not listening, OpenAI may finish on a browser error page.
Copy the full URL from the address bar and paste it below to complete the login manually.
</p>
<form
className="mt-4 space-y-3"
onSubmit={manualCallbackForm.handleSubmit((values) => {
if (!attemptId) {
return;
}
manualCompleteMutation.mutate({
callbackUrl: values.callbackUrl,
currentAttemptId: attemptId,
});
})}
>
<Input
placeholder="http://localhost:1455/auth/callback?code=...&state=..."
{...manualCallbackForm.register('callbackUrl')}
/>
<p className="text-xs text-rose-300">
{String(
manualCallbackForm.formState.errors.callbackUrl?.message ?? '',
)}
</p>
<Button
type="submit"
variant="outline"
disabled={!attemptId || manualCompleteMutation.isPending}
>
{manualCompleteMutation.isPending
? 'Processing callback…'
: 'Complete with pasted URL'}
</Button>
</form>
</div>
{attempt?.lastError ? (
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/8 p-4 text-sm text-rose-200">
{attempt.lastError}
@@ -477,7 +541,7 @@ function ConnectAccountDialog() {
</div>
<div className="rounded-2xl border border-white/10 bg-white/4 p-4 text-sm leading-6 text-slate-400">
CodexDash reuses the Codex public-client login shape discovered in
codex-pool, but presents it as an integrated popup flow instead of asking you to paste cookies manually.
codex-pool, but presents it as an integrated popup flow with a manual pasted-URL fallback instead of asking you to paste cookies manually.
</div>
<Button className="w-full" disabled={startMutation.isPending} type="submit">
{startMutation.isPending ? 'Preparing login…' : 'Continue to OpenAI'}

View File

@@ -1,6 +1,7 @@
import type {
AuthResponse,
CodexLoginAttemptResponse,
CompleteCodexManualLoginInput,
ConnectedAccount,
LoginInput,
RegisterInput,
@@ -59,6 +60,17 @@ export const api = {
}),
getCodexLoginAttempt: (attemptId: string) =>
request<CodexLoginAttemptResponse>(`/codex/accounts/login/${attemptId}`),
completeCodexManualLogin: (
attemptId: string,
input: CompleteCodexManualLoginInput,
) =>
request<CodexLoginAttemptResponse>(
`/codex/accounts/login/${attemptId}/manual-complete`,
{
method: 'POST',
body: JSON.stringify(input),
},
),
cancelCodexLoginAttempt: (attemptId: string) =>
request<{ ok: boolean }>(`/codex/accounts/login/${attemptId}/cancel`, {
method: 'POST',