codex.test.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. import { describe, expect, test } from "bun:test"
  2. import {
  3. CodexAuthPlugin,
  4. parseJwtClaims,
  5. extractAccountIdFromClaims,
  6. extractAccountId,
  7. renderOAuthError,
  8. type IdTokenClaims,
  9. } from "../../src/plugin/openai/codex"
  10. function createTestJwt(payload: object): string {
  11. const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")
  12. const body = Buffer.from(JSON.stringify(payload)).toString("base64url")
  13. return `${header}.${body}.sig`
  14. }
  15. describe("plugin.codex", () => {
  16. test("escapes provider errors in callback HTML", () => {
  17. const error = `</div><script>alert("xss" & 'more')</script>`
  18. const html = renderOAuthError(error)
  19. expect(html).toContain("&lt;/div&gt;&lt;script&gt;alert(&quot;xss&quot; &amp; &#39;more&#39;)&lt;/script&gt;")
  20. expect(html).not.toContain(error)
  21. })
  22. describe("parseJwtClaims", () => {
  23. test("parses valid JWT with claims", () => {
  24. const payload = { email: "test@example.com", chatgpt_account_id: "acc-123" }
  25. const jwt = createTestJwt(payload)
  26. const claims = parseJwtClaims(jwt)
  27. expect(claims).toEqual(payload)
  28. })
  29. test("returns undefined for JWT with less than 3 parts", () => {
  30. expect(parseJwtClaims("invalid")).toBeUndefined()
  31. expect(parseJwtClaims("only.two")).toBeUndefined()
  32. })
  33. test("returns undefined for invalid base64", () => {
  34. expect(parseJwtClaims("a.!!!invalid!!!.b")).toBeUndefined()
  35. })
  36. test("returns undefined for invalid JSON payload", () => {
  37. const header = Buffer.from("{}").toString("base64url")
  38. const invalidJson = Buffer.from("not json").toString("base64url")
  39. expect(parseJwtClaims(`${header}.${invalidJson}.sig`)).toBeUndefined()
  40. })
  41. })
  42. describe("extractAccountIdFromClaims", () => {
  43. test("extracts chatgpt_account_id from root", () => {
  44. const claims: IdTokenClaims = { chatgpt_account_id: "acc-root" }
  45. expect(extractAccountIdFromClaims(claims)).toBe("acc-root")
  46. })
  47. test("extracts chatgpt_account_id from nested https://api.openai.com/auth", () => {
  48. const claims: IdTokenClaims = {
  49. "https://api.openai.com/auth": { chatgpt_account_id: "acc-nested" },
  50. }
  51. expect(extractAccountIdFromClaims(claims)).toBe("acc-nested")
  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. test("extracts from organizations array as fallback", () => {
  61. const claims: IdTokenClaims = {
  62. organizations: [{ id: "org-123" }, { id: "org-456" }],
  63. }
  64. expect(extractAccountIdFromClaims(claims)).toBe("org-123")
  65. })
  66. test("returns undefined when no accountId found", () => {
  67. const claims: IdTokenClaims = { email: "test@example.com" }
  68. expect(extractAccountIdFromClaims(claims)).toBeUndefined()
  69. })
  70. })
  71. describe("extractAccountId", () => {
  72. test("extracts from id_token first", () => {
  73. const idToken = createTestJwt({ chatgpt_account_id: "from-id-token" })
  74. const accessToken = createTestJwt({ chatgpt_account_id: "from-access-token" })
  75. expect(
  76. extractAccountId({
  77. id_token: idToken,
  78. access_token: accessToken,
  79. refresh_token: "rt",
  80. }),
  81. ).toBe("from-id-token")
  82. })
  83. test("falls back to access_token when id_token has no accountId", () => {
  84. const idToken = createTestJwt({ email: "test@example.com" })
  85. const accessToken = createTestJwt({
  86. "https://api.openai.com/auth": { chatgpt_account_id: "from-access" },
  87. })
  88. expect(
  89. extractAccountId({
  90. id_token: idToken,
  91. access_token: accessToken,
  92. refresh_token: "rt",
  93. }),
  94. ).toBe("from-access")
  95. })
  96. test("returns undefined when no tokens have accountId", () => {
  97. const token = createTestJwt({ email: "test@example.com" })
  98. expect(
  99. extractAccountId({
  100. id_token: token,
  101. access_token: token,
  102. refresh_token: "rt",
  103. }),
  104. ).toBeUndefined()
  105. })
  106. test("handles missing id_token", () => {
  107. const accessToken = createTestJwt({ chatgpt_account_id: "acc-123" })
  108. expect(
  109. extractAccountId({
  110. id_token: "",
  111. access_token: accessToken,
  112. refresh_token: "rt",
  113. }),
  114. ).toBe("acc-123")
  115. })
  116. })
  117. test("installs websocket transport only when experimental websockets are enabled", async () => {
  118. const disabled = await CodexAuthPlugin({} as never)
  119. const enabled = await CodexAuthPlugin({} as never, { experimentalWebSockets: true })
  120. const disabledOptions = await disabled.auth!.loader!(
  121. async () => ({ type: "api", key: "sk-test" }) as never,
  122. {} as never,
  123. )
  124. const enabledOptions = await enabled.auth!.loader!(
  125. async () => ({ type: "api", key: "sk-test" }) as never,
  126. {} as never,
  127. )
  128. expect(disabledOptions.fetch).toBeUndefined()
  129. expect(enabledOptions.fetch).toBeFunction()
  130. await enabled.dispose?.()
  131. })
  132. test("deduplicates concurrent Codex token refreshes", async () => {
  133. let auth = {
  134. type: "oauth" as const,
  135. refresh: "refresh-old",
  136. access: "",
  137. expires: 0,
  138. }
  139. const authUpdates: Array<{
  140. body: { refresh: string; access: string; expires: number; accountId?: string }
  141. }> = []
  142. let resolveRefresh: (() => void) | undefined
  143. const refreshReady = new Promise<void>((resolve) => {
  144. resolveRefresh = resolve
  145. })
  146. let refreshRequests = 0
  147. const apiRequests: { authorization: string | null; accountId: string | null }[] = []
  148. using server = Bun.serve({
  149. port: 0,
  150. async fetch(request) {
  151. const url = new URL(request.url)
  152. if (url.pathname === "/oauth/token") {
  153. expect(await request.text()).toContain("refresh_token=refresh-old")
  154. refreshRequests += 1
  155. await refreshReady
  156. return Response.json({
  157. id_token: createTestJwt({ chatgpt_account_id: "acc-123" }),
  158. access_token: "access-new",
  159. refresh_token: "refresh-new",
  160. expires_in: 3600,
  161. })
  162. }
  163. if (url.pathname === "/backend-api/codex/responses") {
  164. apiRequests.push({
  165. authorization: request.headers.get("authorization"),
  166. accountId: request.headers.get("ChatGPT-Account-Id"),
  167. })
  168. return new Response("{}", { status: 200 })
  169. }
  170. return new Response("unexpected request", { status: 500 })
  171. },
  172. })
  173. const hooks = await CodexAuthPlugin(
  174. {
  175. client: {
  176. auth: {
  177. async set(input: { body: { refresh: string; access: string; expires: number; accountId?: string } }) {
  178. authUpdates.push(input)
  179. auth = {
  180. type: "oauth",
  181. refresh: input.body.refresh,
  182. access: input.body.access,
  183. expires: input.body.expires,
  184. ...(input.body.accountId && { accountId: input.body.accountId }),
  185. }
  186. },
  187. },
  188. } as never,
  189. project: {} as never,
  190. directory: "",
  191. worktree: "",
  192. experimental_workspace: {
  193. register() {},
  194. },
  195. serverUrl: new URL("https://example.com"),
  196. $: {} as never,
  197. },
  198. {
  199. issuer: server.url.origin,
  200. codexApiEndpoint: new URL("/backend-api/codex/responses", server.url).toString(),
  201. },
  202. )
  203. const loaded = await hooks.auth!.loader!(async () => auth as never, {} as never)
  204. const first = loaded.fetch!("https://api.openai.com/v1/responses")
  205. const second = loaded.fetch!("https://api.openai.com/v1/responses")
  206. await waitFor(() => refreshRequests === 1)
  207. expect(apiRequests).toHaveLength(0)
  208. resolveRefresh!()
  209. await Promise.all([first, second])
  210. expect(refreshRequests).toBe(1)
  211. expect(authUpdates).toHaveLength(1)
  212. expect(authUpdates[0]?.body.refresh).toBe("refresh-new")
  213. expect(authUpdates[0]?.body.access).toBe("access-new")
  214. expect(authUpdates[0]?.body.accountId).toBe("acc-123")
  215. expect(apiRequests).toEqual([
  216. { authorization: "Bearer access-new", accountId: "acc-123" },
  217. { authorization: "Bearer access-new", accountId: "acc-123" },
  218. ])
  219. })
  220. })
  221. async function waitFor(predicate: () => boolean) {
  222. const started = Date.now()
  223. while (!predicate()) {
  224. if (Date.now() - started > 1_000) throw new Error("timed out waiting for condition")
  225. await new Promise((resolve) => setTimeout(resolve, 1))
  226. }
  227. }