• Joined on 2026-08-25

@abds10/capability-auth (1.0.5)

Published 2026-10-10 07:51:17 +00:00 by deploy

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 统一鉴权:JWKS 本地验签、会话校验、角色/服务/设备令牌中间件

@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
Details
npm
2026-10-10 07:51:17 +00:00
26
UNLICENSED
latest
50 KiB
Assets (1)
Versions (6) View all
1.0.5 2026-10-10
1.0.4 2026-10-01
1.0.3 2026-09-10
1.0.2 2026-09-10
1.0.1 2026-09-10