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;
|
||||
}
|
||||
Reference in New Issue
Block a user