diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index c5dbc460..e5f95112 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -3643,6 +3643,8 @@ const SUBS = [ { agent: "commandcode-plan", name: "Command Code", icon: "commandcode", plans: "Pro · GOAT · Max · Ultra", own: true }, // devin's credentials.toml keeps one account too { agent: "devin", name: "Devin", icon: "devin", plans: "Pro · Enterprise", single: true }, + // Kiro's own sign-in page (Google, GitHub, Builder ID, Identity Center); kiro-cli's or the IDE's is read too + { agent: "kiro", name: "Kiro", icon: "kiro-color", plans: "Free · Pro · Pro+ · Power", single: true }, // Google's sign-ins; Gemini CLI's own account is read too { agent: "gemini", name: "Gemini CLI", icon: "geminicli-color", plans: "Code Assist Standard · Enterprise", own: true }, // accounts can also come from another tool's export (Antigravity Cockpit, Antigravity Manager, CLIProxyAPI) diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 5070bfb3..5b93dc39 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -212,7 +212,7 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON { // a Cursor subscription is served by the gateway, not an agent magpie configures out.Account.Name, out.Account.Icon = "Cursor CLI", "cursor" } else if a.Agent == "kiro" { - // Kiro's sign-in is kiro-cli's or the Kiro IDE's + // Kiro's sign-in is magpie's own, kiro-cli's or the Kiro IDE's out.Account.Name, out.Account.Icon = "Kiro", "kiro-color" } else if a.Agent == "antigravity" { out.Account.Name, out.Account.Icon = "Antigravity", "antigravity-color" diff --git a/internal/provider/kiro.go b/internal/provider/kiro.go index 58b2e0f9..4068b671 100644 --- a/internal/provider/kiro.go +++ b/internal/provider/kiro.go @@ -200,12 +200,15 @@ func readKiroIDE() (kiroCred, bool) { return c, true } -// readKiro is the sign-in to use: the key saved on the provider, else -// kiro-cli's, else the IDE's. +// readKiro is the sign-in to use: the key saved on the provider, else the +// one made in magpie, else kiro-cli's, else the IDE's. func readKiro(key string) (kiroCred, bool) { if key != "" { return kiroCred{access: key, method: "apikey", region: "us-east-1"}, true } + if c, ok := readKiroMagpie(); ok { + return c, true + } if c, ok := readKiroCLI(); ok { return c, true } @@ -253,7 +256,7 @@ func KiroAuthOf(ctx context.Context, key string, stale bool) (KiroAuth, error) { read, ok := readKiro(key) if !ok { kiroAuthCache.ok = false - return KiroAuth{}, errors.New("Kiro isn't signed in; sign in with `kiro-cli login` or the Kiro IDE, or save a Kiro API key on the provider") + return KiroAuth{}, errors.New("Kiro isn't signed in; add the Kiro subscription in magpie, sign in with `kiro-cli login` or the Kiro IDE, or save a Kiro API key on the provider") } c := kiroAuthCache.cred if !kiroAuthCache.ok || kiroAuthCache.key != key || read.access != c.access || !c.fresh() || stale { diff --git a/internal/provider/kiro_signin.go b/internal/provider/kiro_signin.go new file mode 100644 index 00000000..103d6a2b --- /dev/null +++ b/internal/provider/kiro_signin.go @@ -0,0 +1,314 @@ +package provider + +// Signing in to Kiro from magpie, the way the Kiro IDE does: Kiro's sign-in +// page (app.kiro.dev) offers Google, GitHub, AWS Builder ID and IAM +// Identity Center, and sends the browser back to a port on this machine. +// Google and GitHub come back with a code Kiro's auth service trades for +// tokens; Builder ID and Identity Center come back with where to sign in +// at AWS, which is then done with a client registered for it, as the IDE +// does. The sign-in is magpie's own, kept beside logins.json, so kiro-cli's +// and the IDE's are left as they are; while it is there it is the one used. + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// Where Kiro signs in; vars so tests can answer them. +var ( + kiroPortalURL = "https://app.kiro.dev" + kiroAuthService = "https://prod.us-east-1.auth.desktop.kiro.dev" + kiroOIDC = func(region string) string { return "https://oidc." + region + ".amazonaws.com" } + // the ports Kiro's sign-in page sends the browser back to, the IDE's + kiroCallbackPorts = []int{3128, 4649, 6588, 8008, 9091, 49153, 50153, 51153, 52153, 53153} +) + +// kiroScopes are what the IDE asks AWS for. +var kiroScopes = []string{"codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations", + "codewhisperer:transformations", "codewhisperer:taskassist"} + +// kiroIDEUA is how the IDE names itself to Kiro's auth service. +var kiroIDEUA = func() string { + h, _ := os.Hostname() + sum := sha256.Sum256([]byte("magpie:" + h)) + return "KiroIDE-1.1.70-" + hex.EncodeToString(sum[:]) +}() + +// kiroSignInPath is magpie's own Kiro sign-in. +func kiroSignInPath() string { return filepath.Join(filepath.Dir(Path()), "kiro-auth-token.json") } + +// kiroSaved is magpie's Kiro sign-in as it is kept: the IDE's shape, with +// an AWS sign-in's client beside it. +type kiroSaved struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt string `json:"expiresAt"` + AuthMethod string `json:"authMethod"` // social | IdC + Provider string `json:"provider"` // Google, Github, BuilderId, Enterprise + Region string `json:"region,omitempty"` + ProfileArn string `json:"profileArn,omitempty"` + ClientID string `json:"clientId,omitempty"` + ClientSecret string `json:"clientSecret,omitempty"` +} + +// readKiroMagpie reads the sign-in made in magpie. +func readKiroMagpie() (kiroCred, bool) { + path := kiroSignInPath() + b, err := os.ReadFile(path) + if err != nil { + return kiroCred{}, false + } + var t kiroSaved + if json.Unmarshal(b, &t) != nil || t.AccessToken == "" { + return kiroCred{}, false + } + c := kiroCred{access: t.AccessToken, refresh: t.RefreshToken, region: t.Region, profile: t.ProfileArn, + clientID: t.ClientID, clientSecret: t.ClientSecret, idePath: path} + c.expires, _ = time.Parse(time.RFC3339Nano, t.ExpiresAt) + if c.region == "" { + c.region = "us-east-1" + } + if strings.EqualFold(t.AuthMethod, "IdC") && c.clientID != "" { + c.method = "idc" + } else { + c.method, c.social = "social", true + } + return c, true +} + +// kiroFlow is where a Kiro sign-in is between its two pages. +type kiroFlow struct { + port int + // an AWS sign-in, once Kiro's page has sent the browser on to it + region, clientID, clientSecret, provider string + state, verifier string +} + +// startKiroSignIn opens Kiro's sign-in page, to come back to one of the +// ports it sends browsers back to. +func startKiroSignIn(s *signInFlow, challenge string) (net.Listener, error) { + var ln net.Listener + var err error + for _, p := range kiroCallbackPorts { + if ln, err = net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)); err == nil { + break + } + } + if ln == nil { + return nil, errors.New("the ports Kiro's sign-in comes back to are all busy; close the Kiro IDE's sign-in and try again") + } + port := ln.Addr().(*net.TCPAddr).Port + s.kiro = &kiroFlow{port: port} + s.redirect = fmt.Sprintf("http://localhost:%d", port) + q := url.Values{} + q.Set("state", s.state) + q.Set("code_challenge", challenge) + q.Set("code_challenge_method", "S256") + q.Set("redirect_uri", s.redirect) + q.Set("redirect_from", "KiroIDE") + s.mu.Lock() + s.st.URL = kiroPortalURL + "/signin?" + q.Encode() + s.mu.Unlock() + return ln, nil +} + +// kiroCallback is the browser back from Kiro's page, or from AWS's. +func (s *signInFlow) kiroCallback(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/oauth/callback", "/signin/callback": + default: + http.NotFound(w, r) + return + } + q := r.URL.Query() + if s.status().State != "waiting" { + signInPage(w, false, "This sign-in is over", "Start it again from magpie.") + return + } + fail := func(msg string) { + s.finish(SignInState{State: "failed", Error: msg}) + signInPage(w, false, "Sign-in didn't finish", msg) + } + if e := q.Get("error"); e != "" { + msg := q.Get("error_description") + if msg == "" { + msg = e + } + fail(msg) + return + } + k := s.kiro + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + var saved kiroSaved + switch { + case k.state != "" && q.Get("state") == k.state: + // back from AWS + if q.Get("code") == "" { + fail("AWS sent back no code") + return + } + var tok struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int `json:"expiresIn"` + } + err := kiroSignInPost(ctx, kiroOIDC(k.region)+"/token", map[string]string{"clientId": k.clientID, "clientSecret": k.clientSecret, + "grantType": "authorization_code", "redirectUri": k.awsRedirect(), "code": q.Get("code"), "codeVerifier": k.verifier}, &tok) + if err == nil && tok.AccessToken == "" { + err = errors.New("AWS sent back no token") + } + if err != nil { + fail(err.Error()) + return + } + saved = kiroSaved{AccessToken: tok.AccessToken, RefreshToken: tok.RefreshToken, ExpiresAt: kiroExpiry(tok.ExpiresIn), + AuthMethod: "IdC", Provider: k.provider, Region: k.region, ClientID: k.clientID, ClientSecret: k.clientSecret} + case q.Get("state") != s.state: + // not ours: someone else's page, or a stale tab + signInPage(w, false, "This link isn't from magpie's sign-in", "Start it again from magpie.") + return + default: + switch opt := q.Get("login_option"); opt { + case "google", "github": + var tok struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ProfileArn string `json:"profileArn"` + ExpiresIn int `json:"expiresIn"` + } + err := kiroSignInPost(ctx, kiroAuthService+"/oauth/token", map[string]string{"code": q.Get("code"), + "code_verifier": s.verifier, "redirect_uri": s.redirect + r.URL.Path + "?login_option=" + opt}, &tok) + if err == nil && tok.AccessToken == "" { + err = errors.New("Kiro sent back no token") + } + if err != nil { + fail(err.Error()) + return + } + provider := map[string]string{"google": "Google", "github": "Github"}[opt] + saved = kiroSaved{AccessToken: tok.AccessToken, RefreshToken: tok.RefreshToken, ExpiresAt: kiroExpiry(tok.ExpiresIn), + AuthMethod: "social", Provider: provider, Region: "us-east-1", ProfileArn: tok.ProfileArn} + case "builderid", "awsidc", "internal": + // on to AWS, with a client registered for this sign-in + issuer, region := q.Get("issuer_url"), q.Get("idc_region") + if issuer == "" || region == "" { + fail("Kiro's page didn't say where to sign in at AWS") + return + } + var reg struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + } + err := kiroSignInPost(ctx, kiroOIDC(region)+"/client/register", map[string]any{"clientName": "Kiro IDE", "clientType": "public", + "scopes": kiroScopes, "grantTypes": []string{"authorization_code", "refresh_token"}, + "redirectUris": []string{"http://127.0.0.1/oauth/callback"}, "issuerUrl": issuer}, ®) + if err == nil && reg.ClientID == "" { + err = errors.New("AWS registered no client") + } + if err != nil { + fail(err.Error()) + return + } + k.region, k.clientID, k.clientSecret = region, reg.ClientID, reg.ClientSecret + k.provider = map[string]string{"builderid": "BuilderId", "awsidc": "Enterprise", "internal": "Internal"}[opt] + k.state, k.verifier = randomToken(24), randomToken(48) + sum := sha256.Sum256([]byte(k.verifier)) + a := url.Values{} + a.Set("response_type", "code") + a.Set("client_id", k.clientID) + a.Set("redirect_uri", k.awsRedirect()) + a.Set("scopes", strings.Join(kiroScopes, ",")) + a.Set("state", k.state) + a.Set("code_challenge", base64.RawURLEncoding.EncodeToString(sum[:])) + a.Set("code_challenge_method", "S256") + http.Redirect(w, r, kiroOIDC(region)+"/authorize?"+a.Encode(), http.StatusFound) + return + case "external_idp": + fail("magpie can't sign in with a company's own identity provider yet; sign in with `kiro-cli login`, and magpie uses that") + return + default: + fail(fmt.Sprintf("Kiro's page came back with a sign-in magpie doesn't know (%q)", opt)) + return + } + } + b, _ := json.MarshalIndent(saved, "", " ") + if err := writePrivate(kiroSignInPath(), append(b, '\n')); err != nil { + fail(err.Error()) + return + } + forgetAccountCaches() + user, plan := askKiroIdentity("") + kiroStatus.Lock() + kiroStatus.key, kiroStatus.user, kiroStatus.plan, kiroStatus.at = "", user, plan, time.Now() + kiroStatus.Unlock() + if user == "" { + user = "Kiro account" + } + s.finish(SignInState{State: "done", User: user, Plan: plan, Using: true}) + signInPage(w, true, "You're signed in", fmt.Sprintf("%s is added to magpie. You can close this tab.", user)) +} + +// awsRedirect is where AWS sends the browser back. +func (k *kiroFlow) awsRedirect() string { + return fmt.Sprintf("http://127.0.0.1:%d/oauth/callback", k.port) +} + +func kiroExpiry(in int) string { + if in <= 0 { + in = 3600 + } + return time.Now().Add(time.Duration(in) * time.Second).UTC().Format(time.RFC3339Nano) +} + +// kiroSignInPost posts JSON to Kiro's auth service or AWS's sign-in. +func kiroSignInPost(ctx context.Context, u string, body any, dst any) error { + b, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", kiroIDEUA) + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + out, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if res.StatusCode/100 != 2 { + var e struct { + Message string `json:"message"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + _ = json.Unmarshal(out, &e) + msg := e.ErrorDescription + if msg == "" { + msg = e.Message + } + if msg == "" { + msg = e.Error + } + if msg == "" { + msg = http.StatusText(res.StatusCode) + } + return fmt.Errorf("the sign-in was refused (%d): %s", res.StatusCode, msg) + } + return json.Unmarshal(out, dst) +} diff --git a/internal/provider/kiro_signin_test.go b/internal/provider/kiro_signin_test.go new file mode 100644 index 00000000..00bb1b46 --- /dev/null +++ b/internal/provider/kiro_signin_test.go @@ -0,0 +1,165 @@ +package provider + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// kiroFakeSignIn answers Kiro's auth service and AWS's sign-in, checking +// what the IDE would send; it returns the sign-in's page and a browser that +// doesn't follow redirects. +func kiroFakeSignIn(t *testing.T) (start func() (SignInState, url.Values), browser *http.Client) { + t.Helper() + kiroSandbox(t) + var challenge, redirect string + var awsChallenge string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var in map[string]any + json.NewDecoder(r.Body).Decode(&in) + str := func(k string) string { s, _ := in[k].(string); return s } + verify := func(verifier, want string) bool { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) == want + } + switch r.URL.Path { + case "/oauth/token": // Kiro's, for Google and GitHub + if str("code") != "gcode" || !verify(str("code_verifier"), challenge) || str("redirect_uri") != redirect+"/oauth/callback?login_option=google" || + !strings.HasPrefix(r.Header.Get("User-Agent"), "KiroIDE-") { + t.Errorf("Kiro token request %v", in) + w.WriteHeader(400) + return + } + json.NewEncoder(w).Encode(map[string]any{"accessToken": "social-at", "refreshToken": "social-rt", "profileArn": "arn:aws:codewhisperer:us-east-1:1:profile/P", "expiresIn": 3600}) + case "/client/register": + if in["issuerUrl"] != "https://view.awsapps.com/start" || in["clientType"] != "public" { + t.Errorf("register %v", in) + } + json.NewEncoder(w).Encode(map[string]any{"clientId": "cid", "clientSecret": "csecret", "clientSecretExpiresAt": time.Now().Add(90 * 24 * time.Hour).Unix()}) + case "/token": // AWS's + if str("grantType") != "authorization_code" || str("code") != "awscode" || str("clientId") != "cid" || str("clientSecret") != "csecret" || + !verify(str("codeVerifier"), awsChallenge) || !strings.HasPrefix(str("redirectUri"), "http://127.0.0.1:") { + t.Errorf("AWS token request %v", in) + w.WriteHeader(400) + return + } + json.NewEncoder(w).Encode(map[string]any{"accessToken": "idc-at", "refreshToken": "idc-rt", "expiresIn": 3600}) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + auth, oidc, ports, ask := kiroAuthService, kiroOIDC, kiroCallbackPorts, askKiroIdentity + kiroAuthService, kiroOIDC, kiroCallbackPorts = srv.URL, func(string) string { return srv.URL }, []int{0} + askKiroIdentity = func(string) (string, string) { + if c, ok := readKiro(""); ok && c.access == "social-at" { + return "me@example.com", "KIRO PRO" + } + return "", "" + } + t.Cleanup(func() { kiroAuthService, kiroOIDC, kiroCallbackPorts, askKiroIdentity = auth, oidc, ports, ask }) + browser = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + start = func() (SignInState, url.Values) { + st, err := StartSignIn("kiro") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { CancelSignIn(st.ID) }) + u, _ := url.Parse(st.URL) + q := u.Query() + if !strings.HasPrefix(st.URL, "https://app.kiro.dev/signin?") || q.Get("redirect_from") != "KiroIDE" || q.Get("code_challenge_method") != "S256" { + t.Fatalf("page %s", st.URL) + } + challenge, redirect = q.Get("code_challenge"), q.Get("redirect_uri") + return st, q + } + kiroSetAWSChallenge = func(c string) { awsChallenge = c } + return start, browser +} + +// kiroSetAWSChallenge tells the fake AWS the challenge its page was sent. +var kiroSetAWSChallenge func(string) + +// Signed in with Google on Kiro's page, the account is magpie's own and +// the one used, ahead of kiro-cli's, which is left as it was. +func TestKiroSignInWithGoogle(t *testing.T) { + start, browser := kiroFakeSignIn(t) + writeKiroCLI(t, map[string]any{"kirocli:social:token": map[string]any{"access_token": "cli-at", "expires_at": "2099-01-01T00:00:00Z"}}) + st, q := start() + res, err := browser.Get(q.Get("redirect_uri") + "/oauth/callback?login_option=google&code=gcode&state=" + url.QueryEscape(q.Get("state"))) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if st, _ = SignInStatus(st.ID); st.State != "done" || st.User != "me@example.com" || st.Plan != "KIRO PRO" || !st.Using { + t.Fatalf("status %+v", st) + } + c, ok := readKiro("") + if !ok || c.access != "social-at" || c.refresh != "social-rt" || c.method != "social" || c.profile != "arn:aws:codewhisperer:us-east-1:1:profile/P" || !c.fresh() { + t.Fatalf("cred %+v", c) + } + if row := readKiroRow(t, "kirocli:social:token"); row["access_token"] != "cli-at" { + t.Fatalf("kiro-cli's sign-in changed: %v", row) + } + if p, ok := kiroAccount(); !ok || p.Account.User != "me@example.com" { + t.Fatalf("account %+v", p.Account) + } +} + +// Builder ID goes on from Kiro's page to AWS's, with a client registered +// for it, and comes back an AWS sign-in magpie can refresh. +func TestKiroSignInWithBuilderID(t *testing.T) { + start, browser := kiroFakeSignIn(t) + st, q := start() + res, err := browser.Get(q.Get("redirect_uri") + "/signin/callback?login_option=builderid&issuer_url=" + url.QueryEscape("https://view.awsapps.com/start") + + "&idc_region=us-east-1&state=" + url.QueryEscape(q.Get("state"))) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + loc, _ := url.Parse(res.Header.Get("Location")) + a := loc.Query() + if res.StatusCode != http.StatusFound || loc.Path != "/authorize" || a.Get("client_id") != "cid" || a.Get("response_type") != "code" || + !strings.Contains(a.Get("scopes"), "codewhisperer:conversations") || !strings.HasSuffix(a.Get("redirect_uri"), "/oauth/callback") { + t.Fatalf("on to AWS: %d %s", res.StatusCode, loc) + } + kiroSetAWSChallenge(a.Get("code_challenge")) + // a stale tab's answer is turned away + if res, err = browser.Get(a.Get("redirect_uri") + "?code=awscode&state=nope"); err == nil { + res.Body.Close() + } + if st, _ = SignInStatus(st.ID); st.State != "waiting" { + t.Fatalf("a stranger's answer ended it: %+v", st) + } + if res, err = browser.Get(a.Get("redirect_uri") + "?code=awscode&state=" + url.QueryEscape(a.Get("state"))); err != nil { + t.Fatal(err) + } + res.Body.Close() + if st, _ = SignInStatus(st.ID); st.State != "done" || st.User != "Kiro account" { + t.Fatalf("status %+v", st) + } + c, ok := readKiro("") + if !ok || c.access != "idc-at" || c.method != "idc" || c.clientID != "cid" || c.clientSecret != "csecret" || c.region != "us-east-1" { + t.Fatalf("cred %+v", c) + } +} + +// A company's own identity provider is left to kiro-cli, and said so. +func TestKiroSignInExternalIdP(t *testing.T) { + start, browser := kiroFakeSignIn(t) + st, q := start() + res, err := browser.Get(q.Get("redirect_uri") + "/oauth/callback?login_option=external_idp&state=" + url.QueryEscape(q.Get("state"))) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if st, _ = SignInStatus(st.ID); st.State != "failed" || !strings.Contains(st.Error, "kiro-cli login") { + t.Fatalf("status %+v", st) + } +} diff --git a/internal/provider/signin.go b/internal/provider/signin.go index e429f715..9c892ed2 100644 --- a/internal/provider/signin.go +++ b/internal/provider/signin.go @@ -68,6 +68,7 @@ type signInFlow struct { redirect string srv *http.Server stop func() // ends an agent's own login command, when that is the sign-in + kiro *kiroFlow done chan struct{} } @@ -228,6 +229,11 @@ func (s *signInFlow) begin() error { s.mu.Lock() s.st.URL = devinAuthorizeURL + "?" + q.Encode() s.mu.Unlock() + case "kiro": + // Kiro's sign-in page, as the Kiro IDE opens it + if ln, err = startKiroSignIn(s, challenge); err != nil { + return err + } case "copilot": // GitHub's device code, as Copilot's editors sign in if err := startCopilotSignIn(s); err != nil { @@ -367,6 +373,10 @@ func (s *signInFlow) finish(out SignInState) bool { } func (s *signInFlow) callback(w http.ResponseWriter, r *http.Request) { + if s.kiro != nil { + s.kiroCallback(w, r) + return + } switch r.URL.Path { case "/callback", "/auth/callback", "/oauth2callback", "/oauth-callback": case "/cancel":