From 2942ba874e20f819697bc9fe9e01e18acbb9f5b6 Mon Sep 17 00:00:00 2001 From: Felipe Almeman <4226997+zhiru@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:06:35 -0300 Subject: [PATCH] Feat/codex device flow (#3195) Codex public device-flow connect link (ticket-gated) + dashboard CTA. Integrated into release/v3.8.11. --- .gitignore | 3 + .../dashboard/providers/[id]/page.tsx | 126 +++++++ src/app/api/codex/connect/[token]/route.ts | 111 +++++++ .../api/oauth/[provider]/[action]/route.ts | 118 +++++++ .../codex/[token]/CodexConnectClient.tsx | 210 ++++++++++++ src/app/connect/codex/[token]/page.tsx | 19 ++ src/lib/oauth/codexDeviceFlow.ts | 313 ++++++++++++++++++ src/lib/oauth/connectionPersistence.ts | 90 +++++ src/lib/oauth/deviceFlowTickets.ts | 109 ++++++ src/lib/oauth/providers.ts | 17 + src/server/authz/classify.ts | 10 + src/server/authz/types.ts | 1 + src/shared/constants/publicApiRoutes.ts | 3 + src/shared/validation/schemas.ts | 16 + tests/unit/codex-device-flow-tickets.test.ts | 67 ++++ tests/unit/codex-device-flow.test.ts | 191 +++++++++++ tests/unit/codex-finalize-tokens.test.ts | 77 +++++ tests/unit/i18n-nest-dotted-keys.test.ts | 47 +++ tsconfig.json | 3 +- 19 files changed, 1530 insertions(+), 1 deletion(-) create mode 100644 src/app/api/codex/connect/[token]/route.ts create mode 100644 src/app/connect/codex/[token]/CodexConnectClient.tsx create mode 100644 src/app/connect/codex/[token]/page.tsx create mode 100644 src/lib/oauth/codexDeviceFlow.ts create mode 100644 src/lib/oauth/connectionPersistence.ts create mode 100644 src/lib/oauth/deviceFlowTickets.ts create mode 100644 tests/unit/codex-device-flow-tickets.test.ts create mode 100644 tests/unit/codex-device-flow.test.ts create mode 100644 tests/unit/codex-finalize-tokens.test.ts create mode 100644 tests/unit/i18n-nest-dotted-keys.test.ts diff --git a/.gitignore b/.gitignore index 6f31e0d47e..31f56c82e1 100644 --- a/.gitignore +++ b/.gitignore @@ -192,3 +192,6 @@ scripts/i18n/_pending-keys.json # PR Reviews and local feedback files pr_reviews*.json +#hidden local data directories (never commit) +.local-data/ +.data-dev/ \ No newline at end of file diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 36261fd46b..b23b051841 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -1439,6 +1439,13 @@ export default function ProviderDetailPage() { ); const [exportingCodexAuthId, setExportingCodexAuthId] = useState(null); const [importCodexModalOpen, setImportCodexModalOpen] = useState(false); + // "Adicionar Externo": public shareable device-flow link state. + const [externalLinkModalOpen, setExternalLinkModalOpen] = useState(false); + const [externalLinkUrl, setExternalLinkUrl] = useState(""); + const [externalLinkToken, setExternalLinkToken] = useState(null); + const [externalLinkLoading, setExternalLinkLoading] = useState(false); + const [externalLinkError, setExternalLinkError] = useState(null); + const { copied: externalLinkCopied, copy: externalLinkCopy } = useCopyToClipboard(); const [applyingClaudeAuthId, setApplyingClaudeAuthId] = useState(null); const [applyClaudeModalConnectionId, setApplyClaudeModalConnectionId] = useState( null @@ -1992,6 +1999,69 @@ export default function ProviderDetailPage() { openApiKeyAddFlow(); }, [isOAuth, openApiKeyAddFlow]); + // "Adicionar Externo": generate a single-use public link so a third party can + // complete the Codex device flow in their own browser. + const openExternalLinkFlow = useCallback(async () => { + setExternalLinkModalOpen(true); + setExternalLinkUrl(""); + setExternalLinkToken(null); + setExternalLinkError(null); + setExternalLinkLoading(true); + try { + const res = await fetch(`/api/oauth/${providerId}/public-link`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const data = await res.json().catch(() => ({})); + if (res.ok && data?.url) { + setExternalLinkUrl(data.url); + setExternalLinkToken(data.token || null); + } else { + setExternalLinkError(data?.error || "Falha ao gerar o link."); + } + } catch { + setExternalLinkError("Não foi possível contatar o servidor."); + } finally { + setExternalLinkLoading(false); + } + }, [providerId]); + + // While the share popup is open, poll the ticket status so the dashboard can + // notify + refresh the connections the moment the external visitor finishes. + useEffect(() => { + if (!externalLinkModalOpen || !externalLinkToken) return; + let active = true; + const interval = setInterval(async () => { + if (!active) return; + try { + const res = await fetch( + `/api/oauth/${providerId}/public-link-status?token=${encodeURIComponent(externalLinkToken)}` + ); + const data = await res.json().catch(() => ({})); + if (!active) return; + if (data?.status === "completed") { + active = false; + clearInterval(interval); + notify.success("Conta Codex conectada pelo link externo."); + fetchConnections(); + setExternalLinkModalOpen(false); + setExternalLinkToken(null); + } else if (data?.status === "expired") { + active = false; + clearInterval(interval); + setExternalLinkError("O link expirou sem ser concluído."); + } + } catch { + /* transient network error — keep polling */ + } + }, 3000); + return () => { + active = false; + clearInterval(interval); + }; + }, [externalLinkModalOpen, externalLinkToken, providerId, notify, fetchConnections]); + const gateConnectionFlow = useCallback( (callback: () => void) => { if (subscriptionRisk && !riskAcknowledged && !isRiskAcknowledged(providerId)) { @@ -4402,6 +4472,16 @@ export default function ProviderDetailPage() { Experimental OAuth )} + {providerId === "codex" && ( + + )} {providerId === "codex" && ( + + +

+ sync + Aguardando a autenticação no navegador da pessoa… esta janela atualiza sozinha. +

+ + ) : null} + + + )} {/* Claude Apply Auth Modal */} {providerId === "claude" && applyClaudeModalConnectionId && ( } +) { + const { token } = await params; + const ticket = peekDeviceFlowTicket(token); + if (!ticket || ticket.provider !== PROVIDER || ticket.status !== "pending") { + return NextResponse.json( + { valid: false, error: "This link is invalid, already used, or expired." }, + { status: 404 } + ); + } + return NextResponse.json({ + valid: true, + provider: ticket.provider, + expiresAt: new Date(ticket.expiresAt).toISOString(), + }); +} + +// POST — the browser finished the device flow; consume the ticket and persist. +export async function POST( + request: Request, + { params }: { params: Promise<{ token: string }> } +) { + const { token } = await params; + + let rawBody: any; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ success: false, error: "Invalid JSON body" }, { status: 400 }); + } + + const validation = validateBody(oauthDeviceCompleteSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + // Claim the ticket FIRST (single-use): an invalid/expired/already-used token + // must never reach token mapping or persistence. Claiming also blocks + // concurrent/duplicate submissions. + const ticket = claimDeviceFlowTicket(token, PROVIDER); + if (!ticket) { + return NextResponse.json( + { success: false, error: "This link is invalid, already used, or expired." }, + { status: 410 } + ); + } + + const { + access_token: accessToken, + refresh_token: refreshToken, + id_token: idToken, + expires_in: expiresIn, + } = validation.data; + + try { + const tokenData = await finalizeTokens(PROVIDER, { + access_token: accessToken, + refresh_token: refreshToken, + id_token: idToken, + expires_in: expiresIn, + }); + + const connection = await persistOAuthConnection(PROVIDER, tokenData, ticket.connectionId); + + // Record completion so the dashboard poll can notify + refresh. + completeDeviceFlowTicket(token, { + connectionId: connection.id, + email: connection.email ?? null, + }); + + return NextResponse.json({ + success: true, + connection: { id: connection.id, provider: connection.provider, email: connection.email }, + }); + } catch (err: any) { + // Release the claim so the visitor can retry within the link's lifetime. + releaseDeviceFlowTicket(token); + console.error("Codex public device-flow completion error:", err); + return NextResponse.json( + { success: false, error: sanitizeErrorMessage(err?.message) || "Failed to save connection" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 9a23e5ff11..c087a7ae77 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -4,10 +4,13 @@ import { getProvider, generateAuthData, exchangeTokens, + finalizeTokens, requestDeviceCode, pollForToken, resolveBrowserOAuthRedirectUri, } from "@/lib/oauth/providers"; +import { persistOAuthConnection } from "@/lib/oauth/connectionPersistence"; +import { createDeviceFlowTicket, getDeviceFlowTicketStatus } from "@/lib/oauth/deviceFlowTickets"; import { createProviderConnection, updateProviderConnection, @@ -21,6 +24,7 @@ import { startLocalServer } from "@/lib/oauth/utils/server"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { jsonObjectSchema, + oauthDeviceCompleteSchema, oauthExchangeSchema, oauthImportTokenSchema, oauthPollSchema, @@ -41,6 +45,13 @@ if (!globalThis.__windsurfCallbackState) { /** Providers that use the PKCE browser callback flow (like Codex). */ const PKCE_CALLBACK_PROVIDERS = new Set(["codex"]); +/** + * Providers whose device flow runs in the user's browser (auth.openai.com blocks + * datacenter IPs but allows CORS), so the server never polls — it only persists + * the final tokens via the `device-complete` action. See src/lib/oauth/codexDeviceFlow.ts. + */ +const BROWSER_DEVICE_FLOW_PROVIDERS = new Set(["codex"]); + /** * Providers whose PKCE flow has been retired but whose import-token path is * still active. Returning 410 Gone on `authorize` / `start-callback-server` / @@ -66,6 +77,20 @@ function safeEqual(a: string | null | undefined, b: string | null | undefined): return timingSafeEqual(ba, bb); } +/** + * Resolve the externally reachable base URL for public share links. Prefers the + * configured public base URL; otherwise derives it from forwarded headers so the + * link points at the host the operator actually serves (not an internal origin). + */ +function resolvePublicBaseUrl(request: Request): string { + const env = process.env.NEXT_PUBLIC_BASE_URL || process.env.OMNIROUTE_PUBLIC_BASE_URL; + if (env && env.trim()) return env.trim().replace(/\/+$/, ""); + const host = request.headers.get("x-forwarded-host") || request.headers.get("host"); + const proto = request.headers.get("x-forwarded-proto") || "https"; + if (host) return `${proto}://${host}`; + return new URL(request.url).origin; +} + async function requireOAuthRouteAuth(request: Request) { if (!(await isAuthRequired(request))) return null; if (await isAuthenticated(request)) return null; @@ -194,6 +219,17 @@ export async function GET( return await handleStartCallbackServer(provider, searchParams); } + if (action === "public-link-status") { + // Dashboard polls this (authenticated) to learn when the external visitor + // finished the device flow, so it can notify + refresh the connections. + const token = searchParams.get("token"); + if (!token) { + return NextResponse.json({ error: "Missing token" }, { status: 400 }); + } + const { status, result } = getDeviceFlowTicketStatus(token); + return NextResponse.json({ status, connection: result }); + } + return NextResponse.json({ error: "Unknown action" }, { status: 400 }); } catch (error) { console.error("OAuth GET error:", error); @@ -365,6 +401,12 @@ export async function POST( return NextResponse.json({ error: validation.error }, { status: 400 }); } body = validation.data; + } else if (action === "device-complete") { + const validation = validateBody(oauthDeviceCompleteSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + body = validation.data; } if (action === "exchange") { @@ -755,6 +797,82 @@ export async function POST( } } + if (action === "public-link") { + // Generate a single-use, short-lived public link so a third party can + // complete the Codex device flow in their own browser (see Fase 6). + if (!BROWSER_DEVICE_FLOW_PROVIDERS.has(provider)) { + return NextResponse.json( + { + error: `public-link not supported for provider: ${provider}. Supported: ${[...BROWSER_DEVICE_FLOW_PROVIDERS].join(", ")}`, + }, + { status: 400 } + ); + } + + const connectionId = + rawBody && typeof rawBody.connectionId === "string" ? rawBody.connectionId : undefined; + const { token, expiresAt } = createDeviceFlowTicket(provider, connectionId); + + return NextResponse.json({ + url: `${resolvePublicBaseUrl(request)}/connect/codex/${token}`, + token, + expiresAt: new Date(expiresAt).toISOString(), + }); + } + + if (action === "device-complete") { + // The browser-driven Codex device flow already performed the device + // authorization + token exchange against auth.openai.com (the server's + // datacenter IP is blocked by Cloudflare, so it cannot). Here we only map + // the final tokens and persist the connection — no HTTP exchange/poll. + if (!BROWSER_DEVICE_FLOW_PROVIDERS.has(provider)) { + return NextResponse.json( + { + error: `device-complete not supported for provider: ${provider}. Supported: ${[...BROWSER_DEVICE_FLOW_PROVIDERS].join(", ")}`, + }, + { status: 400 } + ); + } + + const { + access_token: accessToken, + refresh_token: refreshToken, + id_token: idToken, + expires_in: expiresIn, + connectionId, + } = body; + + let tokenData: any; + try { + tokenData = await finalizeTokens(provider, { + access_token: accessToken, + refresh_token: refreshToken, + id_token: idToken, + expires_in: expiresIn, + }); + } catch (finalizeErr: any) { + return NextResponse.json( + { + success: false, + error: sanitizeErrorMessage(finalizeErr?.message) || "Failed to finalize tokens", + }, + { status: 500 } + ); + } + + const connection = await persistOAuthConnection(provider, tokenData, connectionId); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + displayName: connection.displayName, + }, + }); + } + return NextResponse.json({ error: "Unknown action" }, { status: 400 }); } catch (error) { console.error("OAuth POST error:", error); diff --git a/src/app/connect/codex/[token]/CodexConnectClient.tsx b/src/app/connect/codex/[token]/CodexConnectClient.tsx new file mode 100644 index 0000000000..4c8a9e9d80 --- /dev/null +++ b/src/app/connect/codex/[token]/CodexConnectClient.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Button from "@/shared/components/Button"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { + runCodexDeviceFlow, + CodexDeviceFlowError, + type CodexUserCode, +} from "@/lib/oauth/codexDeviceFlow"; + +type Status = "validating" | "ready" | "starting" | "awaiting" | "saving" | "success" | "error"; + +/** + * Drives the public Codex device flow entirely in the visitor's browser + * (auth.openai.com blocks datacenter IPs but allows CORS), then posts the final + * tokens back to the ticket-gated completion endpoint for persistence. + */ +export default function CodexConnectClient({ token }: { token: string }) { + const [status, setStatus] = useState("validating"); + const [error, setError] = useState(null); + const [userCode, setUserCode] = useState(null); + const { copied, copy } = useCopyToClipboard(); + const abortRef = useRef(null); + + // Validate the link on load so we can show "ready" vs "expired" before starting. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(`/api/codex/connect/${token}`); + if (cancelled) return; + if (res.ok) { + setStatus("ready"); + } else { + const data = await res.json().catch(() => ({})); + setError(data?.error || "This link is invalid or expired."); + setStatus("error"); + } + } catch { + if (!cancelled) { + setError("Could not reach the server to validate this link."); + setStatus("error"); + } + } + })(); + return () => { + cancelled = true; + }; + }, [token]); + + // Abort any in-flight device flow if the visitor leaves. + useEffect(() => () => abortRef.current?.abort(), []); + + const start = useCallback(async () => { + setError(null); + setUserCode(null); + setStatus("starting"); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + const tokens = await runCodexDeviceFlow({ + signal: controller.signal, + onUserCode: (uc) => { + setUserCode(uc); + setStatus("awaiting"); + }, + }); + + setStatus("saving"); + const res = await fetch(`/api/codex/connect/${token}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(tokens), + }); + const data = await res.json().catch(() => ({})); + if (res.ok && data?.success) { + setStatus("success"); + } else { + setError(data?.error || "Could not save the connection. The link may have expired."); + setStatus("error"); + } + } catch (err) { + if (err instanceof CodexDeviceFlowError) { + setError( + err.code === "device_disabled" + ? "Device code login is disabled for this OpenAI account. Enable it in ChatGPT security settings (or ask your workspace admin)." + : err.code === "timeout" + ? "Authorization timed out. Click Start again to retry." + : err.code === "aborted" + ? "Authentication was cancelled." + : err.message + ); + } else { + setError("Unexpected error during authentication. Please try again."); + } + setStatus("error"); + } + }, [token]); + + return ( +
+
+
+
+ key +
+

Connect OpenAI Codex

+

+ Authorize a ChatGPT account to finish setting up this connection. +

+
+ + {status === "validating" && ( +

Validating link…

+ )} + + {status === "ready" && ( +
+

+ Click below to generate a one-time code, then sign in to OpenAI. +

+ +
+ )} + + {status === "starting" && ( +

Requesting code from OpenAI…

+ )} + + {status === "awaiting" && userCode && ( +
+

+ 1. Open the OpenAI verification page and 2. enter this code. This page updates + automatically once you authorize. +

+ +
+

Your code

+
+ + {userCode.userCode} + + +
+
+ +
+ + +
+ +

Waiting for authorization…

+
+ )} + + {status === "saving" && ( +

Saving connection…

+ )} + + {status === "success" && ( +
+
+ check_circle +
+

Connected!

+

+ The Codex account was registered. You can close this tab. +

+
+ )} + + {status === "error" && ( +
+
+ error +
+

{error}

+ +
+ )} +
+
+ ); +} diff --git a/src/app/connect/codex/[token]/page.tsx b/src/app/connect/codex/[token]/page.tsx new file mode 100644 index 0000000000..108651c15f --- /dev/null +++ b/src/app/connect/codex/[token]/page.tsx @@ -0,0 +1,19 @@ +import CodexConnectClient from "./CodexConnectClient"; + +/** + * Public Codex connect page (outside the dashboard auth gate). + * + * A third party opens the shared `/codex/connect/{token}` link and completes the + * Codex device flow in their own browser. Server component only resolves the + * route token and hands it to the client component that drives the flow. + */ +export const dynamic = "force-dynamic"; + +export default async function CodexConnectPage({ + params, +}: { + params: Promise<{ token: string }>; +}) { + const { token } = await params; + return ; +} diff --git a/src/lib/oauth/codexDeviceFlow.ts b/src/lib/oauth/codexDeviceFlow.ts new file mode 100644 index 0000000000..2e6508ef88 --- /dev/null +++ b/src/lib/oauth/codexDeviceFlow.ts @@ -0,0 +1,313 @@ +/** + * Codex (OpenAI) browser-driven device authorization flow. + * + * Runs ENTIRELY in the user's browser. `auth.openai.com` blocks datacenter IPs + * (Cloudflare) but allows CORS, so the device flow MUST originate from the + * user's browser — never from the OmniRoute server. The final tokens are handed + * to the backend for persistence (see the OAuth route's persistence path). + * + * Wire contract (from OpenAI Codex CLI — codex-rs/login/src/device_code_auth.rs, + * cross-checked against tumf/opencode-openai-device-auth). This is NOT the + * RFC 8628 device grant; it is OpenAI's custom "deviceauth" flow: + * + * 1. POST {BASE}/api/accounts/deviceauth/usercode (JSON) { client_id } + * -> 200 { device_auth_id, user_code|usercode, interval } + * -> 404 device code login disabled for this account/workspace (admin gating) + * 2. POST {BASE}/api/accounts/deviceauth/token (JSON) { device_auth_id, user_code } + * -> 200 { authorization_code, code_verifier } (PKCE is generated server-side!) + * -> 403 | 404 authorization still pending → keep polling + * 3. POST {BASE}/oauth/token (form) authorization_code grant + * with code + code_verifier (from step 2) + redirect_uri = {BASE}/deviceauth/callback + * -> 200 { access_token, refresh_token, id_token, expires_in } + * + * Verification: the user opens {BASE}/codex/device and types the user_code. + * + * NOTE: this module must stay free of server-only imports (e.g. CODEX_CONFIG, + * open-sse) so it can be bundled for the browser. The client_id below is the + * public Codex CLI client identifier (same literal as CODEX_CONFIG.clientId); + * it relies on PKCE, not secrecy (RFC 8252). + */ + +const BASE_URL = "https://auth.openai.com"; +const API_BASE_URL = `${BASE_URL}/api/accounts`; +const DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +const VERIFICATION_URI = `${BASE_URL}/codex/device`; +const REDIRECT_URI = `${BASE_URL}/deviceauth/callback`; + +/** Total time the user has to authorize before we give up (OpenAI expires the code in 15 min). */ +const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; +const DEFAULT_INTERVAL_SEC = 5; + +export type CodexDeviceFlowErrorCode = + | "device_disabled" + | "usercode_failed" + | "exchange_failed" + | "timeout" + | "aborted" + | "network"; + +export class CodexDeviceFlowError extends Error { + code: CodexDeviceFlowErrorCode; + status?: number; + + constructor(code: CodexDeviceFlowErrorCode, message: string, status?: number) { + super(message); + this.name = "CodexDeviceFlowError"; + this.code = code; + this.status = status; + } +} + +export interface CodexUserCode { + /** Opaque handle returned by the usercode endpoint; required to poll. */ + deviceAuthId: string; + /** One-time code the user types at the verification URL. */ + userCode: string; + /** Server-suggested poll interval, in seconds. */ + intervalSec: number; + /** URL the user opens to enter the code. */ + verificationUri: string; +} + +export interface CodexDeviceTokens { + access_token: string; + refresh_token: string; + id_token: string; + expires_in: number; +} + +interface RunOptions { + /** Public Codex CLI client id; defaults to the embedded public value. */ + clientId?: string; + /** Called once the user code is obtained, so the UI can display / copy / open it. */ + onUserCode?: (userCode: CodexUserCode) => void; + /** Abort the flow (e.g. modal closed). */ + signal?: AbortSignal; + /** Override the overall timeout (defaults to 15 minutes). */ + timeoutMs?: number; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new CodexDeviceFlowError("aborted", "Device flow aborted"); + } +} + +function delay(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + const onAbort = () => { + cleanup(); + reject(new CodexDeviceFlowError("aborted", "Device flow aborted")); + }; + function cleanup() { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + } + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + }); +} + +function normalizeInterval(raw: unknown): number { + const n = typeof raw === "string" ? parseInt(raw, 10) : typeof raw === "number" ? raw : NaN; + return Number.isFinite(n) && n > 0 ? n : DEFAULT_INTERVAL_SEC; +} + +/** + * Step 1 — request a one-time user code + device_auth_id. + * A 404 here means device code login is disabled for this account/workspace. + */ +export async function requestUserCode( + clientId: string = DEFAULT_CLIENT_ID, + signal?: AbortSignal +): Promise { + let res: Response; + try { + res = await fetch(`${API_BASE_URL}/deviceauth/usercode`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ client_id: clientId }), + signal, + }); + } catch (e: any) { + if (e?.name === "AbortError") throw new CodexDeviceFlowError("aborted", "Device flow aborted"); + throw new CodexDeviceFlowError("network", `Failed to reach OpenAI: ${e?.message || e}`); + } + + if (res.status === 404) { + throw new CodexDeviceFlowError( + "device_disabled", + "Device code login is not enabled for this account. Enable it in ChatGPT security settings (or ask your workspace admin), or use the localhost 'Adicionar' flow.", + 404 + ); + } + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new CodexDeviceFlowError( + "usercode_failed", + `Failed to request device code (${res.status}): ${text}`, + res.status + ); + } + + const data: any = await res.json(); + const userCode = data.user_code || data.usercode; + if (!data.device_auth_id || !userCode) { + throw new CodexDeviceFlowError( + "usercode_failed", + "Device code response missing device_auth_id or user_code" + ); + } + + return { + deviceAuthId: data.device_auth_id, + userCode, + intervalSec: normalizeInterval(data.interval), + verificationUri: VERIFICATION_URI, + }; +} + +/** + * Step 2 — poll until the user authorizes. Returns the authorization_code and the + * server-generated code_verifier needed for the token exchange. + */ +export async function pollForAuthorization( + deviceAuthId: string, + userCode: string, + intervalSec: number, + opts: { signal?: AbortSignal; timeoutMs?: number } = {} +): Promise<{ authorizationCode: string; codeVerifier: string }> { + const { signal, timeoutMs = DEFAULT_TIMEOUT_MS } = opts; + const deadline = startMonotonic() + timeoutMs; + + while (true) { + throwIfAborted(signal); + await delay(intervalSec * 1000, signal); + + if (startMonotonic() >= deadline) { + throw new CodexDeviceFlowError("timeout", "Authorization timed out. Start a new session."); + } + + let res: Response; + try { + res = await fetch(`${API_BASE_URL}/deviceauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }), + signal, + }); + } catch (e: any) { + if (e?.name === "AbortError") + throw new CodexDeviceFlowError("aborted", "Device flow aborted"); + // Transient network error — keep polling until the deadline. + continue; + } + + if (res.ok) { + const data: any = await res.json(); + if (!data.authorization_code || !data.code_verifier) { + throw new CodexDeviceFlowError( + "usercode_failed", + "Authorization response missing authorization_code or code_verifier" + ); + } + return { authorizationCode: data.authorization_code, codeVerifier: data.code_verifier }; + } + + // 403 / 404 → still pending; anything else is a hard failure. + if (res.status === 403 || res.status === 404) continue; + + const text = await res.text().catch(() => ""); + throw new CodexDeviceFlowError( + "usercode_failed", + `Polling failed (${res.status}): ${text}`, + res.status + ); + } +} + +/** Step 3 — exchange the authorization_code (+ server code_verifier) for tokens. */ +export async function exchangeCodeForTokens( + authorizationCode: string, + codeVerifier: string, + clientId: string = DEFAULT_CLIENT_ID, + signal?: AbortSignal +): Promise { + let res: Response; + try { + res = await fetch(`${BASE_URL}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId, + code: authorizationCode, + code_verifier: codeVerifier, + redirect_uri: REDIRECT_URI, + }).toString(), + signal, + }); + } catch (e: any) { + if (e?.name === "AbortError") throw new CodexDeviceFlowError("aborted", "Device flow aborted"); + throw new CodexDeviceFlowError("network", `Token exchange failed: ${e?.message || e}`); + } + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new CodexDeviceFlowError( + "exchange_failed", + `Token exchange failed (${res.status}): ${text}`, + res.status + ); + } + + const data: any = await res.json(); + if (!data.access_token) { + throw new CodexDeviceFlowError("exchange_failed", "Token exchange returned no access_token"); + } + return { + access_token: data.access_token, + refresh_token: data.refresh_token, + id_token: data.id_token, + expires_in: data.expires_in, + }; +} + +/** + * High-level orchestrator: request the user code (firing `onUserCode` so the UI can + * show it), poll until authorized, then exchange for tokens. The caller then ships + * the returned tokens to the backend for persistence. + */ +export async function runCodexDeviceFlow(opts: RunOptions = {}): Promise { + const clientId = opts.clientId || DEFAULT_CLIENT_ID; + throwIfAborted(opts.signal); + + const userCode = await requestUserCode(clientId, opts.signal); + opts.onUserCode?.(userCode); + + const { authorizationCode, codeVerifier } = await pollForAuthorization( + userCode.deviceAuthId, + userCode.userCode, + userCode.intervalSec, + { signal: opts.signal, timeoutMs: opts.timeoutMs } + ); + + return exchangeCodeForTokens(authorizationCode, codeVerifier, clientId, opts.signal); +} + +/** Monotonic-ish clock that tolerates environments where performance is unavailable. */ +function startMonotonic(): number { + return typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : Date.now(); +} diff --git a/src/lib/oauth/connectionPersistence.ts b/src/lib/oauth/connectionPersistence.ts new file mode 100644 index 0000000000..4bbffec13f --- /dev/null +++ b/src/lib/oauth/connectionPersistence.ts @@ -0,0 +1,90 @@ +/** + * Shared upsert for OAuth provider connections, used by both the authenticated + * OAuth route (`device-complete`) and the public Codex device-flow completion + * endpoint. Mirrors the exchange/poll/poll-callback persistence: normalize the + * display name, compute expiry, match an existing connection by id or email + * (+ Codex workspaceId) and update it, else create a new one, then sync to Cloud. + */ +import { timingSafeEqual } from "crypto"; +import { + createProviderConnection, + updateProviderConnection, + getProviderConnections, + isCloudEnabled, +} from "@/models"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/lib/cloudSync"; + +/** + * Constant-time string comparison to prevent timing-oracle attacks (CWE-208). + * Handles null/undefined safely and different-length strings. + */ +function safeEqual(a: string | null | undefined, b: string | null | undefined): boolean { + if (a == null || b == null) return a === b; + const ba = Buffer.from(String(a)); + const bb = Buffer.from(String(b)); + if (ba.length !== bb.length) return false; + return timingSafeEqual(ba, bb); +} + +async function syncToCloudIfEnabled(): Promise { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing to cloud after OAuth:", error); + } +} + +export async function persistOAuthConnection( + provider: string, + tokenData: any, + connectionId?: string +) { + // Normalize: if name is missing, use email or displayName as fallback label. + if (!tokenData.name && (tokenData.email || tokenData.displayName)) { + tokenData.name = tokenData.email || tokenData.displayName; + } + + const expiresAt = tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null; + + let connection: any; + if (tokenData.email) { + const existing = await getProviderConnections({ provider }); + const match = existing.find((c: any) => { + if (c.id && safeEqual(connectionId, c.id)) return true; + if (!safeEqual(c.email, tokenData.email) || c.authType !== "oauth") return false; + // For Codex, also check workspaceId to avoid overwriting a different workspace. + if (provider === "codex" && tokenData.providerSpecificData?.workspaceId) { + const existingWorkspace = c.providerSpecificData?.workspaceId; + return safeEqual(existingWorkspace, tokenData.providerSpecificData.workspaceId); + } + return true; + }); + const matchId = typeof match?.id === "string" ? match.id : null; + if (matchId) { + connection = await updateProviderConnection(matchId, { + ...tokenData, + expiresAt, + testStatus: "active", + isActive: true, + }); + } + } + if (!connection) { + connection = await createProviderConnection({ + provider, + authType: "oauth", + ...tokenData, + expiresAt, + testStatus: "active", + }); + } + + await syncToCloudIfEnabled(); + return connection; +} diff --git a/src/lib/oauth/deviceFlowTickets.ts b/src/lib/oauth/deviceFlowTickets.ts new file mode 100644 index 0000000000..51de5eef3d --- /dev/null +++ b/src/lib/oauth/deviceFlowTickets.ts @@ -0,0 +1,109 @@ +/** + * Single-use, short-lived tickets for the public "Adicionar Externo" Codex link. + * + * The dashboard generates a ticket and shares a public URL + * (`/connect/codex/{token}`). A third party opens it and completes the Codex + * device flow in their own browser; the public completion endpoint claims + + * completes the ticket before persisting the connection. The dashboard polls the + * ticket status so it can notify + refresh once the remote login finishes. + * + * Backed by an in-memory `globalThis` map, mirroring the existing + * `__codexCallbackState` pattern in the OAuth route. Trade-off: tickets do not + * survive a restart and are not shared across instances — acceptable for a + * short-lived (15 min), single-use link. + */ +import { randomBytes } from "crypto"; + +const TICKET_TTL_MS = 15 * 60 * 1000; // matches OpenAI's device code expiry +const STORE_KEY = "__codexDeviceFlowTickets"; + +export type DeviceFlowTicketStatus = "pending" | "claimed" | "completed"; + +export interface DeviceFlowTicketResult { + connectionId: string; + email: string | null; +} + +export interface DeviceFlowTicket { + token: string; + provider: string; + /** Optional target connection to update instead of creating a new one. */ + connectionId?: string; + /** Epoch ms. */ + expiresAt: number; + status: DeviceFlowTicketStatus; + /** Populated once the device flow completes and the connection is persisted. */ + result?: DeviceFlowTicketResult; +} + +function store(): Map { + const g = globalThis as unknown as { [STORE_KEY]?: Map }; + if (!g[STORE_KEY]) g[STORE_KEY] = new Map(); + return g[STORE_KEY]!; +} + +function prune(): void { + const now = Date.now(); + const map = store(); + for (const [token, ticket] of map) { + if (ticket.expiresAt <= now) map.delete(token); + } +} + +/** Create a ticket and return its opaque token + expiry. */ +export function createDeviceFlowTicket( + provider: string, + connectionId?: string +): { token: string; expiresAt: number } { + prune(); + const token = randomBytes(32).toString("base64url"); + const expiresAt = Date.now() + TICKET_TTL_MS; + store().set(token, { token, provider, connectionId, expiresAt, status: "pending" }); + return { token, expiresAt }; +} + +/** Return a ticket if it exists and has not expired (any status). */ +export function peekDeviceFlowTicket(token: string): DeviceFlowTicket | null { + prune(); + const ticket = store().get(token); + if (!ticket || ticket.expiresAt <= Date.now()) return null; + return ticket; +} + +/** + * Claim a still-pending ticket for the given provider so the device flow can be + * completed exactly once. Marks it "claimed" to reject concurrent/duplicate + * submissions. Returns the ticket, or null if invalid/expired/used/wrong-provider. + */ +export function claimDeviceFlowTicket(token: string, provider: string): DeviceFlowTicket | null { + const ticket = peekDeviceFlowTicket(token); + if (!ticket || ticket.provider !== provider || ticket.status !== "pending") return null; + ticket.status = "claimed"; + return ticket; +} + +/** Mark a claimed ticket completed and record the resulting connection. */ +export function completeDeviceFlowTicket(token: string, result: DeviceFlowTicketResult): void { + const ticket = store().get(token); + if (!ticket) return; + ticket.status = "completed"; + ticket.result = result; +} + +/** Revert a claimed ticket back to pending so the visitor can retry after a failure. */ +export function releaseDeviceFlowTicket(token: string): void { + const ticket = store().get(token); + if (ticket && ticket.status === "claimed") ticket.status = "pending"; +} + +/** + * Status for the dashboard poll. Returns "expired" when the ticket is gone + * (missing or past its TTL), otherwise the live status + result if completed. + */ +export function getDeviceFlowTicketStatus( + token: string +): { status: DeviceFlowTicketStatus | "expired"; result: DeviceFlowTicketResult | null } { + const ticket = peekDeviceFlowTicket(token); + if (!ticket) return { status: "expired", result: null }; + return { status: ticket.status, result: ticket.result ?? null }; +} diff --git a/src/lib/oauth/providers.ts b/src/lib/oauth/providers.ts index 713cffd31d..19d378f668 100644 --- a/src/lib/oauth/providers.ts +++ b/src/lib/oauth/providers.ts @@ -198,6 +198,23 @@ export async function exchangeTokens(providerName, code, redirectUri, codeVerifi return provider.mapTokens(tokens, extra); } +/** + * Finalize tokens obtained out-of-band (e.g. the browser-driven Codex device + * flow, where the browser performs the auth.openai.com exchange because the + * server's datacenter IP is blocked). Runs the provider's postExchange + + * mapTokens — the same tail as exchangeTokens — without an HTTP token exchange. + */ +export async function finalizeTokens(providerName, tokens) { + const provider = getProvider(providerName); + + let extra = null; + if (provider.postExchange) { + extra = await provider.postExchange(tokens); + } + + return provider.mapTokens(tokens, extra); +} + /** * Request device code (for device_code flow) */ diff --git a/src/server/authz/classify.ts b/src/server/authz/classify.ts index 31f84fdcc0..fd5b9d4cd2 100644 --- a/src/server/authz/classify.ts +++ b/src/server/authz/classify.ts @@ -61,6 +61,16 @@ export function classifyRoute(rawPath: string, method: string = "GET"): RouteCla }; } + // Public, ticket-gated device-flow connect pages (e.g. /connect/codex/{token}). + // Anyone with the shared link completes the provider login in their own browser. + if (normalizedPath === "/connect" || normalizedPath.startsWith("/connect/")) { + return { + routeClass: "PUBLIC", + reason: "public_connect_page", + normalizedPath, + }; + } + if (normalizedPath.startsWith("/dashboard")) { return { routeClass: "MANAGEMENT", diff --git a/src/server/authz/types.ts b/src/server/authz/types.ts index 443001dbca..f96493b93e 100644 --- a/src/server/authz/types.ts +++ b/src/server/authz/types.ts @@ -29,6 +29,7 @@ export type ClassificationReason = | "public_readonly_prefix" | "dashboard_prefix" | "setup_wizard" + | "public_connect_page" | "client_api_v1" | "client_api_mcp" | "client_api_alias" diff --git a/src/shared/constants/publicApiRoutes.ts b/src/shared/constants/publicApiRoutes.ts index 8e12f6dbd9..0099c50241 100644 --- a/src/shared/constants/publicApiRoutes.ts +++ b/src/shared/constants/publicApiRoutes.ts @@ -7,6 +7,9 @@ const PUBLIC_API_ROUTE_PREFIXES = [ "/api/cloud/", "/api/sync/bundle", "/api/oauth/", + // Public, ticket-gated Codex device-flow completion (validate + persist). + // The handler enforces its own single-use ticket check; no dashboard auth. + "/api/codex/connect/", ]; const PUBLIC_READONLY_API_ROUTE_PREFIXES = [ diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 0f5d503bbf..672d84955f 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1646,6 +1646,22 @@ export const oauthImportTokenSchema = z.object({ connectionId: z.string().optional(), }); +/** + * Persist tokens obtained out-of-band by the browser-driven Codex device flow. + * The browser performs the full device authorization + token exchange against + * auth.openai.com (the server cannot — its datacenter IP is blocked by Cloudflare), + * then ships the final tokens here for mapping + persistence. Token fields use the + * snake_case shape returned by the OAuth token endpoint (consumed directly by + * each provider's mapTokens). + */ +export const oauthDeviceCompleteSchema = z.object({ + access_token: z.string().trim().min(1, "access_token is required"), + refresh_token: z.string().trim().optional(), + id_token: z.string().trim().optional(), + expires_in: z.number().int().positive().optional(), + connectionId: z.string().optional(), +}); + export const cursorImportSchema = z.object({ accessToken: z.string().trim().min(1, "Access token is required"), machineId: z.string().trim().optional(), diff --git a/tests/unit/codex-device-flow-tickets.test.ts b/tests/unit/codex-device-flow-tickets.test.ts new file mode 100644 index 0000000000..b230b65511 --- /dev/null +++ b/tests/unit/codex-device-flow-tickets.test.ts @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + createDeviceFlowTicket, + peekDeviceFlowTicket, + claimDeviceFlowTicket, + completeDeviceFlowTicket, + releaseDeviceFlowTicket, + getDeviceFlowTicketStatus, +} from "@/lib/oauth/deviceFlowTickets"; + +test("createDeviceFlowTicket returns a token + future expiry and starts pending", () => { + const { token, expiresAt } = createDeviceFlowTicket("codex", "conn-1"); + assert.ok(token.length >= 32); + assert.ok(expiresAt > Date.now()); + + const ticket = peekDeviceFlowTicket(token); + assert.ok(ticket); + assert.equal(ticket!.provider, "codex"); + assert.equal(ticket!.connectionId, "conn-1"); + assert.equal(ticket!.status, "pending"); +}); + +test("claimDeviceFlowTicket is single-use (second claim rejected)", () => { + const { token } = createDeviceFlowTicket("codex"); + const first = claimDeviceFlowTicket(token, "codex"); + assert.ok(first); + assert.equal(first!.status, "claimed"); + + // Second claim must fail — it is no longer pending. + assert.equal(claimDeviceFlowTicket(token, "codex"), null); +}); + +test("complete records the result and surfaces it via status", () => { + const { token } = createDeviceFlowTicket("codex"); + claimDeviceFlowTicket(token, "codex"); + completeDeviceFlowTicket(token, { connectionId: "c-9", email: "u@example.com" }); + + const status = getDeviceFlowTicketStatus(token); + assert.equal(status.status, "completed"); + assert.deepEqual(status.result, { connectionId: "c-9", email: "u@example.com" }); +}); + +test("release reverts a claimed ticket to pending so the visitor can retry", () => { + const { token } = createDeviceFlowTicket("codex"); + claimDeviceFlowTicket(token, "codex"); + releaseDeviceFlowTicket(token); + + assert.equal(getDeviceFlowTicketStatus(token).status, "pending"); + // Claimable again after release. + assert.ok(claimDeviceFlowTicket(token, "codex")); +}); + +test("claimDeviceFlowTicket rejects a provider mismatch without claiming", () => { + const { token } = createDeviceFlowTicket("codex"); + assert.equal(claimDeviceFlowTicket(token, "claude"), null); + // Still pending + claimable for the correct provider. + assert.equal(peekDeviceFlowTicket(token)!.status, "pending"); + assert.ok(claimDeviceFlowTicket(token, "codex")); +}); + +test("status is 'expired' for unknown tokens", () => { + assert.equal(peekDeviceFlowTicket("nope"), null); + assert.equal(getDeviceFlowTicketStatus("nope").status, "expired"); + assert.equal(claimDeviceFlowTicket("nope", "codex"), null); +}); diff --git a/tests/unit/codex-device-flow.test.ts b/tests/unit/codex-device-flow.test.ts new file mode 100644 index 0000000000..efa6ec56a6 --- /dev/null +++ b/tests/unit/codex-device-flow.test.ts @@ -0,0 +1,191 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + requestUserCode, + pollForAuthorization, + exchangeCodeForTokens, + runCodexDeviceFlow, + CodexDeviceFlowError, +} from "@/lib/oauth/codexDeviceFlow"; + +const USERCODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode"; +const POLL_URL = "https://auth.openai.com/api/accounts/deviceauth/token"; +const TOKEN_URL = "https://auth.openai.com/oauth/token"; + +function withFetch(handler: (url: string, init?: RequestInit) => Response | Promise) { + const original = global.fetch; + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => + handler(String(input), init)) as typeof fetch; + return () => { + global.fetch = original; + }; +} + +test("requestUserCode posts client_id and normalizes interval + verification uri", async () => { + const calls: Array<{ url: string; body?: string }> = []; + const restore = withFetch((url, init) => { + calls.push({ url, body: typeof init?.body === "string" ? init.body : undefined }); + return new Response( + JSON.stringify({ device_auth_id: "dev-1", user_code: "ABCD-1234", interval: "7" }), + { status: 200 } + ); + }); + + try { + const uc = await requestUserCode("app_test"); + assert.equal(calls[0].url, USERCODE_URL); + assert.deepEqual(JSON.parse(calls[0].body!), { client_id: "app_test" }); + assert.equal(uc.deviceAuthId, "dev-1"); + assert.equal(uc.userCode, "ABCD-1234"); + assert.equal(uc.intervalSec, 7); // string "7" normalized to number + assert.equal(uc.verificationUri, "https://auth.openai.com/codex/device"); + } finally { + restore(); + } +}); + +test("requestUserCode accepts the alternate 'usercode' field", async () => { + const restore = withFetch( + () => new Response(JSON.stringify({ device_auth_id: "d", usercode: "X-1", interval: 0 }), { + status: 200, + }) + ); + try { + const uc = await requestUserCode(); + assert.equal(uc.userCode, "X-1"); + assert.equal(uc.intervalSec, 5); // 0 → default + } finally { + restore(); + } +}); + +test("requestUserCode maps 404 to device_disabled (admin gating)", async () => { + const restore = withFetch(() => new Response("not enabled", { status: 404 })); + try { + await assert.rejects(requestUserCode(), (err: unknown) => { + assert.ok(err instanceof CodexDeviceFlowError); + assert.equal((err as CodexDeviceFlowError).code, "device_disabled"); + assert.equal((err as CodexDeviceFlowError).status, 404); + return true; + }); + } finally { + restore(); + } +}); + +test("pollForAuthorization treats 403 as pending then returns code + verifier", async () => { + let n = 0; + const restore = withFetch(() => { + n += 1; + if (n === 1) return new Response("pending", { status: 403 }); + return new Response( + JSON.stringify({ authorization_code: "auth-code", code_verifier: "ver-123" }), + { status: 200 } + ); + }); + try { + const out = await pollForAuthorization("dev-1", "ABCD", 0.001); + assert.equal(n, 2); + assert.deepEqual(out, { authorizationCode: "auth-code", codeVerifier: "ver-123" }); + } finally { + restore(); + } +}); + +test("pollForAuthorization throws timeout when deadline passes", async () => { + const restore = withFetch(() => new Response("pending", { status: 404 })); + try { + await assert.rejects(pollForAuthorization("dev-1", "ABCD", 0.001, { timeoutMs: 5 }), (err) => { + assert.equal((err as CodexDeviceFlowError).code, "timeout"); + return true; + }); + } finally { + restore(); + } +}); + +test("pollForAuthorization aborts via signal", async () => { + const restore = withFetch(() => new Response("pending", { status: 403 })); + const ac = new AbortController(); + ac.abort(); + try { + await assert.rejects( + pollForAuthorization("dev-1", "ABCD", 0.001, { signal: ac.signal }), + (err) => { + assert.equal((err as CodexDeviceFlowError).code, "aborted"); + return true; + } + ); + } finally { + restore(); + } +}); + +test("exchangeCodeForTokens posts the authorization_code grant with the server verifier", async () => { + let body = ""; + const restore = withFetch((url, init) => { + body = typeof init?.body === "string" ? init.body : ""; + assert.equal(url, TOKEN_URL); + return new Response( + JSON.stringify({ + access_token: "at", + refresh_token: "rt", + id_token: "it", + expires_in: 3600, + }), + { status: 200 } + ); + }); + try { + const tokens = await exchangeCodeForTokens("auth-code", "ver-123", "app_test"); + const params = new URLSearchParams(body); + assert.equal(params.get("grant_type"), "authorization_code"); + assert.equal(params.get("client_id"), "app_test"); + assert.equal(params.get("code"), "auth-code"); + assert.equal(params.get("code_verifier"), "ver-123"); + assert.equal(params.get("redirect_uri"), "https://auth.openai.com/deviceauth/callback"); + assert.equal(tokens.access_token, "at"); + assert.equal(tokens.expires_in, 3600); + } finally { + restore(); + } +}); + +test("runCodexDeviceFlow drives usercode → poll → exchange and reports the user code", async () => { + let polls = 0; + const restore = withFetch((url) => { + if (url === USERCODE_URL) { + return new Response( + JSON.stringify({ device_auth_id: "dev-1", user_code: "WXYZ-9", interval: 0.001 }), + { status: 200 } + ); + } + if (url === POLL_URL) { + polls += 1; + if (polls < 2) return new Response("pending", { status: 404 }); + return new Response( + JSON.stringify({ authorization_code: "auth-code", code_verifier: "ver-123" }), + { status: 200 } + ); + } + if (url === TOKEN_URL) { + return new Response( + JSON.stringify({ access_token: "at", refresh_token: "rt", id_token: "it", expires_in: 60 }), + { status: 200 } + ); + } + return new Response("not-found", { status: 404 }); + }); + + const seen: string[] = []; + try { + const tokens = await runCodexDeviceFlow({ onUserCode: (uc) => seen.push(uc.userCode) }); + assert.deepEqual(seen, ["WXYZ-9"]); + assert.equal(tokens.access_token, "at"); + assert.equal(tokens.refresh_token, "rt"); + assert.equal(tokens.id_token, "it"); + } finally { + restore(); + } +}); diff --git a/tests/unit/codex-finalize-tokens.test.ts b/tests/unit/codex-finalize-tokens.test.ts new file mode 100644 index 0000000000..613c9f8a51 --- /dev/null +++ b/tests/unit/codex-finalize-tokens.test.ts @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { finalizeTokens } from "@/lib/oauth/providers"; + +/** Build a fake (unsigned) JWT whose payload carries the given claims. */ +function makeIdToken(payload: Record): string { + const b64 = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString("base64url"); + return `${b64({ alg: "none", typ: "JWT" })}.${b64(payload)}.sig`; +} + +test("finalizeTokens(codex) maps device-flow tokens and extracts email + workspace from id_token", async () => { + const idToken = makeIdToken({ + email: "user@example.com", + "https://api.openai.com/auth": { + chatgpt_account_id: "acc-123", + chatgpt_plan_type: "plus", + chatgpt_user_id: "cu-1", + user_id: "u-1", + organizations: [], + }, + }); + + const tokenData = await finalizeTokens("codex", { + access_token: "at-1", + refresh_token: "rt-1", + id_token: idToken, + expires_in: 3600, + }); + + assert.equal(tokenData.accessToken, "at-1"); + assert.equal(tokenData.refreshToken, "rt-1"); + assert.equal(tokenData.idToken, idToken); + assert.equal(tokenData.expiresIn, 3600); + assert.equal(tokenData.email, "user@example.com"); + // No organizations → workspace falls back to chatgpt_account_id (Ponto de atenção #1: + // the deviceauth flow can't request id_token_add_organizations). + assert.equal(tokenData.providerSpecificData.workspaceId, "acc-123"); + assert.equal(tokenData.providerSpecificData.workspacePlanType, "plus"); +}); + +test("finalizeTokens(codex) prefers a team org when plan_type is free", async () => { + const idToken = makeIdToken({ + email: "team@example.com", + "https://api.openai.com/auth": { + chatgpt_account_id: "personal-acc", + chatgpt_plan_type: "free", + chatgpt_user_id: "cu-2", + user_id: "u-2", + organizations: [ + { id: "team-acc", is_default: false, role: "member", title: "Acme Team" }, + ], + }, + }); + + const tokenData = await finalizeTokens("codex", { + access_token: "at-2", + id_token: idToken, + expires_in: 60, + }); + + assert.equal(tokenData.email, "team@example.com"); + assert.equal(tokenData.providerSpecificData.workspaceId, "team-acc"); + assert.equal(tokenData.providerSpecificData.workspacePlanType, "team"); +}); + +test("finalizeTokens(codex) tolerates a missing id_token (no metadata)", async () => { + const tokenData = await finalizeTokens("codex", { + access_token: "at-3", + refresh_token: "rt-3", + expires_in: 120, + }); + + assert.equal(tokenData.accessToken, "at-3"); + assert.equal(tokenData.email, null); + assert.equal(tokenData.providerSpecificData.workspaceId, null); +}); diff --git a/tests/unit/i18n-nest-dotted-keys.test.ts b/tests/unit/i18n-nest-dotted-keys.test.ts new file mode 100644 index 0000000000..40878e2680 --- /dev/null +++ b/tests/unit/i18n-nest-dotted-keys.test.ts @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { nestDottedKeys } from "@/i18n/request"; + +test("nestDottedKeys expands flat compliance.eventTypes keys into nested objects", () => { + const input = { + compliance: { + eventTypes: { + "apiKey.activate": "API Key Activated", + "apiKey.scopes.grant": "API Key Scopes Granted", + "apiKey.scopes.revoke": "API Key Scopes Revoked", + "auth.login.success": "Login Successful", + }, + }, + }; + + const out = nestDottedKeys(input) as any; + assert.equal(out.compliance.eventTypes.apiKey.activate, "API Key Activated"); + assert.equal(out.compliance.eventTypes.apiKey.scopes.grant, "API Key Scopes Granted"); + assert.equal(out.compliance.eventTypes.apiKey.scopes.revoke, "API Key Scopes Revoke".concat("d")); + assert.equal(out.compliance.eventTypes.auth.login.success, "Login Successful"); + // No dotted key survives. + assert.equal( + JSON.stringify(out).includes('"apiKey.activate"'), + false, + "dotted keys must be gone" + ); +}); + +test("nestDottedKeys leaves plain nested messages untouched and preserves values with dots", () => { + const input = { + providers: { add: "Add", hint: "Use gpt-4.1 here" }, + list: ["a.b", "c"], + }; + const out = nestDottedKeys(input) as any; + assert.equal(out.providers.add, "Add"); + // A "." in a VALUE must be preserved (only KEYS are nested). + assert.equal(out.providers.hint, "Use gpt-4.1 here"); + assert.deepEqual(out.list, ["a.b", "c"]); +}); + +test("nestDottedKeys ignores prototype-pollution segments", () => { + const out = nestDottedKeys({ "__proto__.polluted": "x", safe: "y" }) as any; + assert.equal(out.safe, "y"); + assert.equal(({} as any).polluted, undefined); +}); diff --git a/tsconfig.json b/tsconfig.json index 117cdf65d9..2395a0e409 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -49,7 +49,8 @@ ".next-playwright/types/**/*.ts", ".next-playwright/dev/types/**/*.ts", ".build/next/types/**/*.ts", - ".build/next/dev/types/**/*.ts" + ".build/next/dev/types/**/*.ts", + ".build/next/dev/dev/types/**/*.ts" ], "exclude": [ "node_modules",