@abds10/capability-auth (1.0.5)
Installation
@abds10:registry=https://git.abds10.cn/api/packages/deploy/npm/npm install @abds10/capability-auth@1.0.5"@abds10/capability-auth": "1.0.5"About this package
@abds10/capability-auth
跨项目统一鉴权。各项目不再自己实现登录/密码逻辑,只装这一个包。
规范:
docs/standards/跨项目互通标准.md第一、三章 账户中心:account.abds10.cn
它做什么
| 能力 | 说明 |
|---|---|
| 本地验签会话 | 用 JWKS 公钥验签会话 JWT,不回调账户中心(账户中心离线不影响已登录用户) |
| 授权版本校验 | 本地缓存 sub → ver(TTL 60 秒),实现"停用/改密最长 60 秒全网生效" |
| 授权与角色 | requireEntitlement / requireAppRole,角色语义由各项目自定义 |
| 服务令牌校验 | 调账户中心校验 svc_*,带 scope 与家庭绑定检查,结果短 TTL 缓存 |
| 设备令牌校验 | 调账户中心校验 dev_*,并把"能访问哪些路径"的权威留在中心 |
| Next 中间件 | withSso(manifest, config, responses) 一行接入 |
| 浏览器会话守卫 | @abds10/capability-auth/client:任何 fetch 撞 401 → 静默续期 → 原样重试,不整页刷新 |
| 服务端静默续期 | @abds10/capability-auth/server:为 SPA / 页内 API 就地换 Cookie,不打断页面 |
设计边界(重要)
- 零框架依赖:核心只用 Web 标准 API,可在 Node / Edge Runtime / Workers 运行
- 零数据库依赖:本包不连任何库。需要"实时"确认的一律走账户中心的 HTTP 接口, 各应用不需要复制令牌表
- 不读环境变量:配置显式传入,便于测试,也避免各项目变量命名不一致
安装
npm install @abds10/capability-auth @abds10/contracts
需要在自己的 .npmrc 里指向 Gitea 包仓库:
@abds10:registry=https://git.abds10.cn/api/packages/deploy/npm/
//git.abds10.cn/api/packages/deploy/npm/:_auth=<base64(deploy:令牌)>
接入(Next.js,四步)
1. 建一个单例 Auth
// src/lib/auth.ts
import { createAuth } from '@abds10/capability-auth'
export const auth = createAuth({
accountBaseUrl: process.env.ACCOUNT_BASE_URL!,
appId: 'aiwrite',
serviceToken: process.env.ACCOUNT_SERVICE_TOKEN!,
// 可选:
// versionCacheTtlMs: 60_000, // 「停用最长多久生效」的上界
// onCenterUnavailable: 'deny', // 中心不可达时:'deny' 拒绝 / 'stale' 放行
})
2. proxy.ts / middleware.ts 三行接入
Next 16 起 middleware 约定更名为 proxy:
// src/proxy.ts
import { NextResponse } from 'next/server'
import { withSso, type ResponseFactory, type SsoRequest } from '@abds10/capability-auth/next'
import manifest from '../app.manifest.json' with { type: 'json' }
/** 把 NextResponse 适配成包需要的接口(换框架时只改这一处) */
const responses: ResponseFactory = {
json: (body, init) => NextResponse.json(body, init),
text: (body, init) => new NextResponse(body, init),
redirect: (url) => NextResponse.redirect(url),
next: () => NextResponse.next(),
}
export default withSso(manifest, {
accountBaseUrl: process.env.ACCOUNT_BASE_URL!,
appId: manifest.app.id,
serviceToken: process.env.ACCOUNT_SERVICE_TOKEN!,
}, responses)
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)'],
}
buildMatcher(publicPaths)可以从 manifest 生成 matcher 字符串, 但 Next 要求 matcher 是静态字面量,所以最终仍要手写常量。
3. 在页面/接口里取当前用户
import { requireUser, requireAppRole, requireService } from '@abds10/capability-auth'
import { auth } from '@/lib/auth'
// Route Handler
export async function GET(req: Request) {
const r = await auth.sessions.fromRequest(req)
if (!r.ok) return Response.json({ error: r.message }, { status: 401 })
const user = r.value.principal // { kind: 'user', id: 'acc_...', entitlements, familyId }
// 业务权限自己判断,例如:
if (!(user.entitlements?.['aiwrite'] ?? []).includes('OWNER')) {
return Response.json({ error: '需要 OWNER' }, { status: 403 })
}
return Response.json({ accountId: user.id })
}
Server Component 里没有 Request 对象,用 cookies() 读出 Cookie 再调 auth.sessions.verifyToken(token)。
4. 被其他项目调用时校验服务令牌
import { requireService } from '@abds10/capability-auth'
import { auth } from '@/lib/auth'
export async function POST(req: Request) {
const svc = await requireService(auth, req, { scope: 'task:write' })
if (!svc.ok) return Response.json({ error: svc.message }, { status: 401 })
// svc.value.ownerApp === 'assistant'
}
浏览器侧:会话静默续期(./client 子路径)
规范:abds10-workspace 的
docs/standards/登录接入与数据安全规范.md第 0 节 R3/R4。 一句铁律:任何用户操作都不能因为掉登录而丢数据。
/api/* 会话过期只返回 401 JSON、从不 302(标准 §1.3.5),所以这层必须由前端兜:
拿到 401 → 用一次隐藏 iframe 页面导航让 withSso 换新会话 → 原样重试该请求。
隐藏 iframe 不清空页面内存(作答/表单/编辑器),这是与"整页刷新"的关键区别。
// app/client/SessionGuard.tsx
'use client'
import { useEffect } from 'react'
import { installSessionGuard } from '@abds10/capability-auth/client'
if (typeof window !== 'undefined') installSessionGuard() // 尽早安装
export default function SessionGuard() {
useEffect(() => { installSessionGuard() }, []) // 幂等兜底
return null
}
// app/session/renew/page.tsx —— 受保护的空落点页(不要放进 auth.publicPaths)
export const dynamic = 'force-dynamic'
export default function SessionRenewPage() { return null }
把 <SessionGuard /> 挂进根 layout.tsx,全站所有 fetch 自动获得保护;
需要统一信封解析的调用改用 apiFetch<T>()(成功取 data,401 抛 SessionExpiredError)。
| 导出 | 作用 |
|---|---|
installSessionGuard(opts?) |
包装 window.fetch:401 → 静默续期 → 原样重试(幂等,全站一次) |
renewSessionSilently() |
隐藏 iframe 触发一次导航续期;并发复用同一次(单飞) |
renewSessionByNavigation() |
最后手段:整页导航去续期/登录(会丢内存,30 秒闸门防循环) |
clearSessionRenewalGuard() |
任一次 API 成功后拆掉闸门 |
apiFetch<T>(input, init?) |
统一信封调用;401 抛 SessionExpiredError |
SessionExpiredError |
会话彻底没救时抛出,调用方只提示、不清数据 |
可选项:renewPath(默认 /session/renew)、timeoutMs(15000)、guardKey、guardMs(30000)。
配套要求:超过 5 分钟的输入流程(长表单、编辑器、整卷作答)必须增量落库 + 断点续做 (规范的 R5)—— 前端内存只是缓存,服务端草稿才是唯一可信来源。
服务端静默续期(./server 子路径)
适用 hash 路由 SPA / 页内 API 密集 / 不想让页面发生任何跳转的场景 —— 平台默认的"导航 302 续期"在这些形态下永远不会触发。
import { createSilentRenewer, mergeCookies } from '@abds10/capability-auth/server'
const renewer = createSilentRenewer() // 默认读 process.env.ACCOUNT_BASE_URL
// 会话失效、准备返回 401 之前:
const r = await renewer.renew(req.headers.cookie ?? '')
if (r.ok) {
res.setHeader('Set-Cookie', r.cookies ?? []) // 新 Cookie 转发给浏览器
req.headers.cookie = mergeCookies(req.headers.cookie, r.cookies) // 就地更新,继续做完当前请求
}
它拿 sso_refresh 去中心 GET /api/auth/refresh,故意不带 redirect_uri,
让中心走 JSON 分支并在同一响应里 Set-Cookie。sso_refresh 始终停在 HttpOnly Cookie 与服务端之间,
前端拿不到;重放/并发由中心的轮换宽限窗口兜底。
| 导出 | 作用 |
|---|---|
createSilentRenewer(opts?) |
返回 { configured, renew(cookieHeader) };失败以 reason 表达,不抛异常 |
mergeCookies(cookieHeader, setCookies) |
把 Set-Cookie 的 name=value 合并进请求头(同名覆盖) |
SilentRenewResult / SilentRenewer / SilentRenewerOptions |
类型定义 |
失败原因:NOT_CONFIGURED(未配中心/无 fetch)、CENTER_UNAVAILABLE(网络/超时)、REFRESH_FAILED(中心拒绝或没下发新会话)。
关键行为与参数
| 参数 | 默认 | 含义 |
|---|---|---|
versionCacheTtlMs |
60000 | 授权版本的本地缓存时长。这就是"停用/改密后最长多久全网生效" |
jwksCacheTtlMs |
6 小时 | 公钥文档多久确认一次。已取到的密钥单独保留 24 小时,中心抖动时不误判 |
tokenCacheTtlMs |
30000 | 服务/设备令牌校验结果的缓存时长。设 0 = 每次回源 |
onCenterUnavailable |
deny |
中心不可达且本地无缓存时:deny 拒绝 / stale 放行 |
可用性设计:本地验签不依赖账户中心;版本校验优先用缓存。 因此账户中心短暂不可达时,已登录用户不受影响——只有"缓存刚好过期又要回源"的少数请求会受影响。
区分两类失败(重要):
- 拿不到 JWKS(中心不可达)→
CENTER_UNAVAILABLE - 拿到了 JWKS 但没有这把 kid(可能是伪造)→
INVALID_TOKEN
开发
pnpm install
pnpm --filter @abds10/capability-auth run build
pnpm --filter @abds10/capability-auth test # 48 项断言(auth 37 + client 7 + server 4)
测试不 mock 内部模块:用 jose 生成真的 Ed25519 密钥、签真的 JWT,
并用一个内存里的假账户中心提供 /jwks.json 等端点,因此覆盖的是真实代码路径。
Dependencies
Dependencies
| ID | Version |
|---|---|
| @abds10/contracts | ^1.0.0 |
| jose | ^5.9.6 |
Development Dependencies
| ID | Version |
|---|---|
| @types/node | ^22 |
Peer Dependencies
| ID | Version |
|---|---|
| next | >=15 |
鄂公网安备42010302002985号