Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions src/lib/oauth/utils/codexAuthImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,23 @@ function decodeJwtPayload(jwt: string): JsonRecord | null {
}
}

function extractExpiresAt(idToken: string): string | null {
const payload = decodeJwtPayload(idToken);
function extractExpFromJwt(jwt: string): number | null {
const payload = decodeJwtPayload(jwt);
if (!payload) return null;
const exp = payload.exp;
if (typeof exp !== "number" || !Number.isFinite(exp)) return null;
return new Date(exp * 1000).toISOString();
return typeof exp === "number" && Number.isFinite(exp) ? exp : null;
}

// Prefer access_token.exp over id_token.exp — the id_token may be expired
// while the access_token (and refresh_token) are still valid. Using a stale
// id_token expiry would mark the connection as expired immediately after import
// and trigger an unnecessary refresh (which can invalidate the token family).
function extractExpiresAt(accessToken: string, idToken: string): string | null {
const accessExp = extractExpFromJwt(accessToken);
if (accessExp !== null) return new Date(accessExp * 1000).toISOString();
const idExp = extractExpFromJwt(idToken);
if (idExp !== null) return new Date(idExp * 1000).toISOString();
return null;
}

function extractJwtEmail(idToken: string): string | null {
Expand Down Expand Up @@ -132,7 +143,7 @@ export function parseAndValidateCodexAuth(raw: unknown): ParsedCodexAuth {
refreshToken,
accountId,
email: extractJwtEmail(idToken),
expiresAt: extractExpiresAt(idToken),
expiresAt: extractExpiresAt(accessToken, idToken),
};
}

Expand Down
87 changes: 87 additions & 0 deletions tests/unit/codex-auth-import-expiry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { parseAndValidateCodexAuth } from "@/lib/oauth/utils/codexAuthImport";

/**
* Guards fix for issue #6075:
* Importing a Codex auth.json where id_token.exp is already expired (but
* access_token.exp is still valid) created a broken connection because
* extractExpiresAt() used id_token.exp — triggering an immediate refresh
* that could invalidate the entire token family.
*
* Fix: extractExpiresAt now prefers access_token.exp over id_token.exp.
*/

// Helpers to build minimal signed-looking JWTs for testing.
// We only need valid base64url-encoded payloads; signature doesn't matter for parsing.
function buildFakeJwt(payload: Record<string, unknown>): string {
const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `header.${encoded}.sig`;
}

const FUTURE = Math.floor(Date.now() / 1000) + 86400; // now + 1 day
const PAST = Math.floor(Date.now() / 1000) - 86400; // now - 1 day

const BASE_ACCOUNT_ID = "acc_test123";
const BASE_PAYLOAD_AUTH = { "https://api.openai.com/auth": { account_id: BASE_ACCOUNT_ID } };

function makeTokens(opts: { accessExp?: number | null; idExp?: number | null }) {
const accessPayload = { ...(opts.accessExp !== undefined ? { exp: opts.accessExp } : {}) };
const idPayload = {
email: "user@example.com",
...(opts.idExp !== undefined ? { exp: opts.idExp } : {}),
...BASE_PAYLOAD_AUTH,
};
return {
id_token: buildFakeJwt(idPayload),
access_token: buildFakeJwt(accessPayload),
refresh_token: "rt_valid_token",
};
}

// tolerance in seconds when comparing derived expiry to the source exp claim
const TOLERANCE_S = 100;

describe("parseAndValidateCodexAuth — expiresAt derivation", () => {
it("uses access_token.exp when it is valid even if id_token.exp is expired", () => {
const tokens = makeTokens({ accessExp: FUTURE, idExp: PAST });
const result = parseAndValidateCodexAuth({ tokens });

// expiresAt should reflect the access token's future expiry, not the past id_token expiry
assert.ok(result.expiresAt, "expected a non-null expiresAt");
const parsed = new Date(result.expiresAt!).getTime() / 1000;
assert.ok(Math.abs(parsed - FUTURE) < TOLERANCE_S, `expected ~${FUTURE}, got ${parsed}`);
});

it("falls back to id_token.exp when access_token has no exp claim", () => {
// access_token has no exp field, id_token has a future exp
const tokens = makeTokens({ accessExp: null, idExp: FUTURE });
const result = parseAndValidateCodexAuth({ tokens });

assert.ok(result.expiresAt, "expected a non-null expiresAt");
const parsed = new Date(result.expiresAt!).getTime() / 1000;
assert.ok(Math.abs(parsed - FUTURE) < TOLERANCE_S, `expected ~${FUTURE}, got ${parsed}`);
});

it("returns null expiresAt when neither token has an exp claim", () => {
const tokens = makeTokens({ accessExp: null, idExp: null });
const result = parseAndValidateCodexAuth({ tokens });
assert.equal(result.expiresAt, null);
});

it("uses access_token.exp when both tokens have future expiries", () => {
const accessExp = FUTURE + 3600;
const idExp = FUTURE;
const tokens = makeTokens({ accessExp, idExp });
const result = parseAndValidateCodexAuth({ tokens });

const parsed = new Date(result.expiresAt!).getTime() / 1000;
assert.ok(Math.abs(parsed - accessExp) < TOLERANCE_S, `expected ~${accessExp}, got ${parsed}`);
});

it("preserves refresh_token from the auth.json (does not null it out)", () => {
const tokens = makeTokens({ accessExp: FUTURE, idExp: PAST });
const result = parseAndValidateCodexAuth({ tokens });
assert.equal(result.refreshToken, "rt_valid_token");
});
});
Loading