package cmd import ( "context" "crypto/rand" "encoding/hex" "fmt" "net" "net/http" "net/url" "os" "sync" "time" ) const browserAuthTimeout = 60 * time.Second // browserAuth opens a browser for the user to authorize the CLI. // Returns the API key on success, or an error if the flow fails/times out. // // Flow: // 1. Start a temporary HTTP server on a random localhost port // 2. Open browser to {apiURL}/cli/auth?state={state}&port={port} // 3. User logs in and clicks "Authorize" on the web page // 4. Web redirects to localhost:{port}/callback?token=tc_...&state={state} // 5. CLI validates state, extracts token, closes server func browserAuth(apiURL string) (string, error) { // Validate apiURL is a well-formed HTTP(S) URL parsed, err := url.Parse(apiURL) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { return "", fmt.Errorf("invalid API URL: %s", apiURL) } state, err := generateState() if err != nil { return "", fmt.Errorf("generate state: %w", err) } // Find a free port listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return "", fmt.Errorf("listen: %w", err) } port := listener.Addr().(*net.TCPAddr).Port // Channel to receive the token from the callback tokenCh := make(chan string, 1) errCh := make(chan error, 1) var once sync.Once mux := http.NewServeMux() mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { handled := false once.Do(func() { handled = true // Validate state to prevent CSRF if r.URL.Query().Get("state") != state { http.Error(w, "Invalid state parameter", http.StatusBadRequest) errCh <- fmt.Errorf("state mismatch") return } // Check if user rejected the authorization if r.URL.Query().Get("rejected") == "1" { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, rejectedHTML) errCh <- fmt.Errorf("authorization rejected by user") return } token := r.URL.Query().Get("token") if token == "" { http.Error(w, "No token received", http.StatusBadRequest) errCh <- fmt.Errorf("empty token in callback") return } // Respond with a success page w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, callbackHTML) tokenCh <- token }) if !handled { http.Error(w, "Already processed", http.StatusConflict) } }) server := &http.Server{Handler: mux} // Start server in background go func() { if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { errCh <- err } }() // Open browser authURL := fmt.Sprintf("%s/unarr/auth?state=%s&port=%d", apiURL, url.QueryEscape(state), port) openBrowser(authURL) // Listen for Enter key to skip to manual fallback skipCh := make(chan struct{}, 1) go func() { buf := make([]byte, 1) for { n, err := os.Stdin.Read(buf) if err != nil || n == 0 { return } if buf[0] == '\n' || buf[0] == '\r' { skipCh <- struct{}{} return } } }() // Wait for callback with countdown ctx, cancel := context.WithTimeout(context.Background(), browserAuthTimeout) defer cancel() ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() remaining := int(browserAuthTimeout.Seconds()) // Show initial countdown fmt.Printf("\r Waiting for browser authorization... %ds (Enter to skip) ", remaining) var token string done := false for !done { select { case token = <-tokenCh: fmt.Print("\r\033[K") // clear countdown line done = true case err := <-errCh: fmt.Print("\r\033[K") shutdownServer(server) return "", err case <-ctx.Done(): fmt.Print("\r\033[K") shutdownServer(server) return "", fmt.Errorf("timed out waiting for browser authorization") case <-skipCh: fmt.Print("\r\033[K") shutdownServer(server) return "", fmt.Errorf("skipped by user") case <-ticker.C: remaining-- if remaining >= 0 { fmt.Printf("\r Waiting for browser authorization... %ds (Enter to skip) ", remaining) } } } shutdownServer(server) return token, nil } func shutdownServer(server *http.Server) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = server.Shutdown(ctx) } func generateState() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // rejectedHTML is the page shown in the browser when the user clicks Cancel. const rejectedHTML = `
You can close this tab. Use unarr init to try again.
You can close this tab and return to your terminal.