一套 API 客户端,两条鉴权通道

Web 用 HTTP-Only Cookie,CLI 用 Bearer Token。后端在中间件里双通道检查,前端把取凭证的方法注入进核心库——feature 代码因此完全不需要知道自己跑在哪个端。

Posted by Jessie Jia on 2026-09-24

接着上一篇讲的多端结构:三个外壳共享一套逻辑包。那鉴权怎么办?

Web 端的标准做法是 HTTP-Only Cookie——浏览器自动带上,JS 读不到,防 XSS。CLI 没有浏览器,也没有 Cookie jar,只能用 Bearer Token。

两种凭证形态完全不同,但不该因此写两套 API。

后端:一个中间件,两条通道

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export async function getAuthenticatedUser(req: Request) {
// 通道 1:CLI 的 Bearer Token
const authHeader = req.headers.get('Authorization')
if (authHeader?.startsWith('Bearer ')) {
const user = await verifyToken(authHeader.substring(7))
if (user) return user
}

// 通道 2:Web 的 HTTP-Only Cookie
const sessionToken = parseCookie(req.headers.get('cookie'), 'session_token')
if (sessionToken) {
const user = await verifySession(sessionToken)
if (user) return user
}

throw new Error('Unauthorized')
}

任一通道通过就把解析出的 user 挂到请求上下文里。下游的业务代码只看 userId,不关心它是从哪条通道来的。

顺序有讲究:先查 Bearer 再查 Cookie。CLI 显式带了 token,那就是它的意图;反过来的话,某些同时带着 Cookie 的场景(比如从浏览器里调试 CLI 的请求)会静默走错身份。

核心库:把取凭证的方法注入进来

难点在客户端。packages/core 里只有一个 API 客户端,Web 和 CLI 都用它,但拿凭证的方式不一样。

不要在库里判断环境,让外壳把方法注入进来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type AuthHeaderProvider = () => string | null | Promise<string | null>

class ApiClient {
private getAuthHeader: AuthHeaderProvider = () => null

public configureAuth(provider: AuthHeaderProvider) {
this.getAuthHeader = provider
}

public async request(endpoint: string, options: RequestInit = {}) {
const headers = new Headers(options.headers)

const tokenHeader = await this.getAuthHeader()
if (tokenHeader) headers.set('Authorization', tokenHeader)

// 浏览器里不用手动处理 Cookie,声明 include 就会自动带上
if (!isServer()) options.credentials = 'include'

return fetch(`${BASE_URL}${endpoint}`, { ...options, headers })
}
}

export const api = new ApiClient()

各个外壳在启动时配一次:

  • CLI:api.configureAuth(() => 'Bearer ' + readTokenFromConfig())
  • Web:什么都不用配,默认 provider 返回 null,Cookie 由浏览器自动带
  • Electron:看它访问的是云端还是本地服务,注入对应的方式

provider 返回的是 Promise 也行,这一点很重要——token 过期时可以在这里静默刷新,调用方无感。

feature 里的代码保持干净

1
2
3
4
5
6
import { api } from '@my-project/core'

export async function fetchChatHistory(id: string) {
const res = await api.request(`/api/chats/${id}`)
return res.json()
}

这段代码在 Web 里跑会自动带 Cookie,在 CLI 里跑会自动带 Bearer。它不需要知道区别,也不应该知道。

这和上一篇里的 FileSaver 是同一个套路:差异在外壳注入,共享包只认接口。

flowchart TD
    subgraph shells["外壳:各自配置凭证"]
        W["Web
不配置,浏览器带 Cookie"] C["CLI
configureAuth 返回 Bearer"] end W --> API["core: ApiClient
统一 request()"] C --> API API --> MW["服务端中间件
先查 Bearer,再查 Cookie"] MW --> U["userId 挂到请求上下文"]

CLI 的 token 从哪来

上面假设了 readTokenFromConfig() 能读到东西,但那个 token 一开始怎么进去的,是这套方案里最容易被略过的一环。

别让用户去网页后台复制粘贴长期 token——那种 token 通常没有过期时间,明文躺在 ~/.config 里,泄漏了也没人知道。

可行的做法是设备授权流程:CLI 起一个本地回调端口,打开浏览器让用户在已登录的 Web 端点一下”授权”,Web 端把一个短期、可吊销、带设备标识的 token 发回来。用户不接触凭证本身,服务端能看到”哪台设备在什么时候被授权了”,也能单独撤掉某一台。

两条通道各自的坑

Cookie 通道要防 CSRF。 Cookie 是浏览器自动带的,这既是它的便利也是它的风险:别的站点发起的请求同样会带上。至少要把 session cookie 设成 SameSite=Lax,涉及写操作的跨站场景还要加 CSRF token。Bearer 通道没有这个问题,因为没人会替你加那个 header。

Bearer 通道要能吊销。 如果 verifyToken 是纯 JWT 验签,那 token 签发之后在过期前是收不回来的。笔记本丢了、token 泄漏了,你只能等它自己过期。要么把 token 存库里查(牺牲一点性能),要么配一个短过期时间加刷新机制。

两条通道的权限不一定该一样。 Web 会话是人在屏幕前操作,CLI token 往往被塞进 CI 脚本里跑。给 CLI token 加 scope(比如只读、只能访问某个项目)是值得的,中间件里除了解析出 userId,把凭证来源也一起挂上,下游就能据此收紧权限。

这三条都不影响前面那个”一套客户端”的结构——它们是在这个结构之上该补的东西。