接着上一篇讲的多端结构:三个外壳共享一套逻辑包。那鉴权怎么办?
Web 端的标准做法是 HTTP-Only Cookie——浏览器自动带上,JS 读不到,防 XSS。CLI 没有浏览器,也没有 Cookie jar,只能用 Bearer Token。
两种凭证形态完全不同,但不该因此写两套 API。
后端:一个中间件,两条通道
1 | export async function getAuthenticatedUser(req: Request) { |
任一通道通过就把解析出的 user 挂到请求上下文里。下游的业务代码只看 userId,不关心它是从哪条通道来的。
顺序有讲究:先查 Bearer 再查 Cookie。CLI 显式带了 token,那就是它的意图;反过来的话,某些同时带着 Cookie 的场景(比如从浏览器里调试 CLI 的请求)会静默走错身份。
核心库:把取凭证的方法注入进来
难点在客户端。packages/core 里只有一个 API 客户端,Web 和 CLI 都用它,但拿凭证的方式不一样。
不要在库里判断环境,让外壳把方法注入进来:
1 | type AuthHeaderProvider = () => string | null | Promise<string | null> |
各个外壳在启动时配一次:
- CLI:
api.configureAuth(() => 'Bearer ' + readTokenFromConfig()) - Web:什么都不用配,默认 provider 返回
null,Cookie 由浏览器自动带 - Electron:看它访问的是云端还是本地服务,注入对应的方式
provider 返回的是 Promise 也行,这一点很重要——token 过期时可以在这里静默刷新,调用方无感。
feature 里的代码保持干净
1 | import { api } from '@my-project/core' |
这段代码在 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,把凭证来源也一起挂上,下游就能据此收紧权限。
这三条都不影响前面那个”一套客户端”的结构——它们是在这个结构之上该补的东西。