fix: add ChatGPT-Account-Id header for organization subscriptions (#7603)
This commit is contained in:
committed by
GitHub
parent
dfe3e79304
commit
1662e149b3
@@ -12,6 +12,7 @@ export namespace Auth {
|
|||||||
refresh: z.string(),
|
refresh: z.string(),
|
||||||
access: z.string(),
|
access: z.string(),
|
||||||
expires: z.number(),
|
expires: z.number(),
|
||||||
|
accountId: z.string().optional(),
|
||||||
enterpriseUrl: z.string().optional(),
|
enterpriseUrl: z.string().optional(),
|
||||||
})
|
})
|
||||||
.meta({ ref: "OAuth" })
|
.meta({ ref: "OAuth" })
|
||||||
|
|||||||
@@ -42,6 +42,46 @@ function generateState(): string {
|
|||||||
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IdTokenClaims {
|
||||||
|
chatgpt_account_id?: string
|
||||||
|
organizations?: Array<{ id: string }>
|
||||||
|
email?: string
|
||||||
|
"https://api.openai.com/auth"?: {
|
||||||
|
chatgpt_account_id?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJwtClaims(token: string): IdTokenClaims | undefined {
|
||||||
|
const parts = token.split(".")
|
||||||
|
if (parts.length !== 3) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(parts[1], "base64url").toString())
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined {
|
||||||
|
return (
|
||||||
|
claims.chatgpt_account_id ||
|
||||||
|
claims["https://api.openai.com/auth"]?.chatgpt_account_id ||
|
||||||
|
claims.organizations?.[0]?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAccountId(tokens: TokenResponse): string | undefined {
|
||||||
|
if (tokens.id_token) {
|
||||||
|
const claims = parseJwtClaims(tokens.id_token)
|
||||||
|
const accountId = claims && extractAccountIdFromClaims(claims)
|
||||||
|
if (accountId) return accountId
|
||||||
|
}
|
||||||
|
if (tokens.access_token) {
|
||||||
|
const claims = parseJwtClaims(tokens.access_token)
|
||||||
|
return claims ? extractAccountIdFromClaims(claims) : undefined
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
|
function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
@@ -380,10 +420,14 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
const currentAuth = await getAuth()
|
const currentAuth = await getAuth()
|
||||||
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
|
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
|
||||||
|
|
||||||
|
// Cast to include accountId field
|
||||||
|
const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
|
||||||
|
|
||||||
// Check if token needs refresh
|
// Check if token needs refresh
|
||||||
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
||||||
log.info("refreshing codex access token")
|
log.info("refreshing codex access token")
|
||||||
const tokens = await refreshAccessToken(currentAuth.refresh)
|
const tokens = await refreshAccessToken(currentAuth.refresh)
|
||||||
|
const newAccountId = extractAccountId(tokens) || authWithAccount.accountId
|
||||||
await input.client.auth.set({
|
await input.client.auth.set({
|
||||||
path: { id: "codex" },
|
path: { id: "codex" },
|
||||||
body: {
|
body: {
|
||||||
@@ -391,9 +435,11 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
refresh: tokens.refresh_token,
|
refresh: tokens.refresh_token,
|
||||||
access: tokens.access_token,
|
access: tokens.access_token,
|
||||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||||
|
...(newAccountId && { accountId: newAccountId }),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
currentAuth.access = tokens.access_token
|
currentAuth.access = tokens.access_token
|
||||||
|
authWithAccount.accountId = newAccountId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build headers
|
// Build headers
|
||||||
@@ -415,20 +461,20 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
// Set authorization header with access token
|
// Set authorization header with access token
|
||||||
headers.set("authorization", `Bearer ${currentAuth.access}`)
|
headers.set("authorization", `Bearer ${currentAuth.access}`)
|
||||||
|
|
||||||
// Rewrite URL to Codex endpoint
|
// Set ChatGPT-Account-Id header for organization subscriptions
|
||||||
let url: URL
|
if (authWithAccount.accountId) {
|
||||||
if (typeof requestInput === "string") {
|
headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
|
||||||
url = new URL(requestInput)
|
|
||||||
} else if (requestInput instanceof URL) {
|
|
||||||
url = requestInput
|
|
||||||
} else {
|
|
||||||
url = new URL(requestInput.url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this is a messages/responses request, redirect to Codex endpoint
|
// Rewrite URL to Codex endpoint
|
||||||
if (url.pathname.includes("/v1/responses") || url.pathname.includes("/chat/completions")) {
|
const parsed =
|
||||||
url = new URL(CODEX_API_ENDPOINT)
|
requestInput instanceof URL
|
||||||
}
|
? requestInput
|
||||||
|
: new URL(typeof requestInput === "string" ? requestInput : requestInput.url)
|
||||||
|
const url =
|
||||||
|
parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions")
|
||||||
|
? new URL(CODEX_API_ENDPOINT)
|
||||||
|
: parsed
|
||||||
|
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
@@ -456,11 +502,13 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
callback: async () => {
|
callback: async () => {
|
||||||
const tokens = await callbackPromise
|
const tokens = await callbackPromise
|
||||||
stopOAuthServer()
|
stopOAuthServer()
|
||||||
|
const accountId = extractAccountId(tokens)
|
||||||
return {
|
return {
|
||||||
type: "success" as const,
|
type: "success" as const,
|
||||||
refresh: tokens.refresh_token,
|
refresh: tokens.refresh_token,
|
||||||
access: tokens.access_token,
|
access: tokens.access_token,
|
||||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||||
|
accountId,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
123
packages/opencode/test/plugin/codex.test.ts
Normal file
123
packages/opencode/test/plugin/codex.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
parseJwtClaims,
|
||||||
|
extractAccountIdFromClaims,
|
||||||
|
extractAccountId,
|
||||||
|
type IdTokenClaims,
|
||||||
|
} from "../../src/plugin/codex"
|
||||||
|
|
||||||
|
function createTestJwt(payload: object): string {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")
|
||||||
|
const body = Buffer.from(JSON.stringify(payload)).toString("base64url")
|
||||||
|
return `${header}.${body}.sig`
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("plugin.codex", () => {
|
||||||
|
describe("parseJwtClaims", () => {
|
||||||
|
test("parses valid JWT with claims", () => {
|
||||||
|
const payload = { email: "test@example.com", chatgpt_account_id: "acc-123" }
|
||||||
|
const jwt = createTestJwt(payload)
|
||||||
|
const claims = parseJwtClaims(jwt)
|
||||||
|
expect(claims).toEqual(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns undefined for JWT with less than 3 parts", () => {
|
||||||
|
expect(parseJwtClaims("invalid")).toBeUndefined()
|
||||||
|
expect(parseJwtClaims("only.two")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns undefined for invalid base64", () => {
|
||||||
|
expect(parseJwtClaims("a.!!!invalid!!!.b")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns undefined for invalid JSON payload", () => {
|
||||||
|
const header = Buffer.from("{}").toString("base64url")
|
||||||
|
const invalidJson = Buffer.from("not json").toString("base64url")
|
||||||
|
expect(parseJwtClaims(`${header}.${invalidJson}.sig`)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("extractAccountIdFromClaims", () => {
|
||||||
|
test("extracts chatgpt_account_id from root", () => {
|
||||||
|
const claims: IdTokenClaims = { chatgpt_account_id: "acc-root" }
|
||||||
|
expect(extractAccountIdFromClaims(claims)).toBe("acc-root")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("extracts chatgpt_account_id from nested https://api.openai.com/auth", () => {
|
||||||
|
const claims: IdTokenClaims = {
|
||||||
|
"https://api.openai.com/auth": { chatgpt_account_id: "acc-nested" },
|
||||||
|
}
|
||||||
|
expect(extractAccountIdFromClaims(claims)).toBe("acc-nested")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prefers root over nested", () => {
|
||||||
|
const claims: IdTokenClaims = {
|
||||||
|
chatgpt_account_id: "acc-root",
|
||||||
|
"https://api.openai.com/auth": { chatgpt_account_id: "acc-nested" },
|
||||||
|
}
|
||||||
|
expect(extractAccountIdFromClaims(claims)).toBe("acc-root")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("extracts from organizations array as fallback", () => {
|
||||||
|
const claims: IdTokenClaims = {
|
||||||
|
organizations: [{ id: "org-123" }, { id: "org-456" }],
|
||||||
|
}
|
||||||
|
expect(extractAccountIdFromClaims(claims)).toBe("org-123")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns undefined when no accountId found", () => {
|
||||||
|
const claims: IdTokenClaims = { email: "test@example.com" }
|
||||||
|
expect(extractAccountIdFromClaims(claims)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("extractAccountId", () => {
|
||||||
|
test("extracts from id_token first", () => {
|
||||||
|
const idToken = createTestJwt({ chatgpt_account_id: "from-id-token" })
|
||||||
|
const accessToken = createTestJwt({ chatgpt_account_id: "from-access-token" })
|
||||||
|
expect(
|
||||||
|
extractAccountId({
|
||||||
|
id_token: idToken,
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: "rt",
|
||||||
|
}),
|
||||||
|
).toBe("from-id-token")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back to access_token when id_token has no accountId", () => {
|
||||||
|
const idToken = createTestJwt({ email: "test@example.com" })
|
||||||
|
const accessToken = createTestJwt({
|
||||||
|
"https://api.openai.com/auth": { chatgpt_account_id: "from-access" },
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
extractAccountId({
|
||||||
|
id_token: idToken,
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: "rt",
|
||||||
|
}),
|
||||||
|
).toBe("from-access")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns undefined when no tokens have accountId", () => {
|
||||||
|
const token = createTestJwt({ email: "test@example.com" })
|
||||||
|
expect(
|
||||||
|
extractAccountId({
|
||||||
|
id_token: token,
|
||||||
|
access_token: token,
|
||||||
|
refresh_token: "rt",
|
||||||
|
}),
|
||||||
|
).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles missing id_token", () => {
|
||||||
|
const accessToken = createTestJwt({ chatgpt_account_id: "acc-123" })
|
||||||
|
expect(
|
||||||
|
extractAccountId({
|
||||||
|
id_token: "",
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: "rt",
|
||||||
|
}),
|
||||||
|
).toBe("acc-123")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -114,6 +114,7 @@ export type AuthOuathResult = { url: string; instructions: string } & (
|
|||||||
refresh: string
|
refresh: string
|
||||||
access: string
|
access: string
|
||||||
expires: number
|
expires: number
|
||||||
|
accountId?: string
|
||||||
}
|
}
|
||||||
| { key: string }
|
| { key: string }
|
||||||
))
|
))
|
||||||
@@ -133,6 +134,7 @@ export type AuthOuathResult = { url: string; instructions: string } & (
|
|||||||
refresh: string
|
refresh: string
|
||||||
access: string
|
access: string
|
||||||
expires: number
|
expires: number
|
||||||
|
accountId?: string
|
||||||
}
|
}
|
||||||
| { key: string }
|
| { key: string }
|
||||||
))
|
))
|
||||||
|
|||||||
Reference in New Issue
Block a user