// Package kiro provides custom protocol handler registration for Kiro OAuth. // This enables the CLI to intercept kiro:// URIs for social authentication (Google/GitHub). package kiro import ( "context" "fmt" "html" "net" "net/http" "net/url" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" "time" log "github.com/sirupsen/logrus" ) const ( // KiroProtocol is the custom URI scheme used by Kiro KiroProtocol = "kiro" // KiroAuthority is the URI authority for authentication callbacks KiroAuthority = "kiro.kiroAgent" // KiroAuthPath is the path for successful authentication KiroAuthPath = "/authenticate-success" // KiroRedirectURI is the full redirect URI for social auth KiroRedirectURI = "kiro://kiro.kiroAgent/authenticate-success" // DefaultHandlerPort is the default port for the local callback server DefaultHandlerPort = 19876 // HandlerTimeout is how long to wait for the OAuth callback HandlerTimeout = 10 * time.Minute ) // ProtocolHandler manages the custom kiro:// protocol handler for OAuth callbacks. type ProtocolHandler struct { port int server *http.Server listener net.Listener resultChan chan *AuthCallback stopChan chan struct{} mu sync.Mutex running bool } // AuthCallback contains the OAuth callback parameters. type AuthCallback struct { Code string State string Error string } // NewProtocolHandler creates a new protocol handler. func NewProtocolHandler() *ProtocolHandler { return &ProtocolHandler{ port: DefaultHandlerPort, resultChan: make(chan *AuthCallback, 1), stopChan: make(chan struct{}), } } // Start starts the local callback server that receives redirects from the protocol handler. func (h *ProtocolHandler) Start(ctx context.Context) (int, error) { h.mu.Lock() defer h.mu.Unlock() if h.running { return h.port, nil } // Drain any stale results from previous runs select { case <-h.resultChan: default: } // Reset stopChan for reuse - close old channel first to unblock any waiting goroutines if h.stopChan != nil { select { case <-h.stopChan: // Already closed default: close(h.stopChan) } } h.stopChan = make(chan struct{}) // Try ports in known range (must match handler script port range) var listener net.Listener var err error portRange := []int{DefaultHandlerPort, DefaultHandlerPort + 1, DefaultHandlerPort + 2, DefaultHandlerPort + 3, DefaultHandlerPort + 4} for _, port := range portRange { listener, err = net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) if err == nil { break } log.Debugf("kiro protocol handler: port %d busy, trying next", port) } if listener == nil { return 0, fmt.Errorf("failed to start callback server: all ports %d-%d are busy", DefaultHandlerPort, DefaultHandlerPort+4) } h.listener = listener h.port = listener.Addr().(*net.TCPAddr).Port mux := http.NewServeMux() mux.HandleFunc("/oauth/callback", h.handleCallback) h.server = &http.Server{ Handler: mux, ReadHeaderTimeout: 10 * time.Second, } go func() { if err := h.server.Serve(listener); err != nil && err != http.ErrServerClosed { log.Debugf("kiro protocol handler server error: %v", err) } }() h.running = true log.Debugf("kiro protocol handler started on port %d", h.port) // Auto-shutdown after context done, timeout, or explicit stop // Capture references to prevent race with new Start() calls currentStopChan := h.stopChan currentServer := h.server currentListener := h.listener go func() { select { case <-ctx.Done(): case <-time.After(HandlerTimeout): case <-currentStopChan: return // Already stopped, exit goroutine } // Only stop if this is still the current server/listener instance h.mu.Lock() if h.server == currentServer && h.listener == currentListener { h.mu.Unlock() h.Stop() } else { h.mu.Unlock() } }() return h.port, nil } // Stop stops the callback server. func (h *ProtocolHandler) Stop() { h.mu.Lock() defer h.mu.Unlock() if !h.running { return } // Signal the auto-shutdown goroutine to exit. // This select pattern is safe because stopChan is only modified while holding h.mu, // and we hold the lock here. The select prevents panic from double-close. select { case <-h.stopChan: // Already closed default: close(h.stopChan) } if h.server != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = h.server.Shutdown(ctx) } h.running = false log.Debug("kiro protocol handler stopped") } // WaitForCallback waits for the OAuth callback and returns the result. func (h *ProtocolHandler) WaitForCallback(ctx context.Context) (*AuthCallback, error) { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(HandlerTimeout): return nil, fmt.Errorf("timeout waiting for OAuth callback") case result := <-h.resultChan: return result, nil } } // GetPort returns the port the handler is listening on. func (h *ProtocolHandler) GetPort() int { return h.port } // handleCallback processes the OAuth callback from the protocol handler script. func (h *ProtocolHandler) handleCallback(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") errParam := r.URL.Query().Get("error") result := &AuthCallback{ Code: code, State: state, Error: errParam, } // Send result select { case h.resultChan <- result: default: // Channel full, ignore duplicate callbacks } // Send success response w.Header().Set("Content-Type", "text/html; charset=utf-8") if errParam != "" { w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, `
Error: %s
You can close this window.
`, html.EscapeString(errParam)) } else { fmt.Fprint(w, `You can close this window and return to the terminal.
`) } } // IsProtocolHandlerInstalled checks if the kiro:// protocol handler is installed. func IsProtocolHandlerInstalled() bool { switch runtime.GOOS { case "linux": return isLinuxHandlerInstalled() case "windows": return isWindowsHandlerInstalled() case "darwin": return isDarwinHandlerInstalled() default: return false } } // InstallProtocolHandler installs the kiro:// protocol handler for the current platform. func InstallProtocolHandler(handlerPort int) error { switch runtime.GOOS { case "linux": return installLinuxHandler(handlerPort) case "windows": return installWindowsHandler(handlerPort) case "darwin": return installDarwinHandler(handlerPort) default: return fmt.Errorf("unsupported platform: %s", runtime.GOOS) } } // UninstallProtocolHandler removes the kiro:// protocol handler. func UninstallProtocolHandler() error { switch runtime.GOOS { case "linux": return uninstallLinuxHandler() case "windows": return uninstallWindowsHandler() case "darwin": return uninstallDarwinHandler() default: return fmt.Errorf("unsupported platform: %s", runtime.GOOS) } } // --- Linux Implementation --- func getLinuxDesktopPath() string { homeDir, _ := os.UserHomeDir() return filepath.Join(homeDir, ".local", "share", "applications", "kiro-oauth-handler.desktop") } func getLinuxHandlerScriptPath() string { homeDir, _ := os.UserHomeDir() return filepath.Join(homeDir, ".local", "bin", "kiro-oauth-handler") } func isLinuxHandlerInstalled() bool { desktopPath := getLinuxDesktopPath() _, err := os.Stat(desktopPath) return err == nil } func installLinuxHandler(handlerPort int) error { // Create directories homeDir, err := os.UserHomeDir() if err != nil { return err } binDir := filepath.Join(homeDir, ".local", "bin") appDir := filepath.Join(homeDir, ".local", "share", "applications") if err := os.MkdirAll(binDir, 0755); err != nil { return fmt.Errorf("failed to create bin directory: %w", err) } if err := os.MkdirAll(appDir, 0755); err != nil { return fmt.Errorf("failed to create applications directory: %w", err) } // Create handler script - tries multiple ports to handle dynamic port allocation scriptPath := getLinuxHandlerScriptPath() scriptContent := fmt.Sprintf(`#!/bin/bash # Kiro OAuth Protocol Handler # Handles kiro:// URIs - tries CLI first, then forwards to Kiro IDE URL="$1" # Check curl availability if ! command -v curl &> /dev/null; then echo "Error: curl is required for Kiro OAuth handler" >&2 exit 1 fi # Extract code and state from URL [[ "$URL" =~ code=([^&]+) ]] && CODE="${BASH_REMATCH[1]}" [[ "$URL" =~ state=([^&]+) ]] && STATE="${BASH_REMATCH[1]}" [[ "$URL" =~ error=([^&]+) ]] && ERROR="${BASH_REMATCH[1]}" # Try CLI proxy on multiple possible ports (default + dynamic range) CLI_OK=0 for PORT in %d %d %d %d %d; do if [ -n "$ERROR" ]; then curl -sf --connect-timeout 1 "http://127.0.0.1:$PORT/oauth/callback?error=$ERROR" && CLI_OK=1 && break elif [ -n "$CODE" ] && [ -n "$STATE" ]; then curl -sf --connect-timeout 1 "http://127.0.0.1:$PORT/oauth/callback?code=$CODE&state=$STATE" && CLI_OK=1 && break fi done # If CLI not available, forward to Kiro IDE if [ $CLI_OK -eq 0 ] && [ -x "/usr/share/kiro/kiro" ]; then /usr/share/kiro/kiro --open-url "$URL" & fi `, handlerPort, handlerPort+1, handlerPort+2, handlerPort+3, handlerPort+4) if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { return fmt.Errorf("failed to write handler script: %w", err) } // Create .desktop file desktopPath := getLinuxDesktopPath() desktopContent := fmt.Sprintf(`[Desktop Entry] Name=Kiro OAuth Handler Comment=Handle kiro:// protocol for CLI Proxy API authentication Exec=%s %%u Type=Application Terminal=false NoDisplay=true MimeType=x-scheme-handler/kiro; Categories=Utility; `, scriptPath) if err := os.WriteFile(desktopPath, []byte(desktopContent), 0644); err != nil { return fmt.Errorf("failed to write desktop file: %w", err) } // Register handler with xdg-mime cmd := exec.Command("xdg-mime", "default", "kiro-oauth-handler.desktop", "x-scheme-handler/kiro") if err := cmd.Run(); err != nil { log.Warnf("xdg-mime registration failed (may need manual setup): %v", err) } // Update desktop database cmd = exec.Command("update-desktop-database", appDir) _ = cmd.Run() // Ignore errors, not critical log.Info("Kiro protocol handler installed for Linux") return nil } func uninstallLinuxHandler() error { desktopPath := getLinuxDesktopPath() scriptPath := getLinuxHandlerScriptPath() if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove desktop file: %w", err) } if err := os.Remove(scriptPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove handler script: %w", err) } log.Info("Kiro protocol handler uninstalled") return nil } // --- Windows Implementation --- func isWindowsHandlerInstalled() bool { // Check registry key existence cmd := exec.Command("reg", "query", `HKCU\Software\Classes\kiro`, "/ve") return cmd.Run() == nil } func installWindowsHandler(handlerPort int) error { homeDir, err := os.UserHomeDir() if err != nil { return err } // Create handler script (PowerShell) scriptDir := filepath.Join(homeDir, ".cliproxyapi") if err := os.MkdirAll(scriptDir, 0755); err != nil { return fmt.Errorf("failed to create script directory: %w", err) } scriptPath := filepath.Join(scriptDir, "kiro-oauth-handler.ps1") scriptContent := fmt.Sprintf(`# Kiro OAuth Protocol Handler for Windows param([string]$url) # Load required assembly for HttpUtility Add-Type -AssemblyName System.Web # Parse URL parameters $uri = [System.Uri]$url $query = [System.Web.HttpUtility]::ParseQueryString($uri.Query) $code = $query["code"] $state = $query["state"] $errorParam = $query["error"] # Try multiple ports (default + dynamic range) $ports = @(%d, %d, %d, %d, %d) $success = $false foreach ($port in $ports) { if ($success) { break } $callbackUrl = "http://127.0.0.1:$port/oauth/callback" try { if ($errorParam) { $fullUrl = $callbackUrl + "?error=" + $errorParam Invoke-WebRequest -Uri $fullUrl -UseBasicParsing -TimeoutSec 1 -ErrorAction Stop | Out-Null $success = $true } elseif ($code -and $state) { $fullUrl = $callbackUrl + "?code=" + $code + "&state=" + $state Invoke-WebRequest -Uri $fullUrl -UseBasicParsing -TimeoutSec 1 -ErrorAction Stop | Out-Null $success = $true } } catch { # Try next port } } `, handlerPort, handlerPort+1, handlerPort+2, handlerPort+3, handlerPort+4) if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil { return fmt.Errorf("failed to write handler script: %w", err) } // Create batch wrapper batchPath := filepath.Join(scriptDir, "kiro-oauth-handler.bat") batchContent := fmt.Sprintf("@echo off\npowershell -ExecutionPolicy Bypass -File \"%s\" %%1\n", scriptPath) if err := os.WriteFile(batchPath, []byte(batchContent), 0644); err != nil { return fmt.Errorf("failed to write batch wrapper: %w", err) } // Register in Windows registry commands := [][]string{ {"reg", "add", `HKCU\Software\Classes\kiro`, "/ve", "/d", "URL:Kiro Protocol", "/f"}, {"reg", "add", `HKCU\Software\Classes\kiro`, "/v", "URL Protocol", "/d", "", "/f"}, {"reg", "add", `HKCU\Software\Classes\kiro\shell`, "/f"}, {"reg", "add", `HKCU\Software\Classes\kiro\shell\open`, "/f"}, {"reg", "add", `HKCU\Software\Classes\kiro\shell\open\command`, "/ve", "/d", fmt.Sprintf("\"%s\" \"%%1\"", batchPath), "/f"}, } for _, args := range commands { cmd := exec.Command(args[0], args[1:]...) if err := cmd.Run(); err != nil { return fmt.Errorf("failed to run registry command: %w", err) } } log.Info("Kiro protocol handler installed for Windows") return nil } func uninstallWindowsHandler() error { // Remove registry keys cmd := exec.Command("reg", "delete", `HKCU\Software\Classes\kiro`, "/f") if err := cmd.Run(); err != nil { log.Warnf("failed to remove registry key: %v", err) } // Remove scripts homeDir, _ := os.UserHomeDir() scriptDir := filepath.Join(homeDir, ".cliproxyapi") _ = os.Remove(filepath.Join(scriptDir, "kiro-oauth-handler.ps1")) _ = os.Remove(filepath.Join(scriptDir, "kiro-oauth-handler.bat")) log.Info("Kiro protocol handler uninstalled") return nil } // --- macOS Implementation --- func getDarwinAppPath() string { homeDir, _ := os.UserHomeDir() return filepath.Join(homeDir, "Applications", "KiroOAuthHandler.app") } func isDarwinHandlerInstalled() bool { appPath := getDarwinAppPath() _, err := os.Stat(appPath) return err == nil } func installDarwinHandler(handlerPort int) error { // Create app bundle structure appPath := getDarwinAppPath() contentsPath := filepath.Join(appPath, "Contents") macOSPath := filepath.Join(contentsPath, "MacOS") if err := os.MkdirAll(macOSPath, 0755); err != nil { return fmt.Errorf("failed to create app bundle: %w", err) } // Create Info.plist plistPath := filepath.Join(contentsPath, "Info.plist") plistContent := `