mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
This commit is contained in:
committed by
GitHub
parent
7951b60bc3
commit
2741cc5a66
1
changelog.d/features/6636-codex-session-import.md
Normal file
1
changelog.d/features/6636-codex-session-import.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(oauth): accept the full ChatGPT session JSON (not just a bare access token) when pasting Codex credentials manually or via `POST /api/oauth/codex/import-token` (#6636)
|
||||
@@ -263,7 +263,8 @@
|
||||
"src/lib/usage/usageHistory.ts": 988,
|
||||
"_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.",
|
||||
"_rebaseline_2026_07_18_7399_xai_oauth_modal": "PR #7399 (xAI OAuth PKCE) own growth: OAuthModal.tsx 993->998 (+5 = provider entry + PKCE flow branch wiring at the existing provider-switch chokepoint; the provider logic itself lives in src/lib/oauth/providers/xai-oauth.ts, new leaf). Third irreducible wiring bump on this modal (969->989->993->998); structural shrink tracked in #3501.",
|
||||
"src/shared/components/OAuthModal.tsx": 998,
|
||||
"_rebaseline_2026_07_19_6636_codex_session_json": "#6636 own growth: OAuthModal.tsx 998->1030 (gate units, split(\"\\n\").length incl. trailing newline; +32 = session-JSON paste branch for handleManualSubmit plus a shared submitCodexAccessToken() helper extracted from the pre-existing bare-JWT branch, mirroring the #5203 oauthBlobSubmit.ts extraction precedent; the normalizer logic itself lives in the new src/lib/oauth/utils/codexSessionImport.ts leaf module, not here). Fourth irreducible wiring bump on this modal (969->989->993->998->1030); structural shrink tracked in #3501.",
|
||||
"src/shared/components/OAuthModal.tsx": 1030,
|
||||
"src/shared/components/RequestLoggerV2.tsx": 1629,
|
||||
"src/shared/components/analytics/charts.tsx": 1558,
|
||||
"_rebaseline_2026_07_10_6318_omp_letta": "PR #6318 (@hamsa0x7, omp+letta CLI integrations) own growth: cliTools.ts (+53 = 2 registry entries incl. omp docsUrl) and cliRuntime.ts (+18 = runtime-detection wiring for the 2 new tools). Cohesive registry/wiring growth at the existing chokepoints; scope reduced from the original 5 tools (pi/codewhale/jcode shipped separately).",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { extractCodexAccountInfo } from "@/lib/oauth/services/codexImport";
|
||||
import { parseCodexSessionJson } from "@/lib/oauth/utils/codexSessionImport";
|
||||
import { createProviderConnection } from "@/models";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
@@ -18,15 +19,79 @@ import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/
|
||||
* expiry (forcing re-auth) instead of attempting a refresh-token exchange —
|
||||
* see open-sse/executors/codex.ts.
|
||||
*
|
||||
* Body: `{ accessToken: string, name?: string }`
|
||||
* Body: `{ accessToken: string, name?: string }` OR, defense-in-depth for
|
||||
* non-UI/API callers (#6636), the full session JSON object copied from
|
||||
* `chatgpt.com/api/auth/session` under `{ session: {...} }`.
|
||||
*
|
||||
* 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(),
|
||||
});
|
||||
const bodySchema = z.union([
|
||||
z.object({
|
||||
accessToken: z.string().trim().min(1, "accessToken is required"),
|
||||
name: z.string().trim().min(1).optional(),
|
||||
}),
|
||||
z.object({
|
||||
session: z.record(z.string(), z.unknown()),
|
||||
name: z.string().trim().min(1).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
type ResolvedBody = { accessToken: string; name?: string };
|
||||
|
||||
/** Resolve either request-body shape to a flat `{ accessToken, name }` pair. */
|
||||
function resolveAccessToken(
|
||||
parsed: z.infer<typeof bodySchema>
|
||||
): { ok: true; resolved: ResolvedBody } | { ok: false; error: string } {
|
||||
if ("accessToken" in parsed) {
|
||||
return { ok: true, resolved: { accessToken: parsed.accessToken, name: parsed.name } };
|
||||
}
|
||||
const result = parseCodexSessionJson(parsed.session);
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
return { ok: true, resolved: { accessToken: result.session.accessToken, name: parsed.name } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse + validate the request body (JSON parse, Zod schema, then the
|
||||
* accessToken/session-JSON union resolution). Returns either the resolved
|
||||
* `{ accessToken, name }` pair or a ready-to-return 400 error response —
|
||||
* keeps POST's own branch count flat as the accepted body shapes grow (#6636).
|
||||
*/
|
||||
async function parseRequestBody(
|
||||
request: Request
|
||||
): Promise<{ ok: true; resolved: ResolvedBody } | { ok: false; response: NextResponse }> {
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(buildErrorBody(400, "Invalid or empty JSON body"), {
|
||||
status: 400,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
buildErrorBody(400, parsed.error.issues[0]?.message ?? "Invalid request body"),
|
||||
{ status: 400 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = resolveAccessToken(parsed.data);
|
||||
if (!resolved.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(buildErrorBody(400, resolved.error), { status: 400 }),
|
||||
};
|
||||
}
|
||||
return { ok: true, resolved: resolved.resolved };
|
||||
}
|
||||
|
||||
async function requireAuth(request: Request): Promise<NextResponse | null> {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
@@ -38,22 +103,10 @@ 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 body = await parseRequestBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
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 { accessToken, name } = body.resolved;
|
||||
const info = extractCodexAccountInfo(accessToken);
|
||||
|
||||
if (!info.email && !info.chatgptAccountId && !name) {
|
||||
|
||||
@@ -58,10 +58,7 @@ function decodeJwtPayload(jwt: unknown): Record<string, unknown> | null {
|
||||
const missingPadding =
|
||||
(BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE;
|
||||
const padded = base64 + "=".repeat(missingPadding);
|
||||
return JSON.parse(Buffer.from(padded, "base64").toString("utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return JSON.parse(Buffer.from(padded, "base64").toString("utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -82,21 +79,31 @@ export function extractCodexAccountInfo(idToken: string): {
|
||||
} {
|
||||
const payload = decodeJwtPayload(idToken);
|
||||
if (!payload) return {};
|
||||
const chatgpt =
|
||||
(payload["https://api.openai.com/auth"] as Record<string, unknown>) || {};
|
||||
const chatgpt = (payload["https://api.openai.com/auth"] as Record<string, unknown>) || {};
|
||||
return {
|
||||
email: typeof payload.email === "string" ? payload.email : undefined,
|
||||
chatgptAccountId:
|
||||
typeof chatgpt.chatgpt_account_id === "string"
|
||||
? chatgpt.chatgpt_account_id
|
||||
: undefined,
|
||||
typeof chatgpt.chatgpt_account_id === "string" ? chatgpt.chatgpt_account_id : undefined,
|
||||
chatgptPlanType:
|
||||
typeof chatgpt.chatgpt_plan_type === "string"
|
||||
? chatgpt.chatgpt_plan_type
|
||||
: undefined,
|
||||
typeof chatgpt.chatgpt_plan_type === "string" ? chatgpt.chatgpt_plan_type : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a JWT's `exp` claim (seconds since epoch, per RFC 7519) without
|
||||
* verifying the signature. Returns `null` when the token isn't a decodable
|
||||
* JWT or carries no numeric `exp`.
|
||||
*
|
||||
* Exported so sibling Codex import paths (e.g. the session-JSON normalizer
|
||||
* at `codexSessionImport.ts`, #6636) can check expiry without duplicating a
|
||||
* 3rd inline JWT decoder.
|
||||
*/
|
||||
export function decodeJwtExp(jwt: unknown): number | null {
|
||||
const payload = decodeJwtPayload(jwt);
|
||||
const exp = payload && typeof payload.exp === "number" ? payload.exp : null;
|
||||
return exp !== null && Number.isFinite(exp) ? exp : null;
|
||||
}
|
||||
|
||||
function pickString(...candidates: (string | undefined)[]): string | undefined {
|
||||
for (const c of candidates) {
|
||||
if (typeof c === "string" && c.trim()) return c.trim();
|
||||
@@ -146,9 +153,7 @@ function unwrapCodexAuthJson(rec: Record<string, unknown>): Record<string, unkno
|
||||
* its camelCase alias when it is absent, so a snake_case or mixed export keeps
|
||||
* working unchanged.
|
||||
*/
|
||||
function applyCamelCaseAliases(
|
||||
rec: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
function applyCamelCaseAliases(rec: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...rec };
|
||||
const fillFrom = (snake: string, value: unknown) => {
|
||||
if (out[snake] === undefined && typeof value === "string" && value) {
|
||||
@@ -181,9 +186,7 @@ export function normalizeCodexImportRecord(input: unknown): NormalizeResult {
|
||||
return { ok: false, error: "Record is not an object" };
|
||||
}
|
||||
|
||||
const rec = applyCamelCaseAliases(
|
||||
unwrapCodexAuthJson(input as Record<string, unknown>),
|
||||
);
|
||||
const rec = applyCamelCaseAliases(unwrapCodexAuthJson(input as Record<string, unknown>));
|
||||
|
||||
// Allow type field to be missing or "codex"; reject anything else explicitly so
|
||||
// users don't accidentally import claude/gemini exports through this path.
|
||||
@@ -211,15 +214,14 @@ export function normalizeCodexImportRecord(input: unknown): NormalizeResult {
|
||||
|
||||
const chatgptAccountId = pickString(
|
||||
fromJwt.chatgptAccountId,
|
||||
rec.account_id as string | undefined,
|
||||
rec.account_id as string | undefined
|
||||
);
|
||||
const chatgptPlanType = pickString(
|
||||
fromJwt.chatgptPlanType,
|
||||
rec.chatgpt_plan_type as string | undefined,
|
||||
rec.chatgpt_plan_type as string | undefined
|
||||
);
|
||||
|
||||
const expiresAt =
|
||||
parseExpiry(rec.expired) ?? parseAccessTokenExp(accessToken);
|
||||
const expiresAt = parseExpiry(rec.expired) ?? parseAccessTokenExp(accessToken);
|
||||
|
||||
const providerSpecificData: CodexImportPayload["providerSpecificData"] = {};
|
||||
if (chatgptAccountId) providerSpecificData.chatgptAccountId = chatgptAccountId;
|
||||
|
||||
125
src/lib/oauth/utils/codexSessionImport.ts
Normal file
125
src/lib/oauth/utils/codexSessionImport.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Codex (OpenAI) session-JSON normalizer
|
||||
*
|
||||
* Accepts the raw JSON object copied straight from
|
||||
* `https://chatgpt.com/api/auth/session` (`{ user: {...}, accessToken, expires }`,
|
||||
* NextAuth's session-endpoint shape) and extracts a bare access token + optional
|
||||
* email, the same credential type already accepted by the bare-token import path
|
||||
* (`POST /api/oauth/codex/import-token`, #1290) — this module only widens what
|
||||
* shapes of pasted input can reach that endpoint.
|
||||
*
|
||||
* Pure: no I/O, no network, no DB import. Safe to unit-test. Mirrors the style
|
||||
* of `codexAuthImport.ts` / `codexImport.ts`, deliberately kept separate from
|
||||
* both: the bulk `auth.json` import requires a `refresh_token`; this path is
|
||||
* for a bare access token and deliberately does not.
|
||||
*
|
||||
* Ref: #6636
|
||||
*/
|
||||
|
||||
import { decodeJwtExp } from "@/lib/oauth/services/codexImport";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ParsedCodexSession = { accessToken: string; email?: string };
|
||||
export type ParseResult = { ok: true; session: ParsedCodexSession } | { ok: false; error: string };
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function toNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
/** Looks like a JWT (3 dot-separated segments) — a loose shape check only. */
|
||||
function looksLikeJwt(value: string): boolean {
|
||||
return value.split(".").length === 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field-name aliases for the access token across known Codex/ChatGPT session
|
||||
* export shapes: the literal `accessToken` returned by `chatgpt.com/api/auth/session`
|
||||
* first, then snake_case and alternate names, then the same aliases nested one
|
||||
* level under `tokens` (mirrors `unwrapCodexAuthJson` in `codexAuthImport.ts`).
|
||||
*/
|
||||
function findAccessToken(rec: Record<string, unknown>): string | undefined {
|
||||
const direct =
|
||||
toNonEmptyString(rec.accessToken) ||
|
||||
toNonEmptyString(rec.access_token) ||
|
||||
toNonEmptyString(rec.sessionToken) ||
|
||||
toNonEmptyString(rec.session_token);
|
||||
if (direct) return direct;
|
||||
|
||||
const nested = toRecord(rec.tokens);
|
||||
if (!nested) return undefined;
|
||||
return toNonEmptyString(nested.access_token) || toNonEmptyString(nested.accessToken);
|
||||
}
|
||||
|
||||
/** True when either expiry signal (top-level `expires` ISO string, or JWT `exp`) is in the past. */
|
||||
function isExpired(rec: Record<string, unknown>, accessToken: string): boolean {
|
||||
const expiresField = toNonEmptyString(rec.expires);
|
||||
if (expiresField) {
|
||||
const ms = Date.parse(expiresField);
|
||||
if (Number.isFinite(ms) && ms <= Date.now()) return true;
|
||||
}
|
||||
const exp = decodeJwtExp(accessToken);
|
||||
return exp !== null && exp * 1000 <= Date.now();
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* True if the pasted text looks like a JSON object (vs a bare JWT or an OAuth
|
||||
* callback URL/code) — a cheap guard so the modal only attempts the JSON
|
||||
* normalizer on plausible input and every other paste shape falls through to
|
||||
* the existing parsers unchanged.
|
||||
*/
|
||||
export function looksLikeCodexSessionJson(value: string): boolean {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (!trimmed.startsWith("{")) return false;
|
||||
try {
|
||||
return toRecord(JSON.parse(trimmed)) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a bare access token (+ optional email) from a parsed
|
||||
* `chatgpt.com/api/auth/session`-shaped JSON value. Returns a typed error
|
||||
* (never throws) for malformed, tokenless, or expired input.
|
||||
*/
|
||||
export function parseCodexSessionJson(raw: unknown): ParseResult {
|
||||
const rec = toRecord(raw);
|
||||
if (!rec) {
|
||||
return { ok: false, error: "Pasted session data is not a JSON object" };
|
||||
}
|
||||
|
||||
const accessToken = findAccessToken(rec);
|
||||
if (!accessToken) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Could not find an access token field in the pasted session JSON (expected accessToken, access_token, sessionToken, or tokens.access_token)",
|
||||
};
|
||||
}
|
||||
if (!looksLikeJwt(accessToken)) {
|
||||
return { ok: false, error: "The access token field does not look like a valid JWT" };
|
||||
}
|
||||
|
||||
if (isExpired(rec, accessToken)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Session is expired — sign in to chatgpt.com again and re-copy the session JSON",
|
||||
};
|
||||
}
|
||||
|
||||
const user = toRecord(rec.user);
|
||||
const email = (user && toNonEmptyString(user.email)) || undefined;
|
||||
|
||||
return { ok: true, session: { accessToken, email } };
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import LinkifiedText from "./LinkifiedText";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { parseResponseBody, getErrorMessage } from "@/shared/utils/api";
|
||||
import { isCredentialBlob, submitCredentialBlob } from "@/shared/components/oauthBlobSubmit";
|
||||
import {
|
||||
looksLikeCodexSessionJson,
|
||||
parseCodexSessionJson,
|
||||
} from "@/lib/oauth/utils/codexSessionImport";
|
||||
|
||||
const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "agy"]);
|
||||
|
||||
@@ -23,6 +27,27 @@ const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex", "xai-oauth"]);
|
||||
*/
|
||||
const IMPORT_TOKEN_ONLY_PROVIDERS = new Set(["windsurf", "devin-cli", "grok-cli"]);
|
||||
|
||||
// POST a bare Codex access token to the access-token-only import endpoint
|
||||
// (#1290); shared by the bare-JWT and session-JSON paste branches (#6636).
|
||||
async function submitCodexAccessToken(
|
||||
accessToken: string,
|
||||
name: string | undefined,
|
||||
setStep: (s: string) => void,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
const res = await fetch("/api/oauth/codex/import-token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ accessToken, name }),
|
||||
});
|
||||
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?.();
|
||||
}
|
||||
|
||||
type OAuthModalProps = {
|
||||
isOpen: boolean;
|
||||
provider?: string;
|
||||
@@ -672,17 +697,24 @@ export default function OAuthModal({
|
||||
// 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"));
|
||||
await submitCodexAccessToken(callbackUrl.trim(), undefined, setStep, onSuccess);
|
||||
return;
|
||||
}
|
||||
|
||||
// Codex: full session JSON from chatgpt.com/api/auth/session
|
||||
// (`{user, accessToken, expires}`), not just the bare token (#6636).
|
||||
if (provider === "codex" && looksLikeCodexSessionJson(callbackUrl)) {
|
||||
const result = parseCodexSessionJson(JSON.parse(callbackUrl.trim()));
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setStep("success");
|
||||
onSuccess?.();
|
||||
await submitCodexAccessToken(
|
||||
result.session.accessToken,
|
||||
result.session.email,
|
||||
setStep,
|
||||
onSuccess
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -141,3 +141,42 @@ test("import-token: error responses never leak a stack trace", async () => {
|
||||
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");
|
||||
});
|
||||
|
||||
// #6636 — defense-in-depth: the route also accepts the full session JSON
|
||||
// object copied from chatgpt.com/api/auth/session under `{ session: {...} }`.
|
||||
test("import-token: accepts a full session-JSON body and creates a connection identical to the bare-accessToken path", async () => {
|
||||
const accessToken = makeJwt({
|
||||
email: "session-json@example.com",
|
||||
"https://api.openai.com/auth": { chatgpt_account_id: "acct-session" },
|
||||
});
|
||||
|
||||
const { status, body } = await postImportToken({
|
||||
session: {
|
||||
user: { email: "session-json@example.com" },
|
||||
accessToken,
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.success, true);
|
||||
assert.equal(body.connection.provider, "codex");
|
||||
assert.equal(body.connection.email, "session-json@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);
|
||||
});
|
||||
|
||||
test("import-token: session-JSON body with an expired session is rejected with an actionable 400", async () => {
|
||||
const accessToken = makeJwt({ email: "expired@example.com" });
|
||||
const { status, body } = await postImportToken({
|
||||
session: {
|
||||
accessToken,
|
||||
expires: new Date(Date.now() - 60_000).toISOString(),
|
||||
},
|
||||
});
|
||||
assert.equal(status, 400);
|
||||
assert.match(body.error.message, /expired/i);
|
||||
});
|
||||
|
||||
118
tests/unit/codex-session-json-import-6636.test.ts
Normal file
118
tests/unit/codex-session-json-import-6636.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
// Unit tests for the Codex session-JSON normalizer (#6636).
|
||||
//
|
||||
// Reproduces the bug: pasting the full JSON object copied from
|
||||
// `https://chatgpt.com/api/auth/session` (`{user, accessToken, expires}`)
|
||||
// into the Codex OAuth modal used to fall through to the OAuth-code parser
|
||||
// and error out — only a bare JWT (`/^eyJ/`) was recognized. These tests
|
||||
// exercise the pure normalizer in isolation (no DB, no fetch).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
looksLikeCodexSessionJson,
|
||||
parseCodexSessionJson,
|
||||
} from "../../src/lib/oauth/utils/codexSessionImport.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("parseCodexSessionJson: extracts accessToken from the exact chatgpt.com/api/auth/session shape", () => {
|
||||
const accessToken = makeJwt({ email: "session@example.com" });
|
||||
const result = parseCodexSessionJson({
|
||||
user: { email: "session@example.com" },
|
||||
accessToken,
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
if (result.ok) {
|
||||
assert.equal(result.session.accessToken, accessToken);
|
||||
assert.equal(result.session.email, "session@example.com");
|
||||
}
|
||||
});
|
||||
|
||||
test("parseCodexSessionJson: accepts access_token, sessionToken, and nested tokens.access_token aliases", () => {
|
||||
const jwt = makeJwt({});
|
||||
const snakeCase = parseCodexSessionJson({ access_token: jwt });
|
||||
assert.equal(snakeCase.ok, true);
|
||||
|
||||
const sessionToken = parseCodexSessionJson({ sessionToken: jwt });
|
||||
assert.equal(sessionToken.ok, true);
|
||||
|
||||
const nested = parseCodexSessionJson({ tokens: { access_token: jwt } });
|
||||
assert.equal(nested.ok, true);
|
||||
if (nested.ok) assert.equal(nested.session.accessToken, jwt);
|
||||
});
|
||||
|
||||
test("parseCodexSessionJson: rejects malformed / non-object input with a typed error, not a throw", () => {
|
||||
assert.equal(parseCodexSessionJson(null).ok, false);
|
||||
assert.equal(parseCodexSessionJson("just a string").ok, false);
|
||||
assert.equal(parseCodexSessionJson(42).ok, false);
|
||||
assert.equal(parseCodexSessionJson([]).ok, false);
|
||||
|
||||
const result = parseCodexSessionJson("just a string");
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) assert.match(result.error, /not a JSON object/i);
|
||||
});
|
||||
|
||||
test("parseCodexSessionJson: rejects an object with no recognizable token field", () => {
|
||||
const result = parseCodexSessionJson({ user: { email: "no-token@example.com" } });
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) assert.match(result.error, /access token/i);
|
||||
});
|
||||
|
||||
test("parseCodexSessionJson: rejects an expired session via the top-level `expires` field", () => {
|
||||
const accessToken = makeJwt({});
|
||||
const result = parseCodexSessionJson({
|
||||
accessToken,
|
||||
expires: new Date(Date.now() - 60_000).toISOString(),
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) assert.match(result.error, /expired/i);
|
||||
});
|
||||
|
||||
test("parseCodexSessionJson: rejects an expired session via the JWT `exp` claim", () => {
|
||||
const pastExpSeconds = Math.floor((Date.now() - 60_000) / 1000);
|
||||
const accessToken = makeJwt({ exp: pastExpSeconds });
|
||||
const result = parseCodexSessionJson({ accessToken });
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) assert.match(result.error, /expired/i);
|
||||
});
|
||||
|
||||
test("parseCodexSessionJson: accepts a non-expired session with a future JWT `exp` claim", () => {
|
||||
const futureExpSeconds = Math.floor((Date.now() + 60_000) / 1000);
|
||||
const accessToken = makeJwt({ exp: futureExpSeconds });
|
||||
const result = parseCodexSessionJson({ accessToken });
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
test("parseCodexSessionJson: rejects a token field that does not look like a JWT", () => {
|
||||
const result = parseCodexSessionJson({ accessToken: "not-a-jwt" });
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) assert.match(result.error, /JWT/i);
|
||||
});
|
||||
|
||||
test("looksLikeCodexSessionJson: true for a JSON object string", () => {
|
||||
assert.equal(looksLikeCodexSessionJson('{"accessToken":"eyJ.eyJ.sig"}'), true);
|
||||
assert.equal(looksLikeCodexSessionJson(' {"user":{}} '), true);
|
||||
});
|
||||
|
||||
test("looksLikeCodexSessionJson: false for a bare JWT, an OAuth callback URL, and malformed JSON", () => {
|
||||
assert.equal(looksLikeCodexSessionJson("eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxIn0.sig"), false);
|
||||
assert.equal(
|
||||
looksLikeCodexSessionJson("https://example.com/callback?code=abc&state=xyz"),
|
||||
false
|
||||
);
|
||||
assert.equal(looksLikeCodexSessionJson("{not valid json"), false);
|
||||
assert.equal(looksLikeCodexSessionJson(""), false);
|
||||
});
|
||||
Reference in New Issue
Block a user