Feat/codex device flow - #3195
Conversation
…istence Add the foundation for Codex login via OpenAI's device authorization flow, which must run in the user's browser: auth.openai.com blocks datacenter IPs (Cloudflare) but allows CORS, so a server-side poll is not viable. - src/lib/oauth/codexDeviceFlow.ts: client-side module implementing OpenAI's custom "deviceauth" flow (usercode -> poll -> authorization_code exchange), with admin-gating (404), pending (403/404), timeout and abort handling. - providers.ts finalizeTokens(): run postExchange + mapTokens on tokens the browser already exchanged, without an HTTP token exchange. - route.ts device-complete action + persistOAuthConnection helper: persist the final tokens via the same email+workspaceId upsert as the other OAuth flows. - oauthDeviceCompleteSchema for request validation. - Unit tests for the device flow module and finalizeTokens/mapTokens (incl. the no-organizations workspace fallback). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fase 6: a shareable single-use link lets a third party complete the Codex device flow in their own browser (which also sidesteps the datacenter-IP block, since the request originates from the visitor's residential IP). - deviceFlowTickets.ts: in-memory, single-use, 15-min tickets (globalThis, mirroring the existing __codexCallbackState pattern). - connectionPersistence.ts: shared persistOAuthConnection extracted from the OAuth route so both the authenticated device-complete action and the public endpoint reuse the same email+workspaceId upsert. - OAuth route: new authenticated `public-link` action (generates the ticket + public URL) and `device-complete`; resolvePublicBaseUrl honors forwarded host. - /api/codex/connect/[token]: public GET (validate) + POST (consume ticket → finalizeTokens → persist), NOT behind dashboard auth. - /codex/connect/[token]: public page that runs the browser device flow, shows the user code + verification URL (open/copy), and auto-completes. - Providers screen: "Adicionar Externo" button + popup with the link (open/copy). - Ticket store unit tests (single-use, provider-mismatch, expiry). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mpletion
Move the public connect page out of the /codex/* namespace (which OmniRoute
aliases to the responses API, causing a 405) to /connect/codex/{token}, and
classify it + its completion endpoint as PUBLIC so a third party reaches them
without dashboard auth.
- authz: classify /connect/* as PUBLIC (new public_connect_page reason); add
/api/codex/connect/ to the public API prefixes.
- deviceFlowTickets: pending -> claimed -> completed lifecycle with the resulting
connection recorded; claim is single-use and reverts on persistence failure.
- public route: claim -> finalizeTokens -> persist -> complete (release on error);
GET validate only succeeds while pending.
- OAuth route: authenticated `public-link-status` action for dashboard polling;
public-link now points at /connect/codex/{token}.
- Providers screen: the "Adicionar Externo" popup polls the ticket status and,
on completion, fires a success toast, refreshes the connection list and closes.
- Ticket store tests updated for the claim/complete/release/status lifecycle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
compliance.eventTypes ships flat keys containing "." (e.g. "apiKey.activate", "auth.login.success"), which next-intl v4 rejects (INVALID_KEY) since "." denotes nesting — crashing message loading. Normalize dotted keys into nested objects in the request config before handing messages to next-intl. Idempotent, recursive, applies to every locale, preserves dots in values, and guards prototype pollution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a public, ticket-gated Codex device-flow connect experience (browser-driven), plus i18n message normalization to support dotted keys under next-intl v4.
Changes:
- Introduces Codex browser-driven device flow (request → poll → exchange) and public connect page + API endpoint.
- Adds single-use in-memory device-flow tickets and shared OAuth connection persistence helper.
- Normalizes i18n message objects by nesting dotted keys and adds unit tests for both i18n + device flow helpers.
Reviewed changes
Copilot reviewed 18 out of 20 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| tsconfig.json | Extends TS include globs for build-time Next types. |
| tests/unit/i18n-nest-dotted-keys.test.ts | Adds unit coverage for dotted-key nesting + prototype pollution protection. |
| tests/unit/codex-finalize-tokens.test.ts | Adds unit coverage for token finalization without server-side exchange. |
| tests/unit/codex-device-flow.test.ts | Adds unit coverage for Codex device-flow steps via mocked fetch. |
| tests/unit/codex-device-flow-tickets.test.ts | Adds unit coverage for single-use ticket lifecycle. |
| src/shared/validation/schemas.ts | Adds schema for out-of-band device-flow token completion payload. |
| src/shared/constants/publicApiRoutes.ts | Marks new public Codex connect API prefix as public. |
| src/server/authz/types.ts | Adds route classification reason for public connect pages. |
| src/server/authz/classify.ts | Treats /connect/* pages as PUBLIC routes. |
| src/lib/oauth/providers.ts | Adds finalizeTokens to reuse map/persist tail without exchanging. |
| src/lib/oauth/deviceFlowTickets.ts | Implements in-memory single-use tickets for public connect flow. |
| src/lib/oauth/connectionPersistence.ts | Adds shared upsert + cloud sync for OAuth connections. |
| src/lib/oauth/codexDeviceFlow.ts | Implements browser-only Codex “deviceauth” flow orchestration. |
| src/i18n/request.ts | Adds nestDottedKeys and applies it to merged messages. |
| src/app/connect/codex/[token]/page.tsx | Adds public connect page wrapper for tokenized route. |
| src/app/connect/codex/[token]/CodexConnectClient.tsx | Adds client UI that runs device flow and POSTs tokens to backend. |
| src/app/api/oauth/[provider]/[action]/route.ts | Adds actions for generating public links, polling ticket status, and device-complete persistence. |
| src/app/api/codex/connect/[token]/route.ts | Adds public ticket-gated codex completion endpoint (GET validate, POST persist). |
| src/app/(dashboard)/dashboard/providers/[id]/page.tsx | Adds dashboard UX to generate + poll external connect link. |
| .gitignore | Ignores local data directories. |
| const out: Record<string, unknown> = {}; | ||
| for (const [key, raw] of Object.entries(value as Record<string, unknown>)) { | ||
| const nested = nestDottedKeys(raw); | ||
| const parts = key.split("."); | ||
| let cursor = out; | ||
| let bail = false; | ||
| for (let i = 0; i < parts.length - 1; i++) { | ||
| const part = parts[i]; | ||
| // Guard against prototype pollution from a crafted message tree. | ||
| if (part === "__proto__" || part === "constructor" || part === "prototype") { | ||
| bail = true; | ||
| break; | ||
| } | ||
| const existing = cursor[part]; | ||
| if (typeof existing !== "object" || existing === null || Array.isArray(existing)) { | ||
| cursor[part] = {}; | ||
| } | ||
| cursor = cursor[part] as Record<string, unknown>; | ||
| } | ||
| if (bail) continue; | ||
| cursor[parts[parts.length - 1]] = nested; | ||
| } |
| const existing = cursor[part]; | ||
| if (typeof existing !== "object" || existing === null || Array.isArray(existing)) { | ||
| cursor[part] = {}; | ||
| } |
| /** | ||
| * 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); | ||
| } |
| 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; | ||
| } |
| /** | ||
| * Public Codex connect page (outside the dashboard auth gate). | ||
| * | ||
| * A third party opens the shared `/codex/connect/{token}` link and completes the |
| /** | ||
| * Public Codex device-flow completion endpoint (NOT behind dashboard auth). | ||
| * | ||
| * Reached by a third party who opened the shared `/codex/connect/{token}` link. |
| export interface CodexDeviceTokens { | ||
| access_token: string; | ||
| refresh_token: string; | ||
| id_token: string; | ||
| expires_in: number; | ||
| } |
| 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")); |
|
Thanks @zhiru — the browser-driven device flow with a shareable
Tests are present which is great. Could you confirm the token lifecycle points above (or point me at where they're enforced)? I'll do a dedicated security review pass on this one rather than batch it. Leaving open. |
# Conflicts: # src/app/(dashboard)/dashboard/providers/[id]/page.tsx # src/i18n/request.ts
|
Thanks @zhiru — this is a genuinely useful feature. 👏 The public ticket-gated device-flow neatly works around I reviewed the security surface and it holds up: tickets are 256-bit ( I resolved two conflicts with
|
…s + add contributor hall Consolidate the split [Unreleased]/[3.8.11] sections into one, add entries for every merged contributor PR that was missing credit (#3170/#3171/#3172 @pizzav-xyz, #3185/#3195 @zhiru, #3188 @xz-dev, #3189/#3203/#3204/#3241 @wilsonicdev, #3191 @bypanghu, #3206 @juandisay, #3217 @oyi77, #3226 @miracuves, #3187/#3200 maintainer), drop stale v3.8.8 leftovers (#2958/#2959 already shipped) and 3 empty v3.8.10 stub headers.
…s + add contributor hall Consolidate the split [Unreleased]/[3.8.11] sections into one, add entries for every merged contributor PR that was missing credit (diegosouzapw#3170/diegosouzapw#3171/diegosouzapw#3172 @pizzav-xyz, diegosouzapw#3185/diegosouzapw#3195 @zhiru, diegosouzapw#3188 @xz-dev, diegosouzapw#3189/diegosouzapw#3203/diegosouzapw#3204/diegosouzapw#3241 @wilsonicdev, diegosouzapw#3191 @bypanghu, diegosouzapw#3206 @juandisay, diegosouzapw#3217 @oyi77, diegosouzapw#3226 @miracuves, diegosouzapw#3187/diegosouzapw#3200 maintainer), drop stale v3.8.8 leftovers (diegosouzapw#2958/diegosouzapw#2959 already shipped) and 3 empty v3.8.10 stub headers.
Codex public device-flow connect link (ticket-gated) + dashboard CTA. Integrated into release/v3.8.11.
…s + add contributor hall Consolidate the split [Unreleased]/[3.8.11] sections into one, add entries for every merged contributor PR that was missing credit (diegosouzapw#3170/diegosouzapw#3171/diegosouzapw#3172 @pizzav-xyz, diegosouzapw#3185/diegosouzapw#3195 @zhiru, diegosouzapw#3188 @xz-dev, diegosouzapw#3189/diegosouzapw#3203/diegosouzapw#3204/diegosouzapw#3241 @wilsonicdev, diegosouzapw#3191 @bypanghu, diegosouzapw#3206 @juandisay, diegosouzapw#3217 @oyi77, diegosouzapw#3226 @miracuves, diegosouzapw#3187/diegosouzapw#3200 maintainer), drop stale v3.8.8 leftovers (diegosouzapw#2958/diegosouzapw#2959 already shipped) and 3 empty v3.8.10 stub headers.
Codex public device-flow connect link (ticket-gated) + dashboard CTA. Integrated into release/v3.8.11.
…s + add contributor hall Consolidate the split [Unreleased]/[3.8.11] sections into one, add entries for every merged contributor PR that was missing credit (diegosouzapw#3170/diegosouzapw#3171/diegosouzapw#3172 @pizzav-xyz, diegosouzapw#3185/diegosouzapw#3195 @zhiru, diegosouzapw#3188 @xz-dev, diegosouzapw#3189/diegosouzapw#3203/diegosouzapw#3204/diegosouzapw#3241 @wilsonicdev, diegosouzapw#3191 @bypanghu, diegosouzapw#3206 @juandisay, diegosouzapw#3217 @oyi77, diegosouzapw#3226 @miracuves, diegosouzapw#3187/diegosouzapw#3200 maintainer), drop stale v3.8.8 leftovers (diegosouzapw#2958/diegosouzapw#2959 already shipped) and 3 empty v3.8.10 stub headers.
Codex public device-flow connect link (ticket-gated) + dashboard CTA. Integrated into release/v3.8.11.
…s + add contributor hall Consolidate the split [Unreleased]/[3.8.11] sections into one, add entries for every merged contributor PR that was missing credit (diegosouzapw#3170/diegosouzapw#3171/diegosouzapw#3172 @pizzav-xyz, diegosouzapw#3185/diegosouzapw#3195 @zhiru, diegosouzapw#3188 @xz-dev, diegosouzapw#3189/diegosouzapw#3203/diegosouzapw#3204/diegosouzapw#3241 @wilsonicdev, diegosouzapw#3191 @bypanghu, diegosouzapw#3206 @juandisay, diegosouzapw#3217 @oyi77, diegosouzapw#3226 @miracuves, diegosouzapw#3187/diegosouzapw#3200 maintainer), drop stale v3.8.8 leftovers (diegosouzapw#2958/diegosouzapw#2959 already shipped) and 3 empty v3.8.10 stub headers.
Summary
Adds Codex login via OpenAI's browser-driven device authorization flow, exposed as a shareable "Adicionar Externo" link. A dashboard user generates a single-use public link (
/connect/codex/{token}); a third party opens it and completes the OpenAI device login in their own browser — required becauseauth.openai.comblocks datacenter IPs (Cloudflare) but allows CORS, so the flow can't run server-side. The resulting tokens are persisted as a Codex connection, and the dashboard polls the ticket so it notifies and refreshes the connection list automatically on completion. Also re-enables the Codex "Import auth" button in the provider header, and includes an app-wide i18n fix so next-intl v4 accepts the existing flat dottedcompliance.eventTypeskeys.Related Issues
Closes #
Related to #
Validation
npm run lint— 0 errors (pre-existingno-explicit-anywarnings only)npm run test:unit— 8395 passing; 3 failures are pre-existing WSL-only environment flakes incli-environment-helpers.test.ts(detectRestrictedEnvironmentreturnswslinstead of gitpod/ci/replit because the local box is WSL). Unrelated to this change; green on CI Linux.npm run test:coverage— to be confirmed by CItest:coveragescript's enforced gate is currently 40%, not 60% — please confirm which threshold applies.Tests Added Or Updated
tests/unit/codex-device-flow.test.ts— client-side flow (usercode → poll → exchange; 404 gating, pending, timeout, abort, orchestrator).tests/unit/codex-finalize-tokens.test.ts—finalizeTokens/CodexmapTokens(email + workspace fromid_token, no-organizationsfallback, missingid_token).tests/unit/codex-device-flow-tickets.test.ts— ticket lifecycle (pending → claimed → completed, single-use, release/retry, provider mismatch, expired).tests/unit/i18n-nest-dotted-keys.test.ts— flat→nested key normalization (preserves dots in values, guards prototype pollution).Coverage Notes
This PR changes
src/. Coverage by area:finalizeTokens, i18n normalization) — covered by the unit tests above./api/codex/connect/[token], OAuth actionspublic-link/public-link-status/device-complete) — payload validation, gating, and the persistence/ticket logic they call are unit-tested; there is no native HTTP integration test for these routes (mocking@/modelsin the native runner is costly).providers/[id]/page.tsx,CodexConnectClient.tsx) — no component test; this is UI over already-covered endpoints, validated manually end-to-end.GETvalidate +POSTcomplete via ticket) to raise coverage on that surface.Reviewer Notes
auth.openai.comdirectly; it depends on open CORS (validated manually E2E). If an environment blocks it, the fallback is a companion app./connect/codex/{token}on purpose —/codex/*is OmniRoute's responses-API alias (returned 405)./connect/*and/api/codex/connect/are classified PUBLIC (no dashboard auth); security comes from the single-use, 15-min, in-memory ticket (globalThis, so it does not survive restarts or span multiple instances).nestDottedKeysinsrc/i18n/request.tsruns per request for every locale — an app-wide fix for a pre-existing invalid-key bug (next-intl v4 rejects dotted keys), not Codex-specific.device-completeaction is a reusable building block not wired to any UI button (the "Externo" flow uses the public endpoint). Keep or remove — TBD.