@magpie-community/opencode-kiro-auth 0.1.4 → 0.1.6

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 +17 -2
  2. package/index.mjs +91 -24
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -39,8 +39,23 @@ Power) and makes its requests, in OpenCode and in magpie. Provider id:
39
39
  A browser sign-in is kept where OpenCode keeps sign-ins (`auth.json`; in
40
40
  magpie, `plugin-auth.json`) as an `oauth` entry. The entry holds the
41
41
  tokens, the profile ARN, the region and, for AWS, the registered client.
42
- Tokens are refreshed 2 minutes before they expire, and also when Kiro
43
- turns one down (a 403, after which the request is tried once more):
42
+ Refreshing such a sign-in:
43
+
44
+ - magpie renews the token 10 minutes before it expires, through the
45
+ plugin's `auth.refresh`, once for the account and before its requests,
46
+ models and usage need it. magpie saves the new token. A failed renewal,
47
+ even one Kiro refused, doesn't mark the account: it is tried again.
48
+ - OpenCode doesn't call that hook: there the token is refreshed 2 minutes
49
+ before it expires, before a request, and saved.
50
+ - Either way, a token Kiro turns down (a 403) is refreshed and the request
51
+ tried once more.
52
+ - Only one refresh runs at a time, magpie's renewal included, and a token
53
+ refreshed but not yet saved is used rather than spending the old refresh
54
+ token again.
55
+ - kiro-cli's and the IDE's sign-ins aren't renewed by magpie: they are
56
+ refreshed before a request, as above, and written back.
57
+
58
+ Tokens are refreshed through:
44
59
 
45
60
  - Google/GitHub: through `prod.<region>.auth.desktop.kiro.dev/refreshToken`.
46
61
  - Builder ID/Identity Center: through AWS's `oidc.<region>.amazonaws.com/token`.
package/index.mjs CHANGED
@@ -188,13 +188,18 @@ async function saveBack(c) {
188
188
 
189
189
  // ---- tokens --------------------------------------------------------------------
190
190
 
191
+ // EXPIRED is the built-in's words for a sign-in Kiro refused, or one with
192
+ // nothing to refresh it.
191
193
  const EXPIRED = "Kiro's sign-in has expired; sign in again"
194
+ const EXPIRED_CLI = EXPIRED + " with `kiro-cli login` or the Kiro IDE"
192
195
 
193
196
  // gone is an error saying the sign-in itself is gone — refused, or never
194
- // there — which alone answers a request with the 401 magpie marks the
195
- // account lapsed on; a refresh that timed out or met a 5xx may pass.
197
+ // there. The built-in marked no Kiro account lapsed, not even then, nor
198
+ // cleared one: every answer and usage read says the account is kept.
196
199
  const gone = (msg) => Object.assign(new Error(msg), { gone: true })
197
- const fresh = (c) => c.method === "apikey" || !c.expires || c.expires - Date.now() > 2 * 60 * 1000
200
+ const EARLY_MS = 2 * 60 * 1000 // a token this close to its end is refreshed before a request
201
+ const LEAD_MS = 10 * 60 * 1000 // and this close, magpie renews it ahead of time (auth.refresh)
202
+ const fresh = (c, early = EARLY_MS) => c.method === "apikey" || !c.expires || c.expires - Date.now() > early
198
203
 
199
204
  async function post(url, contentType, body, headers = {}) {
200
205
  let res
@@ -206,8 +211,8 @@ async function post(url, contentType, body, headers = {}) {
206
211
  }
207
212
  const text = await res.text()
208
213
  if (!res.ok) {
209
- if ([400, 401, 403].includes(res.status)) throw gone(EXPIRED)
210
- throw new Error(`refreshing Kiro's sign-in: ${res.status}`)
214
+ if ([400, 401, 403].includes(res.status)) throw gone(EXPIRED_CLI)
215
+ throw new Error(`refreshing Kiro's sign-in: ${res.status} ${STATUS_TEXT[res.status] ?? ""}`.trimEnd()) // Go's res.Status
211
216
  }
212
217
  return JSON.parse(text)
213
218
  }
@@ -223,7 +228,7 @@ async function refresh(c) {
223
228
  if (n && n.dbKey === c.dbKey && n.access !== c.access && fresh(n)) return n
224
229
  }
225
230
  }
226
- if (!c.refresh) throw gone(EXPIRED)
231
+ if (!c.refresh) throw gone(EXPIRED_CLI)
227
232
  let out
228
233
  switch (c.method) {
229
234
  case "social": {
@@ -246,9 +251,9 @@ async function refresh(c) {
246
251
  break
247
252
  }
248
253
  default:
249
- throw gone(EXPIRED)
254
+ throw gone("Kiro's sign-in has expired")
250
255
  }
251
- if (!out.access) throw gone(EXPIRED)
256
+ if (!out.access) throw gone(EXPIRED_CLI)
252
257
  const n = { ...c, access: out.access, expires: Date.now() + (out.expiresIn > 0 ? out.expiresIn : 3600) * 1000 }
253
258
  if (out.refresh) n.refresh = out.refresh
254
259
  await saveBack(n)
@@ -315,7 +320,7 @@ async function profileOf(c) {
315
320
  signal: AbortSignal.timeout(20_000),
316
321
  })
317
322
  if (!res.ok) {
318
- const msg = `Kiro didn't take the API key: ${res.status}`
323
+ const msg = `Kiro didn't take the API key: ${res.status} ${STATUS_TEXT[res.status] ?? ""}`.trimEnd()
319
324
  throw res.status === 401 || res.status === 403 ? gone(msg) : new Error(msg)
320
325
  }
321
326
  const arn = (await res.json().catch(() => null))?.profile?.arn
@@ -397,8 +402,23 @@ async function credOf(auth) {
397
402
  // Account holds the credentials in use, refreshing them when near their
398
403
  // end or when Kiro turned one down; one refresh at a time, as a refresh
399
404
  // may replace the refresh token.
405
+ //
406
+ // after is what each refresh gave, by the access token it replaced: a
407
+ // sign-in read with that token (its save failed, or magpie hasn't yet
408
+ // saved what auth.refresh gave) goes on with the newer one rather than
409
+ // spending the replaced refresh token again.
400
410
  function account(client) {
401
411
  let held = null // { id, c }
412
+ const after = new Map()
413
+ const newest = (c) => {
414
+ for (let i = 0; i < 16 && after.has(c.access); i++) c = { ...after.get(c.access) }
415
+ return c
416
+ }
417
+ const renewed = (c, n) => {
418
+ after.set(c.access, n)
419
+ if (after.size > 16) after.delete(after.keys().next().value)
420
+ return n
421
+ }
402
422
  let lock = Promise.resolve()
403
423
  const locked = (fn) => {
404
424
  const run = lock.then(fn, fn)
@@ -411,12 +431,12 @@ function account(client) {
411
431
  await client.auth.set({ path: { id: ID }, body: { ...rest, type: "oauth", access: c.access, refresh: c.refresh, expires: c.expires,
412
432
  profileArn: c.profile, region: c.region } }).catch(() => {})
413
433
  }
414
- return (auth, stale = false) =>
434
+ const creds = (auth, stale = false) =>
415
435
  locked(async () => {
416
436
  const id = auth?.type === "api" ? "key:" + auth.key : "oauth:" + (auth?.source ?? "own")
417
437
  // what its owner holds now: it may have refreshed it, signed out, or
418
438
  // signed in to another account since
419
- const read = await credOf(auth)
439
+ let read = await credOf(auth)
420
440
  if (!read) {
421
441
  held = null
422
442
  throw gone(
@@ -425,14 +445,15 @@ function account(client) {
425
445
  : "this Kiro account's sign-in is gone; add it again in magpie",
426
446
  )
427
447
  }
448
+ read = newest(read)
428
449
  let c = held?.id === id ? held.c : null
429
450
  if (!c || read.access !== c.access || !fresh(c) || stale) {
430
451
  if (!read.profile && c && read.access === c.access) read.profile = c.profile
431
452
  if (stale && c && read.access !== c.access && fresh(read)) stale = false // the owner refreshed it already
432
453
  c = read
433
454
  if (!fresh(c) || stale) {
434
- if (c.method === "apikey") throw gone("Kiro turned down the API key")
435
- c = await refresh(c)
455
+ if (c.method === "apikey") throw gone("Kiro turned down the API key saved on the provider")
456
+ c = renewed(c, await refresh(c))
436
457
  await save(auth, c)
437
458
  }
438
459
  }
@@ -443,6 +464,25 @@ function account(client) {
443
464
  held = { id, c }
444
465
  return { token: c.access, tokenType: tokenType(c), profile: c.profile, region: regionOf(c.profile, c.region) }
445
466
  })
467
+ // renew is auth.refresh's: a sign-in kept here (not kiro-cli's or the
468
+ // IDE's, which their owners keep) renewed under the same lock as the
469
+ // requests' refresh, so the two never spend one refresh token. It saves
470
+ // nothing — magpie saves what it gives — and gives only what changed.
471
+ creds.renew = (auth) =>
472
+ locked(async () => {
473
+ const read = await credOf(auth)
474
+ if (!read?.own || !read.refresh) return undefined
475
+ let c = newest(read)
476
+ // renewed here already (a request's refresh, or this hook's whose
477
+ // result magpie hasn't saved yet), the store not yet saying so
478
+ if (c.access === read.access || !fresh(c, LEAD_MS)) c = renewed(c, await refresh(c))
479
+ held = { id: "oauth:own", c }
480
+ const out = { access: c.access, expires: c.expires }
481
+ if (c.refresh !== auth.refresh) out.refresh = c.refresh
482
+ if (c.profile && c.profile !== auth.profileArn) out.profileArn = c.profile
483
+ return out
484
+ })
485
+ return creds
446
486
  }
447
487
 
448
488
  // ---- models ----------------------------------------------------------------------
@@ -712,7 +752,7 @@ function failure(status, body) {
712
752
  const e = JSON.parse(body)
713
753
  if (e?.message) msg = e.message + (e.reason ? ` (${e.reason})` : "")
714
754
  } catch {}
715
- msg ||= `HTTP ${status}`
755
+ msg ||= STATUS_TEXT[status] ?? ""
716
756
  if (msg.includes("CONTENT_LENGTH_EXCEEDS_THRESHOLD") || msg.toLowerCase().includes("input is too long"))
717
757
  return { status: 400, message: "input is too long for the model's context: " + msg }
718
758
  if (msg.includes("INSUFFICIENT_MODEL_CAPACITY")) return { status: 503, message: msg }
@@ -723,12 +763,23 @@ function failure(status, body) {
723
763
  const ERROR_TYPES = { 400: "invalid_request_error", 401: "authentication_error", 402: "billing_error", 403: "permission_error",
724
764
  404: "not_found_error", 413: "request_too_large", 429: "rate_limit_error", 503: "overloaded_error", 529: "overloaded_error" }
725
765
  const errorType = (status) => ERROR_TYPES[status] ?? "api_error"
766
+ // Each failure says X-Magpie-Sign-In: kept, as the built-in never marked
767
+ // an account lapsed (see gone).
726
768
  const errorResponse = (status, message) =>
727
769
  new Response(JSON.stringify({ type: "error", error: { type: errorType(status), message } }), {
728
770
  status,
729
- headers: { "Content-Type": "application/json" },
771
+ headers: { "Content-Type": "application/json", "X-Magpie-Sign-In": "kept" },
730
772
  })
731
773
 
774
+ // kept is res saying the account is kept: a success too, which the
775
+ // built-in never took for a sign-in come back.
776
+ function kept(res) {
777
+ if (res.headers.has("X-Magpie-Sign-In")) return res
778
+ const headers = new Headers(res.headers)
779
+ headers.set("X-Magpie-Sign-In", "kept")
780
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
781
+ }
782
+
732
783
  // ---- the reply: an AWS event stream → Messages ---------------------------------------
733
784
 
734
785
  // frames reads an AWS event stream: each message its length, its headers'
@@ -1087,7 +1138,9 @@ async function reply(it, model, stream) {
1087
1138
  }
1088
1139
 
1089
1140
  // generate answers a Messages request through Kiro's API: once more,
1090
- // refreshed, when Kiro turns the token down (403).
1141
+ // refreshed, when Kiro turns the token down (403). As the built-in: no
1142
+ // credentials is a 401, a failed refresh after the 403 a 502; none marks
1143
+ // the account.
1091
1144
  async function generate(creds, auth, req, signal) {
1092
1145
  const model = String(req.model ?? "auto")
1093
1146
  const budget = thinking(req, model)
@@ -1095,7 +1148,7 @@ async function generate(creds, auth, req, signal) {
1095
1148
  try {
1096
1149
  a = await creds(auth)
1097
1150
  } catch (e) {
1098
- return errorResponse(e?.gone ? 401 : 502, e.message)
1151
+ return errorResponse(401, e.message)
1099
1152
  }
1100
1153
  let res
1101
1154
  try {
@@ -1109,7 +1162,7 @@ async function generate(creds, auth, req, signal) {
1109
1162
  try {
1110
1163
  a = await creds(auth, true)
1111
1164
  } catch (e) {
1112
- return errorResponse(e?.gone ? 401 : 502, e.message)
1165
+ return errorResponse(502, e.message)
1113
1166
  }
1114
1167
  try {
1115
1168
  res = await sendKiro(a, buildKiro(req, model, a.profile, budget), signal)
@@ -1119,10 +1172,11 @@ async function generate(creds, auth, req, signal) {
1119
1172
  }
1120
1173
  }
1121
1174
  if (!res.ok) {
1175
+ // Kiro's own 401 is passed on, as the built-in did, the sign-in kept
1122
1176
  const f = failure(res.status, (await res.text()).slice(0, 1 << 20))
1123
1177
  return errorResponse(f.status, f.message)
1124
1178
  }
1125
- return reply(events(res.body, model, budget), model, req.stream === true)
1179
+ return kept(await reply(events(res.body, model, budget), model, req.stream === true))
1126
1180
  }
1127
1181
 
1128
1182
  // ---- signing in ----------------------------------------------------------------------
@@ -1308,6 +1362,18 @@ export async function KiroAuthPlugin({ client } = {}) {
1308
1362
  return {
1309
1363
  auth: {
1310
1364
  provider: ID,
1365
+ // magpie renews a sign-in kept here LEAD_MS before its end, once for
1366
+ // the account, before its requests, models and usage ask for it; the
1367
+ // check before each request stays for OpenCode, which doesn't call
1368
+ // this. kiro-cli's and the IDE's sign-ins have no end magpie knows
1369
+ // (expires 0) and are never asked for.
1370
+ refreshLead: LEAD_MS,
1371
+ // A failure is thrown as it is, a refusal too: the built-in marked
1372
+ // no Kiro account lapsed (see gone).
1373
+ async refresh(auth) {
1374
+ if (auth?.type !== "oauth" || auth.source || !auth.access || !auth.refresh) return undefined
1375
+ return creds.renew(auth)
1376
+ },
1311
1377
  async loader(getAuth) {
1312
1378
  const auth = await getAuth()
1313
1379
  if (!(auth?.type === "api" && auth.key) && auth?.type !== "oauth") return {}
@@ -1329,14 +1395,15 @@ export async function KiroAuthPlugin({ client } = {}) {
1329
1395
  },
1330
1396
  }
1331
1397
  },
1332
- // the account's credits, as magpie's built-in showed them
1398
+ // the account's credits, as magpie's built-in showed them; a read,
1399
+ // clean or failed, marked the built-in's account nothing
1333
1400
  async usage(getAuth) {
1334
1401
  const auth = await getAuth()
1335
- if (!(auth?.type === "api" && auth.key) && auth?.type !== "oauth") return { error: "not signed in" }
1402
+ if (!(auth?.type === "api" && auth.key) && auth?.type !== "oauth") return { error: "not signed in", signIn: "kept" }
1336
1403
  try {
1337
- return usageOf(await usageLimits(await creds(auth), true))
1404
+ return { ...usageOf(await usageLimits(await creds(auth), true)), signIn: "kept" }
1338
1405
  } catch (e) {
1339
- return { error: e.message }
1406
+ return { error: e.message, signIn: "kept" }
1340
1407
  }
1341
1408
  },
1342
1409
  methods: [
@@ -1373,4 +1440,4 @@ export async function KiroAuthPlugin({ client } = {}) {
1373
1440
  }
1374
1441
 
1375
1442
  // for tests
1376
- export const _internal = { usageOf, buildKiro, events, reply, failure, toolID, thinking, readCLI, readIDE, regionOf, planName, frames }
1443
+ export const _internal = { generate, refresh, usageOf, buildKiro, events, reply, failure, toolID, thinking, readCLI, readIDE, regionOf, planName, frames }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-kiro-auth",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Kiro subscriptions (Free, Pro, Pro+, Power) in OpenCode and magpie",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "exports": "./index.mjs",
8
8
  "files": ["index.mjs", "README.md"],
9
9
  "keywords": ["opencode", "opencode-plugin", "magpie", "kiro"],
10
- "license": "MIT"
10
+ "license": "MIT",
11
+ "repository": { "type": "git", "url": "git+https://github.com/magpie-community/plugins.git", "directory": "packages/kiro" }
11
12
  }