feat: add manual OAuth callback fallback
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user