Files
multica/server/cmd/multica/cmd_auth.go
Bohan Jiang 972c65dbc1 fix(cli): make multica login --token accept the PAT as a value (#2017)
* fix(cli): make `multica login --token` accept the PAT as a value

The flag was registered as a Bool, so `multica login --token <PAT>` parsed
`--token` as `true` and dropped the supplied value as an unused positional
argument, then unconditionally prompted "Enter your personal access token:".
This contradicted the user-facing docs (`cli.mdx`, `CLI_AND_DAEMON.md`,
the in-app `connect-remote-dialog`) which show `--token <mul_...>`.

Switch `--token` to a String flag. Both `--token mul_...` and
`--token=mul_...` now bind the value and skip the prompt. Passing
`--token=` with an empty value (or `multica login --token=""`) still
falls through to the interactive prompt for users who don't want the
token in shell history. Updates the few internal docs that showed the
no-value form.

Fixes #1994

Co-authored-by: multica-agent <github@multica.ai>

* fix(cli): preserve `multica login --token` (no value) prompt path and tighten regression test

Addresses review feedback on #2017:

1. Restore the legacy no-value form. After the prior commit, `multica
   login --token` (no value) errored with `flag needs an argument:
   --token`, which broke the CLI_INSTALL.md / CLI_AND_DAEMON.md flow for
   headless users. Set `NoOptDefVal` on the `--token` flag to a sentinel
   that runAuthLoginToken treats as "prompt me," so:
     - `--token mul_xxx` and `--token=mul_xxx` consume the value (the
       #1994 fix is preserved),
     - `--token` alone falls through to the interactive prompt,
     - `--token=""` (explicit empty) also prompts.
   pflag with `NoOptDefVal` won't bind the next positional as the flag's
   value, so runAuthLogin recovers `--token mul_xxx` (the form from
   #1994) by promoting a single positional arg into the token. loginCmd
   gains `Args: cobra.MaximumNArgs(1)` so multi-positional typos still
   error fast.

2. Tighten regression coverage. Split into TestLoginTokenFlagWiring
   (asserts the production loginCmd.Flags().Lookup("token") is a String
   flag with the prompt-mode NoOptDefVal — would fail if anyone reverts
   the flag to Bool) and TestLoginTokenFlagParsing (drives all five
   documented invocation forms through the same flag wiring + the
   runAuthLogin space-form recovery). The synthetic-only test that the
   reviewer flagged is gone.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 10:53:06 +08:00

467 lines
15 KiB
Go

package main
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
var authCmd = &cobra.Command{
Use: "auth",
Short: "Authenticate multica with Multica",
}
var authStatusCmd = &cobra.Command{
Use: "status",
Short: "Show current authentication status",
RunE: runAuthStatus,
}
var authLogoutCmd = &cobra.Command{
Use: "logout",
Short: "Remove stored authentication token",
RunE: runAuthLogout,
}
// callbackHostFlag lets users override the host/IP that goes into the OAuth
// cli_callback URL. Useful when the CLI sits behind a reverse proxy or the
// auto-detected LAN IP isn't the one the browser can reach.
const callbackHostFlag = "callback-host"
func init() {
authCmd.AddCommand(authStatusCmd)
authCmd.AddCommand(authLogoutCmd)
}
func resolveToken(cmd *cobra.Command) string {
if v := strings.TrimSpace(os.Getenv("MULTICA_TOKEN")); v != "" {
return v
}
profile := resolveProfile(cmd)
cfg, _ := cli.LoadCLIConfigForProfile(profile)
return cfg.Token
}
func resolveAppURL(cmd *cobra.Command) string {
for _, key := range []string{"MULTICA_APP_URL", "FRONTEND_ORIGIN"} {
if val := strings.TrimSpace(os.Getenv(key)); val != "" {
return strings.TrimRight(val, "/")
}
}
profile := resolveProfile(cmd)
cfg, err := cli.LoadCLIConfigForProfile(profile)
if err == nil && cfg.AppURL != "" {
return strings.TrimRight(cfg.AppURL, "/")
}
fmt.Fprintln(os.Stderr, "No app URL configured. Run 'multica setup' first.")
os.Exit(1)
return "" // unreachable
}
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 = "rundll32"
args = []string{"url.dll,FileProtocolHandler", url}
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
return exec.Command(cmd, args...).Start()
}
func runAuthLogin(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("token") {
tokenFlag, _ := cmd.Flags().GetString("token")
// `--token mul_xxx` (space form) is what users actually type — that's
// the form from the docs and from #1994. NoOptDefVal prevents pflag
// from consuming the next arg as the flag value, so it lands here as
// a positional. Promote it to the token value.
if tokenFlag == tokenPromptSentinel && len(args) == 1 {
tokenFlag = args[0]
}
return runAuthLoginToken(cmd, tokenFlag)
}
return runAuthLoginBrowser(cmd)
}
// resolveCallbackBinding picks the host that goes into the `cli_callback`
// URL and the interface the CLI should bind its local HTTP listener to.
//
// The browser running the login flow is on the *server's* machine (or
// wherever the user clicked the link), not on the CLI host. That means the
// callback URL must resolve to an address the browser can actually reach,
// which is different in each topology:
//
// - hosted / public app URL: browser and CLI are on the same machine,
// localhost works.
// - self-host, CLI on server box: same as above.
// - self-host, CLI on a different LAN box: the callback URL must point at
// the CLI's own LAN IP, not the server's.
// - reverse-proxied / FQDN setups: auto-detection can't know the right
// host — the user supplies it via --callback-host.
//
// detectOutbound is injected so tests can exercise the routing decisions
// without real network calls.
func resolveCallbackBinding(flagHost, serverURL, appURL string, detectOutbound func(string) net.IP) (callbackHost, bindAddr string) {
// Explicit flag always wins. Bind on all interfaces so the browser can
// reach us regardless of which interface the host name resolves to.
if h := strings.TrimSpace(flagHost); h != "" {
return h, "0.0.0.0"
}
appIP := urlPrivateIP(appURL)
if appIP == nil {
// Public hostname, FQDN without private-IP mapping, or parse error.
// Loopback is the only safe default — on hosted/public setups the
// browser and CLI live on the same machine.
return "localhost", "127.0.0.1"
}
// app_url is a private LAN IP. Figure out whether the CLI is on that
// same box or a different one by asking the kernel which local address
// it would use to reach the server. Same box → loopback is fine.
// Different box → use the CLI's outbound IP so the browser can reach us.
cliIP := detectOutbound(serverURL)
if cliIP == nil {
// Detection failed (offline, unreachable server, etc.). Fall back to
// the app IP — preserves the pre-existing same-machine behaviour.
return appIP.String(), "0.0.0.0"
}
if cliIP.Equal(appIP) {
return "localhost", "127.0.0.1"
}
return cliIP.String(), "0.0.0.0"
}
// urlPrivateIP returns the hostname of rawURL parsed as an RFC 1918 IP, or
// nil if the URL is unparsable or the host is not a private literal.
func urlPrivateIP(rawURL string) net.IP {
parsed, err := url.Parse(rawURL)
if err != nil {
return nil
}
ip := net.ParseIP(parsed.Hostname())
if ip == nil || !ip.IsPrivate() {
return nil
}
return ip
}
// detectOutboundIP returns the local IPv4 address the OS would use to reach
// serverURL, or nil if detection fails. The UDP dial does not send packets —
// it just causes the kernel to pick a source IP for the destination route.
func detectOutboundIP(serverURL string) net.IP {
parsed, err := url.Parse(serverURL)
if err != nil || parsed.Hostname() == "" {
return nil
}
port := parsed.Port()
if port == "" {
if parsed.Scheme == "https" {
port = "443"
} else {
port = "80"
}
}
conn, err := net.Dial("udp4", net.JoinHostPort(parsed.Hostname(), port))
if err != nil {
return nil
}
defer conn.Close()
local, ok := conn.LocalAddr().(*net.UDPAddr)
if !ok || local.IP == nil {
return nil
}
// Normalise to 4-byte form so Equal() comparisons match net.ParseIP
// output consistently.
if v4 := local.IP.To4(); v4 != nil {
return v4
}
return local.IP
}
func runAuthLoginBrowser(cmd *cobra.Command) error {
serverURL := resolveServerURL(cmd)
appURL := resolveAppURL(cmd)
flagHost, _ := cmd.Flags().GetString(callbackHostFlag)
callbackHost, bindAddr := resolveCallbackBinding(flagHost, serverURL, appURL, detectOutboundIP)
// Pin to "tcp4" — a bare "tcp" on macOS can produce an IPv6-only socket
// that IPv4 clients (including browsers resolving localhost → 127.0.0.1)
// cannot reach. The callback URL is always an IPv4 literal or hostname,
// so an IPv4 listener is what the browser actually needs.
listener, err := net.Listen("tcp4", bindAddr+":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://%s:%d/callback", callbackHost, 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. Reset workspace data on every login — the user or
// server may have changed, so stale workspaces must not persist.
profile := resolveProfile(cmd)
cfg, _ := cli.LoadCLIConfigForProfile(profile)
cfg.WorkspaceID = ""
cfg.Token = patResp.Token
cfg.ServerURL = serverURL
cfg.AppURL = appURL
if err := cli.SaveCLIConfigForProfile(cfg, profile); 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, providedToken string) error {
// The prompt sentinel is what pflag substitutes for `--token` with no
// value (see loginCmd init); treat it the same as an empty string so we
// fall through to the interactive prompt.
if providedToken == tokenPromptSentinel {
providedToken = ""
}
token := strings.TrimSpace(providedToken)
if token == "" {
fmt.Print("Enter your personal access token: ")
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
return fmt.Errorf("no input")
}
token = strings.TrimSpace(scanner.Text())
}
if token == "" {
return fmt.Errorf("token is required")
}
if !strings.HasPrefix(token, "mul_") {
return fmt.Errorf("invalid token format: must start with mul_")
}
serverURL := resolveServerURL(cmd)
client := cli.NewAPIClient(serverURL, "", token)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var me struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := client.GetJSON(ctx, "/api/me", &me); err != nil {
return fmt.Errorf("invalid token: %w", err)
}
profile := resolveProfile(cmd)
cfg, _ := cli.LoadCLIConfigForProfile(profile)
cfg.WorkspaceID = ""
cfg.Token = token
cfg.ServerURL = serverURL
if err := cli.SaveCLIConfigForProfile(cfg, profile); 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 runAuthStatus(cmd *cobra.Command, _ []string) error {
token := resolveToken(cmd)
serverURL := resolveServerURL(cmd)
if token == "" {
fmt.Fprintln(os.Stderr, "Not authenticated. Run 'multica login' to authenticate.")
return nil
}
client := cli.NewAPIClient(serverURL, "", token)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var me struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := client.GetJSON(ctx, "/api/me", &me); err != nil {
fmt.Fprintf(os.Stderr, "Token is invalid or expired: %v\nRun 'multica login' to re-authenticate.\n", err)
return nil
}
prefix := token
if len(prefix) > 12 {
prefix = prefix[:12] + "..."
}
fmt.Fprintf(os.Stderr, "Server: %s\nUser: %s (%s)\nToken: %s\n", serverURL, me.Name, me.Email, prefix)
return nil
}
const callbackSuccessHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Multica — Authenticated</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@media (prefers-color-scheme: dark) {
:root { --bg: #0b0b0f; --card-bg: #16161d; --border: rgba(255,255,255,0.10); --fg: #f5f5f5; --fg2: #a1a1aa; --accent: #22c55e; --accent-bg: rgba(34,197,94,0.12); }
}
@media (prefers-color-scheme: light) {
:root { --bg: #f8f8fa; --card-bg: #ffffff; --border: rgba(0,0,0,0.08); --fg: #0f0f12; --fg2: #71717a; --accent: #16a34a; --accent-bg: rgba(22,163,74,0.08); }
}
body { font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; background: var(--bg); color: var(--fg); display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.card { width: 100%; max-width: 380px; border: 1px solid var(--border); border-radius: 12px; background: var(--card-bg); padding: 40px 32px; text-align: center; }
.icon-wrap { width: 48px; height: 48px; margin: 0 auto 24px; background: var(--accent-bg); border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.icon-wrap svg { width: 24px; height: 24px; color: var(--accent); }
.brand { display: flex; align-items: center; justify-content: center; gap: 6px; margin-bottom: 8px; }
.asterisk { display: inline-block; width: 14px; height: 14px; background: var(--fg); clip-path: polygon(45% 62.1%,45% 100%,55% 100%,55% 62.1%,81.8% 88.9%,88.9% 81.8%,62.1% 55%,100% 55%,100% 45%,62.1% 45%,88.9% 18.2%,81.8% 11.1%,55% 37.9%,55% 0%,45% 0%,45% 37.9%,18.2% 11.1%,11.1% 18.2%,37.9% 45%,0% 45%,0% 55%,37.9% 55%,11.1% 81.8%,18.2% 88.9%); }
h1 { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
p { font-size: 14px; color: var(--fg2); line-height: 1.5; }
.hint { margin-top: 24px; font-size: 13px; color: var(--fg2); opacity: 0.7; }
</style>
</head>
<body>
<div class="card">
<div class="icon-wrap">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
</div>
<div class="brand"><span class="asterisk"></span></div>
<h1>Authentication successful</h1>
<p>You can close this tab and return to the terminal.</p>
<p class="hint">Your CLI session is now authenticated.</p>
</div>
<script>setTimeout(function(){window.close()},3000)</script>
</body>
</html>`
func runAuthLogout(cmd *cobra.Command, _ []string) error {
profile := resolveProfile(cmd)
cfg, _ := cli.LoadCLIConfigForProfile(profile)
if cfg.Token == "" {
fmt.Fprintln(os.Stderr, "Not authenticated.")
return nil
}
cfg.Token = ""
if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Fprintln(os.Stderr, "Token removed. You are now logged out.")
return nil
}