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. 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`. 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. 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 ### 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 ## Local development

View File

@@ -2,6 +2,7 @@ import {
buildCodexAuthorizeUrl, buildCodexAuthorizeUrl,
createPkcePair, createPkcePair,
extractCodexIdentity, extractCodexIdentity,
parseCodexCallbackParams,
renderCodexOauthCallbackHtml, renderCodexOauthCallbackHtml,
} from './codex-oauth'; } 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', () => { it('renders callback html that reports completion back to the frontend window', () => {
const html = renderCodexOauthCallbackHtml({ const html = renderCodexOauthCallbackHtml({
attemptId: 'attempt_123', 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: { export function renderCodexOauthCallbackHtml(input: {
attemptId: string; attemptId: string;
status: 'success' | 'error'; status: 'success' | 'error';

View File

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

View File

@@ -22,6 +22,7 @@ import {
CODEX_OAUTH_TOKEN_URL, CODEX_OAUTH_TOKEN_URL,
createPkcePair, createPkcePair,
extractCodexIdentity, extractCodexIdentity,
parseCodexCallbackParams,
renderCodexOauthCallbackHtml, renderCodexOauthCallbackHtml,
} from './codex-oauth'; } from './codex-oauth';
import { aggregateUsagePayloads } from './usage-aggregator'; import { aggregateUsagePayloads } from './usage-aggregator';
@@ -107,6 +108,34 @@ export class CodexService {
return this.toLoginAttemptView(attempt); 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) { async cancelLoginAttempt(userId: string, attemptId: string) {
const attempt = await this.prisma.openAiLoginAttempt.findFirst({ const attempt = await this.prisma.openAiLoginAttempt.findFirst({
where: { id: attemptId, userId }, where: { id: attemptId, userId },
@@ -205,14 +234,9 @@ export class CodexService {
} }
async handleOauthCallbackRequest(rawUrl: string) { async handleOauthCallbackRequest(rawUrl: string) {
const callbackUrl = new URL(rawUrl, this.getOauthRedirectUri()); const params = parseCodexCallbackParams(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');
if (!state) { if (!params.state) {
return this.renderCallbackPage({ return this.renderCallbackPage({
attemptId: 'unknown', attemptId: 'unknown',
status: 'error', status: 'error',
@@ -221,7 +245,7 @@ export class CodexService {
} }
const attempt = await this.prisma.openAiLoginAttempt.findUnique({ const attempt = await this.prisma.openAiLoginAttempt.findUnique({
where: { state }, where: { state: params.state },
}); });
if (!attempt) { if (!attempt) {
return this.renderCallbackPage({ 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') { if (attempt.status !== 'pending') {
return this.renderCallbackPage({ return {
attemptId: attempt.id, attemptId: attempt.id,
status: attempt.status === 'completed' ? 'success' : 'error', status: attempt.status === 'completed' ? 'success' : 'error',
message: message:
attempt.status === 'completed' attempt.status === 'completed'
? 'This OpenAI account is already connected.' ? 'This OpenAI account is already connected.'
: attempt.lastError || 'This login attempt is no longer active.', : attempt.lastError || 'This login attempt is no longer active.',
}); } as const;
} }
if (attempt.expiresAt.getTime() < Date.now()) { if (attempt.expiresAt.getTime() < Date.now()) {
@@ -247,29 +279,29 @@ export class CodexService {
where: { id: attempt.id }, where: { id: attempt.id },
data: { status: 'expired', lastError: 'Login window expired.' }, data: { status: 'expired', lastError: 'Login window expired.' },
}); });
return this.renderCallbackPage({ return {
attemptId: attempt.id, attemptId: attempt.id,
status: 'error', status: 'error' as const,
message: 'This login window has expired. Please start again.', message: 'This login window has expired. Please start again.',
}); };
} }
if (oauthError) { if (params.oauthError) {
const message = oauthErrorDescription const message = params.oauthErrorDescription
? `${oauthError}: ${oauthErrorDescription}` ? `${params.oauthError}: ${params.oauthErrorDescription}`
: oauthError; : params.oauthError;
await this.prisma.openAiLoginAttempt.update({ await this.prisma.openAiLoginAttempt.update({
where: { id: attempt.id }, where: { id: attempt.id },
data: { status: 'error', lastError: message }, data: { status: 'error', lastError: message },
}); });
return this.renderCallbackPage({ return {
attemptId: attempt.id, attemptId: attempt.id,
status: 'error', status: 'error' as const,
message, message,
}); };
} }
if (!code) { if (!params.code) {
await this.prisma.openAiLoginAttempt.update({ await this.prisma.openAiLoginAttempt.update({
where: { id: attempt.id }, where: { id: attempt.id },
data: { data: {
@@ -277,11 +309,11 @@ export class CodexService {
lastError: 'Missing authorization code from OpenAI.', lastError: 'Missing authorization code from OpenAI.',
}, },
}); });
return this.renderCallbackPage({ return {
attemptId: attempt.id, attemptId: attempt.id,
status: 'error', status: 'error' as const,
message: 'Missing authorization code from OpenAI.', message: 'Missing authorization code from OpenAI.',
}); };
} }
try { try {
@@ -290,7 +322,7 @@ export class CodexService {
this.getEncryptionSecret(), this.getEncryptionSecret(),
); );
const tokenResponse = await this.exchangeAuthorizationCode( const tokenResponse = await this.exchangeAuthorizationCode(
code, params.code,
verifier, verifier,
); );
const identity = extractCodexIdentity(tokenResponse.id_token ?? ''); const identity = extractCodexIdentity(tokenResponse.id_token ?? '');
@@ -363,11 +395,11 @@ export class CodexService {
}, },
}); });
return this.renderCallbackPage({ return {
attemptId: attempt.id, attemptId: attempt.id,
status: 'success', status: 'success' as const,
message: `Connected ${sessionIdentity.email ?? attempt.label} to CodexDash.`, message: `Connected ${sessionIdentity.email ?? attempt.label} to CodexDash.`,
}); };
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : 'OpenAI login failed.'; error instanceof Error ? error.message : 'OpenAI login failed.';
@@ -375,11 +407,11 @@ export class CodexService {
where: { id: attempt.id }, where: { id: attempt.id },
data: { status: 'error', lastError: message }, data: { status: 'error', lastError: message },
}); });
return this.renderCallbackPage({ return {
attemptId: attempt.id, attemptId: attempt.id,
status: 'error', status: 'error' as const,
message, message,
}); };
} }
} }
@@ -655,3 +687,15 @@ type LoginAttemptRecord = {
lastError: string | null; lastError: string | null;
account: AccountRecord | 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'; } from '@tanstack/react-query';
import type { import type {
AuthResponse, AuthResponse,
CompleteCodexManualLoginInput,
LoginInput, LoginInput,
RegisterInput, RegisterInput,
StartCodexLoginInput, StartCodexLoginInput,
@@ -70,6 +71,10 @@ const connectSchema = z.object({
emailHint: z.string().optional(), emailHint: z.string().optional(),
}); });
const manualCallbackSchema = z.object({
callbackUrl: z.string().min(10),
});
function AuthShell({ function AuthShell({
onAuthenticated, onAuthenticated,
}: { }: {
@@ -230,7 +235,7 @@ function AuthShell({
<div className="space-y-3 text-sm text-slate-400"> <div className="space-y-3 text-sm text-slate-400">
<p> <p>
OpenAI account connection now uses a real sign-in flow based on the Codex client OAuth pattern. 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> </p>
<Button <Button
type="button" type="button"
@@ -263,12 +268,17 @@ function ConnectAccountDialog() {
resolver: zodResolver(connectSchema), resolver: zodResolver(connectSchema),
defaultValues: { label: '', emailHint: '' }, defaultValues: { label: '', emailHint: '' },
}); });
const manualCallbackForm = useForm<z.infer<typeof manualCallbackSchema>>({
resolver: zodResolver(manualCallbackSchema),
defaultValues: { callbackUrl: '' },
});
const startMutation = useMutation({ const startMutation = useMutation({
mutationFn: api.startCodexLogin, mutationFn: api.startCodexLogin,
onSuccess: (response) => { onSuccess: (response) => {
setAttemptId(response.attemptId); setAttemptId(response.attemptId);
setAuthorizeUrl(response.authorizeUrl); setAuthorizeUrl(response.authorizeUrl);
manualCallbackForm.reset();
popupRef.current = window.open( popupRef.current = window.open(
response.authorizeUrl, response.authorizeUrl,
'codexdash-openai-login', 'codexdash-openai-login',
@@ -283,6 +293,19 @@ function ConnectAccountDialog() {
onError: (error: Error) => toast.error(error.message), 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({ const attemptQuery = useQuery({
enabled: Boolean(attemptId), enabled: Boolean(attemptId),
queryKey: ['codex-login-attempt', attemptId], queryKey: ['codex-login-attempt', attemptId],
@@ -297,6 +320,7 @@ function ConnectAccountDialog() {
toast.success('Login attempt cancelled.'); toast.success('Login attempt cancelled.');
setAttemptId(null); setAttemptId(null);
setAuthorizeUrl(null); setAuthorizeUrl(null);
manualCallbackForm.reset();
popupRef.current?.close(); popupRef.current?.close();
}, },
onError: (error: Error) => toast.error(error.message), onError: (error: Error) => toast.error(error.message),
@@ -321,6 +345,7 @@ function ConnectAccountDialog() {
setAttemptId(null); setAttemptId(null);
setAuthorizeUrl(null); setAuthorizeUrl(null);
form.reset(); form.reset();
manualCallbackForm.reset();
popupRef.current?.close(); popupRef.current?.close();
void queryClient.invalidateQueries({ queryKey: ['usage-summary'] }); void queryClient.invalidateQueries({ queryKey: ['usage-summary'] });
}, 0); }, 0);
@@ -331,7 +356,7 @@ function ConnectAccountDialog() {
handledAttemptStatusRef.current = statusKey; handledAttemptStatusRef.current = statusKey;
toast.error(attempt.lastError || 'OpenAI login failed.'); toast.error(attempt.lastError || 'OpenAI login failed.');
} }
}, [attemptQuery.data, form, queryClient]); }, [attemptQuery.data, form, manualCallbackForm, queryClient]);
useEffect(() => { useEffect(() => {
function onMessage(event: MessageEvent) { function onMessage(event: MessageEvent) {
@@ -424,6 +449,45 @@ function ConnectAccountDialog() {
</div> </div>
) : null} ) : 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 ? ( {attempt?.lastError ? (
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/8 p-4 text-sm text-rose-200"> <div className="rounded-2xl border border-rose-500/20 bg-rose-500/8 p-4 text-sm text-rose-200">
{attempt.lastError} {attempt.lastError}
@@ -477,7 +541,7 @@ function ConnectAccountDialog() {
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/4 p-4 text-sm leading-6 text-slate-400"> <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 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> </div>
<Button className="w-full" disabled={startMutation.isPending} type="submit"> <Button className="w-full" disabled={startMutation.isPending} type="submit">
{startMutation.isPending ? 'Preparing login…' : 'Continue to OpenAI'} {startMutation.isPending ? 'Preparing login…' : 'Continue to OpenAI'}

View File

@@ -1,6 +1,7 @@
import type { import type {
AuthResponse, AuthResponse,
CodexLoginAttemptResponse, CodexLoginAttemptResponse,
CompleteCodexManualLoginInput,
ConnectedAccount, ConnectedAccount,
LoginInput, LoginInput,
RegisterInput, RegisterInput,
@@ -59,6 +60,17 @@ export const api = {
}), }),
getCodexLoginAttempt: (attemptId: string) => getCodexLoginAttempt: (attemptId: string) =>
request<CodexLoginAttemptResponse>(`/codex/accounts/login/${attemptId}`), 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) => cancelCodexLoginAttempt: (attemptId: string) =>
request<{ ok: boolean }>(`/codex/accounts/login/${attemptId}/cancel`, { request<{ ok: boolean }>(`/codex/accounts/login/${attemptId}/cancel`, {
method: 'POST', method: 'POST',

View File

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