feat: add codex oauth login flow

This commit is contained in:
2026-05-01 01:59:21 +09:00
parent 0ced12cb81
commit e9496f2c4a
13 changed files with 1502 additions and 218 deletions

View File

@@ -8,28 +8,57 @@ datasource db {
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts OpenAiAccount[]
id String @id @default(cuid())
email String @unique
passwordHash String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts OpenAiAccount[]
loginAttempts OpenAiLoginAttempt[]
}
model OpenAiAccount {
id String @id @default(cuid())
userId String
label String
emailHint String?
encryptedCookie String
lastUsageJson Json?
lastSyncedAt DateTime?
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
userId String
label String
emailHint String?
providerEmail String?
providerAccountId String?
planType String?
authType String @default("codex-oauth")
encryptedSessionJson String
sessionExpiresAt DateTime?
lastValidatedAt DateTime?
lastUsageJson Json?
lastSyncedAt DateTime?
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
loginAttempts OpenAiLoginAttempt[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
}
model OpenAiLoginAttempt {
id String @id @default(cuid())
userId String
accountId String?
label String
emailHint String?
status String @default("pending")
state String @unique
encryptedCodeVerifier String
expiresAt DateTime
completedAt DateTime?
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
account OpenAiAccount? @relation(fields: [accountId], references: [id], onDelete: SetNull)
@@index([userId, status])
}

View File

@@ -0,0 +1,83 @@
import {
buildCodexAuthorizeUrl,
createPkcePair,
extractCodexIdentity,
renderCodexOauthCallbackHtml,
} from './codex-oauth';
describe('codex-oauth helpers', () => {
it('builds the OpenAI authorize URL with Codex-specific PKCE parameters', () => {
const url = new URL(
buildCodexAuthorizeUrl({
state: 'state-123',
codeChallenge: 'challenge-abc',
redirectUri: 'http://localhost:1455/auth/callback',
}),
);
expect(url.origin + url.pathname).toBe(
'https://auth.openai.com/oauth/authorize',
);
expect(url.searchParams.get('response_type')).toBe('code');
expect(url.searchParams.get('client_id')).toBe(
'app_EMoamEEZ73f0CkXaXp7hrann',
);
expect(url.searchParams.get('redirect_uri')).toBe(
'http://localhost:1455/auth/callback',
);
expect(url.searchParams.get('scope')).toBe(
'openid profile email offline_access',
);
expect(url.searchParams.get('code_challenge')).toBe('challenge-abc');
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
expect(url.searchParams.get('state')).toBe('state-123');
expect(url.searchParams.get('originator')).toBe('codex_cli_rs');
expect(url.searchParams.get('codex_cli_simplified_flow')).toBe('true');
expect(url.searchParams.get('id_token_add_organizations')).toBe('true');
});
it('creates a valid PKCE verifier/challenge pair', () => {
const pair = createPkcePair(Buffer.alloc(32, 7));
expect(pair.verifier).toBe('BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc');
expect(pair.challenge).toBe('3Ev4DHdHPRMPoN6GukAY_pi7IUAF5qWJHRK6kURvnoE');
});
it('extracts account identity fields from the id token payload', () => {
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString(
'base64url',
);
const payload = Buffer.from(
JSON.stringify({
email: 'operator@example.com',
exp: 1_800_000_000,
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_123',
chatgpt_plan_type: 'pro',
},
}),
).toString('base64url');
const token = `${header}.${payload}.signature`;
expect(extractCodexIdentity(token)).toEqual({
email: 'operator@example.com',
accountId: 'acct_123',
planType: 'pro',
expiresAt: new Date(1_800_000_000 * 1000),
});
});
it('renders callback html that reports completion back to the frontend window', () => {
const html = renderCodexOauthCallbackHtml({
attemptId: 'attempt_123',
status: 'success',
frontendOrigin: 'http://localhost:5173',
message: 'Connected successfully',
});
expect(html).toContain('codexdash:oauth-complete');
expect(html).toContain('attempt_123');
expect(html).toContain('Connected successfully');
expect(html).toContain('http://localhost:5173');
});
});

View File

@@ -0,0 +1,178 @@
import { createHash } from 'node:crypto';
export const CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
export const CODEX_OAUTH_AUTHORIZE_URL =
'https://auth.openai.com/oauth/authorize';
export const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token';
export const CODEX_OAUTH_DEFAULT_REDIRECT_URI =
'http://localhost:1455/auth/callback';
export type CodexIdentity = {
email: string | null;
accountId: string | null;
planType: string | null;
expiresAt: Date | null;
};
export function createPkcePair(bytes: Uint8Array) {
const verifier = Buffer.from(bytes).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
export function buildCodexAuthorizeUrl(input: {
state: string;
codeChallenge: string;
redirectUri?: string;
}) {
const url = new URL(CODEX_OAUTH_AUTHORIZE_URL);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', CODEX_OAUTH_CLIENT_ID);
url.searchParams.set(
'redirect_uri',
input.redirectUri ?? CODEX_OAUTH_DEFAULT_REDIRECT_URI,
);
url.searchParams.set('scope', 'openid profile email offline_access');
url.searchParams.set('code_challenge', input.codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('id_token_add_organizations', 'true');
url.searchParams.set('codex_cli_simplified_flow', 'true');
url.searchParams.set('state', input.state);
url.searchParams.set('originator', 'codex_cli_rs');
return url.toString();
}
export function extractCodexIdentity(idToken: string): CodexIdentity {
const payload = parseJwtPayload(idToken);
if (!payload) {
return {
email: null,
accountId: null,
planType: null,
expiresAt: null,
};
}
const authClaim = readRecord(payload['https://api.openai.com/auth']);
const profileClaim = readRecord(payload['https://api.openai.com/profile']);
const email =
readString(profileClaim?.email) ?? readString(payload.email) ?? null;
const accountId =
readString(authClaim?.chatgpt_account_id) ??
readString(payload.chatgpt_account_id) ??
null;
const planType =
readString(authClaim?.chatgpt_plan_type) ??
readString(payload.chatgpt_plan_type) ??
null;
const exp = readNumber(payload.exp);
return {
email,
accountId,
planType,
expiresAt: exp ? new Date(exp * 1000) : null,
};
}
export function renderCodexOauthCallbackHtml(input: {
attemptId: string;
status: 'success' | 'error';
frontendOrigin: string;
message: string;
}) {
const payload = JSON.stringify({
type: 'codexdash:oauth-complete',
attemptId: input.attemptId,
status: input.status,
message: input.message,
});
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodexDash login</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #020617;
color: #e2e8f0;
font-family: Inter, system-ui, sans-serif;
}
main {
width: min(92vw, 420px);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 24px;
padding: 24px;
background: rgba(15, 23, 42, 0.92);
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.35);
}
h1 { margin: 0 0 8px; font-size: 1.25rem; }
p { margin: 0; line-height: 1.6; color: #cbd5e1; }
</style>
</head>
<body>
<main>
<h1>${escapeHtml(input.status === 'success' ? 'Connected to CodexDash' : 'CodexDash login failed')}</h1>
<p>${escapeHtml(input.message)}</p>
</main>
<script>
const payload = ${payload};
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(payload, ${JSON.stringify(input.frontendOrigin)});
window.close();
}
} catch (error) {
console.error(error);
}
</script>
</body>
</html>`;
}
function parseJwtPayload(token: string): Record<string, unknown> | null {
const [, payload] = token.split('.');
if (!payload) {
return null;
}
try {
return JSON.parse(
Buffer.from(payload, 'base64url').toString('utf8'),
) as Record<string, unknown>;
} catch {
return null;
}
}
function readRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function readString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
function readNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function escapeHtml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}

View File

@@ -6,34 +6,71 @@ import {
Param,
Post,
Query,
Res,
UseGuards,
} from '@nestjs/common';
import type { Response } from 'express';
import {
CurrentUser,
type AuthenticatedUser,
} from '../common/current-user.decorator';
import { JwtAuthGuard } from '../common/jwt-auth.guard';
import { CodexService } from './codex.service';
import { ConnectAccountDto } from './dto/connect-account.dto';
import { StartCodexLoginDto } from './dto/start-codex-login.dto';
@UseGuards(JwtAuthGuard)
@Controller('codex')
export class CodexController {
constructor(private readonly codexService: CodexService) {}
@UseGuards(JwtAuthGuard)
@Get('accounts')
listAccounts(@CurrentUser() user: AuthenticatedUser) {
return this.codexService.listAccounts(user.sub);
}
@Post('accounts')
connectAccount(
@UseGuards(JwtAuthGuard)
@Post('accounts/login/start')
startAccountLogin(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: ConnectAccountDto,
@Body() dto: StartCodexLoginDto,
) {
return this.codexService.connectAccount(user.sub, dto);
return this.codexService.startAccountLogin(user.sub, dto);
}
@UseGuards(JwtAuthGuard)
@Get('accounts/login/:attemptId')
getLoginAttempt(
@CurrentUser() user: AuthenticatedUser,
@Param('attemptId') attemptId: string,
) {
return this.codexService.getLoginAttempt(user.sub, attemptId);
}
@UseGuards(JwtAuthGuard)
@Post('accounts/login/:attemptId/cancel')
cancelLoginAttempt(
@CurrentUser() user: AuthenticatedUser,
@Param('attemptId') attemptId: string,
) {
return this.codexService.cancelLoginAttempt(user.sub, attemptId);
}
@Get('accounts/login/callback')
async oauthCallback(
@Query() query: Record<string, string>,
@Res() res: Response,
) {
const search = new URLSearchParams(query);
const result = await this.codexService.handleOauthCallbackRequest(
`${search.size > 0 ? `?${search.toString()}` : ''}`,
);
return res
.status(result.statusCode)
.contentType('text/html')
.send(result.html);
}
@UseGuards(JwtAuthGuard)
@Delete('accounts/:accountId')
deleteAccount(
@CurrentUser() user: AuthenticatedUser,
@@ -42,6 +79,7 @@ export class CodexController {
return this.codexService.deleteAccount(user.sub, accountId);
}
@UseGuards(JwtAuthGuard)
@Get('usage-summary')
getUsageSummary(
@CurrentUser() user: AuthenticatedUser,

View File

@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto';
import {
BadRequestException,
Injectable,
@@ -5,14 +6,41 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { ConnectedAccount, UsageSummary } from '@codexdash/shared-types';
import type {
CodexLoginAttemptResponse,
ConnectedAccount,
StartCodexLoginResponse,
UsageSummary,
} from '@codexdash/shared-types';
import { decryptString, encryptString } from '../common/crypto';
import { PrismaService } from '../prisma/prisma.service';
import { ConnectAccountDto } from './dto/connect-account.dto';
import { StartCodexLoginDto } from './dto/start-codex-login.dto';
import {
buildCodexAuthorizeUrl,
CODEX_OAUTH_CLIENT_ID,
CODEX_OAUTH_DEFAULT_REDIRECT_URI,
CODEX_OAUTH_TOKEN_URL,
createPkcePair,
extractCodexIdentity,
renderCodexOauthCallbackHtml,
} from './codex-oauth';
import { aggregateUsagePayloads } from './usage-aggregator';
type UsageApiResponse = Record<string, unknown>;
type StoredCodexSession = {
accessToken: string;
refreshToken: string;
idToken: string;
accountId: string | null;
};
type CodexTokenResponse = {
access_token: string;
refresh_token?: string;
id_token?: string;
};
@Injectable()
export class CodexService {
constructor(
@@ -20,26 +48,86 @@ export class CodexService {
private readonly configService: ConfigService,
) {}
async connectAccount(
async startAccountLogin(
userId: string,
dto: ConnectAccountDto,
): Promise<ConnectedAccount> {
const usage = await this.fetchUsage(dto.cookieHeader);
const account = await this.prisma.openAiAccount.create({
dto: StartCodexLoginDto,
): Promise<StartCodexLoginResponse> {
const pkce = createPkcePair(randomBytes(32));
const state = randomBytes(32).toString('base64url');
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
const attempt = await this.prisma.openAiLoginAttempt.create({
data: {
userId,
label: dto.label.trim(),
emailHint: dto.emailHint?.trim() || null,
encryptedCookie: encryptString(
dto.cookieHeader,
state,
encryptedCodeVerifier: encryptString(
pkce.verifier,
this.getEncryptionSecret(),
),
lastUsageJson: usage as Prisma.InputJsonValue,
lastSyncedAt: new Date(),
expiresAt,
},
});
return this.toAccountView(account);
return {
attemptId: attempt.id,
authorizeUrl: buildCodexAuthorizeUrl({
state,
codeChallenge: pkce.challenge,
redirectUri: this.getOauthRedirectUri(),
}),
expiresAt: expiresAt.toISOString(),
};
}
async getLoginAttempt(
userId: string,
attemptId: string,
): Promise<CodexLoginAttemptResponse> {
const attempt = await this.prisma.openAiLoginAttempt.findFirst({
where: { id: attemptId, userId },
include: { account: true },
});
if (!attempt) {
throw new NotFoundException('Login attempt not found');
}
if (
attempt.status === 'pending' &&
attempt.expiresAt.getTime() < Date.now()
) {
const expired = await this.prisma.openAiLoginAttempt.update({
where: { id: attempt.id },
data: { status: 'expired', lastError: 'Login window expired.' },
include: { account: true },
});
return this.toLoginAttemptView(expired);
}
return this.toLoginAttemptView(attempt);
}
async cancelLoginAttempt(userId: string, attemptId: string) {
const attempt = await this.prisma.openAiLoginAttempt.findFirst({
where: { id: attemptId, userId },
});
if (!attempt) {
throw new NotFoundException('Login attempt not found');
}
if (attempt.status !== 'pending') {
return { ok: true };
}
await this.prisma.openAiLoginAttempt.update({
where: { id: attempt.id },
data: {
status: 'cancelled',
lastError: 'Login cancelled by user.',
},
});
return { ok: true };
}
async listAccounts(userId: string) {
@@ -59,6 +147,10 @@ export class CodexService {
throw new NotFoundException('Connected account not found');
}
await this.prisma.openAiLoginAttempt.updateMany({
where: { accountId },
data: { accountId: null },
});
await this.prisma.openAiAccount.delete({ where: { id: accountId } });
return { ok: true };
}
@@ -76,17 +168,8 @@ export class CodexService {
}
try {
const usage = await this.fetchUsage(
decryptString(account.encryptedCookie, this.getEncryptionSecret()),
);
return this.prisma.openAiAccount.update({
where: { id: account.id },
data: {
lastUsageJson: usage as Prisma.InputJsonValue,
lastSyncedAt: new Date(),
lastError: null,
},
});
const updated = await this.refreshAccountUsage(account);
return updated;
} catch (error) {
return this.prisma.openAiAccount.update({
where: { id: account.id },
@@ -121,25 +204,371 @@ export class CodexService {
};
}
private async fetchUsage(cookieHeader: string): Promise<UsageApiResponse> {
const response = await fetch(
'https://chatgpt.com/backend-api/api/codex/usage',
{
headers: {
accept: 'application/json',
cookie: cookieHeader,
'user-agent': 'CodexDash/0.1 (+https://example.invalid)',
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');
if (!state) {
return this.renderCallbackPage({
attemptId: 'unknown',
status: 'error',
message: 'Missing OAuth state.',
});
}
const attempt = await this.prisma.openAiLoginAttempt.findUnique({
where: { state },
});
if (!attempt) {
return this.renderCallbackPage({
attemptId: 'unknown',
status: 'error',
message: 'This CodexDash login attempt no longer exists.',
});
}
if (attempt.status !== 'pending') {
return this.renderCallbackPage({
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.',
});
}
if (attempt.expiresAt.getTime() < Date.now()) {
await this.prisma.openAiLoginAttempt.update({
where: { id: attempt.id },
data: { status: 'expired', lastError: 'Login window expired.' },
});
return this.renderCallbackPage({
attemptId: attempt.id,
status: 'error',
message: 'This login window has expired. Please start again.',
});
}
if (oauthError) {
const message = oauthErrorDescription
? `${oauthError}: ${oauthErrorDescription}`
: oauthError;
await this.prisma.openAiLoginAttempt.update({
where: { id: attempt.id },
data: { status: 'error', lastError: message },
});
return this.renderCallbackPage({
attemptId: attempt.id,
status: 'error',
message,
});
}
if (!code) {
await this.prisma.openAiLoginAttempt.update({
where: { id: attempt.id },
data: {
status: 'error',
lastError: 'Missing authorization code from OpenAI.',
},
});
return this.renderCallbackPage({
attemptId: attempt.id,
status: 'error',
message: 'Missing authorization code from OpenAI.',
});
}
try {
const verifier = decryptString(
attempt.encryptedCodeVerifier,
this.getEncryptionSecret(),
);
const tokenResponse = await this.exchangeAuthorizationCode(
code,
verifier,
);
const identity = extractCodexIdentity(tokenResponse.id_token ?? '');
let session: StoredCodexSession = {
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token ?? '',
idToken: tokenResponse.id_token ?? '',
accountId: identity.accountId,
};
const usage = await this.fetchUsageWithSession(session);
session = usage.session;
const sessionIdentity = extractCodexIdentity(session.idToken);
const existing = sessionIdentity.accountId
? await this.prisma.openAiAccount.findFirst({
where: {
userId: attempt.userId,
providerAccountId: sessionIdentity.accountId,
},
})
: null;
const account = existing
? await this.prisma.openAiAccount.update({
where: { id: existing.id },
data: {
label: attempt.label,
emailHint: attempt.emailHint,
providerEmail: sessionIdentity.email,
providerAccountId: sessionIdentity.accountId,
planType: sessionIdentity.planType,
encryptedSessionJson: encryptString(
JSON.stringify(session),
this.getEncryptionSecret(),
),
sessionExpiresAt: sessionIdentity.expiresAt,
lastValidatedAt: new Date(),
lastUsageJson: usage.payload as Prisma.InputJsonValue,
lastSyncedAt: new Date(),
lastError: null,
},
})
: await this.prisma.openAiAccount.create({
data: {
userId: attempt.userId,
label: attempt.label,
emailHint: attempt.emailHint,
providerEmail: sessionIdentity.email,
providerAccountId: sessionIdentity.accountId,
planType: sessionIdentity.planType,
authType: 'codex-oauth',
encryptedSessionJson: encryptString(
JSON.stringify(session),
this.getEncryptionSecret(),
),
sessionExpiresAt: sessionIdentity.expiresAt,
lastValidatedAt: new Date(),
lastUsageJson: usage.payload as Prisma.InputJsonValue,
lastSyncedAt: new Date(),
},
});
await this.prisma.openAiLoginAttempt.update({
where: { id: attempt.id },
data: {
status: 'completed',
completedAt: new Date(),
lastError: null,
accountId: account.id,
},
});
return this.renderCallbackPage({
attemptId: attempt.id,
status: 'success',
message: `Connected ${sessionIdentity.email ?? attempt.label} to CodexDash.`,
});
} catch (error) {
const message =
error instanceof Error ? error.message : 'OpenAI login failed.';
await this.prisma.openAiLoginAttempt.update({
where: { id: attempt.id },
data: { status: 'error', lastError: message },
});
return this.renderCallbackPage({
attemptId: attempt.id,
status: 'error',
message,
});
}
}
private async refreshAccountUsage(account: OpenAiAccountRecord) {
let session = this.readStoredSession(account.encryptedSessionJson);
if (
account.sessionExpiresAt &&
account.sessionExpiresAt.getTime() - Date.now() < 2 * 60 * 1000 &&
session.refreshToken
) {
session = await this.refreshStoredSession(session);
}
const usage = await this.fetchUsageWithSession(session);
session = usage.session;
const identity = extractCodexIdentity(session.idToken);
return this.prisma.openAiAccount.update({
where: { id: account.id },
data: {
providerEmail: identity.email,
providerAccountId: identity.accountId,
planType: identity.planType,
encryptedSessionJson: encryptString(
JSON.stringify(session),
this.getEncryptionSecret(),
),
sessionExpiresAt: identity.expiresAt,
lastValidatedAt: new Date(),
lastUsageJson: usage.payload as Prisma.InputJsonValue,
lastSyncedAt: new Date(),
lastError: null,
},
});
}
private async fetchUsageWithSession(
session: StoredCodexSession,
allowRefresh = true,
): Promise<{ payload: UsageApiResponse; session: StoredCodexSession }> {
const urls = [
'https://chatgpt.com/backend-api/api/codex/usage',
'https://chatgpt.com/backend-api/wham/usage',
];
const errors: string[] = [];
for (const url of urls) {
const response = await fetch(url, {
headers: this.buildUsageHeaders(session),
});
if (response.ok) {
return {
payload: (await response.json()) as UsageApiResponse,
session,
};
}
if (
(response.status === 401 || response.status === 403) &&
allowRefresh &&
session.refreshToken
) {
const refreshed = await this.refreshStoredSession(session);
return this.fetchUsageWithSession(refreshed, false);
}
errors.push(`${url} -> ${response.status}`);
}
throw new BadRequestException(
`Codex usage request failed (${errors.join(', ')})`,
);
}
private async exchangeAuthorizationCode(code: string, verifier: string) {
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: CODEX_OAUTH_CLIENT_ID,
code,
redirect_uri: this.getOauthRedirectUri(),
code_verifier: verifier,
});
const response = await fetch(CODEX_OAUTH_TOKEN_URL, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
const error = await response.text();
throw new BadRequestException(
`Codex usage request failed with status ${response.status}`,
`OpenAI token exchange failed (${response.status}): ${error || 'No details returned.'}`,
);
}
return (await response.json()) as UsageApiResponse;
const payload = (await response.json()) as CodexTokenResponse;
if (!payload.access_token) {
throw new BadRequestException(
'OpenAI token exchange returned an empty access token.',
);
}
return payload;
}
private async refreshStoredSession(session: StoredCodexSession) {
const response = await fetch(CODEX_OAUTH_TOKEN_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({
client_id: CODEX_OAUTH_CLIENT_ID,
grant_type: 'refresh_token',
refresh_token: session.refreshToken,
scope: 'openid profile email',
}),
});
if (!response.ok) {
const error = await response.text();
throw new BadRequestException(
`OpenAI token refresh failed (${response.status}): ${error || 'No details returned.'}`,
);
}
const payload = (await response.json()) as CodexTokenResponse;
if (!payload.access_token) {
throw new BadRequestException(
'OpenAI token refresh returned an empty access token.',
);
}
const nextSession: StoredCodexSession = {
accessToken: payload.access_token,
refreshToken: payload.refresh_token ?? session.refreshToken,
idToken: payload.id_token ?? session.idToken,
accountId:
extractCodexIdentity(payload.id_token ?? session.idToken).accountId ??
session.accountId,
};
return nextSession;
}
private buildUsageHeaders(
session: StoredCodexSession,
): Record<string, string> {
const headers: Record<string, string> = {
accept: 'application/json',
authorization: `Bearer ${session.accessToken}`,
'user-agent': 'CodexDash/0.2 (+https://github.com/darvell/codex-pool)',
originator: 'codex_cli_rs',
};
if (session.accountId) {
headers['ChatGPT-Account-ID'] = session.accountId;
}
return headers;
}
private readStoredSession(encryptedSessionJson: string): StoredCodexSession {
return JSON.parse(
decryptString(encryptedSessionJson, this.getEncryptionSecret()),
) as StoredCodexSession;
}
private renderCallbackPage(input: {
attemptId: string;
status: 'success' | 'error';
message: string;
}) {
return {
statusCode: input.status === 'success' ? 200 : 400,
html: renderCodexOauthCallbackHtml({
attemptId: input.attemptId,
status: input.status,
message: input.message,
frontendOrigin: this.getFrontendOrigin(),
}),
};
}
private getEncryptionSecret() {
@@ -149,20 +578,48 @@ export class CodexService {
);
}
private toAccountView(account: {
id: string;
label: string;
emailHint: string | null;
lastUsageJson: unknown;
lastSyncedAt: Date | null;
lastError: string | null;
createdAt: Date;
}): ConnectedAccount {
private getFrontendOrigin() {
return (
this.configService.get<string>('CODEXDASH_FRONTEND_ORIGIN') ??
'http://localhost:5173'
);
}
private getOauthRedirectUri() {
return (
this.configService.get<string>('CODEX_OAUTH_REDIRECT_URI') ??
CODEX_OAUTH_DEFAULT_REDIRECT_URI
);
}
private toLoginAttemptView(
attempt: LoginAttemptRecord,
): CodexLoginAttemptResponse {
return {
id: attempt.id,
label: attempt.label,
emailHint: attempt.emailHint,
status: attempt.status as CodexLoginAttemptResponse['status'],
expiresAt: attempt.expiresAt.toISOString(),
completedAt: attempt.completedAt?.toISOString() ?? null,
lastError: attempt.lastError,
connectedAccount: attempt.account
? this.toAccountView(attempt.account)
: null,
};
}
private toAccountView(account: AccountRecord): ConnectedAccount {
return {
id: account.id,
label: account.label,
emailHint: account.emailHint,
providerEmail: account.providerEmail,
providerAccountId: account.providerAccountId,
planType: account.planType,
authType: 'codex-oauth',
status: account.lastError ? 'error' : 'active',
sessionExpiresAt: account.sessionExpiresAt?.toISOString() ?? null,
lastSyncedAt: account.lastSyncedAt?.toISOString() ?? null,
lastError: account.lastError,
usage: (account.lastUsageJson as Record<string, unknown> | null) ?? null,
@@ -170,3 +627,31 @@ export class CodexService {
};
}
}
type AccountRecord = {
id: string;
label: string;
emailHint: string | null;
providerEmail: string | null;
providerAccountId: string | null;
planType: string | null;
encryptedSessionJson: string;
sessionExpiresAt: Date | null;
lastUsageJson: unknown;
lastSyncedAt: Date | null;
lastError: string | null;
createdAt: Date;
};
type OpenAiAccountRecord = AccountRecord;
type LoginAttemptRecord = {
id: string;
label: string;
emailHint: string | null;
status: string;
expiresAt: Date;
completedAt: Date | null;
lastError: string | null;
account: AccountRecord | null;
};

View File

@@ -1,16 +0,0 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
import { ConnectAccountInput } from '@codexdash/shared-types';
export class ConnectAccountDto implements ConnectAccountInput {
@IsString()
@MinLength(2)
label!: string;
@IsOptional()
@IsString()
emailHint?: string;
@IsString()
@MinLength(20)
cookieHeader!: string;
}

View File

@@ -0,0 +1,12 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
import { StartCodexLoginInput } from '@codexdash/shared-types';
export class StartCodexLoginDto implements StartCodexLoginInput {
@IsString()
@MinLength(2)
label!: string;
@IsOptional()
@IsString()
emailHint?: string;
}

View File

@@ -1,6 +1,8 @@
import { NestFactory } from '@nestjs/core';
import { createServer } from 'node:http';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { CodexService } from './codex/codex.service';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@@ -14,5 +16,39 @@ async function bootstrap() {
);
await app.listen(process.env.PORT ?? 3001);
const codexService = app.get(CodexService);
const callbackUrl = new URL(
process.env.CODEX_OAUTH_REDIRECT_URI ??
'http://localhost:1455/auth/callback',
);
const callbackServer = createServer((req, res) => {
void (async () => {
if (!req.url) {
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Bad request');
return;
}
const requestedUrl = new URL(req.url, callbackUrl);
if (
req.method !== 'GET' ||
requestedUrl.pathname !== callbackUrl.pathname
) {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Not found');
return;
}
const result = await codexService.handleOauthCallbackRequest(req.url);
res.writeHead(result.statusCode, {
'content-type': 'text/html; charset=utf-8',
});
res.end(result.html);
})();
});
callbackServer.listen(Number(callbackUrl.port || 80), callbackUrl.hostname);
}
void bootstrap();