mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
committed by
GitHub
parent
cbe2ec1244
commit
cd10a6f5f4
@@ -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
|
||||
|
||||
|
||||
103
src/app/api/oauth/codex/import-token/route.ts
Normal file
103
src/app/api/oauth/codex/import-token/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
|
||||
143
tests/unit/codex-import-token-route.test.ts
Normal file
143
tests/unit/codex-import-token-route.test.ts
Normal 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");
|
||||
});
|
||||
119
tests/unit/db-providers-access-token-1290.test.ts
Normal file
119
tests/unit/db-providers-access-token-1290.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user