xai.test.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. import { describe, expect, test } from "bun:test"
  2. import {
  3. accessTokenIsExpiring,
  4. buildAuthorizeUrl,
  5. pollDeviceCodeToken,
  6. requestDeviceCode,
  7. XaiAuthPlugin,
  8. } from "../../src/plugin/xai"
  9. import { OAUTH_DUMMY_KEY } from "../../src/auth"
  10. function makeJwt(payload: object): string {
  11. const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url")
  12. const body = Buffer.from(JSON.stringify(payload)).toString("base64url")
  13. return `${header}.${body}.sig`
  14. }
  15. function makeInput(opts?: { failSet?: boolean }) {
  16. const setCalls: Array<Record<string, unknown>> = []
  17. return {
  18. input: {
  19. client: {
  20. auth: {
  21. set: async (req: Record<string, unknown>) => {
  22. setCalls.push(req)
  23. if (opts?.failSet) throw new Error("auth.set boom")
  24. },
  25. },
  26. },
  27. } as any,
  28. setCalls,
  29. }
  30. }
  31. function makeServer(handler: (request: Request, url: URL) => Response | Promise<Response>) {
  32. return Bun.serve({
  33. port: 0,
  34. fetch: (request) => handler(request, new URL(request.url)),
  35. })
  36. }
  37. function serverOptions(server: ReturnType<typeof Bun.serve>) {
  38. return {
  39. authorizeUrl: new URL("/oauth2/authorize", server.url).toString(),
  40. tokenUrl: new URL("/oauth2/token", server.url).toString(),
  41. deviceAuthorizationUrl: new URL("/oauth2/device/code", server.url).toString(),
  42. }
  43. }
  44. describe("plugin.xai", () => {
  45. describe("accessTokenIsExpiring", () => {
  46. test("returns true for an already-expired JWT", () => {
  47. expect(accessTokenIsExpiring(makeJwt({ exp: Math.floor(Date.now() / 1000) - 60 }), 0)).toBe(true)
  48. })
  49. test("returns false for a fresh JWT outside the skew window", () => {
  50. expect(accessTokenIsExpiring(makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600 }), 0)).toBe(false)
  51. })
  52. test("honors the skew window", () => {
  53. const nearExpiry = makeJwt({ exp: Math.floor(Date.now() / 1000) + 30 })
  54. expect(accessTokenIsExpiring(nearExpiry, 60_000)).toBe(true)
  55. expect(accessTokenIsExpiring(nearExpiry, 0)).toBe(false)
  56. })
  57. test("clamps negative skew to zero rather than refusing to refresh", () => {
  58. expect(accessTokenIsExpiring(makeJwt({ exp: Math.floor(Date.now() / 1000) - 1 }), -60_000)).toBe(true)
  59. })
  60. test("returns false for opaque and malformed tokens", () => {
  61. expect(accessTokenIsExpiring("opaque-token-no-dots", 0)).toBe(false)
  62. expect(accessTokenIsExpiring("", 0)).toBe(false)
  63. expect(accessTokenIsExpiring(undefined, 0)).toBe(false)
  64. expect(accessTokenIsExpiring(makeJwt({ sub: "user-1" }), 0)).toBe(false)
  65. expect(accessTokenIsExpiring(makeJwt({ exp: "1234" }), 0)).toBe(false)
  66. expect(accessTokenIsExpiring("header.!!!not-valid-base64-or-json!!!.sig", 0)).toBe(false)
  67. })
  68. })
  69. describe("buildAuthorizeUrl", () => {
  70. const pkce = { verifier: "ver", challenge: "chal" }
  71. test("includes required OAuth + PKCE + OIDC params", () => {
  72. const url = new URL(buildAuthorizeUrl(pkce, "state-abc", "nonce-xyz"))
  73. const params = url.searchParams
  74. expect(url.origin + url.pathname).toBe("https://auth.x.ai/oauth2/authorize")
  75. expect(params.get("response_type")).toBe("code")
  76. expect(params.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828")
  77. expect(params.get("redirect_uri")).toBe("http://127.0.0.1:56121/callback")
  78. expect(params.get("scope")).toBe("openid profile email offline_access grok-cli:access api:access")
  79. expect(params.get("code_challenge")).toBe("chal")
  80. expect(params.get("code_challenge_method")).toBe("S256")
  81. expect(params.get("state")).toBe("state-abc")
  82. expect(params.get("nonce")).toBe("nonce-xyz")
  83. expect(params.get("plan")).toBe("generic")
  84. expect(params.get("referrer")).toBe("kirincode")
  85. })
  86. test("supports endpoint override for local integration tests", () => {
  87. const url = new URL(buildAuthorizeUrl(pkce, "s", "n", { authorizeUrl: "http://127.0.0.1/oauth2/authorize" }))
  88. expect(url.origin + url.pathname).toBe("http://127.0.0.1/oauth2/authorize")
  89. })
  90. })
  91. describe("loader", () => {
  92. test("returns no options unless stored auth is OAuth and exposes methods in order", async () => {
  93. const hooks = await XaiAuthPlugin({} as any)
  94. expect(await hooks.auth!.loader!(async () => ({ type: "api", key: "sk-test" }), {} as any)).toEqual({})
  95. expect(
  96. await hooks.auth!.loader!(async () => ({ type: "wellknown", key: "k", token: "t" }) as any, {} as any),
  97. ).toEqual({})
  98. expect(hooks.auth!.methods.map((m) => [m.type, m.label])).toEqual([
  99. ["oauth", "xAI Grok OAuth (SuperGrok Subscription)"],
  100. ["oauth", "xAI Grok OAuth (Headless / Remote / VPS)"],
  101. ["api", "Manually enter API Key"],
  102. ])
  103. })
  104. test("replaces the dummy bearer, sets User-Agent, and preserves caller headers", async () => {
  105. const { input } = makeInput()
  106. const captured: Headers[] = []
  107. using server = makeServer((request) => {
  108. captured.push(request.headers)
  109. return new Response("{}", { status: 200 })
  110. })
  111. const hooks = await XaiAuthPlugin(input)
  112. const opts = await hooks.auth!.loader!(
  113. async () => ({ type: "oauth", access: "live-token", refresh: "rt", expires: Date.now() + 3600_000 }),
  114. {} as any,
  115. )
  116. expect(opts.apiKey).toBe(OAUTH_DUMMY_KEY)
  117. expect(opts.baseURL).toBeUndefined()
  118. await opts.fetch!(new URL("/chat/completions", server.url), {
  119. headers: { Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-keep": "yes" },
  120. })
  121. expect(captured[0].get("authorization")).toBe("Bearer live-token")
  122. expect(captured[0].get("x-keep")).toBe("yes")
  123. expect(captured[0].get("user-agent")).toMatch(/^opencode\//)
  124. })
  125. test("does not mutate caller headers and supports HeadersInit shapes", async () => {
  126. const { input } = makeInput()
  127. const captured: Headers[] = []
  128. using server = makeServer((request) => {
  129. captured.push(request.headers)
  130. return new Response("{}", { status: 200 })
  131. })
  132. const opts = await (
  133. await XaiAuthPlugin(input)
  134. ).auth!.loader!(
  135. async () => ({ type: "oauth", access: "tok", refresh: "rt", expires: Date.now() + 3600_000 }),
  136. {} as any,
  137. )
  138. const objHeaders: Record<string, string> = {
  139. Authorization: `Bearer ${OAUTH_DUMMY_KEY}`,
  140. "x-trace": "plain-object",
  141. }
  142. await opts.fetch!(new URL("/chat/completions", server.url), { headers: objHeaders })
  143. expect(objHeaders).toEqual({ Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-trace": "plain-object" })
  144. const arrayHeaders: [string, string][] = [["x-trace", "tuple-array"]]
  145. const arrayCopy = arrayHeaders.map(([key, value]) => [key, value] as [string, string])
  146. await opts.fetch!(new URL("/chat/completions", server.url), { headers: arrayHeaders })
  147. expect(arrayHeaders).toEqual(arrayCopy)
  148. const headersInstance = new Headers({ "x-trace": "headers-instance" })
  149. await opts.fetch!(new URL("/chat/completions", server.url), { headers: headersInstance })
  150. expect(headersInstance.get("x-trace")).toBe("headers-instance")
  151. expect(captured.map((headers) => headers.get("x-trace"))).toEqual([
  152. "plain-object",
  153. "tuple-array",
  154. "headers-instance",
  155. ])
  156. for (const headers of captured) {
  157. expect(headers.get("authorization")).toBe("Bearer tok")
  158. expect(headers.get("user-agent")).toMatch(/^opencode\//)
  159. }
  160. })
  161. test("preserves headers from Request input and lets init headers override them", async () => {
  162. const { input } = makeInput()
  163. const captured: Headers[] = []
  164. using server = makeServer((request) => {
  165. captured.push(request.headers)
  166. return new Response("{}", { status: 200 })
  167. })
  168. const opts = await (
  169. await XaiAuthPlugin(input)
  170. ).auth!.loader!(
  171. async () => ({ type: "oauth", access: "tok", refresh: "rt", expires: Date.now() + 3600_000 }),
  172. {} as any,
  173. )
  174. await opts.fetch!(
  175. new Request(new URL("/chat/completions", server.url), {
  176. headers: {
  177. Authorization: `Bearer ${OAUTH_DUMMY_KEY}`,
  178. "content-type": "application/json",
  179. "x-trace": "request",
  180. },
  181. }),
  182. { headers: { "x-trace": "init", "x-extra": "yes" } },
  183. )
  184. expect(captured[0].get("authorization")).toBe("Bearer tok")
  185. expect(captured[0].get("content-type")).toBe("application/json")
  186. expect(captured[0].get("x-trace")).toBe("init")
  187. expect(captured[0].get("x-extra")).toBe("yes")
  188. })
  189. test("falls through to plain fetch when stored auth flips from oauth to api", async () => {
  190. const { input } = makeInput()
  191. const captured: Headers[] = []
  192. using server = makeServer((request) => {
  193. captured.push(request.headers)
  194. return new Response("{}", { status: 200 })
  195. })
  196. let firstCall = true
  197. const opts = await (
  198. await XaiAuthPlugin(input)
  199. ).auth!.loader!(async () => {
  200. if (firstCall) {
  201. firstCall = false
  202. return { type: "oauth", access: "tok", refresh: "rt", expires: Date.now() + 3600_000 }
  203. }
  204. return { type: "api", key: "sk-new" }
  205. }, {} as any)
  206. await opts.fetch!(new URL("/chat/completions", server.url), {
  207. headers: { Authorization: "Bearer sk-from-aisdk", "x-keep": "v" },
  208. })
  209. expect(captured[0].get("authorization")).toBe("Bearer sk-from-aisdk")
  210. expect(captured[0].get("x-keep")).toBe("v")
  211. })
  212. test("deduplicates concurrent refreshes within a loader instance", async () => {
  213. const { input, setCalls } = makeInput()
  214. let tokenRequests = 0
  215. const apiRequests: Headers[] = []
  216. using server = makeServer(async (request, url) => {
  217. if (url.pathname === "/oauth2/token") {
  218. tokenRequests++
  219. expect(await request.text()).toContain("refresh_token=rt-old")
  220. await new Promise((resolve) => setTimeout(resolve, 30))
  221. return Response.json({ access_token: "new-access", refresh_token: "rt-new", expires_in: 3600 })
  222. }
  223. apiRequests.push(request.headers)
  224. return new Response("{}", { status: 200 })
  225. })
  226. const opts = await (
  227. await XaiAuthPlugin(input, serverOptions(server))
  228. ).auth!.loader!(async () => ({ type: "oauth" as const, access: "old", refresh: "rt-old", expires: 0 }), {} as any)
  229. await Promise.all([
  230. opts.fetch!(new URL("/chat/completions", server.url), { headers: {} }),
  231. opts.fetch!(new URL("/chat/completions", server.url), { headers: {} }),
  232. ])
  233. expect(tokenRequests).toBe(1)
  234. expect(apiRequests.map((headers) => headers.get("authorization"))).toEqual([
  235. "Bearer new-access",
  236. "Bearer new-access",
  237. ])
  238. expect(setCalls).toHaveLength(1)
  239. expect((setCalls[0].body as any).refresh).toBe("rt-new")
  240. })
  241. test("does not share refresh single-flight across loader instances", async () => {
  242. const { input } = makeInput()
  243. const tokenRequests: string[] = []
  244. const apiRequests: string[] = []
  245. using server = makeServer(async (request, url) => {
  246. if (url.pathname === "/oauth2/token") {
  247. const refreshToken = new URLSearchParams(await request.text()).get("refresh_token")!
  248. tokenRequests.push(refreshToken)
  249. await new Promise((resolve) => setTimeout(resolve, 20))
  250. return Response.json({
  251. access_token: `access-${refreshToken}`,
  252. refresh_token: `next-${refreshToken}`,
  253. expires_in: 3600,
  254. })
  255. }
  256. apiRequests.push(request.headers.get("authorization")!)
  257. return new Response("{}", { status: 200 })
  258. })
  259. const hooks = await XaiAuthPlugin(input, serverOptions(server))
  260. const first = await hooks.auth!.loader!(
  261. async () => ({ type: "oauth", access: "old-a", refresh: "rt-a", expires: 0 }),
  262. {} as any,
  263. )
  264. const second = await hooks.auth!.loader!(
  265. async () => ({ type: "oauth", access: "old-b", refresh: "rt-b", expires: 0 }),
  266. {} as any,
  267. )
  268. await Promise.all([
  269. first.fetch!(new URL("/chat/completions", server.url), { headers: {} }),
  270. second.fetch!(new URL("/chat/completions", server.url), { headers: {} }),
  271. ])
  272. expect(tokenRequests.sort()).toEqual(["rt-a", "rt-b"])
  273. expect(apiRequests.sort()).toEqual(["Bearer access-rt-a", "Bearer access-rt-b"])
  274. })
  275. test("starts a new refresh after success and clears the refresh promise after failure", async () => {
  276. const { input } = makeInput()
  277. let tokenRequests = 0
  278. using server = makeServer((_, url) => {
  279. if (url.pathname === "/oauth2/token") {
  280. tokenRequests++
  281. if (tokenRequests === 2) return new Response("temporarily unavailable", { status: 503 })
  282. return Response.json({
  283. access_token: `new-${tokenRequests}`,
  284. refresh_token: `rt-${tokenRequests}`,
  285. expires_in: 3600,
  286. })
  287. }
  288. return new Response("{}", { status: 200 })
  289. })
  290. const opts = await (
  291. await XaiAuthPlugin(input, serverOptions(server))
  292. ).auth!.loader!(async () => ({ type: "oauth", access: "old", refresh: "rt-old", expires: 0 }), {} as any)
  293. await opts.fetch!(new URL("/chat/completions", server.url), { headers: {} })
  294. await expect(opts.fetch!(new URL("/chat/completions", server.url), { headers: {} })).rejects.toThrow(
  295. /xAI token refresh failed \(503\)/,
  296. )
  297. await opts.fetch!(new URL("/chat/completions", server.url), { headers: {} })
  298. expect(tokenRequests).toBe(3)
  299. })
  300. test("handles refresh response variants and persistence failure", async () => {
  301. const { input, setCalls } = makeInput({ failSet: true })
  302. const captured: Headers[] = []
  303. using server = makeServer((request, url) => {
  304. if (url.pathname === "/oauth2/token") return Response.json({ access_token: "new-access", expires_in: 3600 })
  305. captured.push(request.headers)
  306. return new Response("{}", { status: 200 })
  307. })
  308. const opts = await (
  309. await XaiAuthPlugin(input, serverOptions(server))
  310. ).auth!.loader!(async () => ({ type: "oauth", access: "old", refresh: "rt-old", expires: 0 }), {} as any)
  311. const resp = await opts.fetch!(new URL("/chat/completions", server.url), { headers: {} })
  312. expect(resp.status).toBe(200)
  313. expect(captured[0].get("authorization")).toBe("Bearer new-access")
  314. expect((setCalls[0].body as any).refresh).toBe("rt-old")
  315. })
  316. test("refreshes based on stored expiry or JWT expiry and skips refresh when both are fresh", async () => {
  317. const { input, setCalls } = makeInput()
  318. let tokenRequests = 0
  319. using server = makeServer((_, url) => {
  320. if (url.pathname === "/oauth2/token") {
  321. tokenRequests++
  322. return Response.json({ access_token: "new-access", refresh_token: "rt-new", expires_in: 3600 })
  323. }
  324. return new Response("{}", { status: 200 })
  325. })
  326. const fresh = await (
  327. await XaiAuthPlugin(input, serverOptions(server))
  328. ).auth!.loader!(
  329. async () => ({
  330. type: "oauth",
  331. access: makeJwt({ exp: Math.floor(Date.now() / 1000) + 24 * 3600 }),
  332. refresh: "rt",
  333. expires: Date.now() + 24 * 3600 * 1000,
  334. }),
  335. {} as any,
  336. )
  337. await fresh.fetch!(new URL("/chat/completions", server.url), { headers: {} })
  338. expect(tokenRequests).toBe(0)
  339. const jwtExpiring = await (
  340. await XaiAuthPlugin(input, serverOptions(server))
  341. ).auth!.loader!(
  342. async () => ({
  343. type: "oauth",
  344. access: makeJwt({ exp: Math.floor((Date.now() + 30_000) / 1000) }),
  345. refresh: "rt-old",
  346. expires: Date.now() + 24 * 3600 * 1000,
  347. }),
  348. {} as any,
  349. )
  350. const missingExpires = await (
  351. await XaiAuthPlugin(input, serverOptions(server))
  352. ).auth!.loader!(async () => ({ type: "oauth", access: "opaque-token", refresh: "rt", expires: 0 }), {} as any)
  353. await jwtExpiring.fetch!(new URL("/chat/completions", server.url), { headers: {} })
  354. await missingExpires.fetch!(new URL("/chat/completions", server.url), { headers: {} })
  355. expect(tokenRequests).toBe(2)
  356. expect(setCalls).toHaveLength(2)
  357. })
  358. test("network failure during refresh surfaces the underlying fetch error", async () => {
  359. const { input } = makeInput()
  360. const opts = await (
  361. await XaiAuthPlugin(input, { tokenUrl: "http://127.0.0.1:9/oauth2/token" })
  362. ).auth!.loader!(async () => ({ type: "oauth", access: "old", refresh: "rt", expires: 0 }), {} as any)
  363. await expect(opts.fetch!("https://api.x.ai/v1/chat/completions", { headers: {} })).rejects.toThrow()
  364. })
  365. })
  366. describe("device code flow", () => {
  367. test("authorize advertises verification URL + user code and returns success on callback", async () => {
  368. using server = makeServer((_, url) => {
  369. if (url.pathname === "/oauth2/device/code") {
  370. return Response.json({
  371. device_code: "DEVICE-1",
  372. user_code: "ABCD-1234",
  373. verification_uri: "https://x.ai/device",
  374. verification_uri_complete: "https://x.ai/device?user_code=ABCD-1234",
  375. expires_in: 600,
  376. interval: 5,
  377. })
  378. }
  379. if (url.pathname === "/oauth2/token") {
  380. return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 })
  381. }
  382. return new Response("unexpected request", { status: 500 })
  383. })
  384. const hooks = await XaiAuthPlugin({} as any, serverOptions(server))
  385. const headless = hooks.auth!.methods.find(
  386. (m): m is Extract<typeof m, { type: "oauth" }> =>
  387. m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)",
  388. )!
  389. const result = await headless.authorize!()
  390. expect(result.method).toBe("auto")
  391. expect(result.url).toBe("https://x.ai/device?user_code=ABCD-1234")
  392. expect(result.instructions).toContain("https://x.ai/device")
  393. expect(result.instructions).toContain("ABCD-1234")
  394. expect(await (result as any).callback()).toMatchObject({ type: "success", refresh: "RT", access: "AT" })
  395. })
  396. test("authorize falls back to verification_uri when verification_uri_complete is absent", async () => {
  397. using server = makeServer((_, url) => {
  398. if (url.pathname === "/oauth2/device/code") {
  399. return Response.json({
  400. device_code: "DEVICE-2",
  401. user_code: "WXYZ-9876",
  402. verification_uri: "https://x.ai/device",
  403. })
  404. }
  405. return new Response("unexpected request", { status: 500 })
  406. })
  407. const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find(
  408. (m): m is Extract<typeof m, { type: "oauth" }> =>
  409. m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)",
  410. )!
  411. expect((await headless.authorize!()).url).toBe("https://x.ai/device")
  412. })
  413. test("requestDeviceCode posts form body, validates fields, and surfaces endpoint errors", async () => {
  414. let capturedBody = ""
  415. using server = makeServer(async (request, url) => {
  416. if (url.pathname === "/missing") return Response.json({ device_code: "x" })
  417. if (url.pathname === "/error") return new Response("rate limited", { status: 429 })
  418. expect(request.method).toBe("POST")
  419. expect(request.headers.get("content-type")).toBe("application/x-www-form-urlencoded")
  420. expect(request.headers.get("accept")).toBe("application/json")
  421. expect(request.headers.get("user-agent")).toMatch(/^opencode\//)
  422. capturedBody = await request.text()
  423. return Response.json({ device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device" })
  424. })
  425. await requestDeviceCode({ deviceAuthorizationUrl: new URL("/oauth2/device/code", server.url).toString() })
  426. const parsed = new URLSearchParams(capturedBody)
  427. expect(parsed.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828")
  428. expect(parsed.get("scope")).toContain("offline_access")
  429. expect(parsed.get("scope")).toContain("grok-cli:access")
  430. expect(parsed.get("scope")).toContain("api:access")
  431. await expect(
  432. requestDeviceCode({ deviceAuthorizationUrl: new URL("/error", server.url).toString() }),
  433. ).rejects.toThrow(/429.*rate limited/)
  434. await expect(
  435. requestDeviceCode({ deviceAuthorizationUrl: new URL("/missing", server.url).toString() }),
  436. ).rejects.toThrow(/missing device_code/)
  437. })
  438. test("pollDeviceCodeToken resolves on success and posts the device-code grant", async () => {
  439. let tokenCalls = 0
  440. using server = makeServer(async (request) => {
  441. tokenCalls++
  442. expect(request.headers.get("content-type")).toBe("application/x-www-form-urlencoded")
  443. const body = new URLSearchParams(await request.text())
  444. expect(body.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code")
  445. expect(body.get("device_code")).toBe("DC-1")
  446. return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 })
  447. })
  448. const tokens = await pollDeviceCodeToken(
  449. { device_code: "DC-1", user_code: "UC", verification_uri: "https://x.ai/device", interval: 1, expires_in: 600 },
  450. { sleep: async () => {}, tokenUrl: new URL("/oauth2/token", server.url).toString() },
  451. )
  452. expect(tokens.access_token).toBe("AT")
  453. expect(tokens.refresh_token).toBe("RT")
  454. expect(tokenCalls).toBe(1)
  455. })
  456. test("pollDeviceCodeToken honors authorization_pending and slow_down", async () => {
  457. let n = 0
  458. using server = makeServer(() => {
  459. n++
  460. if (n === 1) return Response.json({ error: "authorization_pending" }, { status: 400 })
  461. if (n === 2) return Response.json({ error: "slow_down" }, { status: 400 })
  462. return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 })
  463. })
  464. const sleeps: number[] = []
  465. const tokens = await pollDeviceCodeToken(
  466. { device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 5, expires_in: 600 },
  467. { sleep: async (ms) => void sleeps.push(ms), tokenUrl: new URL("/oauth2/token", server.url).toString() },
  468. )
  469. expect(tokens.access_token).toBe("AT")
  470. expect(n).toBe(3)
  471. expect(sleeps).toEqual([8_000, 13_000])
  472. })
  473. test("pollDeviceCodeToken handles terminal errors and timeout", async () => {
  474. for (const [body, error] of [
  475. [{ error: "access_denied" }, /authorization was denied/],
  476. [{ error: "expired_token" }, /device code expired/],
  477. [{ error: "server_error", error_description: "oops" }, /500.*oops/],
  478. ] as const) {
  479. using server = makeServer(() => Response.json(body, { status: 500 }))
  480. await expect(
  481. pollDeviceCodeToken(
  482. {
  483. device_code: "DC",
  484. user_code: "UC",
  485. verification_uri: "https://x.ai/device",
  486. interval: 1,
  487. expires_in: 600,
  488. },
  489. { sleep: async () => {}, tokenUrl: new URL("/oauth2/token", server.url).toString() },
  490. ),
  491. ).rejects.toThrow(error)
  492. }
  493. using pending = makeServer(() => Response.json({ error: "authorization_pending" }, { status: 400 }))
  494. let tick = 0
  495. await expect(
  496. pollDeviceCodeToken(
  497. { device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 1, expires_in: 1 },
  498. {
  499. sleep: async () => {},
  500. now: () => 1_000_000 + tick++ * 600,
  501. tokenUrl: new URL("/oauth2/token", pending.url).toString(),
  502. },
  503. ),
  504. ).rejects.toThrow(/timed out/)
  505. })
  506. test("pollDeviceCodeToken normalizes bad interval and expires_in values", async () => {
  507. const badIntervals: Array<unknown> = [Number.NaN, "NaN", "garbage", -5, null, 0]
  508. for (const bad of badIntervals) {
  509. let n = 0
  510. using server = makeServer(() => {
  511. n++
  512. if (n === 1) return Response.json({ error: "authorization_pending" }, { status: 400 })
  513. return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 })
  514. })
  515. const sleeps: number[] = []
  516. await pollDeviceCodeToken(
  517. {
  518. device_code: "DC",
  519. user_code: "UC",
  520. verification_uri: "https://x.ai/device",
  521. interval: bad as number,
  522. expires_in: 600,
  523. },
  524. { sleep: async (ms) => void sleeps.push(ms), tokenUrl: new URL("/oauth2/token", server.url).toString() },
  525. )
  526. expect(sleeps[0]).toBe(8_000)
  527. }
  528. for (const bad of [Number.NaN, "NaN", "garbage", -5, null, 0]) {
  529. using server = makeServer(() => Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 }))
  530. expect(
  531. (
  532. await pollDeviceCodeToken(
  533. {
  534. device_code: "DC",
  535. user_code: "UC",
  536. verification_uri: "https://x.ai/device",
  537. interval: 1,
  538. expires_in: bad as number,
  539. },
  540. { sleep: async () => {}, tokenUrl: new URL("/oauth2/token", server.url).toString() },
  541. )
  542. ).access_token,
  543. ).toBe("AT")
  544. }
  545. })
  546. test("device-code authorize callback returns failed when polling errors", async () => {
  547. using server = makeServer((_, url) => {
  548. if (url.pathname === "/oauth2/device/code") {
  549. return Response.json({
  550. device_code: "DC",
  551. user_code: "UC",
  552. verification_uri: "https://x.ai/device",
  553. interval: 0,
  554. expires_in: 600,
  555. })
  556. }
  557. return Response.json({ error: "access_denied" }, { status: 400 })
  558. })
  559. const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find(
  560. (m): m is Extract<typeof m, { type: "oauth" }> =>
  561. m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)",
  562. )!
  563. expect(await ((await headless.authorize!()) as any).callback()).toEqual({ type: "failed" })
  564. })
  565. })
  566. })