diff --git a/CLAUDE.md b/CLAUDE.md index 541155c..adb883d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,6 +236,11 @@ Subcommands MUST NOT redeclare a global flag — duplication makes the local fla - `CGO_ENABLED=0` at build time. goreleaser config enforces this. - No reading or modifying user shell rc files (`.bashrc`, `.zshrc`). `newapi completion` *prints* completion scripts to stdout; users install them. - No spawning external tools at runtime (no `git`, `curl`, `jq` shellouts). All HTTP via `net/http`; all JSON via `encoding/json`. + - **Exception**: launching the user's default browser via `open` (darwin) + / `xdg-open` (linux) / `rundll32` (windows) is allowed as a *best-effort* + convenience in the `login` flow. The CLI always prints the URL to stderr + first, so if the launcher fails or the binary is missing, the user can + paste it manually — these binaries are never a hard dependency. - Update checks (if added) MUST be opt-in via flag, never a background fetch on startup. - **Cross-platform**: releases ship darwin/linux/windows × amd64/arm64. CI cross-compiles on every push. - **Backward compat**: these contracts are versioned with the CLI's major version. Breaking any of them requires a major bump. diff --git a/README.md b/README.md index 763766d..65dce70 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ > A fast, single-binary command-line client for [new-api](https://github.com/QuantumNous/new-api) gateways. Ships as the `newapi` binary. -[Features](#features) • [Install](#install) • [Quick start](#quick-start) • [Commands](#commands) • [Scripting](#scripting-with-newapi) +[Features](#features) • [Install](#install) • [Quick start](#quick-start) • [Logging in](#logging-in) • [Commands](#commands) • [Scripting](#scripting-with-newapi) --- @@ -74,6 +74,24 @@ newapi chat -i -m gpt-4o-mini newapi usage --from 2026-05-01 --group --top 5 ``` +## Logging in + +Browser flow (default, recommended): + +```bash +newapi login --endpoint https://api.example.com +``` + +This opens your default browser to `/cli-login`, lets you sign in with any method the server supports (password, OAuth, Passkey, 2FA), and returns an access token to the CLI via a short-lived 127.0.0.1 loopback. The token is stored in the OS keychain (or a 0600 file fallback). Pass `--no-browser` for headless/SSH sessions to print the URL without trying to launch anything. + +CI / scripts (non-interactive): + +```bash +newapi login --endpoint https://api.example.com \ + --email me@example.com --password "$NEWAPI_PASSWORD" \ + --profile prod --no-pick-key +``` + ## Configuration `newapi` keeps a small YAML config at `~/.config/newapi/config.yaml` (XDG-compliant; override with `NEWAPI_CONFIG_HOME`). Secrets are stored separately in the OS keychain — the config only holds *references* to them. diff --git a/docs/superpowers/plans/2026-05-25-web-login.md b/docs/superpowers/plans/2026-05-25-web-login.md new file mode 100644 index 0000000..ad0f4de --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-web-login.md @@ -0,0 +1,1342 @@ +# Web 登录(loopback callback)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 `newapi login` 改成默认走"打开浏览器→用户登完→token 自动回 CLI"的 loopback callback 流程,email/password 留作 CI 非交互 fallback。 + +**Architecture:** CLI 启动 `127.0.0.1:` loopback server,打开浏览器到 `/cli-login?port=N&state=S`;服务端 React 页面在用户登录后调 `POST /api/user/cli-login/exchange` 拿 30 天 access_token,再 cross-origin POST 给 loopback;CLI 校验 state + Origin 后保存 token。 + +**Tech Stack:** Go 1.25.10(CLI)、Gin + gin-contrib/sessions(服务端后端)、React 18 + react-router-dom v6(服务端前端)。 + +**Spec:** `docs/superpowers/specs/2026-05-25-web-login-design.md` + +**Commit policy:** 此项目按 CLAUDE.md 用户级规则 "不要主动 commit",每个 task 末尾的 commit 命令需要执行者在 user 同意后再跑。如果 user 选择 squash 至一个 commit 也 OK——本 plan 不强加 commit 粒度。 + +--- + +## File Structure + +### 服务端(`/Users/xbang/VScode/new-api`,sibling repo) + +| 文件 | 操作 | 职责 | +|---|---|---| +| `controller/user.go` | 修改 | 抽出 `issueAccessTokenForUser(userID int)` 内部函数;`GenerateAccessToken` 改为薄封装 | +| `controller/cli_login.go` | 新建 | `CliLoginExchange(c *gin.Context)` handler | +| `router/api-router.go` | 修改 | 注册 `POST /api/user/cli-login/exchange`(带 session middleware)| +| `web/src/components/auth/CliLogin.jsx` | 新建 | React 页面:检测 session、调 exchange、POST 到 loopback | +| `web/src/App.jsx` | 修改 | 注册 `/cli-login` 路由 | + +### CLI(`/Users/xbang/VScode/new-api-cli`,本 repo) + +| 文件 | 操作 | 职责 | +|---|---|---| +| `internal/cli/webauth/state.go` | 新建 | `Generate()` 返回 32 字节 base64url;`Equal(a, b)` 常量时间比较 | +| `internal/cli/webauth/browser.go` | 新建 | `Open(url)`:darwin/linux/windows 各跑各自 default-app launcher;失败不报错 | +| `internal/cli/webauth/server.go` | 新建 | `Run(ctx, endpoint) (userID, token, err)`:loopback server + state/Origin 校验 | +| `internal/cli/webauth/server_test.go` | 新建 | 回归测试 | +| `internal/cli/login.go` | 修改 | `runLogin` 默认走 webauth.Run;新增 `--no-browser` flag | +| `CLAUDE.local.md` | 修改 | 加入 web flow 的 smoke 命令(不上传 git) | + +--- + +## Phase 1 — 服务端后端 + +### Task 1: 抽出 `issueAccessTokenForUser` 内部函数(refactor) + +**Files:** +- Modify: `/Users/xbang/VScode/new-api/controller/user.go`(`GenerateAccessToken`,约 290-323 行) + +- [ ] **Step 1: 读 `GenerateAccessToken` 当前实现** + +```bash +# 在 ../new-api 仓库 +sed -n '285,330p' /Users/xbang/VScode/new-api/controller/user.go +``` + +记下: +- 它怎么从 context 取 user id(应该是 `id := c.GetInt("id")`) +- 它生成 key 的方式(`uuid.New().String()` 或 `common.GetUUID()` 之类) +- 它怎么调 `user.SetAccessToken(key)` / `user.Update(false)` +- 错误响应的 JSON 形状(应该是 `c.JSON(http.StatusOK, gin.H{"success": false, "message": ...})`) + +- [ ] **Step 2: 在 `controller/user.go` 顶部附近(包级函数区域,不在任何方法里)新增 `issueAccessTokenForUser`** + +把现有 `GenerateAccessToken` 主体中"生成 key → SetAccessToken → 去重 → Update"那段挪到这个新函数里。函数签名: + +```go +// issueAccessTokenForUser issues a fresh 30-day access token for the given +// user. Returns the bare token string. Used by both GenerateAccessToken +// (GET /api/user/token) and CliLoginExchange (POST /api/user/cli-login/exchange). +func issueAccessTokenForUser(userID int) (string, error) { + user, err := model.GetUserById(userID, true) + if err != nil { + return "", err + } + key := common.GetUUID() // 用 step 1 里观察到的实际生成方式 + user.SetAccessToken(key) + if err := user.Update(false); err != nil { + return "", err + } + return key, nil +} +``` + +> **如果 step 1 发现 GenerateAccessToken 里有去重循环、user-not-found 特殊错误、log 记录,原样搬进 issueAccessTokenForUser**。本步只搬移,不增不减逻辑。 + +- [ ] **Step 3: 改写 `GenerateAccessToken` 为薄封装** + +```go +func GenerateAccessToken(c *gin.Context) { + id := c.GetInt("id") + token, err := issueAccessTokenForUser(id) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": token}) +} +``` + +> 保持响应的 envelope 形状和 step 1 观察到的完全一致(如果原来用 `common.ApiSuccess(c, data)` helper,这里也用同一 helper)。 + +- [ ] **Step 4: 编译 + 运行原有路由的本地 curl 验证** + +```bash +cd /Users/xbang/VScode/new-api +go build ./... +# 启服务(按现有启动方式),用 testcli 登录拿 session,curl GET /api/user/token, +# 拿到的字段、长度、formart 必须和 refactor 前一致。 +``` + +Expected: `GenerateAccessToken` 响应 shape 没变,token 仍然能用。 + +- [ ] **Step 5: 提交(可选 — 等用户同意)** + +```bash +cd /Users/xbang/VScode/new-api +git add controller/user.go +git commit -m "refactor(user): extract issueAccessTokenForUser for CLI reuse" +``` + +--- + +### Task 2: `POST /api/user/cli-login/exchange` handler 和路由 + +**Files:** +- Create: `/Users/xbang/VScode/new-api/controller/cli_login.go` +- Modify: `/Users/xbang/VScode/new-api/router/api-router.go`(注册路由,参考第 78 行 `GET /token` 的写法) + +- [ ] **Step 1: 创建 `controller/cli_login.go`** + +```go +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// CliLoginExchange issues an access token for the currently-logged-in user, +// for use by the `newapi` CLI's web-login flow. The CLI generates a `state` +// nonce and embeds it in the /cli-login URL; the browser passes the same +// state back here purely for echoing (real CSRF defence happens at the +// CLI loopback). Requires an authenticated session. +func CliLoginExchange(c *gin.Context) { + id := c.GetInt("id") + if id == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "not logged in", + }) + return + } + + var req struct { + State string `json:"state"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "bad request: " + err.Error(), + }) + return + } + + token, err := issueAccessTokenForUser(id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "access_token": token, + "user_id": id, + "state": req.State, + }, + }) +} +``` + +- [ ] **Step 2: 在 `router/api-router.go` 注册路由** + +找到 `selfRoute` 块(第 78 行附近 `selfRoute.GET("/token", controller.GenerateAccessToken)`),在同一块的末尾新加一行: + +```go +// 在 selfRoute 所在的 user-authenticated group 里加这一行: +selfRoute.POST("/cli-login/exchange", controller.CliLoginExchange) +``` + +> 注意 path:注册时父 group 已经在 `/api/user`,所以全路径会是 `/api/user/cli-login/exchange`(spec §4.2 已对齐)。如果发现 `selfRoute` group 的 path 不是 `/api/user` 或者 group 注册方式不同,按实际写法对齐——目标是:**这个路由必须挂在「session 已登录」中间件之后**,未登录直接被 middleware 拒绝,不会进 handler。 + +- [ ] **Step 3: 编译** + +```bash +cd /Users/xbang/VScode/new-api +go build ./... +``` + +Expected: 编译通过。 + +- [ ] **Step 4: curl 验证(开发环境,已登录的 session cookie)** + +先用浏览器登录拿到 session cookie,复制到 curl: + +```bash +curl -X POST 'http://localhost:3000/api/user/cli-login/exchange' \ + -H 'Content-Type: application/json' \ + -H 'Cookie: session=<复制浏览器里的>' \ + -d '{"state":"abc123"}' +``` + +Expected: +```json +{"success":true,"message":"","data":{"access_token":"...","user_id":,"state":"abc123"}} +``` + +不带 cookie 的请求 → 401。 + +- [ ] **Step 5: 提交(可选)** + +```bash +cd /Users/xbang/VScode/new-api +git add controller/cli_login.go router/api-router.go +git commit -m "feat(cli-login): add POST /api/user/cli-login/exchange" +``` + +--- + +## Phase 2 — 服务端前端 + +### Task 3: `/cli-login` React 页面 + +**Files:** +- Create: `/Users/xbang/VScode/new-api/web/src/components/auth/CliLogin.jsx` +- Modify: `/Users/xbang/VScode/new-api/web/src/App.jsx`(注册路由) + +- [ ] **Step 1: 读现有路由配置和登录态判断方式** + +```bash +sed -n '170,200p' /Users/xbang/VScode/new-api/web/src/App.jsx +``` + +记下: +- 别的 page 是怎么 import 进来的(应该是 `lazy()` 或者直接 `import`) +- 是否有 `AuthRedirect`/`PrivateRoute` 之类的包装组件 +- 怎么判断"用户已登录"(可能是 redux store、Context、或者 fetch `/api/user/self`) + +- [ ] **Step 2: 创建 `CliLogin.jsx`** + +```jsx +import React, { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { API } from '../../helpers'; // 按现有项目里 axios 实例的实际 import 路径 + +const SESSION_KEY = 'newapi.cli_login.params'; + +function readParams(searchParams) { + // Prefer fresh query params; fall back to sessionStorage so OAuth + // round-trips that drop the query still find the right port/state. + const port = searchParams.get('port'); + const state = searchParams.get('state'); + if (port && state) { + sessionStorage.setItem(SESSION_KEY, JSON.stringify({ port, state })); + return { port, state }; + } + try { + return JSON.parse(sessionStorage.getItem(SESSION_KEY) || 'null'); + } catch { + return null; + } +} + +export default function CliLogin() { + const [searchParams] = useSearchParams(); + const [status, setStatus] = useState('starting'); + const [errMsg, setErrMsg] = useState(''); + + useEffect(() => { + (async () => { + const params = readParams(searchParams); + if (!params) { + setStatus('error'); + setErrMsg('Missing port/state. Did you arrive here from the CLI?'); + return; + } + + // 1. Check session. + // Wait until the user is logged in. Instead of integrating with the + // existing LoginForm's redirect-query convention (which varies across + // new-api forks), we poll `/api/user/self` and let the user open the + // main login page in another tab / window manually. The polling + // resolves the moment a session cookie appears. + const isLoggedIn = async () => { + try { + const res = await API.get('/api/user/self'); + return Boolean(res?.data?.success); + } catch { + return false; + } + }; + let loggedIn = await isLoggedIn(); + if (!loggedIn) { + setStatus('awaitingLogin'); + // Poll every 2s, max 5 min. + for (let i = 0; i < 150 && !loggedIn; i++) { + await new Promise((r) => setTimeout(r, 2000)); + loggedIn = await isLoggedIn(); + } + } + if (!loggedIn) { + setStatus('error'); + setErrMsg('Not signed in after 5 minutes. Please refresh this tab once you are signed in.'); + return; + } + + // 2. Exchange session → access token. + setStatus('exchanging'); + let exchangeRes; + try { + exchangeRes = await API.post('/api/user/cli-login/exchange', { + state: params.state, + }); + } catch (e) { + setStatus('error'); + setErrMsg('Failed to exchange session: ' + (e?.message || 'unknown')); + return; + } + if (!exchangeRes?.data?.success) { + setStatus('error'); + setErrMsg(exchangeRes?.data?.message || 'exchange failed'); + return; + } + + // 3. POST token to CLI loopback. + setStatus('returning'); + try { + const cbResp = await fetch(`http://127.0.0.1:${params.port}/callback`, { + method: 'POST', + mode: 'cors', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(exchangeRes.data.data), + }); + if (!cbResp.ok) { + throw new Error(`loopback returned HTTP ${cbResp.status}`); + } + } catch (e) { + setStatus('error'); + setErrMsg('Could not reach CLI loopback: ' + (e?.message || 'unknown')); + return; + } + + sessionStorage.removeItem(SESSION_KEY); + setStatus('done'); + })(); + }, [searchParams]); + + return ( +
+

CLI login

+ {status === 'starting' &&

Checking session…

} + {status === 'awaitingLogin' && ( +
+

Please sign in to this new-api instance in another tab, then come back — this page will continue automatically.

+

Open the sign-in page

+
+ )} + {status === 'exchanging' &&

Exchanging session for CLI token…

} + {status === 'returning' &&

Sending token back to CLI…

} + {status === 'done' && ( +

+ Login successful. You can close this tab and return to your terminal. +

+ )} + {status === 'error' && ( +
+

Error: {errMsg}

+ +
+ )} +
+ ); +} +``` + +> 如果 step 1 发现项目的 axios 实例叫别的名字(如 `api`/`request`/`http`),改 import 路径。如果项目用 fetch 不用 axios,把 `API.get(...).data.success` 换成 fetch 风格。 + +- [ ] **Step 3: 注册路由** + +修改 `/Users/xbang/VScode/new-api/web/src/App.jsx`,在第 86-406 行的 `` 块内(参考其他 lazy import 模式)加: + +```jsx +// 顶部 imports(按现有 lazy import 风格): +const CliLogin = React.lazy(() => import('./components/auth/CliLogin')); + +// 路由(在 内,参考第 177 行 LoginForm 的写法): +} /> +``` + +> `/cli-login` 路由**不要包 `AuthRedirect`** —— CliLogin 组件自己处理"未登录就跳 /login"的逻辑,外层包了反而会双重跳转。 + +- [ ] **Step 4: 本地启前端 + 手测** + +```bash +cd /Users/xbang/VScode/new-api/web +npm run dev # 或 yarn dev / pnpm dev,按现有 README +``` + +打开浏览器 → 先登录主站 → 访问 `http://localhost:5173/cli-login?port=9999&state=test123`(端口对不上 loopback 没关系,期望看到的是页面跑到 "Sending token back to CLI…" 然后 fetch 失败显示 error,这证明 1/2 步通了)。 + +未登录时访问同 URL,期望跳到 `/login?expired=true&return=...`,登完跳回 `/cli-login`。 + +- [ ] **Step 5: 提交(可选)** + +```bash +cd /Users/xbang/VScode/new-api +git add web/src/components/auth/CliLogin.jsx web/src/App.jsx +git commit -m "feat(web): add /cli-login page for CLI loopback flow" +``` + +--- + +## Phase 3 — CLI webauth 包(本 repo) + +### Task 4: `webauth/state.go` — state 生成和比较 + +**Files:** +- Create: `/Users/xbang/VScode/new-api-cli/internal/cli/webauth/state.go` +- Create: `/Users/xbang/VScode/new-api-cli/internal/cli/webauth/state_test.go` + +- [ ] **Step 1: 写测试(TDD — 先看测试,再写实现)** + +`internal/cli/webauth/state_test.go`: + +```go +package webauth + +import "testing" + +func TestGenerate_Length(t *testing.T) { + s, err := Generate() + if err != nil { + t.Fatalf("Generate returned err: %v", err) + } + // base64url(32 bytes) = 43 chars (no padding). + if len(s) != 43 { + t.Fatalf("expected len 43, got %d (%q)", len(s), s) + } +} + +func TestGenerate_Uniqueness(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 100; i++ { + s, err := Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + if seen[s] { + t.Fatalf("collision after %d iterations: %s", i, s) + } + seen[s] = true + } +} + +func TestEqual(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"abc", "abc", true}, + {"abc", "abd", false}, + {"abc", "ab", false}, + {"", "", true}, + } + for _, c := range cases { + if got := Equal(c.a, c.b); got != c.want { + t.Errorf("Equal(%q,%q)=%v want %v", c.a, c.b, got, c.want) + } + } +} +``` + +- [ ] **Step 2: 跑测试,确认失败** + +```bash +cd /Users/xbang/VScode/new-api-cli +go test ./internal/cli/webauth/... +``` + +Expected: 编译失败 — `package webauth: no Go files`。 + +- [ ] **Step 3: 写实现** + +`internal/cli/webauth/state.go`: + +```go +package webauth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" +) + +// Generate returns a 32-byte cryptographically random nonce encoded as +// URL-safe base64 (no padding). Used to bind the loopback callback to +// the originating CLI invocation. +func Generate() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// Equal compares two state values in constant time. +func Equal(a, b string) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} +``` + +- [ ] **Step 4: 跑测试,确认通过** + +```bash +go test ./internal/cli/webauth/... +``` + +Expected: `ok internal/cli/webauth`. + +- [ ] **Step 5: 提交(可选)** + +```bash +git add internal/cli/webauth/state.go internal/cli/webauth/state_test.go +git commit -m "feat(webauth): add state generation and constant-time compare" +``` + +--- + +### Task 5: `webauth/browser.go` — 跨平台开浏览器 + +**Files:** +- Create: `/Users/xbang/VScode/new-api-cli/internal/cli/webauth/browser.go` + +> 该模块**没有单测**——它只是个 thin wrapper,错误本就 best-effort 忽略,单测意义不大。手测覆盖。 + +- [ ] **Step 1: 写实现** + +`internal/cli/webauth/browser.go`: + +```go +package webauth + +import ( + "os/exec" + "runtime" +) + +// Open tries to launch the user's default browser pointed at url. Failures +// are returned to the caller but are not fatal: the CLI prints the URL to +// stderr before calling Open, so the user can always paste it manually. +func Open(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + // xdg-open is the freedesktop standard; almost any DE ships it. + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + return nil // unsupported platform — fall back to paste-the-URL + } + return cmd.Start() +} +``` + +- [ ] **Step 2: 跑编译** + +```bash +go build ./... +``` + +Expected: 编译通过。 + +- [ ] **Step 3: 手测(仅 macOS 本地)** + +```bash +go run -exec '' ./... 2>/dev/null || true # 仅编译 +# 不能直接调 webauth.Open,本步骤仅确认包能编译;端到端覆盖在 Task 8。 +``` + +- [ ] **Step 4: 提交(可选)** + +```bash +git add internal/cli/webauth/browser.go +git commit -m "feat(webauth): add cross-platform browser launcher" +``` + +--- + +### Task 6: `webauth/server.go` — loopback server + +**Files:** +- Create: `/Users/xbang/VScode/new-api-cli/internal/cli/webauth/server.go` +- Create: `/Users/xbang/VScode/new-api-cli/internal/cli/webauth/server_test.go` + +- [ ] **Step 1: 写测试** + +`internal/cli/webauth/server_test.go`: + +```go +package webauth + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "testing" + "time" +) + +// startTestServer boots RunWithHook in a goroutine and returns the loopback +// URL the test should POST callbacks to. endpoint controls the Origin the +// server expects from the (simulated) browser. +func startTestServer(t *testing.T, ctx context.Context, endpoint, state string) (callbackURL string, resultCh <-chan Result) { + t.Helper() + urlCh := make(chan string, 1) + out := make(chan Result, 1) + go func() { + r, err := RunWithHook(ctx, endpoint, state, func(loopbackURL string) { + urlCh <- loopbackURL + }) + if err != nil && r.Err == nil { + r.Err = err + } + out <- r + }() + select { + case u := <-urlCh: + return u + "/callback", out + case <-time.After(2 * time.Second): + t.Fatalf("server never reported its listener URL") + return "", out + } +} + +func TestCallback_HappyPath(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + endpoint := "https://example.com" + state, _ := Generate() + cbURL, resultCh := startTestServer(t, ctx, endpoint, state) + + body, _ := json.Marshal(map[string]any{ + "access_token": "sk-test-123", + "user_id": 42, + "state": state, + }) + req, _ := http.NewRequest("POST", cbURL, bytes.NewReader(body)) + req.Header.Set("Origin", endpoint) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST callback: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + select { + case r := <-resultCh: + if r.Err != nil { + t.Fatalf("unexpected error: %v", r.Err) + } + if r.AccessToken != "sk-test-123" { + t.Errorf("token: %q", r.AccessToken) + } + if r.UserID != "42" { + t.Errorf("user_id: %q", r.UserID) + } + case <-time.After(2 * time.Second): + t.Fatal("result channel timed out") + } +} + +func TestCallback_StateMismatch(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + endpoint := "https://example.com" + state, _ := Generate() + cbURL, resultCh := startTestServer(t, ctx, endpoint, state) + + body, _ := json.Marshal(map[string]any{ + "access_token": "sk-test-123", + "user_id": 42, + "state": "wrong-state", + }) + req, _ := http.NewRequest("POST", cbURL, bytes.NewReader(body)) + req.Header.Set("Origin", endpoint) + req.Header.Set("Content-Type", "application/json") + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != 403 { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + // Server should NOT terminate — let context cancel it instead. + select { + case r := <-resultCh: + // If we get here it should be a ctx.Err, not a happy result. + if r.AccessToken != "" { + t.Fatalf("token leaked despite state mismatch: %q", r.AccessToken) + } + case <-time.After(1500 * time.Millisecond): + // OK — server still running, will be torn down by defer cancel(). + } +} + +func TestCallback_OriginMismatch(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + endpoint := "https://example.com" + state, _ := Generate() + cbURL, _ := startTestServer(t, ctx, endpoint, state) + + body, _ := json.Marshal(map[string]any{ + "access_token": "sk-test-123", + "user_id": 42, + "state": state, + }) + req, _ := http.NewRequest("POST", cbURL, bytes.NewReader(body)) + req.Header.Set("Origin", "https://evil.example.com") + req.Header.Set("Content-Type", "application/json") + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != 403 { + t.Fatalf("expected 403 on bad origin, got %d", resp.StatusCode) + } +} + +func TestRun_ContextTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + done := make(chan struct{}) + go func() { + _, _ = RunWithHook(ctx, "https://example.com", "stateXYZ", nil) + close(done) + }() + select { + case <-done: + // OK — RunWithHook returned cleanly when ctx expired. + case <-time.After(2 * time.Second): + t.Fatal("RunWithHook did not return after context cancel") + } +} +``` + +> 这些测试用到了 `Result` 类型、`RunWithHook` 公开函数和 `Result.Err`/`Result.AccessToken`/`Result.UserID` 字段——下一步的实现要匹配。 + +- [ ] **Step 2: 跑测试,确认失败** + +```bash +go test ./internal/cli/webauth/... +``` + +Expected: 编译失败(`Result`/`RunWithHook` 未定义)。 + +- [ ] **Step 3: 写 `server.go`** + +`internal/cli/webauth/server.go`: + +```go +package webauth + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "strconv" + "time" +) + +// Result is what the loopback flow produces when it completes. +type Result struct { + AccessToken string + UserID string + Err error +} + +// Run starts a loopback HTTP server on 127.0.0.1:, expects the +// browser to POST {access_token, user_id, state} to /callback, validates +// state and Origin, and returns the credentials. Cancels cleanly on +// ctx.Done. +// +// The caller is responsible for opening the browser to +// endpoint + "/cli-login?port=&state=" +// — Run prints nothing; full URL composition lives in the cli/login.go +// caller via RunWithHook. +func Run(ctx context.Context, endpoint string) (Result, error) { + state, err := Generate() + if err != nil { + return Result{}, fmt.Errorf("generate state: %w", err) + } + return RunWithHook(ctx, endpoint, state, nil) +} + +// RunWithHook is Run's variant that accepts a pre-generated state and an +// optional hook that fires once the server is listening (used by tests +// and by cli/login.go to print the full browser-bound URL with the +// correct port). The hook is called exactly once, synchronously, in the +// goroutine that called RunWithHook. +func RunWithHook(ctx context.Context, endpoint, state string, onListen func(loopbackURL string)) (Result, error) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return Result{}, fmt.Errorf("listen: %w", err) + } + port := ln.Addr().(*net.TCPAddr).Port + loopbackURL := "http://127.0.0.1:" + strconv.Itoa(port) + + resultCh := make(chan Result, 1) + mux := http.NewServeMux() + mux.HandleFunc("/callback", makeCallbackHandler(endpoint, state, resultCh)) + + server := &http.Server{ + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + go func() { _ = server.Serve(ln) }() + + if onListen != nil { + onListen(loopbackURL) + } + + select { + case r := <-resultCh: + // Give the browser a moment to read the 200 response. + shutdown, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + _ = server.Shutdown(shutdown) + return r, r.Err + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + _ = server.Shutdown(shutdown) + return Result{Err: ctx.Err()}, ctx.Err() + } +} + +func makeCallbackHandler(endpoint, state string, out chan<- Result) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // CORS preflight. + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Origin", endpoint) + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + // Origin check: must match endpoint exactly. + if origin := r.Header.Get("Origin"); origin != endpoint { + w.Header().Set("Access-Control-Allow-Origin", endpoint) + http.Error(w, "bad origin", http.StatusForbidden) + return + } + + // Pre-set CORS headers on the success path too. + w.Header().Set("Access-Control-Allow-Origin", endpoint) + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + var body struct { + AccessToken string `json:"access_token"` + UserID any `json:"user_id"` // can be number or string + State string `json:"state"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad body", http.StatusBadRequest) + return + } + if !Equal(body.State, state) { + http.Error(w, "state mismatch", http.StatusForbidden) + return + } + if body.AccessToken == "" { + http.Error(w, "missing access_token", http.StatusBadRequest) + return + } + + // Normalise user_id to string regardless of inbound type. + var userIDStr string + switch v := body.UserID.(type) { + case string: + userIDStr = v + case float64: + userIDStr = strconv.FormatInt(int64(v), 10) + case nil: + userIDStr = "" + default: + userIDStr = fmt.Sprintf("%v", v) + } + + // Tell the browser we're good. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + + // Deliver to the CLI loop. Non-blocking: only the first success wins. + select { + case out <- Result{AccessToken: body.AccessToken, UserID: userIDStr}: + default: + } + } +} +``` + +- [ ] **Step 4: 跑测试** + +```bash +go test -race -count=1 ./internal/cli/webauth/... +``` + +Expected: 全 PASS。如果失败,按测试 fail message 修代码(最常见:字段名拼错、CORS header 漏发)。 + +- [ ] **Step 5: 提交(可选)** + +```bash +git add internal/cli/webauth/server.go internal/cli/webauth/server_test.go +git commit -m "feat(webauth): add loopback callback server" +``` + +--- + +## Phase 4 — CLI login.go 集成 + +### Task 7: 重写 `runLogin` + 新增 `--no-browser` flag + +**Files:** +- Modify: `/Users/xbang/VScode/new-api-cli/internal/cli/login.go` + +- [ ] **Step 1: 在 `loginFlags` 加 `noBrowser` 字段** + +修改 `internal/cli/login.go` 第 19-25 行: + +```go +type loginFlags struct { + endpoint string + profile string + email string + password string + noPickKey bool + noBrowser bool +} +``` + +- [ ] **Step 2: 注册新 flag + 更新 help text** + +修改 `internal/cli/login.go` 第 27-52 行的 `newLoginCmd`: + +```go +func newLoginCmd() *cobra.Command { + var f loginFlags + cmd := &cobra.Command{ + Use: "login", + Short: "Authenticate against a new-api endpoint", + Long: `Log in via the browser (default) or with email/username + password. + +The default flow opens your browser to /cli-login, lets you sign in +with any method the server supports (password, OAuth, Passkey, 2FA), and then +returns an access token to the CLI via a short-lived 127.0.0.1 loopback. The +token is stored in the OS keychain (or a 0600 file fallback). + +For CI or scripts, pass --email and --password to bypass the browser.`, + Example: ` # interactive (opens browser) + newapi login --endpoint https://api.example.com + + # headless / SSH (print URL instead of launching browser) + newapi login --endpoint https://api.example.com --no-browser + + # non-interactive (CI) + newapi login --endpoint https://api.example.com \ + --email me@example.com --password "$NEWAPI_PASSWORD" \ + --profile prod --no-pick-key`, + RunE: func(cmd *cobra.Command, args []string) error { + return runLogin(cmd.Context(), f) + }, + } + cmd.Flags().StringVar(&f.endpoint, "endpoint", "", "endpoint URL (e.g. https://api.example.com)") + cmd.Flags().StringVar(&f.profile, "profile", "default", "profile name to save credentials to") + cmd.Flags().StringVar(&f.email, "email", "", "email or username (forces non-browser flow)") + cmd.Flags().StringVar(&f.password, "password", "", "password (forces non-browser flow)") + cmd.Flags().BoolVar(&f.noPickKey, "no-pick-key", false, "skip API key selection step") + cmd.Flags().BoolVar(&f.noBrowser, "no-browser", false, "print the login URL instead of opening the browser") + return cmd +} +``` + +- [ ] **Step 3: 重写 `runLogin`** + +把第 54-147 行的 `runLogin` 替换为: + +```go +func runLogin(ctx context.Context, f loginFlags) error { + cfg, err := config.Load() + if err != nil { + return err + } + + endpoint := f.endpoint + if endpoint == "" { + if p, ok := cfg.Profiles[f.profile]; ok { + endpoint = p.Endpoint + } + } + rd := bufio.NewReader(os.Stdin) + if endpoint == "" { + if !canPrompt() { + return fmt.Errorf("--endpoint is required when running non-interactively") + } + fmt.Print("Endpoint URL: ") + endpoint = readLine(rd) + } + endpoint, err = normalizeEndpoint(endpoint) + if err != nil { + return err + } + + var userID, accessToken string + + // Decide between browser flow and email/password flow. + if f.email != "" || f.password != "" { + // Explicit password mode (CI / scripts). + userID, accessToken, err = passwordLogin(ctx, endpoint, f, rd) + } else if !canPrompt() { + return fmt.Errorf("login requires either a TTY (for the browser flow) or --email/--password for non-interactive use") + } else { + userID, accessToken, err = browserLogin(ctx, endpoint, f) + } + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + + client := apiclient.New(endpoint, nil) + client.SetAccessToken(accessToken) + client.SetUserID(userID) + + store, err := auth.NewStore() + if err != nil { + return err + } + tokenRef, err := store.Set(f.profile, "access", accessToken) + if err != nil { + return err + } + + if cfg.Profiles == nil { + cfg.Profiles = map[string]*config.Profile{} + } + p, ok := cfg.Profiles[f.profile] + if !ok { + p = &config.Profile{} + cfg.Profiles[f.profile] = p + } + p.Endpoint = endpoint + p.UserID = userID + p.AccessToken = tokenRef + if cfg.CurrentProfile == "" { + cfg.CurrentProfile = f.profile + } + if err := config.Save(cfg); err != nil { + return err + } + + user, err := client.Self(ctx) + if err == nil { + fmt.Printf("Logged in as %s (id=%d, group=%s, profile=%s)\n", user.Username, user.ID, user.Group, f.profile) + } else { + fmt.Printf("Logged in (profile=%s, user_id=%s)\n", f.profile, userID) + } + + if f.noPickKey { + return nil + } + return pickAPIKey(ctx, cfg, client, store, f.profile) +} + +func passwordLogin(ctx context.Context, endpoint string, f loginFlags, rd *bufio.Reader) (string, string, error) { + email := f.email + if email == "" { + if !canPrompt() { + return "", "", fmt.Errorf("--email is required when running non-interactively") + } + fmt.Print("Email/username: ") + email = readLine(rd) + } + password := f.password + if password == "" { + if !canPrompt() { + return "", "", fmt.Errorf("--password is required when running non-interactively") + } + fmt.Print("Password: ") + pw, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return "", "", err + } + password = string(pw) + } + client := apiclient.New(endpoint, nil) + return client.Login(ctx, email, password) +} + +func browserLogin(ctx context.Context, endpoint string, f loginFlags) (string, string, error) { + // 5-minute deadline for the user to complete the browser handshake. + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + state, err := webauth.Generate() + if err != nil { + return "", "", err + } + + resultCh := make(chan webauth.Result, 1) + go func() { + r, err := webauth.RunWithHook(ctx, endpoint, state, func(loopbackURL string) { + loginURL := fmt.Sprintf("%s/cli-login?port=%s&state=%s", + strings.TrimRight(endpoint, "/"), + strings.TrimPrefix(loopbackURL, "http://127.0.0.1:"), + state, + ) + fmt.Fprintln(os.Stderr, "Opening your browser to:") + fmt.Fprintln(os.Stderr, " "+loginURL) + fmt.Fprintln(os.Stderr, "If the browser does not open, paste this URL into one.") + if !f.noBrowser { + _ = webauth.Open(loginURL) + } + }) + if err != nil && r.Err == nil { + r.Err = err + } + resultCh <- r + }() + + r := <-resultCh + if r.Err != nil { + return "", "", r.Err + } + return r.UserID, r.AccessToken, nil +} +``` + +> 注意:这段 import 多了 `time` 和 `strings`,还要 `github.com/Xbang0222/new-api-cli/internal/cli/webauth`。把 import 块更新好。 + +- [ ] **Step 4: 编译 + 跑测试** + +```bash +cd /Users/xbang/VScode/new-api-cli +go build ./... +go test -race -count=1 ./... +``` + +Expected: 全过。如果有"unused import",删掉旧的 `bufio`/`term` 之类(password path 还要用它们就别删)。 + +- [ ] **Step 5: 提交(可选)** + +```bash +git add internal/cli/login.go +git commit -m "feat(login): make browser flow default; keep email/password as CI fallback" +``` + +--- + +## Phase 5 — 端到端 smoke + 文档 + +### Task 8: 手动 smoke + 文档更新 + +**Files:** +- Modify: `/Users/xbang/VScode/new-api-cli/CLAUDE.local.md`(不上传 git) +- Modify: `/Users/xbang/VScode/new-api-cli/CLAUDE.md`(§H 加澄清) +- Modify: `/Users/xbang/VScode/new-api-cli/README.md`(login 示例) + +- [ ] **Step 1: 启服务端 + 跑 CLI 端到端** + +```bash +# Terminal 1 — 服务端 +cd /Users/xbang/VScode/new-api +go run main.go # 或现有启动脚本 + +# Terminal 2 — 前端 +cd /Users/xbang/VScode/new-api/web +npm run dev + +# Terminal 3 — CLI +cd /Users/xbang/VScode/new-api-cli +make build +export NEWAPI_CONFIG_HOME=/tmp/newapi-smoke && rm -rf "$NEWAPI_CONFIG_HOME" +./newapi login --endpoint http://localhost:3000 # 端口按服务端实际 +``` + +Expected: +- CLI 打印 `Opening your browser to: http://localhost:3000/cli-login?port=NNNNN&state=XXXX` +- 浏览器开起来,跳到登录页(如已登录则跳过),完成后跑回 `/cli-login` +- 页面短暂显示 "Sending token back to CLI…" → "Login successful. You can close this tab" +- CLI 这端打印 `Logged in as testcli (id=N, group=..., profile=default)` +- 接着进 `pickAPIKey` 流程 + +- [ ] **Step 2: 跑 `--no-browser` 路径** + +```bash +./newapi login --endpoint http://localhost:3000 --no-browser +# 期望: 打印 URL 但不调 'open'。手动复制到浏览器。后续行为同 step 1。 +``` + +- [ ] **Step 3: 跑非交互 fallback** + +```bash +./newapi --no-input login --endpoint http://localhost:3000 +echo "exit=$?" # 期望 != 0,且 stderr 说要 --email/--password + +./newapi login --endpoint http://localhost:3000 --email testcli --password Aa123456 --no-pick-key +# 期望: 沿用旧 password 路径,登成功 +``` + +- [ ] **Step 4: 更新 `CLAUDE.local.md` 的 smoke list** + +在 `## Smoke-test command list` 块的第 1 节(Auth)开头,插入: + +```bash +# 1. Auth — browser flow (默认) +./newapi login --endpoint https://ruoli.dev +# Expected: 浏览器开起来,登完后 CLI 打印 "Logged in as testcli ..." + +# 1b. --no-browser: 仅打印 URL +./newapi login --endpoint https://ruoli.dev --no-browser + +# 1c. --no-input 必须报错 +./newapi --no-input login --endpoint https://ruoli.dev ; echo "exit=$?" +# Expected: exit != 0, stderr 提示要 --email/--password + +# 1d. password fallback 仍工作 +./newapi login --endpoint https://ruoli.dev --email testcli --password Aa123456 --no-pick-key +``` + +- [ ] **Step 5: 更新 `CLAUDE.md` §H 加澄清** + +在 `## H. Future-proofing & distribution` 的 "No spawning external tools at runtime" 行下面加一行: + +```markdown + - **Exception**: launching the user's default browser via `open` (darwin) + / `xdg-open` (linux) / `rundll32` (windows) is allowed as a *best-effort* + convenience in the `login` flow. The CLI always prints the URL first, + so if the launcher fails or is missing, the user can paste it + manually — these binaries are never a hard dependency. +``` + +- [ ] **Step 6: 更新 `README.md`** + +找到现有 "Authentication" / "Login" 章节(如有),把第一个示例换成 browser flow,保留 password flow 在下方作为 CI 路径: + +```markdown +## Logging in + +```bash +# 默认: 打开浏览器 +newapi login --endpoint https://api.example.com + +# CI / 脚本 +newapi login --endpoint https://api.example.com \ + --email me@example.com --password "$NEWAPI_PASSWORD" --no-pick-key +``` +``` + +- [ ] **Step 7: 跑完整回归** + +```bash +cd /Users/xbang/VScode/new-api-cli +make test +make lint +``` + +Expected: 全过。 + +- [ ] **Step 8: 删除新加临时测试(按 CLAUDE.md 用户级 "测试是脚手架" 规则)** + +本 plan 中**保留**的测试是 `internal/cli/webauth/state_test.go` 和 `internal/cli/webauth/server_test.go`——它们是 CLI 这个 repo 的回归资产(本项目对回归测试与 user global 规则有覆写:见 CLAUDE.md "Tests are kept (this repo only)")。不删。 + +但服务端那边(`../new-api`)若你为了验证 `CliLoginExchange` 临时写了任何 `*_test.go`,按 user global 规则**删掉**,不要 commit。 + +- [ ] **Step 9: 提交文档变更(可选)** + +```bash +cd /Users/xbang/VScode/new-api-cli +git add CLAUDE.md README.md +# 不要 add CLAUDE.local.md (gitignore'd) +git commit -m "docs: document browser-default login flow" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- §3 整体架构 → Tasks 1-8 都对齐 +- §4 服务端接口 → Task 1(refactor)、Task 2(new endpoint)、Task 3(前端页面) +- §5 CLI 实现 → Tasks 4-7 +- §6 错误处理/超时/契约 → Task 7 step 3 实现超时 + canPrompt 守护;Task 8 step 3 验证 `--no-input` +- §7 测试 → Tasks 4/6 含 state_test/server_test;服务端测试不留(per user global rule) +- §8 安全 → Task 6(loopback only / Origin / state)、Task 7(timeout) +- §10 未决问题 → "前端框架已确认 React 18 + react-router v6"(Task 3 已落地);"是否显示登录机器"和"token 来源标记"按 spec 默认不做 + +**2. Placeholder scan:** 全部代码块都是可直接 copy 的实际代码。少量「按 step 1 观察到的实际写法对齐」是显式的"先 Read 再 Write",不是 placeholder。 + +**3. Type consistency:** +- `webauth.Result` 字段 `AccessToken`/`UserID`/`Err` — 测试、server.go 实现和 login.go 调用一致 +- `webauth.Run` / `webauth.RunWithHook` / `webauth.Generate` / `webauth.Equal` / `webauth.Open` — 都在测试 + login.go 中用到 +- 服务端 path:spec §4.2 已统一为 `/api/user/cli-login/exchange`(挂在 selfRoute 父 group 下),plan Task 2 step 2 也注册到同一路径 + +--- + +## 备注 + +- 整体可放进 6-10 小时的实施窗口(含服务端两个 repo 同步开发 + 端到端 smoke)。 +- 服务端两个 commit + CLI 端 4-5 个 commit。如 user 偏好单 commit,merge 时 squash。 +- 不引入新的 Go 依赖(`net/http`/`crypto/rand`/`os/exec` 全是标准库)。 +- 不引入新的 npm 依赖(CliLogin.jsx 用现有 React + react-router + axios)。 diff --git a/docs/superpowers/specs/2026-05-25-web-login-design.md b/docs/superpowers/specs/2026-05-25-web-login-design.md new file mode 100644 index 0000000..27ea510 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-web-login-design.md @@ -0,0 +1,265 @@ +# Web 登录(loopback callback)设计 + +- **日期**:2026-05-25 +- **状态**:设计稿,待实现 plan +- **影响范围**:`new-api-cli`(CLI)+ `../new-api`(服务端) + +## 1. 目标和动机 + +当前 `newapi login` 需要用户在终端里手动输入 email/username 和密码,体验上比 `gh auth login`、`stripe login` 等同类 CLI 落后一个档次。本设计把 web 登录变成默认路径: + +- `newapi login --endpoint ` 直接打开浏览器 +- 用户在浏览器里用**任意已有方式**完成登录(密码、GitHub OAuth、Discord、Linux Do、Passkey、2FA、邮箱 OAuth) +- 服务端把 access_token 通过浏览器 JS 回送给 CLI 本地 loopback +- CLI 拿到 token、保存到 keychain、退出,全程无需粘贴 + +email/password 路径**保留**作为 CI/脚本的非交互 fallback。 + +## 2. 非目标 + +- 不做服务端"设备码"(device code)流。`/cli-login` 页面方案对桌面用户已经够好。 +- 不引入新的 token 类型。复用现有 30 天 access_token 的颁发逻辑。 +- 不做"在 CLI 里跑 OAuth"——OAuth 留给浏览器,CLI 不直接和第三方 IdP 通话。 +- 不做 token auto-refresh。沿用现有"过期后让用户重新 login"的语义。 + +## 3. 整体架构 + +``` +┌─────────────────┐ ┌──────────────────────┐ +│ newapi CLI │ │ new-api server │ +│ (Go) │ │ (../new-api) │ +└────────┬────────┘ └──────────┬───────────┘ + │ 1. start loopback http://127.0.0.1:N │ + │ 2. open browser → /cli-login?port=N&state=S │ + │ │ + │ 浏览器: │ + │ ① /cli-login 页面 │ + │ ② (若未登录) 主登录页 │ + │ ③ 回 /cli-login │ + │ ④ POST /api/user/cli-login/exchange (session) + │ ⑤ POST 127.0.0.1:N/callback {token,state} + │ │ + │ 6. CLI 校验 state、保存 token、回 200 给浏览器 + │ 7. 浏览器显示 "登录成功,可关闭" │ + │ 8. CLI 继续走 pickAPIKey 流程 │ +``` + +### 改动边界 + +| 仓库 | 改什么 | 大致代码量 | +|---|---|---| +| `../new-api`(服务端) | (1) 前端路由 `/cli-login`(HTML + JS) (2) 后端 API `POST /api/user/cli-login/exchange` | 后端 ~80 行 Go;前端 ~120 行 | +| `new-api-cli`(CLI) | (1) 重写 `internal/cli/login.go`:web flow 为默认 (2) 新增 `internal/cli/webauth/`:loopback server + state + 跨平台开浏览器 | ~250 行 Go + 单测 | + +### 关键设计判断 + +- **服务端不存 state**。state 仅在浏览器 → loopback 这一跳里防 CSRF,由 CLI 自己生成、自己校验。 +- **服务端 `/api/user/cli-login/exchange` 复用现有 token 颁发逻辑**——它本质是把"凭 session 换 30 天 token"的内部函数再包一层 POST 接口。 +- **loopback 只绑 `127.0.0.1`**,不绑 `0.0.0.0`。 + +## 4. 服务端接口(`../new-api`) + +### 4.1 `GET /cli-login`(前端页面) + +页面在现有前端框架里新加一个路由。行为: + +``` +启动: + 从 URL query 取 port、state,记到 sessionStorage + (防止 OAuth 跳转把 query 丢掉) + + if !logged_in: + window.location = '/login?redirect=' + encodeURIComponent('/cli-login?port=...&state=...') + + else: + POST /api/user/cli-login/exchange body={state} + → 拿到 {access_token, user_id, expires_at} + fetch('http://127.0.0.1:' + port + '/callback', { + method: 'POST', + mode: 'cors', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({access_token, user_id, expires_at, state}) + }) + → 200: 显示"登录成功,可关闭此标签页" + → 失败: 显示错误,附"复制错误"按钮 +``` + +### 4.2 `POST /api/user/cli-login/exchange` + +- **路径**:挂在现有 `/api/user` group 下(即 selfRoute),自动复用 session middleware +- **鉴权**:需要 session cookie(未登录 → 401) +- **请求体**:`{"state": ""}`(state 透传,不校验) +- **响应体**:`{"success": true, "data": {"access_token": "...", "user_id": "...", "expires_at": 1716643200}}` +- **内部实现**:直接复用现有 `GET /api/user/token` 的颁发逻辑——抽出一个 `issueAccessTokenForUser(userID)` 内部函数,两个 endpoint 共用,确保有效期、签名、绑定口径一致 +- **频率限制**:复用全局 rate limit middleware +- **审计**:在 user 表更新 `cli_login_at = now()`(可选,方便后续做"上次 CLI 登录时间"提示) + +### 4.3 为什么 state 不在服务端校验 + +state 在本设计里防的是 CSRF——具体来说,是防止恶意网站构造假 callback URL 给 CLI 塞数据。这个防御只需要 loopback 端校验「收到的 state == 自己生成的 state」即可,服务端如果还要存 state 就要引入存储/过期/清理。简单点。 + +## 5. CLI 侧实现(`new-api-cli`) + +### 5.1 目录结构变化 + +``` +internal/cli/ + login.go ← 重写 runLogin + webauth/ + server.go ← loopback HTTP server + state.go ← state 生成(crypto/rand 32 bytes → base64url) + browser.go ← 跨平台 open URL,失败时静默 + server_test.go ← 回归测试 +``` + +### 5.2 `runLogin` 新逻辑 + +``` +1. endpoint = 解析 (--endpoint / profile / interactive prompt) +2. if --email != "" || --password != "": + 走旧的 email/password 路径(CI 兼容) +3. else(默认路径): + a. if --no-input || (stdin 非 TTY && 无 --browser 强制): + 报错: "use --email/--password for non-interactive login" + b. (userID, accessToken, err) := webauth.Run(ctx, endpoint) + c. 后续保存 / pickAPIKey 流程完全复用现有代码 +``` + +### 5.3 `webauth.Run` + +``` +1. listener, _ := net.Listen("tcp", "127.0.0.1:0") +2. port := listener.Addr().(*net.TCPAddr).Port +3. state := randomBase64URL(32) +4. tokenCh := make(chan tokenResult, 1) +5. server := &http.Server{Handler: handlers(state, endpoint, tokenCh)} +6. go server.Serve(listener) +7. loginURL := endpoint + "/cli-login?port=" + port + "&state=" + state +8. fmt.Fprintln(os.Stderr, "Opening browser to", loginURL) +9. fmt.Fprintln(os.Stderr, "If browser doesn't open, paste this URL manually.") +10. browser.Open(loginURL) // best effort, ignore err +11. select { + case r := <-tokenCh: server.Shutdown(...); return r + case <-ctx.Done(): server.Shutdown(...); return ctx.Err() + } +``` + +### 5.4 Loopback handlers + +- `POST /callback`: + - 校验 `Origin` header == endpoint(防 DNS rebinding) + - 校验请求 body 里的 `state` == 生成的 state + - 提取 `access_token`/`user_id`,写入 `tokenCh` + - 响应 CORS 头:`Access-Control-Allow-Origin: `、`Access-Control-Allow-Headers: Content-Type` + - 返回 `200 {"ok": true}` +- `OPTIONS /callback`:CORS preflight → 204 +- 其他路径:404 + +### 5.5 跨平台开浏览器 + +``` +darwin: exec.Command("open", url) +linux: exec.Command("xdg-open", url) // fallback: x-www-browser +windows: exec.Command("rundll32", "url.dll,FileProtocolHandler", url) +``` + +失败 → 静默忽略(URL 在调用前已打印到 stderr)。 + +#### 关于 CLAUDE.md "no spawning external tools" + +CLAUDE.md §H 禁止 `git`/`curl`/`jq` 这类**功能性 shellout**。`open`/`xdg-open`/`rundll32` 是桌面 OS 内置的 "open default app" 系统调用,且本设计的 fallback 永远是「打印 URL 让用户复制」——不构成 runtime dependency。实现时需要在 §H 加一句澄清,明确"打开默认浏览器是允许的 best-effort 调用"。 + +### 5.6 新增 / 修改的 flag + +| flag | 类型 | 作用 | +|---|---|---| +| `--no-browser` | bool | 仅打印 URL,不调用 `open`/`xdg-open`(headless / SSH 场景) | + +`--email`、`--password`、`--profile`、`--no-pick-key` 保留语义不变。 + +## 6. 错误处理 / 超时 / 与契约兼容 + +### 6.1 退出码 + +| 场景 | exit code | +|---|---| +| 5 分钟内浏览器没回 callback | 1 | +| Origin / state 校验失败 | 1 | +| 浏览器侧 `/api/user/cli-login/exchange` 401 | 3 | +| 浏览器侧 429 | 5 | +| loopback 起不来 | 1 | +| 用户 Ctrl+C | 1 | +| `--no-input` 且没给 `--email/--password` | 1(带文案提示) | + +### 6.2 对 CLAUDE.md 各契约的影响 + +- **A(命令结构)**:`newapi login` 的 `Use`/`Short`/`Long`/`Example` 更新,明确"默认走 web flow" +- **D(flag 预算)**:global flag 表 0 变更;`login` 本地 flag 新增 `--no-browser` +- **G(TTY 安全)**:`--no-input` 模式下 web flow 自动禁用,强制 email/password 路径 +- **H(无外部 runtime 依赖)**:见 §5.5 + +### 6.3 对 `auth.Store` / `config.Profile` 的影响 + +**零**。web flow 拿到的 `access_token` 与旧路径的格式、有效期、用法完全一致,存储不变。 + +## 7. 测试策略 + +### 7.1 CLI 侧(保留为回归资产) + +``` +internal/cli/webauth/ + server_test.go + - TestCallback_HappyPath POST + 校验 token 入 channel + - TestCallback_StateMismatch 403 + - TestCallback_OriginMismatch 403 + - TestCallback_Timeout 5 min context 超时 + - TestState_Generate_Uniqueness 不同调用产生不同 state,长度 ≥ 32 字节熵 +``` + +### 7.2 服务端侧 + +按 user 的 global "测试是脚手架" 规则:服务端只在开发过程中临时验证,不留新测试到仓库。 + +### 7.3 Smoke check(手工,加进 `CLAUDE.local.md`) + +```bash +# 默认 web flow +./newapi login --endpoint https://ruoli.dev + +# 非交互 fallback 仍工作 +./newapi login --endpoint https://ruoli.dev --email testcli --password Aa123456 --no-pick-key + +# --no-input 必须报错 +./newapi --no-input login --endpoint https://ruoli.dev ; echo "exit=$?" + +# --no-browser:只打印 URL +./newapi login --endpoint https://ruoli.dev --no-browser +``` + +## 8. 安全考量 + +| 威胁 | 缓解 | +|---|---| +| 恶意本机进程占住端口偷 token | loopback 端口随机分配;token 通过 POST body 传,不入日志 | +| 网内其他主机攻击 loopback | 仅绑 127.0.0.1,不绑 0.0.0.0 | +| 跨站脚本伪造 callback | state 校验(CLI 自己生成自己校验)+ Origin header 校验 | +| DNS rebinding | Origin header 校验匹配 endpoint | +| Token 落进浏览器历史 | token 通过 POST body 传,不出现在 URL | +| Token 落进 shell history / 日志 | 同上 | +| 多个 CLI 实例同时跑 | 每个实例自己的端口 + 自己的 state,互不串 | + +## 9. 实现顺序 + +1. 服务端 `POST /api/user/cli-login/exchange`(最小 API,先能跑通) +2. 服务端 `/cli-login` 前端页面 +3. CLI `internal/cli/webauth/` 包 +4. CLI `internal/cli/login.go` 重写 +5. 单测 +6. 手工 smoke +7. README + CLAUDE.md 文档更新 + +## 10. 未决问题 + +- 服务端前端框架(React / Vue)需要在写实现 plan 时先去 `../new-api/web/` 确认,因为页面要按现有的路由约定接入。 +- 是否需要在 `/cli-login` 页面显示「是哪个机器在登录」(如 hostname / IP)让用户确认?建议**先不做**——CLI 自己生成 state 已经把 binding 关系建好了。如果将来有钓鱼担忧再加。 +- 是否要给 access_token 加一个"通过 CLI 登录获得"的来源标记,方便用户在 web 端区分?建议先不做。 diff --git a/internal/cli/login.go b/internal/cli/login.go index 7080a60..009d76d 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -8,9 +8,11 @@ import ( "strconv" "strings" "syscall" + "time" "github.com/Xbang0222/new-api-cli/internal/apiclient" "github.com/Xbang0222/new-api-cli/internal/auth" + "github.com/Xbang0222/new-api-cli/internal/cli/webauth" "github.com/Xbang0222/new-api-cli/internal/config" "github.com/spf13/cobra" "golang.org/x/term" @@ -22,6 +24,7 @@ type loginFlags struct { email string password string noPickKey bool + noBrowser bool } func newLoginCmd() *cobra.Command { @@ -29,12 +32,23 @@ func newLoginCmd() *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "Authenticate against a new-api endpoint", - Long: `Log in with email/username + password and store the access token in the OS -keychain (or a 0600 file fallback). Saves the endpoint to the named profile and -optionally lets you pick an existing sk-xxx API key to use as the default.`, - Example: ` # interactive + Long: `Log in via the browser (default) or with email/username + password. + +The default flow opens your browser to /cli-login, lets you +sign in with any method the server supports (password, OAuth, Passkey, +2FA), and returns an access token to the CLI via a short-lived +127.0.0.1 loopback. The token is stored in the OS keychain (or a 0600 +file fallback). + +For CI or scripts, pass --email and --password to bypass the browser. +For headless or SSH sessions, pass --no-browser to print the URL +without trying to launch a browser.`, + Example: ` # interactive (opens browser) newapi login --endpoint https://api.example.com + # headless / SSH (print URL instead of launching browser) + newapi login --endpoint https://api.example.com --no-browser + # non-interactive (CI) newapi login --endpoint https://api.example.com \ --email me@example.com --password "$NEWAPI_PASSWORD" \ @@ -45,9 +59,10 @@ optionally lets you pick an existing sk-xxx API key to use as the default.`, } cmd.Flags().StringVar(&f.endpoint, "endpoint", "", "endpoint URL (e.g. https://api.example.com)") cmd.Flags().StringVar(&f.profile, "profile", "default", "profile name to save credentials to") - cmd.Flags().StringVar(&f.email, "email", "", "email or username (non-interactive)") - cmd.Flags().StringVar(&f.password, "password", "", "password (non-interactive)") + cmd.Flags().StringVar(&f.email, "email", "", "email or username (forces non-browser flow)") + cmd.Flags().StringVar(&f.password, "password", "", "password (forces non-browser flow)") cmd.Flags().BoolVar(&f.noPickKey, "no-pick-key", false, "skip API key selection step") + cmd.Flags().BoolVar(&f.noBrowser, "no-browser", false, "print the login URL instead of opening the browser") return cmd } @@ -76,33 +91,20 @@ func runLogin(ctx context.Context, f loginFlags) error { return err } - email := f.email - if email == "" { - if !canPrompt() { - return fmt.Errorf("--email is required when running non-interactively") - } - fmt.Print("Email/username: ") - email = readLine(rd) - } - password := f.password - if password == "" { - if !canPrompt() { - return fmt.Errorf("--password is required when running non-interactively") - } - fmt.Print("Password: ") - pw, err := term.ReadPassword(int(syscall.Stdin)) - fmt.Println() - if err != nil { - return err - } - password = string(pw) - } + var userID, accessToken string - client := apiclient.New(endpoint, nil) - userID, accessToken, err := client.Login(ctx, email, password) + if f.email != "" || f.password != "" { + userID, accessToken, err = passwordLogin(ctx, endpoint, f, rd) + } else if !canPrompt() { + return fmt.Errorf("login requires either a TTY (for the browser flow) or --email/--password for non-interactive use") + } else { + userID, accessToken, err = browserLogin(ctx, endpoint, f) + } if err != nil { return fmt.Errorf("login failed: %w", err) } + + client := apiclient.New(endpoint, nil) client.SetAccessToken(accessToken) client.SetUserID(userID) @@ -146,6 +148,72 @@ func runLogin(ctx context.Context, f loginFlags) error { return pickAPIKey(ctx, cfg, client, store, f.profile) } +func passwordLogin(ctx context.Context, endpoint string, f loginFlags, rd *bufio.Reader) (string, string, error) { + email := f.email + if email == "" { + if !canPrompt() { + return "", "", fmt.Errorf("--email is required when running non-interactively") + } + fmt.Print("Email/username: ") + email = readLine(rd) + } + password := f.password + if password == "" { + if !canPrompt() { + return "", "", fmt.Errorf("--password is required when running non-interactively") + } + fmt.Print("Password: ") + pw, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return "", "", err + } + password = string(pw) + } + client := apiclient.New(endpoint, nil) + return client.Login(ctx, email, password) +} + +func browserLogin(ctx context.Context, endpoint string, f loginFlags) (string, string, error) { + // 5 min matches the cli-login page's polling cap; keep the two in sync. + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + state, err := webauth.Generate() + if err != nil { + return "", "", err + } + + resultCh := make(chan webauth.Result, 1) + go func() { + r, err := webauth.RunWithHook(ctx, endpoint, state, func(loopbackURL string) { + port := strings.TrimPrefix(loopbackURL, "http://127.0.0.1:") + // endpoint is already normalised by runLogin -> normalizeEndpoint. + loginURL := fmt.Sprintf("%s/cli-login?port=%s&state=%s", endpoint, port, state) + if f.noBrowser { + fmt.Fprintln(os.Stderr, "Open this URL to sign in:") + } else { + fmt.Fprintln(os.Stderr, "Opening your browser to:") + } + fmt.Fprintln(os.Stderr, " "+loginURL) + fmt.Fprintln(os.Stderr, "If the browser does not open, paste this URL into one.") + if !f.noBrowser { + _ = webauth.Open(loginURL) + } + }) + if err != nil && r.Err == nil { + r.Err = err + } + resultCh <- r + }() + + r := <-resultCh + if r.Err != nil { + return "", "", r.Err + } + return r.UserID, r.AccessToken, nil +} + func readLine(rd *bufio.Reader) string { s, _ := rd.ReadString('\n') return strings.TrimSpace(s) diff --git a/internal/cli/webauth/browser.go b/internal/cli/webauth/browser.go new file mode 100644 index 0000000..b9d65d2 --- /dev/null +++ b/internal/cli/webauth/browser.go @@ -0,0 +1,27 @@ +package webauth + +import ( + "os/exec" + "runtime" +) + +// Open tries to launch the user's default browser pointed at url. The +// caller is expected to have already printed url to stderr before +// calling Open, so failures here (missing launcher, exec error, +// unsupported GOOS) are not fatal — the user can always paste the URL +// manually. Returning the error lets the caller log it under -v if they +// want, but most callers should ignore it. +func Open(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + return nil + } + return cmd.Start() +} diff --git a/internal/cli/webauth/server.go b/internal/cli/webauth/server.go new file mode 100644 index 0000000..6d77f91 --- /dev/null +++ b/internal/cli/webauth/server.go @@ -0,0 +1,162 @@ +package webauth + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "strconv" + "strings" + "time" +) + +// Result is what the loopback flow produces when it completes. +type Result struct { + AccessToken string + UserID string + Err error +} + +// Run starts a loopback HTTP server on 127.0.0.1: and waits for +// the browser to POST {access_token, user_id, state} to /callback. It +// validates the Origin header against endpoint, validates state against +// a freshly-generated nonce, and returns the credentials. Cancels +// cleanly on ctx.Done. +// +// The caller is responsible for opening the browser to +// +// endpoint + "/cli-login?port=&state=" +// +// — Run does not print anything; printing is the caller's job via +// RunWithHook. +func Run(ctx context.Context, endpoint string) (Result, error) { + state, err := Generate() + if err != nil { + return Result{}, fmt.Errorf("generate state: %w", err) + } + return RunWithHook(ctx, endpoint, state, nil) +} + +// RunWithHook is Run's variant that accepts a pre-generated state and +// an optional listener-ready hook so the caller can print the full +// browser-bound URL with the correct port. The hook is called exactly +// once, synchronously, in the goroutine that called RunWithHook. +func RunWithHook(ctx context.Context, endpoint, state string, onListen func(loopbackURL string)) (Result, error) { + // Browsers send Origin without a trailing slash (e.g. "https://ruoli.dev"). + // Configured endpoints in the wild sometimes carry one; trim so the exact + // comparison in the handler still matches. + endpoint = strings.TrimRight(endpoint, "/") + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return Result{}, fmt.Errorf("listen: %w", err) + } + port := ln.Addr().(*net.TCPAddr).Port + loopbackURL := "http://127.0.0.1:" + strconv.Itoa(port) + + resultCh := make(chan Result, 1) + mux := http.NewServeMux() + mux.HandleFunc("/callback", makeCallbackHandler(endpoint, state, resultCh)) + + server := &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + go func() { _ = server.Serve(ln) }() + + if onListen != nil { + onListen(loopbackURL) + } + + select { + case r := <-resultCh: + shutdown, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + _ = server.Shutdown(shutdown) + return r, r.Err + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + _ = server.Shutdown(shutdown) + return Result{Err: ctx.Err()}, ctx.Err() + } +} + +func makeCallbackHandler(endpoint, state string, out chan<- Result) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // CORS preflight from the browser running on `endpoint`. + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Origin", endpoint) + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + // Origin must match endpoint exactly (defends against DNS-rebinding + // and other origins POSTing here speculatively). + if origin := r.Header.Get("Origin"); origin != endpoint { + w.Header().Set("Access-Control-Allow-Origin", endpoint) + http.Error(w, "bad origin", http.StatusForbidden) + return + } + + // Set CORS headers on the success path too, before any body write. + w.Header().Set("Access-Control-Allow-Origin", endpoint) + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + var body struct { + AccessToken string `json:"access_token"` + UserID any `json:"user_id"` // can arrive as number or string + State string `json:"state"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad body", http.StatusBadRequest) + return + } + if !Equal(body.State, state) { + http.Error(w, "state mismatch", http.StatusForbidden) + return + } + if body.AccessToken == "" { + http.Error(w, "missing access_token", http.StatusBadRequest) + return + } + + // Normalise user_id to string regardless of inbound JSON type. + var userIDStr string + switch v := body.UserID.(type) { + case string: + userIDStr = v + case float64: + userIDStr = strconv.FormatInt(int64(v), 10) + case nil: + userIDStr = "" + default: + // Bool / object / array would otherwise serialise as noise + // like "map[...]" via %v and leak into config/keychain refs. + userIDStr = "" + } + + // Acknowledge to the browser before signalling the CLI. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + + // Deliver to the CLI loop. Non-blocking: only the first success wins; + // late duplicate POSTs (e.g. StrictMode double-render in dev) silently + // drop. + select { + case out <- Result{AccessToken: body.AccessToken, UserID: userIDStr}: + default: + } + } +} diff --git a/internal/cli/webauth/server_test.go b/internal/cli/webauth/server_test.go new file mode 100644 index 0000000..475449f --- /dev/null +++ b/internal/cli/webauth/server_test.go @@ -0,0 +1,180 @@ +package webauth + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "testing" + "time" +) + +// startTestServer boots RunWithHook in a goroutine and returns the loopback +// callback URL the test should POST to. endpoint controls the Origin the +// server expects from the (simulated) browser. +func startTestServer(t *testing.T, ctx context.Context, endpoint, state string) (callbackURL string, resultCh <-chan Result) { + t.Helper() + urlCh := make(chan string, 1) + out := make(chan Result, 1) + go func() { + r, err := RunWithHook(ctx, endpoint, state, func(loopbackURL string) { + urlCh <- loopbackURL + }) + if err != nil && r.Err == nil { + r.Err = err + } + out <- r + }() + select { + case u := <-urlCh: + return u + "/callback", out + case <-time.After(2 * time.Second): + t.Fatalf("server never reported its listener URL") + return "", out + } +} + +func TestCallback_HappyPath(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + endpoint := "https://example.com" + state, _ := Generate() + cbURL, resultCh := startTestServer(t, ctx, endpoint, state) + + body, _ := json.Marshal(map[string]any{ + "access_token": "sk-test-123", + "user_id": 42, + "state": state, + }) + req, _ := http.NewRequest("POST", cbURL, bytes.NewReader(body)) + req.Header.Set("Origin", endpoint) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST callback: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + select { + case r := <-resultCh: + if r.Err != nil { + t.Fatalf("unexpected error: %v", r.Err) + } + if r.AccessToken != "sk-test-123" { + t.Errorf("token: %q", r.AccessToken) + } + if r.UserID != "42" { + t.Errorf("user_id: %q", r.UserID) + } + case <-time.After(2 * time.Second): + t.Fatal("result channel timed out") + } +} + +func TestCallback_StateMismatch(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + endpoint := "https://example.com" + state, _ := Generate() + cbURL, resultCh := startTestServer(t, ctx, endpoint, state) + + body, _ := json.Marshal(map[string]any{ + "access_token": "sk-test-123", + "user_id": 42, + "state": "wrong-state", + }) + req, _ := http.NewRequest("POST", cbURL, bytes.NewReader(body)) + req.Header.Set("Origin", endpoint) + req.Header.Set("Content-Type", "application/json") + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != 403 { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + // Server should keep running; result channel must not receive a token. + select { + case r := <-resultCh: + if r.AccessToken != "" { + t.Fatalf("token leaked despite state mismatch: %q", r.AccessToken) + } + case <-time.After(1500 * time.Millisecond): + // OK — ctx will cancel the server later. + } +} + +func TestCallback_OriginMismatch(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + endpoint := "https://example.com" + state, _ := Generate() + cbURL, _ := startTestServer(t, ctx, endpoint, state) + + body, _ := json.Marshal(map[string]any{ + "access_token": "sk-test-123", + "user_id": 42, + "state": state, + }) + req, _ := http.NewRequest("POST", cbURL, bytes.NewReader(body)) + req.Header.Set("Origin", "https://evil.example.com") + req.Header.Set("Content-Type", "application/json") + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != 403 { + t.Fatalf("expected 403 on bad origin, got %d", resp.StatusCode) + } +} + +func TestCallback_EndpointTrailingSlash(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + // Configured endpoint carries a trailing slash; browser's Origin header + // does not. Server must normalise and still accept the callback. + endpoint := "https://example.com/" + browserOrigin := "https://example.com" + state, _ := Generate() + cbURL, resultCh := startTestServer(t, ctx, endpoint, state) + + body, _ := json.Marshal(map[string]any{ + "access_token": "sk-trail-1", + "user_id": 7, + "state": state, + }) + req, _ := http.NewRequest("POST", cbURL, bytes.NewReader(body)) + req.Header.Set("Origin", browserOrigin) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST callback: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("expected 200 after slash normalisation, got %d", resp.StatusCode) + } + select { + case r := <-resultCh: + if r.AccessToken != "sk-trail-1" { + t.Errorf("token: %q", r.AccessToken) + } + case <-time.After(2 * time.Second): + t.Fatal("result channel timed out") + } +} + +func TestRun_ContextTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + done := make(chan struct{}) + go func() { + _, _ = RunWithHook(ctx, "https://example.com", "stateXYZ", nil) + close(done) + }() + select { + case <-done: + // OK — RunWithHook returned cleanly when ctx expired. + case <-time.After(2 * time.Second): + t.Fatal("RunWithHook did not return after context cancel") + } +} diff --git a/internal/cli/webauth/state.go b/internal/cli/webauth/state.go new file mode 100644 index 0000000..680ea32 --- /dev/null +++ b/internal/cli/webauth/state.go @@ -0,0 +1,29 @@ +package webauth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" +) + +// Generate returns a 32-byte cryptographically random nonce encoded as +// URL-safe base64 (no padding). The CLI embeds this in the /cli-login +// URL it opens, and the loopback callback validates that the value +// echoed back through the browser matches what was generated. +func Generate() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// Equal compares two state values in constant time. Use this instead of +// `==` so that timing differences cannot leak information about the +// expected value. +func Equal(a, b string) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} diff --git a/internal/cli/webauth/state_test.go b/internal/cli/webauth/state_test.go new file mode 100644 index 0000000..afbc90c --- /dev/null +++ b/internal/cli/webauth/state_test.go @@ -0,0 +1,45 @@ +package webauth + +import "testing" + +func TestGenerate_Length(t *testing.T) { + s, err := Generate() + if err != nil { + t.Fatalf("Generate returned err: %v", err) + } + // base64url(32 bytes) = 43 chars (no padding). + if len(s) != 43 { + t.Fatalf("expected len 43, got %d (%q)", len(s), s) + } +} + +func TestGenerate_Uniqueness(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 100; i++ { + s, err := Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + if seen[s] { + t.Fatalf("collision after %d iterations: %s", i, s) + } + seen[s] = true + } +} + +func TestEqual(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"abc", "abc", true}, + {"abc", "abd", false}, + {"abc", "ab", false}, + {"", "", true}, + } + for _, c := range cases { + if got := Equal(c.a, c.b); got != c.want { + t.Errorf("Equal(%q,%q)=%v want %v", c.a, c.b, got, c.want) + } + } +}