diff --git a/.gitignore b/.gitignore index d9c40de0..7d7617b7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules dist *.log .DS_Store +.envrc # build outputs .next diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index d7e45010..de94a4ee 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -54,7 +54,7 @@ function LoginPageContent() { await sendCode(email); setStep("code"); setCode(""); - setCooldown(60); + setCooldown(10); } catch (err) { setError( err instanceof Error ? err.message : "Failed to send code. Make sure the server is running." @@ -70,6 +70,34 @@ function LoginPageContent() { setError(""); setSubmitting(true); try { + const cliCallback = searchParams.get("cli_callback"); + if (cliCallback) { + // CLI browser login: verify code, get JWT, redirect to CLI callback. + // Only allow http://localhost callbacks to prevent open redirect / JWT theft. + try { + const cbUrl = new URL(cliCallback); + if (cbUrl.protocol !== "http:") { + setError("Invalid callback URL"); + setSubmitting(false); + return; + } + if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1") { + setError("Invalid callback URL"); + setSubmitting(false); + return; + } + } catch { + setError("Invalid callback URL"); + setSubmitting(false); + return; + } + const { token } = await api.verifyCode(email, value); + const cliState = searchParams.get("cli_state") || ""; + const separator = cliCallback.includes("?") ? "&" : "?"; + window.location.href = `${cliCallback}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(cliState)}`; + return; + } + await verifyCode(email, value); const wsList = await api.listWorkspaces(); await hydrateWorkspace(wsList); @@ -90,7 +118,7 @@ function LoginPageContent() { setError(""); try { await sendCode(email); - setCooldown(60); + setCooldown(10); } catch (err) { setError( err instanceof Error ? err.message : "Failed to resend code" diff --git a/server/cmd/multica/cmd_auth.go b/server/cmd/multica/cmd_auth.go index 09dd1883..194c41e4 100644 --- a/server/cmd/multica/cmd_auth.go +++ b/server/cmd/multica/cmd_auth.go @@ -3,8 +3,15 @@ package main import ( "bufio" "context" + "crypto/rand" + "encoding/hex" "fmt" + "net" + "net/http" + "net/url" "os" + "os/exec" + "runtime" "strings" "time" @@ -20,7 +27,7 @@ var authCmd = &cobra.Command{ var authLoginCmd = &cobra.Command{ Use: "login", - Short: "Authenticate with a personal access token", + Short: "Authenticate with Multica", RunE: runAuthLogin, } @@ -37,6 +44,7 @@ var authLogoutCmd = &cobra.Command{ } func init() { + authLoginCmd.Flags().Bool("token", false, "Authenticate by pasting a personal access token") authCmd.AddCommand(authLoginCmd) authCmd.AddCommand(authStatusCmd) authCmd.AddCommand(authLogoutCmd) @@ -50,7 +58,158 @@ func resolveToken() string { return cfg.Token } +func resolveAppURL() string { + if val := strings.TrimSpace(os.Getenv("MULTICA_APP_URL")); val != "" { + return strings.TrimRight(val, "/") + } + return "http://localhost:3000" +} + +func openBrowser(url string) error { + var cmd string + var args []string + switch runtime.GOOS { + case "darwin": + cmd = "open" + args = []string{url} + case "linux": + cmd = "xdg-open" + args = []string{url} + case "windows": + cmd = "cmd" + args = []string{"/c", "start", url} + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + return exec.Command(cmd, args...).Start() +} + func runAuthLogin(cmd *cobra.Command, _ []string) error { + useToken, _ := cmd.Flags().GetBool("token") + if useToken { + return runAuthLoginToken(cmd) + } + return runAuthLoginBrowser(cmd) +} + +func runAuthLoginBrowser(cmd *cobra.Command) error { + serverURL := resolveServerURL(cmd) + appURL := resolveAppURL() + + // Start a local HTTP server on a random port to receive the callback. + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return fmt.Errorf("failed to start local server: %w", err) + } + defer listener.Close() + + port := listener.Addr().(*net.TCPAddr).Port + callbackURL := fmt.Sprintf("http://localhost:%d/callback", port) + + // Generate a random state parameter for CSRF protection. + stateBytes := make([]byte, 16) + if _, err := rand.Read(stateBytes); err != nil { + return fmt.Errorf("failed to generate state: %w", err) + } + state := hex.EncodeToString(stateBytes) + + loginURL := fmt.Sprintf("%s/login?cli_callback=%s&cli_state=%s", appURL, url.QueryEscape(callbackURL), url.QueryEscape(state)) + + // Channel to receive the JWT from the browser callback. + jwtCh := make(chan string, 1) + errCh := make(chan error, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + http.Error(w, "missing token", http.StatusBadRequest) + return + } + returnedState := r.URL.Query().Get("state") + if returnedState != state { + http.Error(w, "invalid state parameter", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(callbackSuccessHTML)) + jwtCh <- token + }) + + srv := &http.Server{Handler: mux} + go func() { + if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + defer srv.Close() + + // Open the browser. + fmt.Fprintln(os.Stderr, "Opening browser to authenticate...") + if err := openBrowser(loginURL); err != nil { + fmt.Fprintf(os.Stderr, "Could not open browser automatically.\n") + } + fmt.Fprintf(os.Stderr, "If the browser didn't open, visit:\n %s\n\nWaiting for authentication...\n", loginURL) + + // Wait for the JWT from the callback (timeout 5 minutes). + var jwtToken string + select { + case jwtToken = <-jwtCh: + case err := <-errCh: + return fmt.Errorf("local server error: %w", err) + case <-time.After(5 * time.Minute): + return fmt.Errorf("timed out waiting for authentication") + } + + // Use the JWT to create a PAT via the existing API. + client := cli.NewAPIClient(serverURL, "", jwtToken) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "unknown" + } + patName := fmt.Sprintf("CLI (%s)", hostname) + expiresInDays := 90 + + var patResp struct { + Token string `json:"token"` + } + err = client.PostJSON(ctx, "/api/tokens", map[string]any{ + "name": patName, + "expires_in_days": expiresInDays, + }, &patResp) + if err != nil { + return fmt.Errorf("failed to create access token: %w", err) + } + + // Verify the PAT works. + patClient := cli.NewAPIClient(serverURL, "", patResp.Token) + var me struct { + Name string `json:"name"` + Email string `json:"email"` + } + if err := patClient.GetJSON(ctx, "/api/me", &me); err != nil { + return fmt.Errorf("token verification failed: %w", err) + } + + // Save to config. + cfg, _ := cli.LoadCLIConfig() + cfg.Token = patResp.Token + if cfg.ServerURL == "" { + cfg.ServerURL = serverURL + } + if err := cli.SaveCLIConfig(cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Fprintf(os.Stderr, "Authenticated as %s (%s)\nToken saved to config.\n", me.Name, me.Email) + return nil +} + +func runAuthLoginToken(cmd *cobra.Command) error { fmt.Print("Enter your personal access token: ") scanner := bufio.NewScanner(os.Stdin) if !scanner.Scan() { @@ -123,6 +282,45 @@ func runAuthStatus(cmd *cobra.Command, _ []string) error { return nil } +const callbackSuccessHTML = ` + +
+ + +You can close this tab and return to the terminal.
+Your CLI session is now authenticated.
+