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 = ` + + + + +Multica — Authenticated + + + +
+
+ +
+
+

Authentication successful

+

You can close this tab and return to the terminal.

+

Your CLI session is now authenticated.

+
+ + +` + func runAuthLogout(_ *cobra.Command, _ []string) error { cfg, _ := cli.LoadCLIConfig() if cfg.Token == "" { diff --git a/server/internal/cli/client.go b/server/internal/cli/client.go index 548d078c..7bd88d6e 100644 --- a/server/internal/cli/client.go +++ b/server/internal/cli/client.go @@ -84,6 +84,36 @@ func (c *APIClient) DeleteJSON(ctx context.Context, path string) error { return nil } +// PostJSON performs a POST request with a JSON body. +func (c *APIClient) PostJSON(ctx context.Context, path string, body any, out any) error { + data, err := json.Marshal(body) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + c.setHeaders(req) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("POST %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(respData))) + } + if out == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + // PutJSON performs a PUT request with a JSON body. func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) error { data, err := json.Marshal(body) diff --git a/server/internal/cli/client_test.go b/server/internal/cli/client_test.go new file mode 100644 index 00000000..7fbe933c --- /dev/null +++ b/server/internal/cli/client_test.go @@ -0,0 +1,104 @@ +package cli + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestPostJSON(t *testing.T) { + type reqBody struct { + Name string `json:"name"` + Age int `json:"age"` + } + type respBody struct { + ID string `json:"id"` + } + + t.Run("success", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("expected Content-Type application/json, got %s", ct) + } + if auth := r.Header.Get("Authorization"); auth != "Bearer test-token" { + t.Errorf("expected Authorization Bearer test-token, got %s", auth) + } + + var body reqBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + if body.Name != "alice" || body.Age != 30 { + t.Errorf("unexpected body: %+v", body) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(respBody{ID: "123"}) + })) + defer srv.Close() + + client := NewAPIClient(srv.URL, "", "test-token") + var out respBody + err := client.PostJSON(context.Background(), "/test", reqBody{Name: "alice", Age: 30}, &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.ID != "123" { + t.Errorf("expected ID 123, got %s", out.ID) + } + }) + + t.Run("error status", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, "bad request") + })) + defer srv.Close() + + client := NewAPIClient(srv.URL, "", "test-token") + err := client.PostJSON(context.Background(), "/test", reqBody{Name: "bob"}, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if got := err.Error(); got != "POST /test returned 400: bad request" { + t.Errorf("unexpected error message: %s", got) + } + }) + + t.Run("nil output", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + client := NewAPIClient(srv.URL, "", "test-token") + err := client.PostJSON(context.Background(), "/test", reqBody{Name: "charlie"}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("workspace header", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ws := r.Header.Get("X-Workspace-ID"); ws != "ws-abc" { + t.Errorf("expected X-Workspace-ID ws-abc, got %s", ws) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(respBody{ID: "456"}) + })) + defer srv.Close() + + client := NewAPIClient(srv.URL, "ws-abc", "test-token") + var out respBody + err := client.PostJSON(context.Background(), "/test", reqBody{}, &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index 4575fdea..d0e4fedb 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -213,9 +213,9 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) { return } - // Rate limit: max 1 code per 60 seconds per email + // Rate limit: max 1 code per 10 seconds per email latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email) - if err == nil && time.Since(latest.CreatedAt.Time) < 60*time.Second { + if err == nil && time.Since(latest.CreatedAt.Time) < 10*time.Second { writeError(w, http.StatusTooManyRequests, "please wait before requesting another code") return } diff --git a/server/migrations/009_inbox_actor.down.sql b/server/migrations/012_inbox_actor.down.sql similarity index 100% rename from server/migrations/009_inbox_actor.down.sql rename to server/migrations/012_inbox_actor.down.sql diff --git a/server/migrations/009_inbox_actor.up.sql b/server/migrations/012_inbox_actor.up.sql similarity index 100% rename from server/migrations/009_inbox_actor.up.sql rename to server/migrations/012_inbox_actor.up.sql