fix: decode codex jwt payloads as base64url

This commit is contained in:
Peter Steinberger
2026-05-26 14:26:37 +01:00
parent ae5fc4629b
commit 53ede53847
4 changed files with 71 additions and 40 deletions

View File

@@ -45,6 +45,19 @@ describe("resolveCodexAuthIdentity", () => {
});
});
it("decodes URL-safe base64 JWT payloads", () => {
const accessToken = createJwt({
"https://api.openai.com/auth": {
chatgpt_account_id: "w_ébé_1fzcswWN6Pi5zL",
},
});
expect(accessToken.split(".")[1]).toContain("_");
expect(resolveCodexAuthIdentity({ accessToken })).toEqual({
accountId: "w_ébé_1fzcswWN6Pi5zL",
});
});
it("falls back to credential email before synthetic ids", () => {
const identity = resolveCodexAuthIdentity({
accessToken: createJwt({}),

View File

@@ -17,6 +17,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
});
}
import { resolveCodexAuthIdentity } from "./openai-codex-auth-identity.js";
import { oauthErrorHtml, oauthSuccessHtml } from "./openai-codex-oauth-page.runtime.js";
import type {
OAuthCredentials,
@@ -32,7 +33,6 @@ const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
const TOKEN_URL = "https://auth.openai.com/oauth/token";
const REDIRECT_URI = "http://localhost:1455/auth/callback";
const SCOPE = "openid profile email offline_access";
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number };
type TokenFailure = { type: "failed"; message: string; status?: number };
@@ -43,13 +43,6 @@ type TokenResponseJson = {
expires_in?: number;
};
type JwtPayload = {
[JWT_CLAIM_PATH]?: {
chatgpt_account_id?: string;
};
[key: string]: unknown;
};
function createState(): string {
if (!randomBytes) {
throw new Error("OpenAI Codex OAuth is only available in Node.js environments");
@@ -89,20 +82,6 @@ function parseAuthorizationInput(input: string): { code?: string; state?: string
return { code: value };
}
function decodeJwt(token: string): JwtPayload | null {
try {
const parts = token.split(".");
if (parts.length !== 3) {
return null;
}
const payload = parts[1] ?? "";
const decoded = atob(payload);
return JSON.parse(decoded) as JwtPayload;
} catch {
return null;
}
}
function formatMissingTokenResponseFields(json: TokenResponseJson): string {
const missing: string[] = [];
if (!json.access_token) {
@@ -310,9 +289,7 @@ function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
}
function getAccountId(accessToken: string): string | null {
const payload = decodeJwt(accessToken);
const auth = payload?.[JWT_CLAIM_PATH];
const accountId = auth?.chatgpt_account_id;
const accountId = resolveCodexAuthIdentity({ accessToken }).accountId;
return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
}

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { extractOpenAICodexAccountId } from "./openai-codex-responses.js";
function createJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${header}.${body}.signature`;
}
describe("extractOpenAICodexAccountId", () => {
it("decodes URL-safe base64 JWT payloads", () => {
const accessToken = createJwt({
"https://api.openai.com/auth": {
chatgpt_account_id: "w_ébé_1fzcswWN6Pi5zL",
},
});
expect(accessToken.split(".")[1]).toContain("_");
expect(extractOpenAICodexAccountId(accessToken)).toBe("w_ébé_1fzcswWN6Pi5zL");
});
it("rejects tokens without a Codex account id", () => {
expect(() => extractOpenAICodexAccountId(createJwt({}))).toThrow(
"Failed to extract accountId from token",
);
});
});

View File

@@ -106,6 +106,12 @@ interface RequestBody {
[key: string]: unknown;
}
type CodexJwtPayload = {
[JWT_CLAIM_PATH]?: {
chatgpt_account_id?: unknown;
};
};
// ============================================================================
// Retry Helpers
// ============================================================================
@@ -172,7 +178,7 @@ export const streamOpenAICodexResponses: StreamFunction<
throw new Error(`No API key for provider: ${model.provider}`);
}
const accountId = extractAccountId(apiKey);
const accountId = extractOpenAICodexAccountId(apiKey);
let body = buildRequestBody(model, context, options);
const nextBody = await options?.onPayload?.(body, model);
if (nextBody !== undefined) {
@@ -1458,21 +1464,29 @@ async function parseErrorResponse(
// Auth & Headers
// ============================================================================
function extractAccountId(token: string): string {
try {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("Invalid token");
}
const payload = JSON.parse(atob(parts[1]));
const accountId = payload?.[JWT_CLAIM_PATH]?.chatgpt_account_id;
if (!accountId) {
throw new Error("No account ID in token");
}
return accountId;
} catch {
throw new Error("Failed to extract accountId from token");
function decodeCodexJwtPayload(token: string): CodexJwtPayload | null {
const parts = token.split(".");
if (parts.length !== 3) {
return null;
}
try {
const decoded = Buffer.from(parts[1] ?? "", "base64url").toString("utf8");
const parsed = JSON.parse(decoded);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as CodexJwtPayload)
: null;
} catch {
return null;
}
}
export function extractOpenAICodexAccountId(token: string): string {
const accountId = decodeCodexJwtPayload(token)?.[JWT_CLAIM_PATH]?.chatgpt_account_id;
if (typeof accountId === "string" && accountId.length > 0) {
return accountId;
}
throw new Error("Failed to extract accountId from token");
}
function createCodexRequestId(): string {