diff --git a/changelog.d/features/6636-codex-session-import.md b/changelog.d/features/6636-codex-session-import.md new file mode 100644 index 0000000000..17d794f787 --- /dev/null +++ b/changelog.d/features/6636-codex-session-import.md @@ -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) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 57e16cf512..5586cc71ba 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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).", diff --git a/src/app/api/oauth/codex/import-token/route.ts b/src/app/api/oauth/codex/import-token/route.ts index 6706f2b5a5..4601b2ae2d 100644 --- a/src/app/api/oauth/codex/import-token/route.ts +++ b/src/app/api/oauth/codex/import-token/route.ts @@ -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 +): { 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 { 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) { diff --git a/src/lib/oauth/services/codexImport.ts b/src/lib/oauth/services/codexImport.ts index 9f5b929d34..aa8a362c44 100644 --- a/src/lib/oauth/services/codexImport.ts +++ b/src/lib/oauth/services/codexImport.ts @@ -58,10 +58,7 @@ function decodeJwtPayload(jwt: unknown): Record | 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; } 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) || {}; + const chatgpt = (payload["https://api.openai.com/auth"] as Record) || {}; 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): Record, -): Record { +function applyCamelCaseAliases(rec: Record): Record { const out: Record = { ...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), - ); + const rec = applyCamelCaseAliases(unwrapCodexAuthJson(input as Record)); // 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; diff --git a/src/lib/oauth/utils/codexSessionImport.ts b/src/lib/oauth/utils/codexSessionImport.ts new file mode 100644 index 0000000000..ec63de0973 --- /dev/null +++ b/src/lib/oauth/utils/codexSessionImport.ts @@ -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 | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : 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 | 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, 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 } }; +} diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 07bb0a34ad..7d079724b2 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -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 { + 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; + 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; - 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; } diff --git a/tests/unit/codex-import-token-route.test.ts b/tests/unit/codex-import-token-route.test.ts index 83f2c8ad3f..bff8c49b83 100644 --- a/tests/unit/codex-import-token-route.test.ts +++ b/tests/unit/codex-import-token-route.test.ts @@ -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); +}); diff --git a/tests/unit/codex-session-json-import-6636.test.ts b/tests/unit/codex-session-json-import-6636.test.ts new file mode 100644 index 0000000000..301ad2b7d1 --- /dev/null +++ b/tests/unit/codex-session-json-import-6636.test.ts @@ -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 { + 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); +});