@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.
- package/README.md +17 -2
- package/index.mjs +70 -8
- 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
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|