feat: add codex oauth login flow
This commit is contained in:
@@ -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])
|
||||
}
|
||||
|
||||
83
apps/api/src/codex/codex-oauth.spec.ts
Normal file
83
apps/api/src/codex/codex-oauth.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
178
apps/api/src/codex/codex-oauth.ts
Normal file
178
apps/api/src/codex/codex-oauth.ts
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
12
apps/api/src/codex/dto/start-codex-login.dto.ts
Normal file
12
apps/api/src/codex/dto/start-codex-login.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user