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

@@ -26,11 +26,12 @@ CodexDash now reuses the public-client OAuth/PKCE shape found in [`darvell/codex
3. The web app opens the OpenAI authorization page in a popup.
4. After successful login, OpenAI redirects back to the local callback bridge at `http://localhost:1455/auth/callback`.
5. The callback bridge exchanges the authorization code for tokens, encrypts the session JSON in SQLite, and posts the result back to the main app window.
6. CodexDash refreshes usage using the saved OAuth session and shows both the aggregate view and per-account details.
6. If the callback bridge is unavailable, the user can copy the final `localhost:1455` URL from the browser address bar and paste it back into CodexDash to finish the same login attempt manually.
7. CodexDash refreshes usage using the saved OAuth session and shows both the aggregate view and per-account details.
### Important local-dev note
This flow depends on the local callback bridge being reachable on `localhost:1455`. In local development, make sure that port is free before starting the API.
This flow works best when the local callback bridge is reachable on `localhost:1455`, but CodexDash now also supports a manual fallback where the user pastes the final callback URL if that port is unavailable. In local development, make sure that port is free if you want the automatic popup completion path.
## Local development

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',

View File

@@ -53,6 +53,10 @@ export type StartCodexLoginInput = {
emailHint?: string;
};
export type CompleteCodexManualLoginInput = {
callbackUrl: string;
};
export type StartCodexLoginResponse = {
attemptId: string;
authorizeUrl: string;