@vdream/sso-client-auth
v1.0.3
Published
Fastify 插件 —— SSO OAuth2 + PKCE 认证路由(callback / session / refresh / logout / pkce)
Maintainers
Readme
@vdream/sso-client-auth
Fastify 插件 —— SSO OAuth2 + PKCE 认证路由一键集成
将 SSO 认证的五条核心路由(callback / session / refresh / logout / pkce)封装为标准 Fastify 插件,业务系统接入 SSO 时只需注册一个插件,无需重复编写认证逻辑。
目录
特性
- ✅ 零重复代码:五条认证路由开箱即用
- ✅ HttpOnly Cookie:AT / RT 均由服务端管理,前端不持有 Token
- ✅ PKCE 支持:整页跳转场景下 code_verifier 存入 Cookie,不丢失
- ✅ 两段式登出:先撤销业务侧 RT,再引导浏览器清理 SSO 域 Cookie
- ✅ 灵活配置:支持 options 对象传参 + 环境变量两种方式
- ✅ 内置认证钩子:
requireAuth/requireAdmin直接用于 preHandler - ✅ 零侵入:使用
fastify-plugin封装,装饰器和路由均在正确作用域
前提依赖
| 依赖 | 版本要求 | 说明 |
|------|---------|------|
| fastify | >=4.0.0 | peer dependency |
| @fastify/cookie | >=9.0.0 | 必须在本插件之前注册 |
| Node.js | >=18.0.0 | 使用内置 fetch(via undici) |
安装
npm install @vdream/sso-client-auth
# 同时安装必要的前置插件
npm install @fastify/cookie快速开始
// server.js
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import ssoClientAuth from '@vdream/sso-client-auth'
const app = Fastify({ logger: true })
// 1. 必须先注册 @fastify/cookie
await app.register(cookie)
// 2. 注册 SSO 认证插件
await app.register(ssoClientAuth, {
ssoBaseUrl: 'https://sso.example.com',
clientId: 'my-app',
redirectUri: 'https://my-app.example.com/api/auth/callback',
frontendUrl: 'https://my-app.example.com'
})
// 3. 使用内置认证钩子保护业务路由
app.get('/api/profile', {
preHandler: app.ssoAuth.requireAuth
}, async (request) => {
return { user: request.user }
})
await app.listen({ port: 3000 })也可以通过 环境变量 完成配置,无需在代码中写入敏感信息:
SSO_TOKEN_URL=https://sso.example.com SSO_CLIENT_ID=my-app OAUTH_REDIRECT_URI=https://my-app.example.com/api/auth/callback FRONTEND_URL=https://my-app.example.com// 环境变量模式:options 可以不传或只传部分覆盖项 await app.register(ssoClientAuth)
配置项说明
SSO 服务地址
| 配置项 | 环境变量 | 默认值 | 说明 |
|--------|---------|--------|------|
| ssoBaseUrl | SSO_TOKEN_URL / SSO_ISSUER | http://localhost:3001 | SSO 服务根地址,用于拼接 /oauth/token、/oauth/revoke 等端点 |
| ssoLogoutUrl | SSO_LOGOUT_URL | 同 ssoBaseUrl | SSO 登出端点根地址(如与 token 地址不同时单独配置) |
| jwksUri | SSO_JWKS_URI / SSO_JWKS_URL | {ssoBaseUrl}/.well-known/jwks.json | JWKS 公钥获取地址,留空时自动拼接 |
| issuer | SSO_ISSUER | 同 ssoBaseUrl | JWT iss 声明,验证时必须匹配 |
示例:
await app.register(ssoClientAuth, {
ssoBaseUrl: 'https://sso.example.com',
ssoLogoutUrl: 'https://sso.example.com', // 可省略(与 ssoBaseUrl 一致)
jwksUri: 'https://sso.example.com/.well-known/jwks.json',
issuer: 'https://sso.example.com'
})OAuth2 客户端信息
| 配置项 | 环境变量 | 默认值 | 说明 |
|--------|---------|--------|------|
| clientId | SSO_CLIENT_ID / OAUTH_CLIENT_ID | ''(必填) | OAuth2 client_id |
| clientSecret | SSO_CLIENT_SECRET / OAUTH_CLIENT_SECRET | '' | client_secret(公共客户端可留空) |
| redirectUri | OAUTH_REDIRECT_URI | http://localhost:3000/api/auth/callback | OAuth2 授权回调地址(必须与 SSO 白名单一致) |
示例:
await app.register(ssoClientAuth, {
clientId: 'component-app',
clientSecret: '', // PKCE 公共客户端无需 secret
redirectUri: 'https://component.example.com/api/auth/callback'
})前端地址
| 配置项 | 环境变量 | 默认值 | 说明 |
|--------|---------|--------|------|
| frontendUrl | FRONTEND_URL / CORS_ORIGIN | http://localhost:5173 | 前端根地址,/callback 成功/失败后均重定向至此 |
路由前缀
| 配置项 | 环境变量 | 默认值 | 说明 |
|--------|---------|--------|------|
| routePrefix | — | /auth | 认证路由的公共前缀 |
注意: 如果注册时已通过
{ prefix: '/api/auth' }指定前缀,可将routePrefix设为''避免双重前缀:// 方案A:让插件自己管理前缀(推荐) await app.register(ssoClientAuth, { routePrefix: '/api/auth' }) // 最终路由:/api/auth/callback、/api/auth/session ... // 方案B:外部 register prefix + 插件置空 await app.register( async (scope) => { await scope.register(ssoClientAuth, { routePrefix: '' }) }, { prefix: '/api/auth' } )
Cookie 配置
| 配置项 | 环境变量 | 默认值 | 说明 |
|--------|---------|--------|------|
| atCookieName | — | at | Access Token Cookie 名称 |
| rtCookieName | — | rt | Refresh Token Cookie 名称 |
| pkceCookieName | — | pkce_verifier | PKCE verifier Cookie 名称 |
| atTtl | — | 900(15 分钟) | Access Token Cookie 有效期(秒),建议与 SSO AT TTL 一致 |
| rtTtl | — | 604800(7 天) | Refresh Token Cookie 有效期(秒),建议与 SSO RT TTL 一致 |
| pkceTtl | — | 300(5 分钟) | PKCE verifier Cookie 有效期(秒) |
| cookieSecure | — | true(生产环境) | Cookie Secure 标志,生产环境应为 true |
| cookieSameSite | — | lax | Cookie SameSite 策略(lax / strict / none) |
示例:
await app.register(ssoClientAuth, {
atTtl: 30 * 60, // 30 分钟
rtTtl: 30 * 24 * 3600, // 30 天
cookieSecure: true,
cookieSameSite: 'lax'
})功能开关与扩展
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| includeEmail | boolean | true | 是否在用户信息响应中包含 email 字段 |
| buildUser | function(payload) | 见下方 | 自定义用户信息提取函数,入参为 JWT payload |
| jwksCacheMaxAge | number | 3600000(1 小时) | JWKS 公钥缓存时长(毫秒) |
| requestTimeout | number | 5000 | 与 SSO 通信的超时时间(毫秒) |
| revokeTimeout | number | 3000 | 登出时撤销 RT 的超时时间(毫秒) |
默认 buildUser 实现:
// 默认从 JWT payload 提取以下字段
function defaultBuildUser(payload) {
return {
sub: payload.sub,
username: payload.username || payload.preferred_username || '',
email: payload.email || '',
role: payload.role || 'user'
}
}自定义示例:
await app.register(ssoClientAuth, {
buildUser: (payload) => ({
id: payload.sub,
name: payload.name || payload.username,
email: payload.email,
roles: Array.isArray(payload.roles) ? payload.roles : [payload.role || 'user'],
department: payload.department || ''
})
})环境变量速查表
| 环境变量 | 对应配置项 | 说明 |
|---------|-----------|------|
| SSO_TOKEN_URL | ssoBaseUrl | SSO 服务地址 |
| SSO_ISSUER | ssoBaseUrl / issuer | SSO 颁发者地址(兼容写法) |
| SSO_LOGOUT_URL | ssoLogoutUrl | SSO 登出地址(不填则与 SSO_TOKEN_URL 一致) |
| SSO_JWKS_URI | jwksUri | JWKS 公钥地址 |
| SSO_JWKS_URL | jwksUri | JWKS 公钥地址(兼容写法) |
| SSO_CLIENT_ID | clientId | OAuth2 client_id |
| OAUTH_CLIENT_ID | clientId | OAuth2 client_id(兼容写法) |
| SSO_CLIENT_SECRET | clientSecret | OAuth2 client_secret |
| OAUTH_CLIENT_SECRET | clientSecret | OAuth2 client_secret(兼容写法) |
| OAUTH_REDIRECT_URI | redirectUri | OAuth2 回调地址 |
| FRONTEND_URL | frontendUrl | 前端根地址 |
| CORS_ORIGIN | frontendUrl | 前端根地址(兼容写法) |
| NODE_ENV | cookieSecure | production 时自动启用 Cookie Secure |
优先级: options 显式传参 > 环境变量 > 内置默认值
路由说明
插件注册后,以下路由自动可用(以默认前缀 /auth 为例):
GET /auth/callback
SSO 授权码回调端点。SSO 授权成功后重定向至此,后端完成 code → token 的换取,写入 HttpOnly Cookie,再重定向回前端。
Query 参数:
| 参数 | 说明 |
|------|------|
| code | SSO 颁发的授权码 |
| state | 透传的 state 参数(可选) |
| error | SSO 返回的错误码(授权失败时) |
重定向目标:
- 成功:
{frontendUrl}/callback?success=1&user={encodedUserInfo} - 失败:
{frontendUrl}/callback?error={errorCode}
GET /auth/session
检查当前登录状态。浏览器自动携带 Cookie,后端验证 AT 并返回用户信息。
响应示例:
// 200 — 已登录
{
"authenticated": true,
"user": {
"sub": "user-uuid",
"username": "zhangsan",
"email": "[email protected]",
"role": "user"
}
}
// 401 — 未登录 / Token 过期
{
"authenticated": false,
"reason": "token_expired" // no_token | token_expired | invalid_token
}POST /auth/refresh
静默刷新 Access Token。前端检测到 reason: token_expired 时调用,后端用 HttpOnly Cookie 中的 RT 向 SSO 换取新 AT,更新 Cookie。
响应示例:
// 200 — 刷新成功
{
"success": true,
"user": { "sub": "...", "username": "...", "email": "...", "role": "user" },
"expiresIn": 900
}
// 401 — RT 过期或无效
{ "error": "refresh_failed", "message": "会话已过期,请重新登录" }
// 502 — 无法连接 SSO
{ "error": "sso_unreachable", "message": "无法连接 SSO 服务" }POST /auth/logout
两段式登出:
① 后端向 SSO 撤销 RT(fire-and-forget)
② 清除业务域所有认证 Cookie
③ 返回 SSO 浏览器注销地址,前端跳转后清理 SSO 域 Cookie
响应示例:
{
"success": true,
"message": "已登出",
"logoutUrl": "https://sso.example.com/oauth/logout"
}前端使用建议:
const res = await fetch('/auth/logout', { method: 'POST' })
const { logoutUrl } = await res.json()
// 跳转到 SSO 域,清理 SSO Cookie(只有浏览器直接访问才能清除 SSO 域的 Cookie)
window.location.href = logoutUrlPOST /auth/pkce
在发起 OAuth 授权前调用,将 code_verifier 存入 HttpOnly Cookie(5 分钟有效)。整页跳转时 sessionStorage 会丢失,使用 Cookie 可解决此问题。
请求体:
{ "verifier": "your-pkce-code-verifier-string" }响应示例:
{ "success": true }装饰器:fastify.ssoAuth
插件注册后,在 fastify 实例上挂载 ssoAuth 装饰器,提供以下工具:
| 属性/方法 | 类型 | 说明 |
|-----------|------|------|
| config | object | 当前解析后的完整配置 |
| verifyToken(token) | async function | 验证 JWT,返回 payload |
| requireAuth | preHandler hook | 验证 AT(支持 Header 和 Cookie),挂载 request.user |
| requireAdmin | preHandler hook | 验证 AT 且 role === 'admin' |
| clearCookies(reply) | function | 清除所有认证 Cookie |
典型使用场景
保护业务路由
// 单个路由保护
app.get('/api/profile', {
preHandler: app.ssoAuth.requireAuth
}, async (request) => {
// request.user 已由 requireAuth 填充
return { user: request.user }
})
// 路由组保护(批量)
app.register(async (scope) => {
scope.addHook('preHandler', scope.ssoAuth.requireAuth)
scope.get('/me', async (req) => ({ user: req.user }))
scope.get('/dashboard', async (req) => ({ data: 'protected' }))
}, { prefix: '/api' })
// 管理员路由
app.get('/api/admin/users', {
preHandler: app.ssoAuth.requireAdmin
}, async (request) => {
return { users: [] }
})自定义用户信息结构
await app.register(ssoClientAuth, {
// ... 其他配置
buildUser: (payload) => ({
id: payload.sub,
name: payload.name || payload.preferred_username,
email: payload.email,
roles: payload.roles || [payload.role || 'user'],
// 自定义业务字段
tenantId: payload.tenant_id || 'default',
permissions: payload.permissions || []
})
})多实例 / 微服务场景
不同服务对应不同的 client_id,可多次注册到不同前缀:
// 服务 A:组件系统
await app.register(ssoClientAuth, {
routePrefix: '/api/auth',
clientId: 'component-app',
redirectUri: 'https://component.example.com/api/auth/callback',
frontendUrl: 'https://component.example.com'
})
// 服务 B:业务系统(独立 Fastify 实例)
await appB.register(ssoClientAuth, {
routePrefix: '/api/auth',
clientId: 'business-app',
redirectUri: 'https://business.example.com/api/auth/callback',
frontendUrl: 'https://business.example.com'
})与前端配合
以下是前端(Vue 3 / React 等)与本插件配合的标准流程:
// 1. 检查登录状态(页面初始化)
const res = await fetch('/auth/session', { credentials: 'include' })
if (res.status === 401) {
const { reason } = await res.json()
if (reason === 'token_expired') {
// 2. 尝试静默刷新
const refreshRes = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include'
})
if (!refreshRes.ok) {
// 刷新失败,跳转登录
startOAuthFlow()
}
} else {
// 未登录,发起 OAuth 流程
startOAuthFlow()
}
}
// 3. 发起 OAuth + PKCE 授权
async function startOAuthFlow() {
// 生成 PKCE
const verifier = generateCodeVerifier()
const challenge = await generateCodeChallenge(verifier)
// 保存 verifier 到后端 Cookie(整页跳转前必须先调用)
await fetch('/auth/pkce', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verifier }),
credentials: 'include'
})
// 跳转 SSO 授权页
const params = new URLSearchParams({
response_type: 'code',
client_id: 'my-app',
redirect_uri: 'https://my-app.example.com/api/auth/callback',
scope: 'openid profile',
code_challenge: challenge,
code_challenge_method: 'S256'
})
window.location.href = `https://sso.example.com/oauth/authorize?${params}`
}
// 4. 登出
async function logout() {
const res = await fetch('/auth/logout', {
method: 'POST',
credentials: 'include'
})
const { logoutUrl } = await res.json()
window.location.href = logoutUrl
}常见问题
Q: Cookie 不携带?
A: 确保 @fastify/cookie 已在本插件前注册,且前端请求携带 credentials: 'include'。
Q: PKCE verifier 丢失?
A: 确保在整页跳转到 SSO 授权页之前调用了 POST /auth/pkce,且 pkceTtl 足够大(默认 5 分钟)。
Q: JWT 验证失败 / issuer 不匹配?
A: issuer 必须与 SSO 颁发 JWT 时填入的 iss 字段完全一致(包括协议、域名、尾斜杠)。
Q: 生产环境 Cookie 无法设置?
A: 确认 cookieSecure: true 且站点使用 HTTPS;如果前后端跨域,需要 cookieSameSite: 'none' 并确保 HTTPS。
Q: 登出后再次访问自动登录?
A: 本插件只清理业务域 Cookie,必须让浏览器跳转到 logoutUrl(SSO 的 /oauth/logout)才能清理 SSO 域的 Cookie,避免 SSO 端自动重新颁发 Token。
License
MIT © vdream
