@adep/auth-client
v0.1.1
Published
OIDC client SDK for AgentDeploy platform (exported apps and third-party apps)
Readme
@adep/auth-client — OIDC 接入 SDK
授权码 + PKCE 的客户端 SDK(任务单 AUTH-003),给导出子应用与第三方应用共用, 对应 PRD §2.16「登录即服务」的接入面:发现缓存、授权码 + PKCE、token 刷新与轮换、登出。 纯 HTTP 实现,框架无关(浏览器 / Vue / React / Node 服务端都能消费)。
⚠️ 现在还不能拿它接任何 adep 实例:平台侧的 OIDC 端点尚未注册成路由(见「平台侧现状」)。 本包也没有测试、没有仓内消费者、未发布到 npm。要接入请先读那一节。
安装
尚未发布到 npm,目前只能在 AgentDeploy 仓库内以 workspace 协议引用:
{ "dependencies": { "@adep/auth-client": "workspace:*" } }发布准备缺 publishConfig 与 files(缺哪几件见 docs/npm-publishing.md §1 与 §9)。
能力总览
- 发现文档拉取与缓存:
GET ${issuer}/.well-known/openid-configuration,实例内缓存,TTL 默认 5 分钟;clearDiscoveryCache()可手动失效。 - 授权码 + PKCE:
code_verifier取 64 字节随机数的十六进制(128 字符),code_challenge走 S256;state取 32 字节(64 字符)并在换 token 前强制比对,不等即抛(防 CSRF)。 - token 交换与刷新:
clientSecret给了就走client_secret_post(密钥进表单体,不走 Basic); 刷新遇invalid_grant→ 清空会话并抛错(配合服务端的 refresh token 一次性轮换语义)。 - userinfo:带
Bearer <access_token>拉取并回填到会话。 - id_token 基础校验:
iss/aud/exp/iat四项,不验签(见「已知风险」)。 - 登出:清本地会话;仅当发现文档给了
end_session_endpoint才返回 RP-Initiated Logout 跳转 URL。 - 可注入
httpClient:get/post两方法的接口,便于测试替换与自定义 fetch。
用法
import { createAuthClient } from '@adep/auth-client'
const auth = createAuthClient({
issuer: 'https://platform.example.com',
clientId: 'app_xxx',
redirectUri: 'https://myapp.example.com/callback',
postLogoutRedirectUri: 'https://myapp.example.com/',
// clientSecret 只在 Node 服务端构造时传;浏览器包里传等于公开
})
// 1) 跳登录:state / codeVerifier 要活到回调,本包不负责持久化
const { url, state, codeVerifier } = await auth.buildAuthorizeUrl()
sessionStorage.setItem('oidc', JSON.stringify({ state, codeVerifier }))
location.assign(url)
// 2) 回调:参数顺序是 (code, codeVerifier, 期望的 state, 实际回跳带的 state)
const { code, state: returned } = parseQuery(location.search) // parseQuery 由接入方自写,本包不导出
const saved = JSON.parse(sessionStorage.getItem('oidc')!)
const session = await auth.handleCallback(code, saved.codeVerifier, saved.state, returned)
console.log(session.accessToken)
// 3) 之后
const profile = await auth.fetchUserInfo()
const token = await auth.getValidAccessToken() // 过期则自动刷新
await auth.logout() // 返回 RP-Initiated Logout URL;发现文档没给该端点时返回 nullAPI
| 成员 | 行为 | 抛错 / 返回 |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| getDiscovery() | 拉发现文档,TTL 内命中缓存 | 非 200 → Failed to fetch discovery document: HTTP <n> |
| getJwks() | 按发现文档的 jwks_uri 取 JWKS | 非 200 → Failed to fetch JWKS: HTTP <n> |
| buildAuthorizeUrl(params?) | 拼授权 URL(response_type=code、code_challenge_method=S256),返回 {url, state, codeVerifier} | params.extraParams 最后合并,可覆盖同名标准参数 |
| handleCallback(code, codeVerifier, expectedState, actualState) | 校验 state → 换 token → 建会话,有 id_token 时从中解出 userInfo | state 不等 → State mismatch: possible CSRF attack;非 200 → Token exchange failed: <error> - <description> |
| refreshToken() | 用会话里的 refresh token 换新会话(新 refresh 覆盖旧的) | 无 refresh token → No refresh token available;invalid_grant → 清会话并抛 Refresh token invalid or already used (rotation semantics) |
| getSession() / isAccessTokenExpired() | 读内存会话 | 未登录分别是 null 与 true |
| getValidAccessToken() | 过期则先刷新再返回 | 无会话 → No active session |
| fetchUserInfo() | GET userinfo_endpoint,回填 session.userInfo | 非 200 → Failed to fetch userinfo: HTTP <n> |
| verifyIdToken(idToken) | 只校 iss/aud/exp/iat | 返回 {valid, payload, errors},不解签名 |
| logout(options?) | 清会话;performRpInitiatedLogout 默认 true | 有 end_session_endpoint → 返回跳转 URL,否则 null |
| clearDiscoveryCache() | 失效发现文档缓存 | — |
配置项:issuer、clientId、clientSecret?、redirectUri、postLogoutRedirectUri?、
scopes?(默认 openid profile email)、discoveryCacheTtl?(默认 300000 ms)、httpClient?。
运行环境
需要 fetch、crypto.getRandomValues、crypto.subtle.digest、TextEncoder、btoa / atob。
- Node ≥ 19、Bun、浏览器安全上下文(HTTPS 或
localhost)都满足; 浏览器里非安全上下文没有crypto.subtle,buildAuthorizeUrl()会当场抛——这类"本地 http://192.168.x.x 调试登录"的坑与 SDK 无关,是运行环境限制。 - 类型面是
lib: ES2022 + DOM(tsconfig.json里写明),不依赖任何 Bun 专有 API。
平台侧现状(读这一节再决定要不要接)
本包按发现文档去请求各端点,而这些端点在平台代码里的实际状态:
| 本包会请求的 | 平台侧实况 |
| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| /.well-known/openid-configuration | 构造逻辑在 server/domains/authsvc/discovery.ts(含 handleDiscoveryRequest),未注册为路由:server/api-app.ts 对 authsvc 零引用,且 server/domains/authsvc/ 没有 index.ts,插件 loader 扫不到它 |
| /oauth/jwks | 同上,server/domains/authsvc/jwks.ts 有双钥轮转与 handleJwksRequest,调用方目前只有它自己的单测 |
| /oauth/authorize、/oauth/token、/oauth/userinfo、/oauth/revoke | 只有 discovery.ts 拼出来的字符串,没有实现;同意页(consent)在 server/ 下也搜不到 |
| end_session_endpoint | 发现文档不输出该字段 → logout() 恒返回 null(本地清理仍生效) |
结论:拿本包连任何现有实例,第一步 getDiscovery() 就会 404。
接入应用注册面(server/domains/authsvc/clients/registry.ts:密钥哈希、回调白名单精确匹配、一次性明文密钥、
出境开关)只有服务类——它的存储端口 ClientRegistryStore 在仓内没有任何实现,控制台 UI 也不存在。
同批(completed 2026-09-03)三张任务单都标 status: done,实况分三条,每条都可现场复核:
- AUTH-001(OIDC 端点组,
touches含server/api-app.ts/server/wiring.ts):discovery.ts与jwks.ts两个模块在,但没有任何一处把它们注册成路由——server/api-app.ts对authsvc零引用, 且server/domains/authsvc/没有index.ts(17 个域目录里唯一一个),插件装载器扫不到它。 - AUTH-002(接入应用注册表,
touches含server/db/schema/与两侧drizzle/):两份 schema 与全部 迁移里都搜不到接入应用表,ClientRegistryStore无实现,该文件也没有单测(0% 覆盖,即基线那 3 件红之一)。 - AUTH-003(本包 + 导出子应用闭环,
touches含e2e/authsvc/):SDK 在,但e2e/authsvc/目录不存在、 导出模板里没有AUTH_*登录接入段(server/domains/appexport/templates/只有一个template-renderer.ts)。
这属 AGENTS.md §8 第 1 条(验收标准与现有行为冲突),需人类定:补接线单,还是回填三单的实况。
已知风险
verifyIdToken不验签:它只解码 payload 校四项声明。getJwks()取了 JWKS 但没有接进验签路径, 所以"验过了"不等于"签名可信"。需要验签的接入方要么自行用 JWKS 验(本包不内置加密库), 要么只把id_token当非安全提示——真正拿 userinfo 端点换资料才是可信路径。- 会话只存内存:不落
localStorage,刷新页面即丢(上面的sessionStorage那段是自己写的)。 - 不排队并发刷新:多个
getValidAccessToken()同时撞上过期会各发一次刷新, 配合服务端的一次性轮换,第二个请求会拿到invalid_grant并把会话清空。多标签页场景请自行加互斥。 - 服务端不返
expires_in时会话永不过期:expiresAt = Date.now() + expires_in * 1000, 缺字段算出NaN,isAccessTokenExpired()对NaN返回false→ 不会触发刷新。 - 只实现授权码 + 刷新两种 grant:没有
client_credentials、没有主动撤销(revoke)调用、没有动态注册。
脚本
pnpm --filter @adep/auth-client run typecheck:只编本包src/**(2026-09-05 起有包级tsconfig.json; 在那之前tsc向上命中仓库根配置——根配置noEmit: true且范围是全仓,脚本退出 0 但什么都没校验)。pnpm --filter @adep/auth-client run build:产dist/*.js+dist/*.d.ts。 当前不参与发布链路:仓库的发包构建走scripts/build-package.ts,本包不在其支持清单内。- 本包没有测试。要补的话,优先补「发现文档缓存 TTL → 过期重取」「state 不等即抛」
「
invalid_grant清会话」三条——它们都用httpClient注入假响应即可,不需要真 IdP。
