feat(oauth): import Codex connection from a raw ChatGPT access token (#5995)

* 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>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-03 00:15:34 -03:00
committed by GitHub
parent cbe2ec1244
commit cd10a6f5f4
7 changed files with 404 additions and 2 deletions

View File

@@ -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

View File

@@ -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<NextResponse | null> {
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<string, string> = {};
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 }
);
}
}

View File

@@ -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) {

View File

@@ -67,7 +67,15 @@ function decodeJwtPayload(jwt: unknown): Record<string, unknown> | 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;

View File

@@ -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<string, unknown>;
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."

View File

@@ -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, unknown>): 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");
});

View File

@@ -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");
});