@magpie-community/opencode-kiro-auth 0.1.20 → 0.1.21

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 -0
  2. package/index.mjs +154 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,6 +6,23 @@ Power) and makes its requests, in OpenCode and in magpie. Provider id:
6
6
 
7
7
  ## Signing in
8
8
 
9
+ Choose a sign-in method before opening the browser:
10
+
11
+ - **AWS Builder ID** opens AWS directly for a personal account.
12
+ - **IAM Identity Center (organization Start URL)** asks for the organization's
13
+ HTTPS Start URL and AWS region (blank defaults to `us-east-1`), then opens
14
+ that organization's AWS sign-in directly. Use the region of the directory.
15
+ - **Kiro sign-in page** retains the portal for Google, GitHub and other Kiro
16
+ sign-in methods. Callers without prompt inputs retain this behavior.
17
+
18
+ The two direct AWS options skip the portal's first loopback callback. When
19
+ Magpie runs remotely or in Docker, sign in and authorize, then paste the
20
+ complete final `http://127.0.0.1:<port>/oauth/callback?...` address into Magpie's
21
+ Callback URL field. The page need not load on the browser's computer. Local
22
+ browsers that can reach the plugin's listener finish automatically. Direct
23
+ AWS sign-in still uses authorization code + PKCE, not device authorization.
24
+
25
+
9
26
  - **Kiro (Google, GitHub, AWS Builder ID, IAM Identity Center)**: the Kiro
10
27
  IDE's own sign-in.
11
28
  - Kiro's page (`app.kiro.dev/signin`) sends the browser back to one of
package/index.mjs CHANGED
@@ -1936,6 +1936,20 @@ async function signInPost(url, body) {
1936
1936
 
1937
1937
  const expiry = (s) => Date.now() + (s > 0 ? s : 3600) * 1000
1938
1938
 
1939
+ // what Kiro's page says of where to sign in at AWS, taken only in its shape:
1940
+ // the region goes into AWS's host names
1941
+ const AWS_REGION = /^[a-z]{2}(?:-[a-z]+)+-\d{1,2}$/
1942
+ // the ways Kiro's page comes back with a choice of
1943
+ const KIRO_WAYS = ["google", "github", "builderid", "awsidc", "internal", "external_idp"]
1944
+ function httpsURL(s) {
1945
+ try {
1946
+ const u = new URL(s)
1947
+ return u.protocol === "https:" && !u.username && !u.password
1948
+ } catch {
1949
+ return false
1950
+ }
1951
+ }
1952
+
1939
1953
  // whoIs names a new sign-in with the email Kiro has for it, and its plan.
1940
1954
  async function whoIs(c) {
1941
1955
  try {
@@ -1960,8 +1974,21 @@ async function listen() {
1960
1974
  throw new Error("the ports Kiro's sign-in comes back to are all busy; close the Kiro IDE's sign-in and try again")
1961
1975
  }
1962
1976
 
1977
+ // signingIn ends the browser sign-in under way, if any, and frees its
1978
+ // port: magpie keeps one sign-in of a provider at a time, and one left
1979
+ // behind (a tab closed, a paste never made) kept its port till it timed
1980
+ // out, so a few of them left no port for the next
1981
+ let signingIn = null
1982
+
1963
1983
  // browserSignIn is the IDE's: Kiro's sign-in page, back to a port here.
1964
- async function browserSignIn() {
1984
+ async function browserSignIn(inputs = {}) {
1985
+ const choice = inputs.loginMethod || "portal"
1986
+ if (!["portal", "builderid", "enterprise"].includes(choice)) throw new Error("unknown Kiro sign-in method")
1987
+ const issuer = choice === "enterprise" ? String(inputs.startUrl || "").trim() : BUILDER_ID_START
1988
+ const region = String(inputs.region || "us-east-1").trim()
1989
+ if (choice === "enterprise" && !httpsURL(issuer)) throw new Error("Enter the organization's HTTPS Start URL")
1990
+ if (choice !== "portal" && !AWS_REGION.test(region)) throw new Error("Enter a valid AWS region")
1991
+ signingIn?.()
1965
1992
  const { server, port } = await listen()
1966
1993
  const state = randomBytes(24).toString("base64url")
1967
1994
  const verifier = randomBytes(48).toString("base64url")
@@ -1977,7 +2004,39 @@ async function browserSignIn() {
1977
2004
  settle(result)
1978
2005
  }
1979
2006
 
2007
+ // A sign-in comes back here once (Google, GitHub), or twice (Builder ID,
2008
+ // Identity Center: Kiro's page, then AWS's). The browser may be on
2009
+ // another computer (magpie on a server or in Docker): the address its
2010
+ // tab ended on is pasted there, and magpie opens it here, on this port
2011
+ // (magpie's pluginsignin_paste.go), following a 302 to AWS as the next
2012
+ // page. So each stage answers only its own state, once, and a request
2013
+ // that isn't this sign-in's is a 404, which magpie says is the wrong
2014
+ // address at once instead of waiting.
2015
+ let stage = "portal" // then "aws", then "over"
2016
+ let busy = false // a code being traded: a second paste of it waits for the first
2017
+ let onToAWS = "" // the page the portal stage sent the browser on to, said again to a repeat
2018
+ let portalAsked = "" // what that stage was asked, so a repeat asking otherwise isn't taken
2019
+ async function startAWS(opt, issuer, region) {
2020
+ const reg = await signInPost(oidc(region) + "/client/register", { clientName: "Kiro IDE", clientType: "public", scopes: SCOPES,
2021
+ grantTypes: ["authorization_code", "refresh_token"], redirectUris: ["http://127.0.0.1/oauth/callback"], issuerUrl: issuer })
2022
+ if (stage === "over") throw new Error("this sign-in was cancelled")
2023
+ if (!reg.clientId) throw new Error("AWS registered no client")
2024
+ Object.assign(aws, { region, clientId: reg.clientId, clientSecret: reg.clientSecret,
2025
+ provider: { builderid: "BuilderId", awsidc: "Enterprise", internal: "Internal" }[opt],
2026
+ state: randomBytes(24).toString("base64url"), verifier: randomBytes(48).toString("base64url") })
2027
+ const a = new URLSearchParams({ response_type: "code", client_id: aws.clientId, redirect_uri: awsRedirect, scopes: SCOPES.join(","),
2028
+ state: aws.state, code_challenge: createHash("sha256").update(aws.verifier).digest("base64url"), code_challenge_method: "S256" })
2029
+ onToAWS = `${oidc(region)}/authorize?${a}`
2030
+ portalAsked = JSON.stringify([opt, issuer, region])
2031
+ stage = "aws"
2032
+ busy = false
2033
+ return onToAWS
2034
+ }
2035
+
1980
2036
  server.on("request", async (req, res) => {
2037
+ // each answer its own connection: none held open keeps the port once
2038
+ // the sign-in is over
2039
+ res.setHeader("Connection", "close")
1981
2040
  const url = new URL(req.url ?? "/", redirect)
1982
2041
  const html = (code, body) => {
1983
2042
  res.writeHead(code, { "Content-Type": "text/html; charset=utf-8" })
@@ -1985,57 +2044,88 @@ async function browserSignIn() {
1985
2044
  }
1986
2045
  if (url.pathname !== "/oauth/callback" && url.pathname !== "/signin/callback") return res.writeHead(404).end()
1987
2046
  const q = url.searchParams
1988
- if (!waiting) return html(200, page(false, "This sign-in is over", "Start it again."))
2047
+ const over = () => html(404, page(false, "This sign-in is over", "Start it again."))
2048
+ const notOurs = () => html(404, page(false, "This link isn't from this sign-in", "Paste the address of the tab this sign-in opened, or start it again."))
2049
+ if (stage === "over") return over()
1989
2050
  const fail = (msg) => {
2051
+ stage = "over"
1990
2052
  finish({ type: "failed", error: msg })
1991
2053
  html(200, page(false, "Sign-in didn't finish", msg))
1992
2054
  }
2055
+ const got = q.get("state") ?? ""
2056
+ const portal = got === state
2057
+ const fromAWS = stage === "aws" && !!aws.state && got === aws.state
2058
+ // a stale tab, another sign-in's, or a page that just sends a request
2059
+ // here: it says nothing about this one, and can't end it
2060
+ if (!portal && !fromAWS) return notOurs()
2061
+ if (busy) return html(409, page(false, "Already finishing", "This sign-in is finishing with this address already."))
1993
2062
  if (q.get("error")) return fail(q.get("error_description") || q.get("error"))
1994
2063
  let c
1995
2064
  try {
1996
- if (aws.state && q.get("state") === aws.state) {
1997
- // back from AWS
2065
+ if (fromAWS) {
2066
+ // back from AWS, to the address it was sent with
2067
+ if (url.pathname !== "/oauth/callback") return notOurs()
1998
2068
  if (!q.get("code")) return fail("AWS sent back no code")
2069
+ busy = true
1999
2070
  const t = await signInPost(oidc(aws.region) + "/token", { clientId: aws.clientId, clientSecret: aws.clientSecret,
2000
2071
  grantType: "authorization_code", redirectUri: awsRedirect, code: q.get("code"), codeVerifier: aws.verifier })
2001
2072
  if (!t.accessToken) throw new Error("AWS sent back no token")
2002
2073
  c = { access: t.accessToken, refresh: t.refreshToken ?? "", expires: expiry(t.expiresIn), method: "idc", loginProvider: aws.provider,
2003
2074
  region: aws.region, clientId: aws.clientId, clientSecret: aws.clientSecret, profile: "", builderID: isBuilderID(aws.provider) }
2004
- } else if (q.get("state") !== state) {
2005
- // not ours: someone else's page, or a stale tab
2006
- return html(200, page(false, "This link isn't from this sign-in", "Start it again."))
2007
2075
  } else {
2008
2076
  const opt = q.get("login_option") ?? ""
2077
+ const asked = JSON.stringify([opt, q.get("issuer_url"), q.get("idc_region")])
2078
+ if (stage === "aws") {
2079
+ // Kiro's page's address again (pasted twice, or reloaded): the
2080
+ // same AWS page, not a second client and state, which would
2081
+ // leave the first AWS tab's code refused
2082
+ if (onToAWS && asked === portalAsked) {
2083
+ res.writeHead(302, { Location: onToAWS })
2084
+ return res.end()
2085
+ }
2086
+ // chosen again on Kiro's page (Back, then another way): that
2087
+ // choice now, the AWS sign-in left behind dropped. Anything else
2088
+ // with Kiro's page's state isn't a choice, and leaves AWS's going
2089
+ if (!KIRO_WAYS.includes(opt)) return notOurs()
2090
+ stage = "portal"
2091
+ onToAWS = portalAsked = ""
2092
+ for (const k of Object.keys(aws)) delete aws[k]
2093
+ }
2009
2094
  if (opt === "google" || opt === "github") {
2095
+ if (!q.get("code")) return fail("Kiro's page sent back no code")
2096
+ busy = true
2010
2097
  const t = await signInPost(AUTH_SERVICE + "/oauth/token", { code: q.get("code"), code_verifier: verifier,
2011
2098
  redirect_uri: redirect + url.pathname + "?login_option=" + opt })
2012
2099
  if (!t.accessToken) throw new Error("Kiro sent back no token")
2013
2100
  c = { access: t.accessToken, refresh: t.refreshToken ?? "", expires: expiry(t.expiresIn), method: "social",
2014
2101
  loginProvider: opt === "google" ? "Google" : "Github", region: "us-east-1", profile: t.profileArn ?? "" }
2015
2102
  } else if (opt === "builderid" || opt === "awsidc" || opt === "internal") {
2016
- // on to AWS, with a client registered for this sign-in
2103
+ // on to AWS, with a client registered for this sign-in. The region
2104
+ // goes into AWS's host name, so it is only a region's shape
2017
2105
  const issuer = q.get("issuer_url")
2018
2106
  const region = q.get("idc_region")
2019
2107
  if (!issuer || !region) return fail("Kiro's page didn't say where to sign in at AWS")
2020
- const reg = await signInPost(oidc(region) + "/client/register", { clientName: "Kiro IDE", clientType: "public", scopes: SCOPES,
2021
- grantTypes: ["authorization_code", "refresh_token"], redirectUris: ["http://127.0.0.1/oauth/callback"], issuerUrl: issuer })
2022
- if (!reg.clientId) throw new Error("AWS registered no client")
2023
- Object.assign(aws, { region, clientId: reg.clientId, clientSecret: reg.clientSecret,
2024
- provider: { builderid: "BuilderId", awsidc: "Enterprise", internal: "Internal" }[opt],
2025
- state: randomBytes(24).toString("base64url"), verifier: randomBytes(48).toString("base64url") })
2026
- const a = new URLSearchParams({ response_type: "code", client_id: aws.clientId, redirect_uri: awsRedirect, scopes: SCOPES.join(","),
2027
- state: aws.state, code_challenge: createHash("sha256").update(aws.verifier).digest("base64url"), code_challenge_method: "S256" })
2028
- res.writeHead(302, { Location: `${oidc(region)}/authorize?${a}` })
2108
+ if (!AWS_REGION.test(region)) return fail("Kiro's page named a region AWS doesn't have")
2109
+ if (!httpsURL(issuer)) return fail("Kiro's page named an AWS sign-in that isn't an https address")
2110
+ busy = true
2111
+ await startAWS(opt, issuer, region)
2112
+ res.writeHead(302, { Location: onToAWS })
2029
2113
  return res.end()
2030
2114
  } else if (opt === "external_idp") {
2031
2115
  return fail("A company's own identity provider can't be signed in to here yet; sign in with `kiro-cli login`, and use Kiro CLI's sign-in")
2032
2116
  } else return fail(`Kiro's page came back with a sign-in this plugin doesn't know (${JSON.stringify(opt)})`)
2033
2117
  }
2034
2118
  } catch (e) {
2119
+ if (stage === "over") return over()
2120
+ busy = false
2035
2121
  return fail(e.message)
2036
2122
  }
2123
+ if (stage === "over") return over() // ended (a new sign-in, the time) while the code was traded
2124
+ // still busy while it is named: a second paste is told it is finishing
2037
2125
  const who = await whoIs(c)
2126
+ if (stage === "over") return over()
2038
2127
  const user = who.email ? nameOf(who.email, c.loginProvider) : "Kiro account"
2128
+ stage = "over"
2039
2129
  finish({ type: "success", provider: ID, refresh: c.refresh, access: c.access, expires: c.expires, accountId: user,
2040
2130
  ...(uidOf(c.loginProvider) ? { uid: uidOf(c.loginProvider) } : {}),
2041
2131
  method: c.method, loginProvider: c.loginProvider, region: c.region, profileArn: who.profile,
@@ -2043,11 +2133,43 @@ async function browserSignIn() {
2043
2133
  html(200, page(true, "You're signed in", `${user} is signed in. You can close this tab.`))
2044
2134
  })
2045
2135
 
2046
- const timer = setTimeout(() => finish({ type: "failed", error: "the sign-in timed out" }), SIGN_IN_TIMEOUT)
2136
+ const timer = setTimeout(() => {
2137
+ stage = "over"
2138
+ finish({ type: "failed", error: "the sign-in timed out" })
2139
+ }, SIGN_IN_TIMEOUT)
2140
+ const end = () => {
2141
+ if (signingIn === superseded) signingIn = null
2142
+ server.close()
2143
+ }
2144
+ // a new sign-in ends this one, or, over, frees its port at once
2145
+ const superseded = () => {
2146
+ stage = "over"
2147
+ finish({ type: "failed", error: "another sign-in to Kiro was started" })
2148
+ end()
2149
+ }
2150
+ signingIn?.() // one started while this one found its port
2151
+ signingIn = superseded
2047
2152
  done.then(() => {
2048
2153
  clearTimeout(timer)
2049
- setTimeout(() => server.close(), 10_000).unref?.()
2154
+ // a late address (pasted again, a tab reloaded) is told it is over a while
2155
+ setTimeout(end, 10_000).unref?.()
2050
2156
  })
2157
+ if (choice === "builderid" || choice === "enterprise") {
2158
+ try {
2159
+ const url = await startAWS(choice === "enterprise" ? "awsidc" : "builderid", issuer, region)
2160
+ return {
2161
+ url,
2162
+ instructions: "Complete AWS sign-in. If magpie runs on another computer, paste the complete address of the final localhost page back here.",
2163
+ method: "auto",
2164
+ callback: () => done,
2165
+ }
2166
+ } catch (error) {
2167
+ stage = "over"
2168
+ finish({ type: "failed", error: error.message })
2169
+ end()
2170
+ throw error
2171
+ }
2172
+ }
2051
2173
  const challenge = createHash("sha256").update(verifier).digest("base64url")
2052
2174
  const q = new URLSearchParams({ state, code_challenge: challenge, code_challenge_method: "S256", redirect_uri: redirect, redirect_from: "KiroIDE" })
2053
2175
  return {
@@ -2138,7 +2260,19 @@ export async function KiroAuthPlugin({ client } = {}) {
2138
2260
  }
2139
2261
  },
2140
2262
  methods: [
2141
- { type: "oauth", label: "Kiro (Google, GitHub, AWS Builder ID, IAM Identity Center)", authorize: browserSignIn },
2263
+ { type: "oauth", label: "Kiro (Google, GitHub, AWS Builder ID, IAM Identity Center)",
2264
+ prompts: [{ type: "select", key: "loginMethod", message: "Choose a sign-in method", options: [
2265
+ { label: "IAM Identity Center (organization Start URL)", value: "enterprise", hint: "Sign in to your organization with its AWS Start URL" },
2266
+ { label: "AWS Builder ID", value: "builderid", hint: "Open AWS sign-in directly; one callback to paste on a remote server" },
2267
+ { label: "Kiro sign-in page", value: "portal", hint: "Google, GitHub, IAM Identity Center or other Kiro sign-in methods" },
2268
+ ] },
2269
+ { type: "text", key: "startUrl", message: "Organization Start URL", placeholder: "https://d-xxxxxxxxxx.awsapps.com/start",
2270
+ condition: (inputs) => inputs.loginMethod === "enterprise",
2271
+ validate: (value) => httpsURL(String(value || "").trim()) ? undefined : "Enter an HTTPS Start URL" },
2272
+ { type: "text", key: "region", message: "AWS region (blank uses us-east-1)", placeholder: "us-east-1",
2273
+ condition: (inputs) => inputs.loginMethod === "enterprise",
2274
+ validate: (value) => !value || AWS_REGION.test(value.trim()) ? undefined : "Enter a valid AWS region" },
2275
+ ], authorize: browserSignIn },
2142
2276
  { type: "oauth", label: "Kiro CLI's or Kiro IDE's sign-in", authorize: ownSignIn },
2143
2277
  { type: "api", label: "Kiro API key (ksk_…)" },
2144
2278
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-kiro-auth",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Kiro subscriptions (Free, Pro, Pro+, Power) in OpenCode and magpie",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",