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;
}