Skip to content

Commit 1662e14

Browse files
authored
fix: add ChatGPT-Account-Id header for organization subscriptions (anomalyco#7603)
1 parent dfe3e79 commit 1662e14

File tree

4 files changed

+186
-12
lines changed

4 files changed

+186
-12
lines changed

packages/opencode/src/auth/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export namespace Auth {
1212
refresh: z.string(),
1313
access: z.string(),
1414
expires: z.number(),
15+
accountId: z.string().optional(),
1516
enterpriseUrl: z.string().optional(),
1617
})
1718
.meta({ ref: "OAuth" })

packages/opencode/src/plugin/codex.ts

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,46 @@ function generateState(): string {
4242
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
4343
}
4444

45+
export interface IdTokenClaims {
46+
chatgpt_account_id?: string
47+
organizations?: Array<{ id: string }>
48+
email?: string
49+
"https://api.openai.com/auth"?: {
50+
chatgpt_account_id?: string
51+
}
52+
}
53+
54+
export function parseJwtClaims(token: string): IdTokenClaims | undefined {
55+
const parts = token.split(".")
56+
if (parts.length !== 3) return undefined
57+
try {
58+
return JSON.parse(Buffer.from(parts[1], "base64url").toString())
59+
} catch {
60+
return undefined
61+
}
62+
}
63+
64+
export function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined {
65+
return (
66+
claims.chatgpt_account_id ||
67+
claims["https://api.openai.com/auth"]?.chatgpt_account_id ||
68+
claims.organizations?.[0]?.id
69+
)
70+
}
71+
72+
export function extractAccountId(tokens: TokenResponse): string | undefined {
73+
if (tokens.id_token) {
74+
const claims = parseJwtClaims(tokens.id_token)
75+
const accountId = claims && extractAccountIdFromClaims(claims)
76+
if (accountId) return accountId
77+
}
78+
if (tokens.access_token) {
79+
const claims = parseJwtClaims(tokens.access_token)
80+
return claims ? extractAccountIdFromClaims(claims) : undefined
81+
}
82+
return undefined
83+
}
84+
4585
function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
4686
const params = new URLSearchParams({
4787
response_type: "code",
@@ -380,20 +420,26 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
380420
const currentAuth = await getAuth()
381421
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
382422

423+
// Cast to include accountId field
424+
const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
425+
383426
// Check if token needs refresh
384427
if (!currentAuth.access || currentAuth.expires < Date.now()) {
385428
log.info("refreshing codex access token")
386429
const tokens = await refreshAccessToken(currentAuth.refresh)
430+
const newAccountId = extractAccountId(tokens) || authWithAccount.accountId
387431
await input.client.auth.set({
388432
path: { id: "codex" },
389433
body: {
390434
type: "oauth",
391435
refresh: tokens.refresh_token,
392436
access: tokens.access_token,
393437
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
438+
...(newAccountId && { accountId: newAccountId }),
394439
},
395440
})
396441
currentAuth.access = tokens.access_token
442+
authWithAccount.accountId = newAccountId
397443
}
398444

399445
// Build headers
@@ -415,20 +461,20 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
415461
// Set authorization header with access token
416462
headers.set("authorization", `Bearer ${currentAuth.access}`)
417463

418-
// Rewrite URL to Codex endpoint
419-
let url: URL
420-
if (typeof requestInput === "string") {
421-
url = new URL(requestInput)
422-
} else if (requestInput instanceof URL) {
423-
url = requestInput
424-
} else {
425-
url = new URL(requestInput.url)
464+
// Set ChatGPT-Account-Id header for organization subscriptions
465+
if (authWithAccount.accountId) {
466+
headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
426467
}
427468

428-
// If this is a messages/responses request, redirect to Codex endpoint
429-
if (url.pathname.includes("/v1/responses") || url.pathname.includes("/chat/completions")) {
430-
url = new URL(CODEX_API_ENDPOINT)
431-
}
469+
// Rewrite URL to Codex endpoint
470+
const parsed =
471+
requestInput instanceof URL
472+
? requestInput
473+
: new URL(typeof requestInput === "string" ? requestInput : requestInput.url)
474+
const url =
475+
parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions")
476+
? new URL(CODEX_API_ENDPOINT)
477+
: parsed
432478

433479
return fetch(url, {
434480
...init,
@@ -456,11 +502,13 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
456502
callback: async () => {
457503
const tokens = await callbackPromise
458504
stopOAuthServer()
505+
const accountId = extractAccountId(tokens)
459506
return {
460507
type: "success" as const,
461508
refresh: tokens.refresh_token,
462509
access: tokens.access_token,
463510
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
511+
accountId,
464512
}
465513
},
466514
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, expect, test } from "bun:test"
2+
import {
3+
parseJwtClaims,
4+
extractAccountIdFromClaims,
5+
extractAccountId,
6+
type IdTokenClaims,
7+
} from "../../src/plugin/codex"
8+
9+
function createTestJwt(payload: object): string {
10+
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")
11+
const body = Buffer.from(JSON.stringify(payload)).toString("base64url")
12+
return `${header}.${body}.sig`
13+
}
14+
15+
describe("plugin.codex", () => {
16+
describe("parseJwtClaims", () => {
17+
test("parses valid JWT with claims", () => {
18+
const payload = { email: "test@example.com", chatgpt_account_id: "acc-123" }
19+
const jwt = createTestJwt(payload)
20+
const claims = parseJwtClaims(jwt)
21+
expect(claims).toEqual(payload)
22+
})
23+
24+
test("returns undefined for JWT with less than 3 parts", () => {
25+
expect(parseJwtClaims("invalid")).toBeUndefined()
26+
expect(parseJwtClaims("only.two")).toBeUndefined()
27+
})
28+
29+
test("returns undefined for invalid base64", () => {
30+
expect(parseJwtClaims("a.!!!invalid!!!.b")).toBeUndefined()
31+
})
32+
33+
test("returns undefined for invalid JSON payload", () => {
34+
const header = Buffer.from("{}").toString("base64url")
35+
const invalidJson = Buffer.from("not json").toString("base64url")
36+
expect(parseJwtClaims(`${header}.${invalidJson}.sig`)).toBeUndefined()
37+
})
38+
})
39+
40+
describe("extractAccountIdFromClaims", () => {
41+
test("extracts chatgpt_account_id from root", () => {
42+
const claims: IdTokenClaims = { chatgpt_account_id: "acc-root" }
43+
expect(extractAccountIdFromClaims(claims)).toBe("acc-root")
44+
})
45+
46+
test("extracts chatgpt_account_id from nested https://api.openai.com/auth", () => {
47+
const claims: IdTokenClaims = {
48+
"https://api.openai.com/auth": { chatgpt_account_id: "acc-nested" },
49+
}
50+
expect(extractAccountIdFromClaims(claims)).toBe("acc-nested")
51+
})
52+
53+
test("prefers root over nested", () => {
54+
const claims: IdTokenClaims = {
55+
chatgpt_account_id: "acc-root",
56+
"https://api.openai.com/auth": { chatgpt_account_id: "acc-nested" },
57+
}
58+
expect(extractAccountIdFromClaims(claims)).toBe("acc-root")
59+
})
60+
61+
test("extracts from organizations array as fallback", () => {
62+
const claims: IdTokenClaims = {
63+
organizations: [{ id: "org-123" }, { id: "org-456" }],
64+
}
65+
expect(extractAccountIdFromClaims(claims)).toBe("org-123")
66+
})
67+
68+
test("returns undefined when no accountId found", () => {
69+
const claims: IdTokenClaims = { email: "test@example.com" }
70+
expect(extractAccountIdFromClaims(claims)).toBeUndefined()
71+
})
72+
})
73+
74+
describe("extractAccountId", () => {
75+
test("extracts from id_token first", () => {
76+
const idToken = createTestJwt({ chatgpt_account_id: "from-id-token" })
77+
const accessToken = createTestJwt({ chatgpt_account_id: "from-access-token" })
78+
expect(
79+
extractAccountId({
80+
id_token: idToken,
81+
access_token: accessToken,
82+
refresh_token: "rt",
83+
}),
84+
).toBe("from-id-token")
85+
})
86+
87+
test("falls back to access_token when id_token has no accountId", () => {
88+
const idToken = createTestJwt({ email: "test@example.com" })
89+
const accessToken = createTestJwt({
90+
"https://api.openai.com/auth": { chatgpt_account_id: "from-access" },
91+
})
92+
expect(
93+
extractAccountId({
94+
id_token: idToken,
95+
access_token: accessToken,
96+
refresh_token: "rt",
97+
}),
98+
).toBe("from-access")
99+
})
100+
101+
test("returns undefined when no tokens have accountId", () => {
102+
const token = createTestJwt({ email: "test@example.com" })
103+
expect(
104+
extractAccountId({
105+
id_token: token,
106+
access_token: token,
107+
refresh_token: "rt",
108+
}),
109+
).toBeUndefined()
110+
})
111+
112+
test("handles missing id_token", () => {
113+
const accessToken = createTestJwt({ chatgpt_account_id: "acc-123" })
114+
expect(
115+
extractAccountId({
116+
id_token: "",
117+
access_token: accessToken,
118+
refresh_token: "rt",
119+
}),
120+
).toBe("acc-123")
121+
})
122+
})
123+
})

packages/plugin/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export type AuthOuathResult = { url: string; instructions: string } & (
114114
refresh: string
115115
access: string
116116
expires: number
117+
accountId?: string
117118
}
118119
| { key: string }
119120
))
@@ -133,6 +134,7 @@ export type AuthOuathResult = { url: string; instructions: string } & (
133134
refresh: string
134135
access: string
135136
expires: number
137+
accountId?: string
136138
}
137139
| { key: string }
138140
))

0 commit comments

Comments
 (0)