From cd10a6f5f4f1ec5b8db35f97ca7fd3f327be4f65 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:15:34 -0300 Subject: [PATCH] feat(oauth): import Codex connection from a raw ChatGPT access token (#5995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(oauth): import Codex connection from a raw ChatGPT access token OmniRoute's only Codex import path (/api/oauth/codex/import) required both access_token and refresh_token, leaving no import path for a user who only has a bare ChatGPT website access token (no refresh token). - src/lib/db/providers.ts: createProviderConnection gains an explicit authType "access_token" branch — intentionally never deduped (no stable long-lived identity to match on) — and derives the connection name from email/name the same way "oauth" does. - src/lib/oauth/services/codexImport.ts: export extractCodexAccountInfo so the new import path reuses the existing JWT decode instead of duplicating one. - New route POST /api/oauth/codex/import-token (Zod-validated body { accessToken, name? }); errors routed through buildErrorBody / sanitizeErrorMessage. The executor's refreshCredentials() already degrades safely to null when there is no refresh token, forcing re-auth on expiry instead of a refresh exchange. - OAuthModal.tsx: the callback-URL manual-paste path for codex now detects an eyJ-prefixed pasted token and posts it to the new endpoint, mirroring the existing grok-cli raw-token paste pattern. Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com> Inspired-by: https://github.com/decolua/9router/pull/1290 * chore(changelog): restore release entries + add codex token-import bullet --------- Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com> --- CHANGELOG.md | 1 + src/app/api/oauth/codex/import-token/route.ts | 103 +++++++++++++ src/lib/db/providers.ts | 10 +- src/lib/oauth/services/codexImport.ts | 10 +- src/shared/components/OAuthModal.tsx | 20 +++ tests/unit/codex-import-token-route.test.ts | 143 ++++++++++++++++++ .../db-providers-access-token-1290.test.ts | 119 +++++++++++++++ 7 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 src/app/api/oauth/codex/import-token/route.ts create mode 100644 tests/unit/codex-import-token-route.test.ts create mode 100644 tests/unit/db-providers-access-token-1290.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c5054cd8..6bf58f0f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - **feat(dashboard):** suggest HuggingFace Hub media models in the media provider view. (thanks @yicone) - **feat(dashboard):** collapse quota rows and sort by remaining quota in the usage view. (thanks @j2-cuong) - **feat(dashboard):** add a settings toggle for tool-source diagnostics logging. (thanks @DuyPrX) +- **feat(oauth):** import a ChatGPT/Codex connection from a raw access token (no refresh token required). (thanks @ryanngit) ### 🔧 Bug Fixes diff --git a/src/app/api/oauth/codex/import-token/route.ts b/src/app/api/oauth/codex/import-token/route.ts new file mode 100644 index 0000000000..6706f2b5a5 --- /dev/null +++ b/src/app/api/oauth/codex/import-token/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { extractCodexAccountInfo } from "@/lib/oauth/services/codexImport"; +import { createProviderConnection } from "@/models"; +import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +/** + * POST /api/oauth/codex/import-token + * + * Import a Codex (ChatGPT/OpenAI) connection from a bare access token — no + * refresh token required. Covers users who only have a raw ChatGPT website + * access token (e.g. copied from devtools/session storage) and have no path + * through the refresh-token-requiring bulk import at /api/oauth/codex/import. + * + * The connection is created with authType "access_token": with no refresh + * token, the executor's refreshCredentials() degrades to returning null on + * expiry (forcing re-auth) instead of attempting a refresh-token exchange — + * see open-sse/executors/codex.ts. + * + * Body: `{ accessToken: string, name?: string }` + * + * Inspired-by: https://github.com/decolua/9router/pull/1290 + */ + +const bodySchema = z.object({ + accessToken: z.string().trim().min(1, "accessToken is required"), + name: z.string().trim().min(1).optional(), +}); + +async function requireAuth(request: Request): Promise { + if (!(await isAuthRequired(request))) return null; + if (await isAuthenticated(request)) return null; + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { status: 401 }); +} + +export async function POST(request: Request) { + const authResponse = await requireAuth(request); + if (authResponse) return authResponse; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json(buildErrorBody(400, "Invalid or empty JSON body"), { status: 400 }); + } + + const parsed = bodySchema.safeParse(rawBody); + if (!parsed.success) { + return NextResponse.json( + buildErrorBody(400, parsed.error.issues[0]?.message ?? "Invalid request body"), + { status: 400 } + ); + } + + const { accessToken, name } = parsed.data; + const info = extractCodexAccountInfo(accessToken); + + if (!info.email && !info.chatgptAccountId && !name) { + return NextResponse.json( + buildErrorBody( + 400, + "Could not decode any account info from the access token and no name was provided" + ), + { status: 400 } + ); + } + + const providerSpecificData: Record = {}; + if (info.chatgptAccountId) providerSpecificData.chatgptAccountId = info.chatgptAccountId; + if (info.chatgptPlanType) providerSpecificData.chatgptPlanType = info.chatgptPlanType; + + try { + const connection = await createProviderConnection({ + provider: "codex", + authType: "access_token", + accessToken, + email: info.email, + name: name || info.email, + testStatus: "active", + isActive: true, + ...(Object.keys(providerSpecificData).length > 0 ? { providerSpecificData } : {}), + }); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + name: connection.name, + }, + }); + } catch (error) { + return NextResponse.json( + buildErrorBody( + 500, + sanitizeErrorMessage(error instanceof Error ? error.message : String(error)) + ), + { status: 500 } + ); + } +} diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index f4ed72524f..e3eeb5419a 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -231,6 +231,14 @@ export async function createProviderConnection(data: JsonRecord) { data.name, normalizedProviderSpecificData ); + } else if (data.authType === "access_token") { + // #1290 — bare access-token imports (e.g. a raw ChatGPT website access + // token with no refresh token) are intentionally never deduped: every + // import creates a new connection. Unlike oauth (workspace+email) or + // apikey (key-value) imports, a bare access token has no refresh token + // and no stable long-lived identity to safely dedup against — matching + // on email alone here would risk silently overwriting an existing full + // oauth connection for the same account. } if (existing) { @@ -255,7 +263,7 @@ export async function createProviderConnection(data: JsonRecord) { // Generate name: prefer explicit name, then email, then a stable short-ID label. // Avoid sequential "Account N" — it reassigns when accounts are deleted/reordered. let connectionName = data.name || null; - if (!connectionName && data.authType === "oauth") { + if (!connectionName && (data.authType === "oauth" || data.authType === "access_token")) { if (data.email) { connectionName = data.email as string; } else if (data.displayName) { diff --git a/src/lib/oauth/services/codexImport.ts b/src/lib/oauth/services/codexImport.ts index 5ef19be84f..955584e3c6 100644 --- a/src/lib/oauth/services/codexImport.ts +++ b/src/lib/oauth/services/codexImport.ts @@ -67,7 +67,15 @@ function decodeJwtPayload(jwt: unknown): Record | null { } } -function extractCodexAccountInfo(idToken: string): { +/** + * Decode a Codex JWT (id_token, or a bare ChatGPT access token — both carry + * the same `https://api.openai.com/auth` custom claim) into account info. + * + * Exported so other Codex import paths (e.g. the bare-access-token import at + * `/api/oauth/codex/import-token`, #1290) can reuse this decode logic instead + * of duplicating an inline JWT decode. + */ +export function extractCodexAccountInfo(idToken: string): { email?: string; chatgptAccountId?: string; chatgptPlanType?: string; diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 0766a31f68..0c01342aef 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -657,6 +657,26 @@ export default function OAuthModal({ await submitCredentialBlob(provider, callbackUrl, reauthConnection, setStep, onSuccess); return; } + + // Codex: a bare ChatGPT access token (JWT, no refresh token) pasted + // directly instead of a callback URL/code — mirrors the grok-cli + // raw-token paste pattern. Routed through the access-token-only import + // endpoint (#1290) instead of the authorization-code exchange below. + if (provider === "codex" && /^eyJ/.test(callbackUrl.trim())) { + const res = await fetch("/api/oauth/codex/import-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accessToken: callbackUrl.trim() }), + }); + const data = (await parseResponseBody(res)) as Record; + if (!res.ok) { + throw new Error(getErrorMessage(data, res.status, "Failed to import access token")); + } + setStep("success"); + onSuccess?.(); + return; + } + if (!authData) { throw new Error( "OAuth session not initialized. Restart the connection flow and try again." diff --git a/tests/unit/codex-import-token-route.test.ts b/tests/unit/codex-import-token-route.test.ts new file mode 100644 index 0000000000..83f2c8ad3f --- /dev/null +++ b/tests/unit/codex-import-token-route.test.ts @@ -0,0 +1,143 @@ +// Route-wiring tests for /api/oauth/codex/import-token (#1290). +// +// Imports a Codex connection from a bare ChatGPT access token — no refresh +// token required. Auth is disabled via settings (requireLogin:false) so we +// reach the schema/decode logic rather than a 401. DB handles are released +// in test.after (CLAUDE.md learning: unreleased SQLite handles hang node:test). + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-import-token-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const route = await import("../../src/app/api/oauth/codex/import-token/route.ts"); + +function b64url(obj: unknown): string { + return Buffer.from(JSON.stringify(obj)) + .toString("base64") + .replace(/=+$/, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +function makeJwt(payload: Record): string { + const header = b64url({ alg: "RS256", typ: "JWT" }); + const body = b64url(payload); + return `${header}.${body}.signature`; +} + +test.before(async () => { + await settingsDb.updateSettings({ requireLogin: false }); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +async function postImportToken(body: unknown) { + const request = new Request("http://localhost:20128/api/oauth/codex/import-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const response = await route.POST(request); + return { status: response.status, body: await response.json() }; +} + +test("import-token: decodes email + workspace claims from the access token and creates a connection", async () => { + const accessToken = makeJwt({ + email: "bare-token@example.com", + "https://api.openai.com/auth": { + chatgpt_account_id: "acct-bare", + chatgpt_plan_type: "plus", + }, + }); + + const { status, body } = await postImportToken({ accessToken }); + assert.equal(status, 200); + assert.equal(body.success, true); + assert.equal(body.connection.provider, "codex"); + assert.equal(body.connection.email, "bare-token@example.com"); + + const rows = await providersDb.getProviderConnections({ provider: "codex" }); + const created = rows.find((r) => r.id === body.connection.id); + assert.equal(created?.authType, "access_token"); + assert.equal(created?.accessToken, accessToken); + assert.ok(!created?.refreshToken, "no refresh token should be persisted"); + assert.deepEqual(created?.providerSpecificData, { + chatgptAccountId: "acct-bare", + chatgptPlanType: "plus", + }); +}); + +test("import-token: falls back to the explicit `name` when the JWT carries no email", async () => { + const accessToken = makeJwt({ + "https://api.openai.com/auth": { chatgpt_account_id: "acct-noemail" }, + }); + + const { status, body } = await postImportToken({ accessToken, name: "My Bare Token" }); + assert.equal(status, 200); + assert.equal(body.connection.name, "My Bare Token"); +}); + +test("import-token: missing accessToken fails schema validation with 400", async () => { + const { status, body } = await postImportToken({}); + assert.equal(status, 400); + assert.ok(typeof body.error.message === "string" && body.error.message.length > 0); +}); + +test("import-token: empty-string accessToken fails schema validation with 400", async () => { + const { status } = await postImportToken({ accessToken: " " }); + assert.equal(status, 400); +}); + +test("import-token: undecodable token with no name and no claims is rejected with 400", async () => { + const { status, body } = await postImportToken({ accessToken: "not-a-jwt" }); + assert.equal(status, 400); + assert.match(body.error.message, /decode|account info/i); +}); + +test("import-token: undecodable token IS accepted when an explicit name is supplied", async () => { + const { status, body } = await postImportToken({ + accessToken: "not-a-jwt-but-thats-ok", + name: "Manually Labeled", + }); + assert.equal(status, 200); + assert.equal(body.connection.name, "Manually Labeled"); +}); + +test("import-token: malformed JSON body is rejected with 400", async () => { + const request = new Request("http://localhost:20128/api/oauth/codex/import-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{not json", + }); + const response = await route.POST(request); + assert.equal(response.status, 400); +}); + +test("import-token: repeated imports for the same email never dedup (each is a new connection)", async () => { + const tokenA = makeJwt({ email: "repeat@example.com" }); + const tokenB = makeJwt({ email: "repeat@example.com" }); + + const first = await postImportToken({ accessToken: tokenA }); + const second = await postImportToken({ accessToken: tokenB }); + + assert.equal(first.status, 200); + assert.equal(second.status, 200); + assert.notEqual(first.body.connection.id, second.body.connection.id); +}); + +test("import-token: error responses never leak a stack trace", async () => { + const { body } = await postImportToken({}); + assert.ok(!JSON.stringify(body).includes("at /"), "must not leak a stack trace"); + assert.ok(!JSON.stringify(body).includes(".ts:"), "must not leak a source location"); +}); diff --git a/tests/unit/db-providers-access-token-1290.test.ts b/tests/unit/db-providers-access-token-1290.test.ts new file mode 100644 index 0000000000..85d94ec50f --- /dev/null +++ b/tests/unit/db-providers-access-token-1290.test.ts @@ -0,0 +1,119 @@ +// #1290 — bare-access-token Codex import. createProviderConnection must +// never dedup authType "access_token" rows (each import is intentionally a +// new connection — a raw access token has no stable long-lived identity to +// safely match against), and must derive a connection name from email when +// no explicit name is supplied, mirroring the existing "oauth" behavior. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-access-token-1290-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); + +async function resetStorage() { + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + break; + } catch (error: unknown) { + const code = (error as { code?: string } | null)?.code; + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("createProviderConnection: authType access_token never dedups — same email creates a new row each time", async () => { + const first = await providersDb.createProviderConnection({ + provider: "codex", + authType: "access_token", + accessToken: "eyJfirst.token.sig", + email: "user@example.com", + testStatus: "active", + }); + const second = await providersDb.createProviderConnection({ + provider: "codex", + authType: "access_token", + accessToken: "eyJsecond.token.sig", + email: "user@example.com", + testStatus: "active", + }); + + assert.notEqual(first.id, second.id); + + const rows = await providersDb.getProviderConnections({ provider: "codex" }); + assert.equal(rows.length, 2); + assert.deepEqual( + rows.map((r) => r.accessToken).sort(), + ["eyJfirst.token.sig", "eyJsecond.token.sig"].sort() + ); +}); + +test("createProviderConnection: authType access_token falls back to email for the connection name", async () => { + const conn = await providersDb.createProviderConnection({ + provider: "codex", + authType: "access_token", + accessToken: "eyJtoken.sig", + email: "labeled@example.com", + }); + + assert.equal(conn.name, "labeled@example.com"); +}); + +test("createProviderConnection: authType access_token prefers an explicit name over email", async () => { + const conn = await providersDb.createProviderConnection({ + provider: "codex", + authType: "access_token", + accessToken: "eyJtoken.sig", + email: "labeled@example.com", + name: "My Bare Token", + }); + + assert.equal(conn.name, "My Bare Token"); +}); + +test("createProviderConnection: authType access_token does not collide with an existing oauth row for the same email", async () => { + const oauthConn = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + accessToken: "oauth-access", + refreshToken: "oauth-refresh", + email: "shared@example.com", + }); + const tokenConn = await providersDb.createProviderConnection({ + provider: "codex", + authType: "access_token", + accessToken: "eyJbare.token.sig", + email: "shared@example.com", + }); + + assert.notEqual(oauthConn.id, tokenConn.id); + + // The oauth row must be untouched (not silently overwritten by the + // access_token import). + const refreshed = await providersDb.getProviderConnections({ provider: "codex" }); + const oauthRow = refreshed.find((r) => r.id === oauthConn.id); + assert.equal(oauthRow?.authType, "oauth"); + assert.equal(oauthRow?.refreshToken, "oauth-refresh"); +});