@magpie-community/opencode-kiro-auth 0.1.5 → 0.1.7

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 +70 -8
  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
@@ -43,6 +43,13 @@ const SCOPES = ["codewhisperer:completions", "codewhisperer:analysis", "codewhis
43
43
  const IDE_UA = "KiroIDE-1.1.70-" + createHash("sha256").update("magpie:" + hostname()).digest("hex")
44
44
  const DESKTOP_UA = "Kiro-Desktop/0.2.13 (darwin; arm64)"
45
45
  const SIGN_IN_TIMEOUT = 10 * 60 * 1000
46
+ // A Builder ID sign-in has no profile of its own, and Kiro won't list one
47
+ // for it (List-Available-Profiles is a 403, "AWS Builder ID is not supported
48
+ // for this operation"); the Kiro IDE and kiro-cli name this service profile
49
+ // for it instead, so its models, usage and chat are asked with it too.
50
+ const BUILDER_ID_START = "https://view.awsapps.com/start"
51
+ const BUILDER_ID_PROFILE = "arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX"
52
+ const isBuilderID = (provider) => String(provider ?? "").toLowerCase() === "builderid"
46
53
 
47
54
  const env = (k) => process.env[k] ?? ""
48
55
  const home = () => env("HOME") || homedir()
@@ -118,6 +125,8 @@ async function readCLI() {
118
125
  if (kind === "social") c.method = "social"
119
126
  else if (kind === "odic") {
120
127
  c.method = "idc"
128
+ // as kiro-cli tells them apart: Builder ID's start URL, or none
129
+ c.builderID = !m.start_url || m.start_url === BUILDER_ID_START
121
130
  const reg = get("kirocli:odic:device-registration")
122
131
  if (reg) [c.clientId, c.clientSecret] = [reg.client_id ?? "", reg.client_secret ?? ""]
123
132
  } else {
@@ -145,7 +154,7 @@ async function readIDE() {
145
154
  }
146
155
  if (!t?.accessToken) return null
147
156
  const c = { access: t.accessToken, refresh: t.refreshToken ?? "", expires: time(t.expiresAt), region: t.region || "us-east-1",
148
- profile: t.profileArn ?? "", method: "idc", idePath: path }
157
+ profile: t.profileArn ?? "", method: "idc", idePath: path, builderID: isBuilderID(t.provider) }
149
158
  if (t.clientIdHash) {
150
159
  try {
151
160
  const reg = JSON.parse(await readFile(join(ideDir(), t.clientIdHash + ".json"), "utf8"))
@@ -197,7 +206,9 @@ const EXPIRED_CLI = EXPIRED + " with `kiro-cli login` or the Kiro IDE"
197
206
  // there. The built-in marked no Kiro account lapsed, not even then, nor
198
207
  // cleared one: every answer and usage read says the account is kept.
199
208
  const gone = (msg) => Object.assign(new Error(msg), { gone: true })
200
- const fresh = (c) => c.method === "apikey" || !c.expires || c.expires - Date.now() > 2 * 60 * 1000
209
+ const EARLY_MS = 2 * 60 * 1000 // a token this close to its end is refreshed before a request
210
+ const LEAD_MS = 10 * 60 * 1000 // and this close, magpie renews it ahead of time (auth.refresh)
211
+ const fresh = (c, early = EARLY_MS) => c.method === "apikey" || !c.expires || c.expires - Date.now() > early
201
212
 
202
213
  async function post(url, contentType, body, headers = {}) {
203
214
  let res
@@ -287,6 +298,7 @@ class KiroError extends Error {
287
298
  } catch {}
288
299
  super(msg ? `Kiro: ${msg} (${status})` : `Kiro: ${STATUS_TEXT[status] ?? ""}`)
289
300
  this.status = status
301
+ this.body = String(body ?? "")
290
302
  }
291
303
  }
292
304
 
@@ -307,7 +319,7 @@ async function managementCall(region, a, method, query, body) {
307
319
  }
308
320
 
309
321
  // profileOf finds the profile a sign-in that doesn't name one uses: an API
310
- // key's own, or the first Kiro lists in either of its regions.
322
+ // key's own, Builder ID's, or the first Kiro lists in either of its regions.
311
323
  async function profileOf(c) {
312
324
  if (c.method === "apikey") {
313
325
  const res = await fetch(management("us-east-1") + "/", {
@@ -325,6 +337,7 @@ async function profileOf(c) {
325
337
  if (!arn) throw new Error("Kiro didn't say which profile the API key is for")
326
338
  return arn
327
339
  }
340
+ if (c.builderID) return BUILDER_ID_PROFILE
328
341
  let last
329
342
  for (const region of ["us-east-1", "eu-central-1"]) {
330
343
  try {
@@ -332,6 +345,8 @@ async function profileOf(c) {
332
345
  const arn = (out?.profiles ?? []).find((p) => p?.arn)?.arn
333
346
  if (arn) return arn
334
347
  } catch (e) {
348
+ // a Builder ID sign-in the token didn't say was one
349
+ if (c.method === "idc" && e?.status === 403 && e.body.includes("Builder ID")) return BUILDER_ID_PROFILE
335
350
  last = e
336
351
  }
337
352
  }
@@ -394,14 +409,29 @@ async function credOf(auth) {
394
409
  if (!auth.access) return null
395
410
  return { access: auth.access, refresh: auth.refresh ?? "", expires: auth.expires ?? 0, method: auth.method || "social",
396
411
  region: auth.region || "us-east-1", profile: auth.profileArn ?? "", clientId: auth.clientId ?? "", clientSecret: auth.clientSecret ?? "",
397
- own: true }
412
+ builderID: isBuilderID(auth.loginProvider), own: true }
398
413
  }
399
414
 
400
415
  // Account holds the credentials in use, refreshing them when near their
401
416
  // end or when Kiro turned one down; one refresh at a time, as a refresh
402
417
  // may replace the refresh token.
418
+ //
419
+ // after is what each refresh gave, by the access token it replaced: a
420
+ // sign-in read with that token (its save failed, or magpie hasn't yet
421
+ // saved what auth.refresh gave) goes on with the newer one rather than
422
+ // spending the replaced refresh token again.
403
423
  function account(client) {
404
424
  let held = null // { id, c }
425
+ const after = new Map()
426
+ const newest = (c) => {
427
+ for (let i = 0; i < 16 && after.has(c.access); i++) c = { ...after.get(c.access) }
428
+ return c
429
+ }
430
+ const renewed = (c, n) => {
431
+ after.set(c.access, n)
432
+ if (after.size > 16) after.delete(after.keys().next().value)
433
+ return n
434
+ }
405
435
  let lock = Promise.resolve()
406
436
  const locked = (fn) => {
407
437
  const run = lock.then(fn, fn)
@@ -414,12 +444,12 @@ function account(client) {
414
444
  await client.auth.set({ path: { id: ID }, body: { ...rest, type: "oauth", access: c.access, refresh: c.refresh, expires: c.expires,
415
445
  profileArn: c.profile, region: c.region } }).catch(() => {})
416
446
  }
417
- return (auth, stale = false) =>
447
+ const creds = (auth, stale = false) =>
418
448
  locked(async () => {
419
449
  const id = auth?.type === "api" ? "key:" + auth.key : "oauth:" + (auth?.source ?? "own")
420
450
  // what its owner holds now: it may have refreshed it, signed out, or
421
451
  // signed in to another account since
422
- const read = await credOf(auth)
452
+ let read = await credOf(auth)
423
453
  if (!read) {
424
454
  held = null
425
455
  throw gone(
@@ -428,6 +458,7 @@ function account(client) {
428
458
  : "this Kiro account's sign-in is gone; add it again in magpie",
429
459
  )
430
460
  }
461
+ read = newest(read)
431
462
  let c = held?.id === id ? held.c : null
432
463
  if (!c || read.access !== c.access || !fresh(c) || stale) {
433
464
  if (!read.profile && c && read.access === c.access) read.profile = c.profile
@@ -435,7 +466,7 @@ function account(client) {
435
466
  c = read
436
467
  if (!fresh(c) || stale) {
437
468
  if (c.method === "apikey") throw gone("Kiro turned down the API key saved on the provider")
438
- c = await refresh(c)
469
+ c = renewed(c, await refresh(c))
439
470
  await save(auth, c)
440
471
  }
441
472
  }
@@ -446,6 +477,25 @@ function account(client) {
446
477
  held = { id, c }
447
478
  return { token: c.access, tokenType: tokenType(c), profile: c.profile, region: regionOf(c.profile, c.region) }
448
479
  })
480
+ // renew is auth.refresh's: a sign-in kept here (not kiro-cli's or the
481
+ // IDE's, which their owners keep) renewed under the same lock as the
482
+ // requests' refresh, so the two never spend one refresh token. It saves
483
+ // nothing — magpie saves what it gives — and gives only what changed.
484
+ creds.renew = (auth) =>
485
+ locked(async () => {
486
+ const read = await credOf(auth)
487
+ if (!read?.own || !read.refresh) return undefined
488
+ let c = newest(read)
489
+ // renewed here already (a request's refresh, or this hook's whose
490
+ // result magpie hasn't saved yet), the store not yet saying so
491
+ if (c.access === read.access || !fresh(c, LEAD_MS)) c = renewed(c, await refresh(c))
492
+ held = { id: "oauth:own", c }
493
+ const out = { access: c.access, expires: c.expires }
494
+ if (c.refresh !== auth.refresh) out.refresh = c.refresh
495
+ if (c.profile && c.profile !== auth.profileArn) out.profileArn = c.profile
496
+ return out
497
+ })
498
+ return creds
449
499
  }
450
500
 
451
501
  // ---- models ----------------------------------------------------------------------
@@ -1238,7 +1288,7 @@ async function browserSignIn() {
1238
1288
  grantType: "authorization_code", redirectUri: awsRedirect, code: q.get("code"), codeVerifier: aws.verifier })
1239
1289
  if (!t.accessToken) throw new Error("AWS sent back no token")
1240
1290
  c = { access: t.accessToken, refresh: t.refreshToken ?? "", expires: expiry(t.expiresIn), method: "idc", loginProvider: aws.provider,
1241
- region: aws.region, clientId: aws.clientId, clientSecret: aws.clientSecret, profile: "" }
1291
+ region: aws.region, clientId: aws.clientId, clientSecret: aws.clientSecret, profile: "", builderID: isBuilderID(aws.provider) }
1242
1292
  } else if (q.get("state") !== state) {
1243
1293
  // not ours: someone else's page, or a stale tab
1244
1294
  return html(200, page(false, "This link isn't from this sign-in", "Start it again."))
@@ -1325,6 +1375,18 @@ export async function KiroAuthPlugin({ client } = {}) {
1325
1375
  return {
1326
1376
  auth: {
1327
1377
  provider: ID,
1378
+ // magpie renews a sign-in kept here LEAD_MS before its end, once for
1379
+ // the account, before its requests, models and usage ask for it; the
1380
+ // check before each request stays for OpenCode, which doesn't call
1381
+ // this. kiro-cli's and the IDE's sign-ins have no end magpie knows
1382
+ // (expires 0) and are never asked for.
1383
+ refreshLead: LEAD_MS,
1384
+ // A failure is thrown as it is, a refusal too: the built-in marked
1385
+ // no Kiro account lapsed (see gone).
1386
+ async refresh(auth) {
1387
+ if (auth?.type !== "oauth" || auth.source || !auth.access || !auth.refresh) return undefined
1388
+ return creds.renew(auth)
1389
+ },
1328
1390
  async loader(getAuth) {
1329
1391
  const auth = await getAuth()
1330
1392
  if (!(auth?.type === "api" && auth.key) && auth?.type !== "oauth") return {}
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-kiro-auth",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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
  }