2464 字
12 分钟

接入、部署与演进:如何把 SSO 用到业务系统里

接入、部署与演进:如何把 SSO 用到业务系统里#

SSO 项目是否真正有价值,取决于业务系统能不能顺利接入。这个仓库提供了一个 demo/ 项目,用最小实现演示了 public client + Authorization Code + PKCE 的登录流程,以及业务 API 如何用 JWKS 校验 token 和判断权限。

本文分三部分:

  • 业务系统接入流程。
  • demo 项目实现分析。
  • 生产部署和后续演进建议。

业务系统接入流程#

一个业务系统接入 SSO,大致分成五步:

  1. 在 SSO 管理后台创建应用 client。
  2. 配置 redirect URI、logout redirect URI、scope、audience。
  3. 前端使用授权码 + PKCE 跳转到 SSO。
  4. 回调后调用 /oauth/token 换取 token。
  5. 业务 API 校验 access token,并按 permissions 判断接口权限。

推荐业务系统优先使用 OIDC Discovery:

GET {SSO_API}/.well-known/openid-configuration

这个端点会返回:

  • authorization endpoint
  • token endpoint
  • userinfo endpoint
  • JWKS URI
  • revoke endpoint
  • introspection endpoint
  • logout endpoint
  • 支持的 grant type
  • 支持的 PKCE 方法

这样业务系统不用硬编码每个端点。

创建应用 client#

SSO 后台的“应用接入”页面可以创建 OAuth client。

关键字段:

字段说明
clientKey业务系统的 client ID,例如 demo-app
clientTypepublicconfidential
audienceaccess token 面向的资源服务。业务 API 必须校验。
redirectUrisOAuth 回调地址白名单,必须精确匹配。
postLogoutRedirectUris退出后的回跳地址白名单。
allowedScopes允许请求的 scope。
servicePermissionsclient credentials 可用的服务权限。
tokenTtlSecondsaccess token 有效期。
refreshTtlSecondsrefresh token 有效期。

public client 适合纯前端或无法安全保存 secret 的应用,必须使用 PKCE。confidential client 适合后端应用,需要保存 client secret。

创建应用后,SSO 会自动创建:

  • {clientKey}.admin
  • {clientKey}.user
  • {clientKey}.admin 内置角色
  • {clientKey}.user 内置角色
  • {clientKey}-admin 应用管理员账号

这让业务应用一创建就具备基础用户和管理员权限。

前端授权码 + PKCE#

demo 的前端逻辑在 demo/src/auth.js

登录开始时,前端生成:

  • code_verifier
  • code_challenge
  • state

然后跳转到:

GET /oauth/authorize

请求参数包括:

response_type=code
client_id=demo-app
redirect_uri=http://localhost:9091/callback
scope=openid profile email demo-app
audience=demo-app
state=<random>
code_challenge=<S256 challenge>
code_challenge_method=S256

其中:

  • state 防 CSRF。
  • code_verifier 保存在 sessionStorage。
  • code_challenge 是 verifier 的 SHA-256 base64url。
  • redirect_uri 必须和 SSO 后台配置完全一致。
  • audience 必须和 client 配置一致。

SSO 登录完成后,会跳回业务系统:

http://localhost:9091/callback?code=...&state=...

业务前端需要校验回来的 state 是否等于之前保存的值。校验通过后,用 authorization code 换 token:

POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
client_id=demo-app
code=<code>
redirect_uri=http://localhost:9091/callback
code_verifier=<verifier>

返回数据包括:

  • accessToken
  • refreshToken
  • idToken
  • expiresIn
  • tokenType
  • user

demo 把这些数据存在 localStorage。实际生产系统可以根据安全要求改成 BFF 或 httpOnly cookie 模式。

业务 API 校验 token#

demo 的业务 API 在 demo/server/index.js

它使用 jose 的 createRemoteJWKSet() 从 SSO 拉取 JWKS:

const jwks = createRemoteJWKSet(new URL(`${config.ssoApi}/.well-known/jwks.json`))

每次请求业务 API 时:

  1. Authorization header 读取 Bearer token。
  2. 使用 JWKS 校验 JWT 签名。
  3. 校验 issuer。
  4. 校验 audience。
  5. 校验 client_idazp 是否是预期 client。
  6. 读取 permissions
  7. 按接口要求判断权限。

业务 API 的校验代码:

const { payload } = await jwtVerify(token, jwks, {
issuer: config.issuer,
audience: config.audience,
})
if (payload.client_id !== config.clientId && payload.azp !== config.clientId) {
throw new Error('token client 无效')
}

这一步非常关键。只校验签名是不够的,业务系统还必须校验:

  • iss 是可信 SSO。
  • aud 是当前业务 API。
  • client_id / azp 是允许访问当前业务的 client。
  • token 未过期。
  • 权限满足接口要求。

权限判断#

demo API 复用了和 SSO 后端一致的点号权限规则:

function permissionImplies(granted, required) {
if (!granted || !required) return false
if (granted === required) return true
const grantedParts = String(granted).split('.').filter(Boolean)
const requiredParts = String(required).split('.').filter(Boolean)
const adminIndex = grantedParts.indexOf('admin')
if (adminIndex === -1) return false
if (adminIndex === 0) return true
const scope = grantedParts.slice(0, adminIndex)
return scope.every((part, index) => requiredParts[index] === part)
}

接口示例:

接口要求权限
GET /demo-api/permission/profile-readdemo-app.user
GET /demo-api/permission/admindemo-app.admin

如果用户只有 demo-app.user,就不能访问 admin 接口。如果用户拥有 demo-app.admin,则可以覆盖 demo-app.user 这类应用内权限。

用户注册示例#

demo 还提供了一个注册接口:

POST /demo-api/register

它的实现方式是:

  1. demo API 使用配置中的 SSO 管理员账号登录 SSO。
  2. 获取管理后台 access token。
  3. 调用 /api/users 创建用户,并传入 app=demo-app
  4. SSO 后端创建用户后自动分配 demo-app 默认用户角色。

这个实现只适合本地演示。生产系统不应该让业务应用持有 SSO 管理员账号。

更合理的生产方案有几种:

  • SSO 自己提供公开注册流程。
  • SSO 提供邀请注册。
  • 业务系统调用受限的注册 API,而不是完整管理 API。
  • 通过企业身份源同步用户。
  • 对机器间调用使用 client credentials 和最小服务权限。

退出登录#

demo 的退出逻辑:

  1. 清理业务系统本地 token。
  2. 跳转到 SSO 的 /oauth/logout
  3. 传入 client_idpost_logout_redirect_uri
  4. SSO 清理 sso_session cookie 并跳回业务系统。

这能退出 SSO 会话,但业务系统仍需要自己清理本地状态。对多业务系统单点退出,更完整的方案还需要前端通道、后端通道或集中 session 通知机制。

Token 刷新#

demo 前端支持 refresh token:

POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
client_id=demo-app
refresh_token=<refresh_token>

SSO 后端使用 refresh token rotation。业务系统实现刷新时要注意并发问题:

  • 同一时间只允许一个 refresh 请求。
  • 刷新成功后用新 refresh token 覆盖旧值。
  • 旧 refresh token 不能继续使用。

管理后台前端已经做了 refreshing Promise 复用,业务系统也建议采用同样策略。

本地运行#

初始化依赖:

Terminal window
npm install

配置后端环境:

Terminal window
cp backend/.env.example backend/.env

初始化数据库:

Terminal window
npm run db:push
npm run db:seed

启动 SSO 后端和管理后台:

Terminal window
npm run dev

同时启动 demo:

Terminal window
npm run dev:all

默认端口:

服务地址
SSO Backendhttp://localhost:9999
SSO Admin Frontendhttp://localhost:9090
Demo Frontendhttp://localhost:9091
Demo APIhttp://localhost:9092

生产部署建议#

生产部署建议把 SSO 前后端放在同一域名下,例如:

https://sso.example.com

推荐结构:

Internet
Nginx / Caddy / Traefik
SSO frontend static files
SSO backend on 127.0.0.1:9999

反向代理至少需要转发:

  • /api
  • /oauth
  • /.well-known
  • /healthz
  • /readyz

必须设置的生产环境变量:

Terminal window
NODE_ENV=production
COOKIE_SECRET=<openssl rand -base64 32>
APP_BASE_URL=https://sso.example.com
API_BASE_URL=https://sso.example.com
JWT_ISSUER=https://sso.example.com
DATABASE_URL=file:/opt/sso/data/prod.db

SQLite 备份建议使用绝对路径:

Terminal window
SQLITE_BACKUP_DIR=/opt/sso/backups/sqlite
SQLITE_BACKUP_RETENTION_DAYS=7
SQLITE_BACKUP_CRON="30 2 * * *"

生产部署还应该注意:

  • 使用 HTTPS。
  • 使用专用低权限用户运行 Node 进程。
  • 不要用 root 运行应用。
  • 数据库和备份目录要有稳定磁盘。
  • 定期验证备份可恢复。
  • 保管好 .env 和 SQLite 数据库文件。
  • 只运行单个后端实例,除非已经迁移出 SQLite 和内存状态。

SQLite 的边界#

SQLite 对个人 SSO 很合适:

  • 部署简单。
  • 没有额外数据库服务。
  • 备份文件直观。
  • 小流量读写足够。

但它也有清晰边界:

  • 不适合多个后端实例同时写。
  • 写并发能力有限。
  • 权限、审计、登录在高频写入下可能出现锁竞争。
  • 异地备份、灾备和权限隔离能力不如 PostgreSQL。

如果出现以下情况,建议迁移 PostgreSQL:

  • 需要多实例部署。
  • 登录或管理写入频率明显升高。
  • 需要更强备份恢复和审计能力。
  • 需要团队级运维。
  • SQLite 写锁开始成为实际问题。

安全注意事项#

这个项目已经覆盖不少基础安全点,但生产使用时仍要注意:

  • 生产环境必须设置强 COOKIE_SECRET
  • 初始 root/admin 密码必须修改。
  • client secret 只显示一次,业务系统要妥善保存。
  • redirect URI 要精确配置,不要使用过宽泛的地址。
  • 业务 API 必须校验 issaudclient_id、过期时间和权限。
  • 不要只在前端判断权限。
  • access token TTL 不宜过长。
  • refresh token 要按 rotation 方式使用。
  • CORS origin 不要开放 *
  • 定期检查登录日志和审计日志。

当前实现的不足#

从博客分析角度看,项目已经形成闭环,但还有几个可以继续增强的方向。

密钥轮换#

当前只有一个固定 kid。轮换密钥后,旧 token 会立刻无法通过 JWKS 校验。更稳妥的做法:

  • 生成新 key 时保留旧 key。
  • JWKS 同时暴露当前 key 和未过期 token 仍需使用的旧 key。
  • 新 token 使用新 kid
  • 等旧 token 全部过期后删除旧 key。

邮件能力#

当前系统还没有完整邮件能力。后续可以补:

  • 邮箱验证。
  • 忘记密码。
  • 邀请注册。
  • 异常登录提醒。
  • 密码变更通知。

多实例能力#

目前有一些状态在内存里:

  • 验证码 captchaStore
  • session lastSeenAt 节流 map。

单实例没问题,多实例就需要迁移到共享存储,例如 Redis 或数据库。

更完整的 OIDC 兼容#

当前已经有 Discovery、JWKS、ID Token、UserInfo 等基础能力。后续如果要对接更多标准 OIDC client,可以继续增强:

  • nonce 校验。
  • 更完整的 claims 支持。
  • 更细的 scope 与 claim 映射。
  • 更规范的错误响应格式。
  • OIDC conformance 测试。

前端安全#

管理后台和 demo 都把 token 存在 localStorage。实现简单,但 XSS 风险更高。更安全的架构可以考虑:

  • 后端 BFF 保存 token。
  • 浏览器只持有 httpOnly、Secure、SameSite cookie。
  • 前端通过同源 API 与 BFF 通信。

这会增加部署复杂度,但安全边界更清晰。

推荐的博客总结#

这个 SSO 项目最大的价值不是某一个技术点,而是把身份系统常见的基础设施串成了一个可以运行的闭环:

  • 用户登录不是孤立功能,而是和 session、token、日志、锁定策略关联。
  • OAuth/OIDC 不是只发 JWT,而是包括 client 管理、PKCE、授权码、refresh rotation、revoke、introspection。
  • 权限不是简单角色字段,而是用户、用户组、角色、直接权限的合并结果。
  • 管理后台不是只隐藏菜单,而是和后端应用范围控制配合。
  • 业务系统不是信任前端状态,而是在服务端校验 JWKS、issuer、audience 和 permissions。

对个人项目来说,这套实现已经覆盖了大多数自建系统容易遗漏的点。它保持了足够低的部署成本,也保留了向 PostgreSQL、多实例、邮件服务和更完整 OIDC 能力演进的路径。

接入、部署与演进:如何把 SSO 用到业务系统里
https://march7th.online/blog/posts/0038-接入部署与演进如何把-sso-用到业务系统里/
作者
Yiguo
发布于
2026-07-01
许可协议
CC BY-NC-SA 4.0
最后更新于 2026-07-01
所属合集

个人 SSO 权限系统

记录我为个人多项目搭建统一登录和权限管理系统的完整过程,覆盖架构、OAuth、安全闭环、权限模型、接入和部署。

查看完整合集

目录