@magpie-community/opencode-trae-auth 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +25 -5
  2. package/index.mjs +151 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -133,10 +133,28 @@ replaces either.
133
133
 
134
134
  ## Renewal
135
135
 
136
- The JWT is renewed with the refresh token at the realm's auth host,
137
- `/cloudide/api/v3/trae/oauth/ExchangeToken` (`api.trae.cn` for Trae CN,
138
- `growsg-normal.trae.ai` for Trae Global). Trae issues a new refresh token
139
- each time and spends the old one, so only one renewal runs at a time.
136
+ Trae CN (since 0.2.3) renews the JWT as its clients do, bound to the
137
+ account's device:
138
+
139
+ - **Where:** `POST api.trae.cn/trae/api/v3/oauth/ExchangeToken`, with the
140
+ refresh token, the device (`DeviceInfo`: its id, machine id and public
141
+ key) and a `DeviceProof`.
142
+ - **The device key:** an ECDSA P-256 key pair made for each account at
143
+ sign-in (or at the first renewal of one signed in before 0.2.3), kept
144
+ with the sign-in.
145
+ - **The proof:** the key's ECDSA-SHA256 signature (base64) of
146
+ `POST\n/trae/api/v3/oauth/ExchangeToken\n{ClientID}\n{RefreshToken}\n{Timestamp}\n{Nonce}`.
147
+ - **As whom:** TRAE SOLO CN (`en1oxy7wnw8j9n`, `SOLO_PC`) first, then
148
+ Trae CN's IDE (`ono9krqynydwx5`, `IDE_PC`).
149
+ - **The refresh token:** kept; this renewal doesn't spend it.
150
+
151
+ A token the sign-in page gives is renewed this way at once. When Trae
152
+ turns the device-bound renewal away, the plugin renews as before 0.2.3.
153
+
154
+ Trae Global, and Trae CN as a fallback, renew at the realm's auth host's
155
+ `/cloudide/api/v3/trae/oauth/ExchangeToken` (`api.trae.cn`,
156
+ `growsg-normal.trae.ai`). There Trae issues a new refresh token each time
157
+ and spends the old one, so only one renewal runs at a time.
140
158
 
141
159
  magpie renews the JWT ten minutes before it ends (`refreshLead`) through
142
160
  `auth.refresh`. Each request also checks it, two minutes before the end, for
@@ -198,7 +216,9 @@ end), its device (`x-device-id`, `x-device-brand`, `x-device-type`,
198
216
  `x-os-version`, `x-app-version`) and a client's User-Agent (TRAE SOLO
199
217
  CN's Electron shell). Since 0.2.1: Bun's own User-Agent and the chat's
200
218
  headers got 9074 「当前参与用户太多,请稍后再试」 every time
201
- (yetone/magpie#808).
219
+ (yetone/magpie#808). Since 0.2.3 the JWT is also bound to the device (see
220
+ Renewal): a JWT renewed at `/cloudide/…` carries no device, and the claim
221
+ answered it 9074 even with the client's headers.
202
222
 
203
223
  - **Status:** `POST api.trae.cn/trae/api/v2/ug/checkin_credits/status`,
204
224
  body `{"req_source":1}` (magpie's `{}` is sent as that): `enable`,
package/index.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  // The account's fetch also sends Trae CN's own pages on api.trae.cn
10
10
  // (/trae/api/…) as the account, for magpie's daily check-in (每日签到:
11
11
  // /trae/api/v2/ug/checkin_credits/status, then /claim).
12
- import { randomBytes, randomUUID, randomInt } from "node:crypto"
12
+ import { generateKeyPairSync, randomBytes, randomUUID, randomInt, sign } from "node:crypto"
13
13
  import { createServer, STATUS_CODES } from "node:http"
14
14
 
15
15
  const SITES = {
@@ -35,6 +35,21 @@ const SITES = {
35
35
  api: "https://trae-api-cn.mchost.guru", // the models
36
36
  },
37
37
  clientId: "ono9krqynydwx5", // Trae CN's IDE
38
+ // the clients' own renewal, which binds the JWT to a device: ExchangeToken
39
+ // under /trae/api/v3/oauth, signed with the device's P-256 key
40
+ // (DeviceProof). A JWT renewed at /cloudide/… carries no device, and
41
+ // Trae CN answers its check-in claim 9074 「当前参与用户太多」 every time,
42
+ // while the client's own token is let through (mintonight's side-by-side
43
+ // on yetone/magpie#808). Tried as TRAE SOLO CN first, the one
44
+ // qilimixingkong/trae-checkin found the server takes, then as Trae CN's
45
+ // IDE. It doesn't spend the refresh token.
46
+ deviceExchange: {
47
+ path: "/trae/api/v3/oauth/ExchangeToken",
48
+ as: [
49
+ { clientId: "en1oxy7wnw8j9n", platform: "SOLO_PC", version: "0.1.64" },
50
+ { clientId: "ono9krqynydwx5", platform: "IDE_PC", version: "3.3.99" },
51
+ ],
52
+ },
38
53
  appId: "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8",
39
54
  ideVersion: "3.3.65", // named to the authorization page
40
55
  ideVersionCode: "20260401",
@@ -220,7 +235,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
220
235
  // device is the machine an account signs in from, made once at sign-in and
221
236
  // named to trae.cn's authorization page, then sent with every request: a
222
237
  // fresh one each time is what the relays saw requests dropped for.
223
- const newDevice = () => ({ deviceId: digits(19), machineId: randomBytes(16).toString("hex") })
238
+ const newDevice = () => ({ deviceId: digits(19), machineId: randomBytes(16).toString("hex"), ...newDeviceKey() })
239
+
240
+ // newDeviceKey is the device's key pair, an ECDSA P-256 one as the clients
241
+ // keep (iCubeAuthInfo://icube-dc:<device>), whose public half goes with a
242
+ // renewal and whose private half signs it (DeviceProof). An account signed
243
+ // in before 0.2.3 has none and is given one at its next renewal.
244
+ function newDeviceKey() {
245
+ const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" })
246
+ return {
247
+ devicePublicKey: publicKey.export({ type: "spki", format: "pem" }),
248
+ devicePrivateKey: privateKey.export({ type: "pkcs8", format: "pem" }),
249
+ }
250
+ }
224
251
 
225
252
  // whenOf reads one of Trae's expiry times: seconds, milliseconds or ISO
226
253
  function whenOf(v) {
@@ -270,6 +297,7 @@ function toAuth(s, site) {
270
297
  clientId: s.clientId || site.clientId,
271
298
  deviceId: s.deviceId,
272
299
  machineId: s.machineId,
300
+ ...(s.devicePrivateKey ? { devicePublicKey: s.devicePublicKey, devicePrivateKey: s.devicePrivateKey } : {}),
273
301
  // the model host the sign-in named, if it named one
274
302
  ...(site.modelHost(s.api) ? { api: s.api } : {}),
275
303
  ...(s.region ? { region: s.region } : {}),
@@ -287,6 +315,8 @@ function fromAuth(auth, site) {
287
315
  clientId: auth.clientId || site.clientId,
288
316
  deviceId: auth.deviceId || "",
289
317
  machineId: auth.machineId || "",
318
+ devicePublicKey: auth.devicePublicKey || "",
319
+ devicePrivateKey: auth.devicePrivateKey || "",
290
320
  api: auth.api || "",
291
321
  region: site.regionOf(auth),
292
322
  }
@@ -387,6 +417,92 @@ async function exchange(refresh, clientId, site, host = site.hosts.auth) {
387
417
  throw new Error(`${site.realm}: renewing the sign-in: ${statusLine(res.status)} ${msg}`.trim())
388
418
  }
389
419
 
420
+ // proofOf is a renewal's DeviceProof: the device key's ECDSA-SHA256
421
+ // signature (DER, base64) of the method, path, client, refresh token, time
422
+ // and nonce, one to a line, as the clients sign it
423
+ function proofOf(path, clientId, refresh, privateKey, at = Math.floor(Date.now() / 1000), nonce = randomBytes(16).toString("hex")) {
424
+ const text = ["POST", path, clientId, refresh, String(at), nonce].join("\n")
425
+ return { Signature: sign("sha256", Buffer.from(text), privateKey).toString("base64"), Timestamp: at, Nonce: nonce }
426
+ }
427
+
428
+ // deviceInfo is the device a renewal names, its public key with it
429
+ function deviceInfo(a, as, site) {
430
+ return {
431
+ DeviceID: a.deviceId,
432
+ MachineID: a.machineId,
433
+ PlatformCode: as.platform,
434
+ DeviceType: "PC",
435
+ DeviceName: "",
436
+ DeviceModel: "",
437
+ ClientVersion: as.version,
438
+ DevicePublicKey: a.devicePublicKey,
439
+ DeviceBrand: site.brand,
440
+ DeviceCPU: "AMD",
441
+ OSInfo: "Windows",
442
+ OSVersion: "10.0.22631",
443
+ }
444
+ }
445
+
446
+ // boundExchange renews the JWT as the clients do (site.deviceExchange), so
447
+ // that it is bound to the account's device; an account with no device key
448
+ // is given one first. It tries each client the site names, and throws the
449
+ // last answer when none takes it.
450
+ async function boundExchange(a, site, host = site.hosts.auth) {
451
+ const dx = site.deviceExchange
452
+ const d = a.devicePrivateKey && a.devicePublicKey ? a : { ...a, ...newDeviceKey() }
453
+ let last
454
+ for (const as of dx.as) {
455
+ let res
456
+ try {
457
+ res = await fetch(host.replace(/\/+$/, "") + dx.path, {
458
+ method: "POST",
459
+ headers: { "Content-Type": "application/json", ...(a.token ? { Authorization: `Cloud-IDE-JWT ${a.token}` } : {}) },
460
+ body: JSON.stringify({
461
+ ClientID: as.clientId,
462
+ ClientSecret: "",
463
+ RefreshToken: a.refresh,
464
+ DeviceInfo: deviceInfo(d, as, site),
465
+ DeviceProof: proofOf(dx.path, as.clientId, a.refresh, d.devicePrivateKey),
466
+ IDEVersion: as.version,
467
+ }),
468
+ signal: AbortSignal.timeout(20_000),
469
+ })
470
+ } catch (e) {
471
+ last = new Error(`${site.realm}: renewing the sign-in for its device: ` + (e?.message ?? e))
472
+ continue
473
+ }
474
+ const text = await res.text()
475
+ const v = parseJSON(text)
476
+ const r = v.Result ?? v.result ?? {}
477
+ const token = r.Token ?? r.token ?? ""
478
+ if (res.ok && token) {
479
+ return {
480
+ token,
481
+ refresh: r.RefreshToken ?? r.refreshToken ?? a.refresh,
482
+ expires: whenOf(r.TokenExpireAt ?? r.tokenExpireAt) || jwtExp(token),
483
+ devicePublicKey: d.devicePublicKey,
484
+ devicePrivateKey: d.devicePrivateKey,
485
+ }
486
+ }
487
+ const e = errorOf(text)
488
+ last = new Error(`${site.realm}: renewing the sign-in for its device: ${statusLine(res.status)} ${e.message || text.trim().slice(0, 200)}`.trim())
489
+ }
490
+ throw last
491
+ }
492
+
493
+ // renewal renews an account's JWT: bound to its device where the site can
494
+ // (boundExchange), else, or when Trae turns that away, at /cloudide/… as
495
+ // before 0.2.3 (exchange). A device key made for the account goes with
496
+ // either, so the next renewal signs with the same one.
497
+ async function renewal(a, site) {
498
+ if (!site.deviceExchange) return exchange(a.refresh, a.clientId, site)
499
+ const keys = a.devicePrivateKey && a.devicePublicKey ? {} : newDeviceKey()
500
+ try {
501
+ return await boundExchange({ ...a, ...keys }, site)
502
+ } catch {}
503
+ return { ...(await exchange(a.refresh, a.clientId, site)), ...keys }
504
+ }
505
+
390
506
  // ---- the browser sign-in --------------------------------------------------------
391
507
 
392
508
  const page = (ok, title, text) => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${title}</title>
@@ -418,7 +534,16 @@ async function signedIn(q, device, site) {
418
534
  }
419
535
  if (!s.token) throw new Error(`${site.webHost} sent back no token`)
420
536
  if (!s.refresh) throw new Error(`${site.webHost} sent back no refresh token`)
421
- return { ...s, ...device }
537
+ s = { ...s, ...device }
538
+ // the token the page gives is bound to no device: it is renewed for this
539
+ // one at once, so a check-in takes it; when Trae won't, it does as is
540
+ if (site.deviceExchange) {
541
+ try {
542
+ const x = await boundExchange(s, site)
543
+ s = { ...s, token: x.token, refresh: x.refresh, expires: x.expires || s.expires }
544
+ } catch {}
545
+ }
546
+ return s
422
547
  }
423
548
 
424
549
  // browserSignIn is the IDE's sign-in: trae.cn's authorization page, back
@@ -1451,8 +1576,9 @@ const makePlugin = (site) => async ({ client }) => {
1451
1576
  let r = renewing.get(key)
1452
1577
  if (!r) {
1453
1578
  r = (async () => {
1454
- const x = await exchange(a.refresh, a.clientId, site)
1579
+ const x = await renewal(a, site)
1455
1580
  const got = { ...a, token: x.token, refresh: x.refresh, expires: x.expires || jwtExp(x.token) || Date.now() + DAY, clientId: x.clientId || a.clientId, renewed: true }
1581
+ if (x.devicePrivateKey) Object.assign(got, { devicePublicKey: x.devicePublicKey, devicePrivateKey: x.devicePrivateKey })
1456
1582
  renewed.set(a.uid || a.name, { was: a.refresh, got })
1457
1583
  if (keep) await save(toAuth(got, site))
1458
1584
  return got
@@ -1465,10 +1591,11 @@ const makePlugin = (site) => async ({ client }) => {
1465
1591
  // fresh is the account with a token that has a while to go, renewed
1466
1592
  // when it hasn't
1467
1593
  const fresh = async (getAuth) => {
1468
- const a = fromAuth(await getAuth(), site)
1594
+ let a = fromAuth(await getAuth(), site)
1469
1595
  if (!a) throw new Expired(`${site.realm}: not signed in`)
1470
- const last = renewed.get(a.uid || a.name)
1471
- if (last && last.was === a.refresh) return { ...last.got, renewed: false }
1596
+ // a renewal the sign-in doesn't hold yet. A device-bound one keeps the
1597
+ // refresh token, so the token tells it, and it is renewed in its turn
1598
+ if (newerHere(a)) a = { ...renewed.get(a.uid || a.name).got, renewed: false }
1472
1599
  if (!a.expires || a.expires - Date.now() > EARLY_MS || !a.refresh) return a
1473
1600
  try {
1474
1601
  return await renew(a, true)
@@ -1485,9 +1612,22 @@ const makePlugin = (site) => async ({ client }) => {
1485
1612
  const a = fromAuth(auth, site)
1486
1613
  if (!a || !a.refresh) return undefined
1487
1614
  const last = renewed.get(a.uid || a.name)
1488
- if (last && last.was === a.refresh) return { access: last.got.token, refresh: last.got.refresh, expires: last.got.expires }
1489
- const s = await renew(a, false)
1490
- return { access: s.token, refresh: s.refresh, expires: s.expires }
1615
+ // a device key made for the account is kept with the sign-in
1616
+ const out = (s) => ({
1617
+ access: s.token,
1618
+ refresh: s.refresh,
1619
+ expires: s.expires,
1620
+ ...(s.devicePrivateKey && s.devicePrivateKey !== a.devicePrivateKey ? { devicePublicKey: s.devicePublicKey, devicePrivateKey: s.devicePrivateKey } : {}),
1621
+ })
1622
+ if (newerHere(a)) return out(last.got)
1623
+ return out(await renew(a, false))
1624
+ }
1625
+
1626
+ // newerHere: this account was renewed here since the sign-in a was read
1627
+ // from, to a token that runs longer
1628
+ const newerHere = (a) => {
1629
+ const last = renewed.get(a.uid || a.name)
1630
+ return !!last && last.was === a.refresh && last.got.token !== a.token && (last.got.expires || 0) > (a.expires || 0)
1491
1631
  }
1492
1632
 
1493
1633
  // post asks one of Trae's JSON pages with the account
@@ -1819,4 +1959,4 @@ const makePlugin = (site) => async ({ client }) => {
1819
1959
  export const TraeCNAuthPlugin = makePlugin(SITES["trae-cn"])
1820
1960
  export const TraeGlobalAuthPlugin = makePlugin(SITES["trae-global"])
1821
1961
 
1822
- export const _internal = { HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, toolNamed, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, SITES }
1962
+ export const _internal = { HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, toolNamed, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, proofOf, SITES }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-trae-auth",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "OpenCode provider plugins for Trae: Trae CN (trae.cn) and Trae Global (trae.ai), free tier included, with Trae's own browser sign-in",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",