Merge pull request #138 from multica-ai/feat/cli-browser-login
feat(auth): browser-based CLI login
This commit is contained in:
commit
aa3f927a37
8 changed files with 366 additions and 5 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,6 +2,7 @@ node_modules
|
||||||
dist
|
dist
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.envrc
|
||||||
|
|
||||||
# build outputs
|
# build outputs
|
||||||
.next
|
.next
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ function LoginPageContent() {
|
||||||
await sendCode(email);
|
await sendCode(email);
|
||||||
setStep("code");
|
setStep("code");
|
||||||
setCode("");
|
setCode("");
|
||||||
setCooldown(60);
|
setCooldown(10);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error ? err.message : "Failed to send code. Make sure the server is running."
|
err instanceof Error ? err.message : "Failed to send code. Make sure the server is running."
|
||||||
|
|
@ -70,6 +70,34 @@ function LoginPageContent() {
|
||||||
setError("");
|
setError("");
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
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);
|
await verifyCode(email, value);
|
||||||
const wsList = await api.listWorkspaces();
|
const wsList = await api.listWorkspaces();
|
||||||
await hydrateWorkspace(wsList);
|
await hydrateWorkspace(wsList);
|
||||||
|
|
@ -90,7 +118,7 @@ function LoginPageContent() {
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await sendCode(email);
|
await sendCode(email);
|
||||||
setCooldown(60);
|
setCooldown(10);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error ? err.message : "Failed to resend code"
|
err instanceof Error ? err.message : "Failed to resend code"
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,15 @@ package main
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -20,7 +27,7 @@ var authCmd = &cobra.Command{
|
||||||
|
|
||||||
var authLoginCmd = &cobra.Command{
|
var authLoginCmd = &cobra.Command{
|
||||||
Use: "login",
|
Use: "login",
|
||||||
Short: "Authenticate with a personal access token",
|
Short: "Authenticate with Multica",
|
||||||
RunE: runAuthLogin,
|
RunE: runAuthLogin,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,6 +44,7 @@ var authLogoutCmd = &cobra.Command{
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
authLoginCmd.Flags().Bool("token", false, "Authenticate by pasting a personal access token")
|
||||||
authCmd.AddCommand(authLoginCmd)
|
authCmd.AddCommand(authLoginCmd)
|
||||||
authCmd.AddCommand(authStatusCmd)
|
authCmd.AddCommand(authStatusCmd)
|
||||||
authCmd.AddCommand(authLogoutCmd)
|
authCmd.AddCommand(authLogoutCmd)
|
||||||
|
|
@ -50,7 +58,158 @@ func resolveToken() string {
|
||||||
return cfg.Token
|
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 {
|
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: ")
|
fmt.Print("Enter your personal access token: ")
|
||||||
scanner := bufio.NewScanner(os.Stdin)
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
if !scanner.Scan() {
|
if !scanner.Scan() {
|
||||||
|
|
@ -123,6 +282,45 @@ func runAuthStatus(cmd *cobra.Command, _ []string) error {
|
||||||
return nil
|
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(_ *cobra.Command, _ []string) error {
|
func runAuthLogout(_ *cobra.Command, _ []string) error {
|
||||||
cfg, _ := cli.LoadCLIConfig()
|
cfg, _ := cli.LoadCLIConfig()
|
||||||
if cfg.Token == "" {
|
if cfg.Token == "" {
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,36 @@ func (c *APIClient) DeleteJSON(ctx context.Context, path string) error {
|
||||||
return nil
|
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.
|
// PutJSON performs a PUT request with a JSON body.
|
||||||
func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) error {
|
func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) error {
|
||||||
data, err := json.Marshal(body)
|
data, err := json.Marshal(body)
|
||||||
|
|
|
||||||
104
server/internal/cli/client_test.go
Normal file
104
server/internal/cli/client_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -213,9 +213,9 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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)
|
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")
|
writeError(w, http.StatusTooManyRequests, "please wait before requesting another code")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue