diff --git a/README.md b/README.md index 554b7fa..3792b9b 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,12 @@ CodexDash now reuses the public-client OAuth/PKCE shape found in [`darvell/codex 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. +6. If the callback bridge is unavailable, the user can copy the final `localhost:1455` URL from the browser address bar and paste it back into CodexDash to finish the same login attempt manually. +7. CodexDash refreshes usage using the saved OAuth session and shows both the aggregate view and per-account details. ### Important local-dev note -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. +This flow works best when the local callback bridge is reachable on `localhost:1455`, but CodexDash now also supports a manual fallback where the user pastes the final callback URL if that port is unavailable. In local development, make sure that port is free if you want the automatic popup completion path. ## Local development diff --git a/apps/api/src/codex/codex-oauth.spec.ts b/apps/api/src/codex/codex-oauth.spec.ts index 2b01edc..450d2a7 100644 --- a/apps/api/src/codex/codex-oauth.spec.ts +++ b/apps/api/src/codex/codex-oauth.spec.ts @@ -2,6 +2,7 @@ import { buildCodexAuthorizeUrl, createPkcePair, extractCodexIdentity, + parseCodexCallbackParams, renderCodexOauthCallbackHtml, } from './codex-oauth'; @@ -67,6 +68,19 @@ describe('codex-oauth helpers', () => { }); }); + it('parses a pasted localhost callback URL for manual completion', () => { + expect( + parseCodexCallbackParams( + 'http://localhost:1455/auth/callback?code=abc123&state=state-123', + ), + ).toEqual({ + code: 'abc123', + oauthError: null, + oauthErrorDescription: null, + state: 'state-123', + }); + }); + it('renders callback html that reports completion back to the frontend window', () => { const html = renderCodexOauthCallbackHtml({ attemptId: 'attempt_123', diff --git a/apps/api/src/codex/codex-oauth.ts b/apps/api/src/codex/codex-oauth.ts index d720e4a..2f36806 100644 --- a/apps/api/src/codex/codex-oauth.ts +++ b/apps/api/src/codex/codex-oauth.ts @@ -77,6 +77,20 @@ export function extractCodexIdentity(idToken: string): CodexIdentity { }; } +export function parseCodexCallbackParams( + rawUrl: string, + redirectUri = CODEX_OAUTH_DEFAULT_REDIRECT_URI, +) { + const callbackUrl = new URL(rawUrl, redirectUri); + + return { + code: callbackUrl.searchParams.get('code'), + state: callbackUrl.searchParams.get('state'), + oauthError: callbackUrl.searchParams.get('error'), + oauthErrorDescription: callbackUrl.searchParams.get('error_description'), + }; +} + export function renderCodexOauthCallbackHtml(input: { attemptId: string; status: 'success' | 'error'; diff --git a/apps/api/src/codex/codex.controller.ts b/apps/api/src/codex/codex.controller.ts index 7a86623..ff1339c 100644 --- a/apps/api/src/codex/codex.controller.ts +++ b/apps/api/src/codex/codex.controller.ts @@ -16,6 +16,7 @@ import { } from '../common/current-user.decorator'; import { JwtAuthGuard } from '../common/jwt-auth.guard'; import { CodexService } from './codex.service'; +import { CompleteCodexManualLoginDto } from './dto/complete-codex-manual-login.dto'; import { StartCodexLoginDto } from './dto/start-codex-login.dto'; @Controller('codex') @@ -46,6 +47,20 @@ export class CodexController { return this.codexService.getLoginAttempt(user.sub, attemptId); } + @UseGuards(JwtAuthGuard) + @Post('accounts/login/:attemptId/manual-complete') + completeManualLoginAttempt( + @CurrentUser() user: AuthenticatedUser, + @Param('attemptId') attemptId: string, + @Body() dto: CompleteCodexManualLoginDto, + ) { + return this.codexService.completeManualLoginAttempt( + user.sub, + attemptId, + dto.callbackUrl, + ); + } + @UseGuards(JwtAuthGuard) @Post('accounts/login/:attemptId/cancel') cancelLoginAttempt( diff --git a/apps/api/src/codex/codex.service.ts b/apps/api/src/codex/codex.service.ts index 67fcff4..7056a8a 100644 --- a/apps/api/src/codex/codex.service.ts +++ b/apps/api/src/codex/codex.service.ts @@ -22,6 +22,7 @@ import { CODEX_OAUTH_TOKEN_URL, createPkcePair, extractCodexIdentity, + parseCodexCallbackParams, renderCodexOauthCallbackHtml, } from './codex-oauth'; import { aggregateUsagePayloads } from './usage-aggregator'; @@ -107,6 +108,34 @@ export class CodexService { return this.toLoginAttemptView(attempt); } + async completeManualLoginAttempt( + userId: string, + attemptId: string, + rawUrl: string, + ) { + const attempt = await this.prisma.openAiLoginAttempt.findFirst({ + where: { id: attemptId, userId }, + }); + if (!attempt) { + throw new NotFoundException('Login attempt not found'); + } + + const params = parseCodexCallbackParams(rawUrl, this.getOauthRedirectUri()); + if (!params.state) { + throw new BadRequestException( + 'Missing OAuth state in the pasted callback URL.', + ); + } + if (params.state !== attempt.state) { + throw new BadRequestException( + 'This callback URL belongs to a different login attempt.', + ); + } + + await this.completeOauthAttempt(attempt, params); + return this.getLoginAttempt(userId, attemptId); + } + async cancelLoginAttempt(userId: string, attemptId: string) { const attempt = await this.prisma.openAiLoginAttempt.findFirst({ where: { id: attemptId, userId }, @@ -205,14 +234,9 @@ export class CodexService { } async handleOauthCallbackRequest(rawUrl: string) { - const callbackUrl = new URL(rawUrl, this.getOauthRedirectUri()); - const code = callbackUrl.searchParams.get('code'); - const state = callbackUrl.searchParams.get('state'); - const oauthError = callbackUrl.searchParams.get('error'); - const oauthErrorDescription = - callbackUrl.searchParams.get('error_description'); + const params = parseCodexCallbackParams(rawUrl, this.getOauthRedirectUri()); - if (!state) { + if (!params.state) { return this.renderCallbackPage({ attemptId: 'unknown', status: 'error', @@ -221,7 +245,7 @@ export class CodexService { } const attempt = await this.prisma.openAiLoginAttempt.findUnique({ - where: { state }, + where: { state: params.state }, }); if (!attempt) { return this.renderCallbackPage({ @@ -231,15 +255,23 @@ export class CodexService { }); } + const result = await this.completeOauthAttempt(attempt, params); + return this.renderCallbackPage(result); + } + + private async completeOauthAttempt( + attempt: PendingLoginAttemptRecord, + params: ReturnType, + ) { if (attempt.status !== 'pending') { - return this.renderCallbackPage({ + return { attemptId: attempt.id, status: attempt.status === 'completed' ? 'success' : 'error', message: attempt.status === 'completed' ? 'This OpenAI account is already connected.' : attempt.lastError || 'This login attempt is no longer active.', - }); + } as const; } if (attempt.expiresAt.getTime() < Date.now()) { @@ -247,29 +279,29 @@ export class CodexService { where: { id: attempt.id }, data: { status: 'expired', lastError: 'Login window expired.' }, }); - return this.renderCallbackPage({ + return { attemptId: attempt.id, - status: 'error', + status: 'error' as const, message: 'This login window has expired. Please start again.', - }); + }; } - if (oauthError) { - const message = oauthErrorDescription - ? `${oauthError}: ${oauthErrorDescription}` - : oauthError; + if (params.oauthError) { + const message = params.oauthErrorDescription + ? `${params.oauthError}: ${params.oauthErrorDescription}` + : params.oauthError; await this.prisma.openAiLoginAttempt.update({ where: { id: attempt.id }, data: { status: 'error', lastError: message }, }); - return this.renderCallbackPage({ + return { attemptId: attempt.id, - status: 'error', + status: 'error' as const, message, - }); + }; } - if (!code) { + if (!params.code) { await this.prisma.openAiLoginAttempt.update({ where: { id: attempt.id }, data: { @@ -277,11 +309,11 @@ export class CodexService { lastError: 'Missing authorization code from OpenAI.', }, }); - return this.renderCallbackPage({ + return { attemptId: attempt.id, - status: 'error', + status: 'error' as const, message: 'Missing authorization code from OpenAI.', - }); + }; } try { @@ -290,7 +322,7 @@ export class CodexService { this.getEncryptionSecret(), ); const tokenResponse = await this.exchangeAuthorizationCode( - code, + params.code, verifier, ); const identity = extractCodexIdentity(tokenResponse.id_token ?? ''); @@ -363,11 +395,11 @@ export class CodexService { }, }); - return this.renderCallbackPage({ + return { attemptId: attempt.id, - status: 'success', + status: 'success' as const, message: `Connected ${sessionIdentity.email ?? attempt.label} to CodexDash.`, - }); + }; } catch (error) { const message = error instanceof Error ? error.message : 'OpenAI login failed.'; @@ -375,11 +407,11 @@ export class CodexService { where: { id: attempt.id }, data: { status: 'error', lastError: message }, }); - return this.renderCallbackPage({ + return { attemptId: attempt.id, - status: 'error', + status: 'error' as const, message, - }); + }; } } @@ -655,3 +687,15 @@ type LoginAttemptRecord = { lastError: string | null; account: AccountRecord | null; }; + +type PendingLoginAttemptRecord = { + id: string; + userId: string; + label: string; + emailHint: string | null; + status: string; + state: string; + encryptedCodeVerifier: string; + expiresAt: Date; + lastError: string | null; +}; diff --git a/apps/api/src/codex/dto/complete-codex-manual-login.dto.ts b/apps/api/src/codex/dto/complete-codex-manual-login.dto.ts new file mode 100644 index 0000000..281b4b8 --- /dev/null +++ b/apps/api/src/codex/dto/complete-codex-manual-login.dto.ts @@ -0,0 +1,8 @@ +import { IsString, MinLength } from 'class-validator'; +import { CompleteCodexManualLoginInput } from '@codexdash/shared-types'; + +export class CompleteCodexManualLoginDto implements CompleteCodexManualLoginInput { + @IsString() + @MinLength(10) + callbackUrl!: string; +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b0f2fe4..0a99724 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,6 +9,7 @@ import { } from '@tanstack/react-query'; import type { AuthResponse, + CompleteCodexManualLoginInput, LoginInput, RegisterInput, StartCodexLoginInput, @@ -70,6 +71,10 @@ const connectSchema = z.object({ emailHint: z.string().optional(), }); +const manualCallbackSchema = z.object({ + callbackUrl: z.string().min(10), +}); + function AuthShell({ onAuthenticated, }: { @@ -230,7 +235,7 @@ function AuthShell({

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. + After you click connect, CodexDash opens OpenAI login in a popup and can also finish from a pasted callback URL if localhost is unavailable.

) : null} +
+
Manual fallback
+

+ If localhost:1455 is not listening, OpenAI may finish on a browser error page. + Copy the full URL from the address bar and paste it below to complete the login manually. +

+
{ + if (!attemptId) { + return; + } + manualCompleteMutation.mutate({ + callbackUrl: values.callbackUrl, + currentAttemptId: attemptId, + }); + })} + > + +

+ {String( + manualCallbackForm.formState.errors.callbackUrl?.message ?? '', + )} +

+ +
+
+ {attempt?.lastError ? (
{attempt.lastError} @@ -477,7 +541,7 @@ function ConnectAccountDialog() {
CodexDash reuses the Codex public-client login shape discovered in - codex-pool, but presents it as an integrated popup flow instead of asking you to paste cookies manually. + codex-pool, but presents it as an integrated popup flow with a manual pasted-URL fallback instead of asking you to paste cookies manually.