Skip to content

Commit 105db29

Browse files
committed
fix(direct): show the real account email (decode id_token, not access token)
The directRunner showed 'ChatGPT account' because readTokens discarded the id_token and the account label fell back to the access token — which carries the chatgpt_account_id claim but NOT email/name (verified against ~/.codex/auth.json: access has only sub; id_token has email + name). Now we keep the id_token and decode the label from it, and preserve the known label across refreshes (a refresh response may omit the id_token). Updated the test to the real token shape (account-id in access, email in id_token). 114 tests passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8543b6f commit 105db29

3 files changed

Lines changed: 29 additions & 11 deletions

File tree

src/backend/direct/directRunner.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,11 @@ export function directRunner(opts: DirectRunnerOptions = {}): CodexRunner {
6464
if (!s.creds) return null;
6565
// Refresh slightly before expiry.
6666
if (s.creds.expires - Date.now() < 60_000) {
67+
const prevAccount = s.creds.account;
6768
try {
68-
s.creds = await refreshAccessToken(s.creds.refresh, f);
69+
const refreshed = await refreshAccessToken(s.creds.refresh, f);
70+
// A refresh response may omit the id_token; keep the known email/name.
71+
s.creds = { ...refreshed, account: refreshed.account ?? prevAccount };
6972
} catch {
7073
s.creds = undefined;
7174
return null;

src/backend/direct/oauth.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ const JWT_CLAIM = "https://api.openai.com/auth";
2727
export interface OAuthTokens {
2828
access: string;
2929
refresh: string;
30+
/** The id_token, when the grant returned one. Carries email/name claims. */
31+
idToken?: string;
3032
/** Absolute epoch-ms expiry of the access token. */
3133
expires: number;
3234
}
@@ -67,7 +69,8 @@ function accountIdFrom(accessToken: string): string | null {
6769
return typeof id === "string" && id.length > 0 ? id : null;
6870
}
6971

70-
function accountLabelFrom(token: string): string | undefined {
72+
function accountLabelFrom(token: string | undefined): string | undefined {
73+
if (!token) return undefined;
7174
const c = decodeJwt(token) as { email?: string; name?: string } | null;
7275
return c?.email ?? c?.name ?? undefined;
7376
}
@@ -89,17 +92,19 @@ async function readTokens(res: Response, op: string): Promise<OAuthTokens> {
8992
return {
9093
access: json.access_token,
9194
refresh: json.refresh_token,
95+
idToken: json.id_token,
9296
expires: Date.now() + json.expires_in * 1000,
9397
};
9498
}
9599

96-
function credentials(tokens: OAuthTokens, idToken?: string): OAuthCredentials {
100+
function credentials(tokens: OAuthTokens): OAuthCredentials {
97101
const accountId = accountIdFrom(tokens.access);
98102
if (!accountId) throw new Error("could not extract chatgpt_account_id from access token");
103+
// email/name live in the id_token, NOT the access token — prefer it.
99104
return {
100105
...tokens,
101106
accountId,
102-
account: accountLabelFrom(idToken ?? tokens.access),
107+
account: accountLabelFrom(tokens.idToken) ?? accountLabelFrom(tokens.access),
103108
};
104109
}
105110

tests/directRunner.test.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@ import { accountIdFrom } from "../src/backend/direct/oauth.js";
55
import type { SessionCtx } from "../src/backend/types.js";
66
import type { RunStreamEvent } from "../src/core/contract.js";
77

8-
// Build a fake access token JWT whose claim carries the chatgpt_account_id.
9-
function fakeAccessToken(accountId: string, email = "me@example.com"): string {
8+
function jwt(payload: object): string {
109
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url");
11-
const payload = Buffer.from(
12-
JSON.stringify({ email, "https://api.openai.com/auth": { chatgpt_account_id: accountId } }),
13-
).toString("base64url");
14-
return `${header}.${payload}.sig`;
10+
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
11+
return `${header}.${body}.sig`;
12+
}
13+
// REAL shape: the access token carries the account-id claim but NO email/name;
14+
// email/name live in the id_token. (Verified against ~/.codex/auth.json.)
15+
function fakeAccessToken(accountId: string): string {
16+
return jwt({ sub: "user", "https://api.openai.com/auth": { chatgpt_account_id: accountId } });
17+
}
18+
function fakeIdToken(email: string): string {
19+
return jwt({ email, name: "Test User" });
1520
}
1621

1722
const json = (status: number, body: unknown, headers: Record<string, string> = {}) =>
@@ -41,7 +46,12 @@ describe("directRunner — device flow over mocked fetch", () => {
4146
}
4247

4348
const token = fakeAccessToken("acct-9");
44-
const tokenBody = { access_token: token, refresh_token: "r1", expires_in: 3600 };
49+
const tokenBody = {
50+
access_token: token,
51+
refresh_token: "r1",
52+
id_token: fakeIdToken("me@example.com"),
53+
expires_in: 3600,
54+
};
4555

4656
it("startDeviceLogin returns a login URL + user code", async () => {
4757
const f = mockFetch({

0 commit comments

Comments
 (0)