npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vdream/sso-client-auth

v1.0.3

Published

Fastify 插件 —— SSO OAuth2 + PKCE 认证路由(callback / session / refresh / logout / pkce)

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 = logoutUrl

POST /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