@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.
- package/README.md +17 -0
- package/index.mjs +154 -20
- 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
|
-
|
|
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 (
|
|
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
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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)",
|
|
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
|
],
|