diff --git a/.env.example b/.env.example index 8e47349..3f577a0 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ -JWT_SECRET=change-me -ENCRYPTION_SECRET=change-me-32-characters-minimum +JWT_SECRET=*** +ENCRYPTION_SECRET=change-this-to-at-least-32-characters DATABASE_URL=file:./dev.db +CODEXDASH_FRONTEND_ORIGIN=http://localhost:5173 +CODEX_OAUTH_REDIRECT_URI=http://localhost:1455/auth/callback VITE_API_BASE_URL=http://localhost:3001 diff --git a/README.md b/README.md index 1b685b6..554b7fa 100644 --- a/README.md +++ b/README.md @@ -12,29 +12,32 @@ CodexDash is a mobile-first dashboard for monitoring multiple OpenAI Codex accou ## What it does - Create a CodexDash account and sign in -- Connect multiple OpenAI Codex sessions under one CodexDash account -- Refresh `https://chatgpt.com/backend-api/api/codex/usage` for each connected OpenAI account -- Merge numeric usage fields into one aggregate dashboard +- Connect multiple OpenAI Codex accounts under one CodexDash account +- Start an integrated OpenAI login popup instead of pasting cookies manually +- Refresh Codex usage data and merge numeric usage fields into one aggregate dashboard - Inspect each connected account individually with raw API payload details -## Important note about "OpenAI Codex login" +## OpenAI/Codex login flow -OpenAI does not expose a simple third-party OAuth flow for this usage endpoint. +CodexDash now reuses the public-client OAuth/PKCE shape found in [`darvell/codex-pool`](https://github.com/darvell/codex-pool), but wraps it in an app-native flow: -This MVP implements OpenAI account connection as a **session-based login flow**: +1. The user clicks **Connect OpenAI account**. +2. CodexDash API creates a short-lived PKCE login attempt. +3. The web app opens the OpenAI authorization page in a popup. +4. After successful login, OpenAI redirects back to the local callback bridge at `http://localhost:1455/auth/callback`. +5. The callback bridge exchanges the authorization code for tokens, encrypts the session JSON in SQLite, and posts the result back to the main app window. +6. CodexDash refreshes usage using the saved OAuth session and shows both the aggregate view and per-account details. -1. Sign in to `chatgpt.com` in your browser -2. Copy the authenticated `Cookie` header -3. Paste it into the **Connect OpenAI account** dialog in CodexDash +### Important local-dev note -The backend encrypts the cookie header before storing it in SQLite. +This flow depends on the local callback bridge being reachable on `localhost:1455`. In local development, make sure that port is free before starting the API. ## Local development ```bash pnpm install pnpm --filter @codexdash/api exec prisma generate -cd apps/api && DATABASE_URL=file:./dev.db pnpm exec prisma db push +cd apps/api && DATABASE_URL=file:./dev.db pnpm exec prisma db push --accept-data-loss cd ../.. pnpm --filter @codexdash/api start:dev pnpm --filter @codexdash/web dev --host 0.0.0.0 @@ -42,17 +45,14 @@ pnpm --filter @codexdash/web dev --host 0.0.0.0 ## Environment variables -### `apps/api/.env` +### Root `.env` ```env -JWT_SECRET=dev-jwt-secret-for-codexdash -ENCRYPTION_SECRET=dev-encryption-secret-for-codexdash-32chars +JWT_SECRET=*** +ENCRYPTION_SECRET=*** DATABASE_URL=file:./dev.db -``` - -### `apps/web/.env` - -```env +CODEXDASH_FRONTEND_ORIGIN=http://localhost:5173 +CODEX_OAUTH_REDIRECT_URI=http://localhost:1455/auth/callback VITE_API_BASE_URL=http://localhost:3001 ``` @@ -71,6 +71,10 @@ curl http://localhost:3001/health - `POST /auth/login` - `GET /auth/me` - `GET /codex/accounts` -- `POST /codex/accounts` +- `POST /codex/accounts/login/start` +- `GET /codex/accounts/login/attempts/:attemptId` +- `DELETE /codex/accounts/login/attempts/:attemptId` +- `GET /codex/accounts/login/callback` +- `GET /codex/accounts` - `DELETE /codex/accounts/:accountId` - `GET /codex/usage-summary` diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 4b2587e..d2a1492 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) -} \ No newline at end of file +} + +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]) +} diff --git a/apps/api/src/codex/codex-oauth.spec.ts b/apps/api/src/codex/codex-oauth.spec.ts new file mode 100644 index 0000000..2b01edc --- /dev/null +++ b/apps/api/src/codex/codex-oauth.spec.ts @@ -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'); + }); +}); diff --git a/apps/api/src/codex/codex-oauth.ts b/apps/api/src/codex/codex-oauth.ts new file mode 100644 index 0000000..d720e4a --- /dev/null +++ b/apps/api/src/codex/codex-oauth.ts @@ -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 ` + + + + + CodexDash login + + + +
+

${escapeHtml(input.status === 'success' ? 'Connected to CodexDash' : 'CodexDash login failed')}

+

${escapeHtml(input.message)}

+
+ + +`; +} + +function parseJwtPayload(token: string): Record | null { + const [, payload] = token.split('.'); + if (!payload) { + return null; + } + + try { + return JSON.parse( + Buffer.from(payload, 'base64url').toString('utf8'), + ) as Record; + } catch { + return null; + } +} + +function readRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : 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("'", '''); +} diff --git a/apps/api/src/codex/codex.controller.ts b/apps/api/src/codex/codex.controller.ts index b0e7025..7a86623 100644 --- a/apps/api/src/codex/codex.controller.ts +++ b/apps/api/src/codex/codex.controller.ts @@ -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, + @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, diff --git a/apps/api/src/codex/codex.service.ts b/apps/api/src/codex/codex.service.ts index ed2fca2..67fcff4 100644 --- a/apps/api/src/codex/codex.service.ts +++ b/apps/api/src/codex/codex.service.ts @@ -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; +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 { - const usage = await this.fetchUsage(dto.cookieHeader); - const account = await this.prisma.openAiAccount.create({ + dto: StartCodexLoginDto, + ): Promise { + 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 { + 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 { - 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 { + const headers: Record = { + 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('CODEXDASH_FRONTEND_ORIGIN') ?? + 'http://localhost:5173' + ); + } + + private getOauthRedirectUri() { + return ( + this.configService.get('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 | 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; +}; diff --git a/apps/api/src/codex/dto/connect-account.dto.ts b/apps/api/src/codex/dto/connect-account.dto.ts deleted file mode 100644 index 5bee186..0000000 --- a/apps/api/src/codex/dto/connect-account.dto.ts +++ /dev/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; -} diff --git a/apps/api/src/codex/dto/start-codex-login.dto.ts b/apps/api/src/codex/dto/start-codex-login.dto.ts new file mode 100644 index 0000000..a7ed665 --- /dev/null +++ b/apps/api/src/codex/dto/start-codex-login.dto.ts @@ -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; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 63f1cae..5ada6e1 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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(); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 2fed597..b0f2fe4 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,20 +1,54 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import type { AuthResponse, LoginInput, RegisterInput } from '@codexdash/shared-types'; -import { Activity, CirclePlus, Gauge, LogOut, RefreshCw, ShieldCheck, Trash2 } from 'lucide-react'; +import { + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import type { + AuthResponse, + LoginInput, + RegisterInput, + StartCodexLoginInput, +} from '@codexdash/shared-types'; +import { + Activity, + CirclePlus, + ExternalLink, + Gauge, + Link as LinkIcon, + LoaderCircle, + LogOut, + RefreshCw, + ShieldCheck, + Trash2, + Waypoints, +} from 'lucide-react'; import { toast, Toaster } from 'sonner'; import { api } from '@/lib/api'; import { clearToken, getToken, setToken } from '@/lib/storage'; import { flattenNumericMetrics, formatDate, titleizeMetric } from '@/lib/utils'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; import { Progress } from '@/components/ui/progress'; import { Separator } from '@/components/ui/separator'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; @@ -34,27 +68,45 @@ const loginSchema = z.object({ const connectSchema = z.object({ label: z.string().min(2), emailHint: z.string().optional(), - cookieHeader: z.string().min(20), }); -function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthResponse) => void }) { +function AuthShell({ + onAuthenticated, +}: { + onAuthenticated: (response: AuthResponse) => void; +}) { const [mode, setMode] = useState<'login' | 'register'>('register'); const schema = mode === 'register' ? registerSchema : loginSchema; const form = useForm<{ name?: string; email: string; password: string }>({ resolver: zodResolver(schema), - defaultValues: { email: '', password: '', ...(mode === 'register' ? { name: '' } : {}) }, + defaultValues: { + email: '', + password: '', + ...(mode === 'register' ? { name: '' } : {}), + }, }); const mutation = useMutation({ - mutationFn: async (values: { name?: string; email: string; password: string }) => { + mutationFn: async (values: { + name?: string; + email: string; + password: string; + }) => { return mode === 'register' ? api.register(values as RegisterInput) - : api.login({ email: values.email, password: values.password } as LoginInput); + : api.login({ + email: values.email, + password: values.password, + } as LoginInput); }, onSuccess: (response) => { setToken(response.token); onAuthenticated(response); - toast.success(mode === 'register' ? 'Welcome to CodexDash.' : 'Signed in successfully.'); + toast.success( + mode === 'register' + ? 'Welcome to CodexDash.' + : 'Signed in successfully.', + ); }, onError: (error: Error) => toast.error(error.message), }); @@ -65,27 +117,46 @@ function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthRespon
- Mobile-first Codex monitor + + Mobile-first Codex monitor +

CodexDash keeps every Codex account in one gorgeous live dashboard.

- Sign into CodexDash, attach multiple OpenAI Codex sessions, and view combined limits, + Sign into CodexDash, connect multiple OpenAI Codex accounts through a real login flow, and view combined limits, remaining usage, raw API payloads, and per-account drilldowns from a single responsive UI.

{[ - { icon: Gauge, title: 'Unified usage', desc: 'Merge multiple OpenAI accounts into one overview.' }, - { icon: ShieldCheck, title: 'Stored safely', desc: 'Session cookie headers are encrypted at rest.' }, - { icon: Activity, title: 'Live detail', desc: 'See refreshed usage plus raw usage payloads.' }, + { + icon: Gauge, + title: 'Unified usage', + desc: 'Merge multiple OpenAI accounts into one overview.', + }, + { + icon: ShieldCheck, + title: 'Stored safely', + desc: 'OAuth session data is encrypted at rest.', + }, + { + icon: Activity, + title: 'Live detail', + desc: 'See refreshed usage plus raw usage payloads.', + }, ].map((item) => ( -
+
{item.title}
-
{item.desc}
+
+ {item.desc} +
))}
@@ -94,34 +165,63 @@ function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthRespon - {mode === 'register' ? 'Create your account' : 'Welcome back'} + + {mode === 'register' ? 'Create your account' : 'Welcome back'} + {mode === 'register' - ? 'Start with your CodexDash account, then connect OpenAI Codex sessions inside the dashboard.' + ? 'Start with your CodexDash account, then connect OpenAI Codex logins inside the dashboard.' : 'Log in to continue monitoring your combined Codex usage.'} -
mutation.mutate(values))}> + mutation.mutate(values))} + > {mode === 'register' ? (
- -

{String(form.formState.errors.name?.message ?? '')}

+ +

+ {String(form.formState.errors.name?.message ?? '')} +

) : null}
- -

{String(form.formState.errors.email?.message ?? '')}

+ +

+ {String(form.formState.errors.email?.message ?? '')} +

- -

{String(form.formState.errors.password?.message ?? '')}

+ +

+ {String(form.formState.errors.password?.message ?? '')} +

@@ -129,12 +229,20 @@ function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthRespon

- OpenAI account connection is implemented as a session-based Codex login: after signing into - chatgpt.com in your browser, paste the authenticated Cookie{' '} - header into the connect flow. + 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.

-
@@ -147,24 +255,111 @@ function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthRespon function ConnectAccountDialog() { const queryClient = useQueryClient(); const [open, setOpen] = useState(false); + const [attemptId, setAttemptId] = useState(null); + const [authorizeUrl, setAuthorizeUrl] = useState(null); + const popupRef = useRef(null); + const handledAttemptStatusRef = useRef(null); const form = useForm>({ resolver: zodResolver(connectSchema), - defaultValues: { label: '', emailHint: '', cookieHeader: '' }, + defaultValues: { label: '', emailHint: '' }, }); - const mutation = useMutation({ - mutationFn: api.connectAccount, - onSuccess: () => { - toast.success('OpenAI Codex session connected.'); - setOpen(false); - form.reset(); - void queryClient.invalidateQueries({ queryKey: ['usage-summary'] }); + const startMutation = useMutation({ + mutationFn: api.startCodexLogin, + onSuccess: (response) => { + setAttemptId(response.attemptId); + setAuthorizeUrl(response.authorizeUrl); + popupRef.current = window.open( + response.authorizeUrl, + 'codexdash-openai-login', + 'popup=yes,width=520,height=760', + ); + if (!popupRef.current) { + toast.error('Popup was blocked. Use the fallback link inside the dialog.'); + } else { + toast.success('Continue the OpenAI sign-in flow in the popup.'); + } }, onError: (error: Error) => toast.error(error.message), }); + const attemptQuery = useQuery({ + enabled: Boolean(attemptId), + queryKey: ['codex-login-attempt', attemptId], + queryFn: () => api.getCodexLoginAttempt(attemptId as string), + refetchInterval: (query) => + query.state.data?.status === 'pending' ? 2_000 : false, + }); + + const cancelMutation = useMutation({ + mutationFn: api.cancelCodexLoginAttempt, + onSuccess: () => { + toast.success('Login attempt cancelled.'); + setAttemptId(null); + setAuthorizeUrl(null); + popupRef.current?.close(); + }, + onError: (error: Error) => toast.error(error.message), + }); + + useEffect(() => { + const attempt = attemptQuery.data; + if (!attempt) { + return; + } + + const statusKey = `${attempt.id}:${attempt.status}:${attempt.completedAt ?? ''}:${attempt.lastError ?? ''}`; + if (handledAttemptStatusRef.current === statusKey) { + return; + } + + if (attempt.status === 'completed') { + handledAttemptStatusRef.current = statusKey; + window.setTimeout(() => { + toast.success('OpenAI Codex account connected.'); + setOpen(false); + setAttemptId(null); + setAuthorizeUrl(null); + form.reset(); + popupRef.current?.close(); + void queryClient.invalidateQueries({ queryKey: ['usage-summary'] }); + }, 0); + return; + } + + if (attempt.status === 'error' || attempt.status === 'expired') { + handledAttemptStatusRef.current = statusKey; + toast.error(attempt.lastError || 'OpenAI login failed.'); + } + }, [attemptQuery.data, form, queryClient]); + + useEffect(() => { + function onMessage(event: MessageEvent) { + if (event.data?.type !== 'codexdash:oauth-complete') { + return; + } + if (event.data?.attemptId && event.data.attemptId === attemptId) { + void attemptQuery.refetch(); + } + } + + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, [attemptId, attemptQuery]); + + const attempt = attemptQuery.data; + const isPendingAttempt = attempt?.status === 'pending'; + return ( - + { + setOpen(next); + if (!next && isPendingAttempt && attemptId) { + cancelMutation.mutate(attemptId); + } + }} + > + +
+ ) : null} + + {attempt?.lastError ? ( +
+ {attempt.lastError} +
+ ) : null} + +
+ + +
-
- - -
-
- -