接入、部署与演进:如何把 SSO 用到业务系统里
接入、部署与演进:如何把 SSO 用到业务系统里
SSO 项目是否真正有价值,取决于业务系统能不能顺利接入。这个仓库提供了一个 demo/ 项目,用最小实现演示了 public client + Authorization Code + PKCE 的登录流程,以及业务 API 如何用 JWKS 校验 token 和判断权限。
本文分三部分:
- 业务系统接入流程。
- demo 项目实现分析。
- 生产部署和后续演进建议。
业务系统接入流程
一个业务系统接入 SSO,大致分成五步:
- 在 SSO 管理后台创建应用 client。
- 配置 redirect URI、logout redirect URI、scope、audience。
- 前端使用授权码 + PKCE 跳转到 SSO。
- 回调后调用
/oauth/token换取 token。 - 业务 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。 |
clientType | public 或 confidential。 |
audience | access token 面向的资源服务。业务 API 必须校验。 |
redirectUris | OAuth 回调地址白名单,必须精确匹配。 |
postLogoutRedirectUris | 退出后的回跳地址白名单。 |
allowedScopes | 允许请求的 scope。 |
servicePermissions | client credentials 可用的服务权限。 |
tokenTtlSeconds | access token 有效期。 |
refreshTtlSeconds | refresh 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_verifiercode_challengestate
然后跳转到:
GET /oauth/authorize请求参数包括:
response_type=codeclient_id=demo-appredirect_uri=http://localhost:9091/callbackscope=openid profile email demo-appaudience=demo-appstate=<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/tokenContent-Type: application/x-www-form-urlencoded
grant_type=authorization_codeclient_id=demo-appcode=<code>redirect_uri=http://localhost:9091/callbackcode_verifier=<verifier>返回数据包括:
accessTokenrefreshTokenidTokenexpiresIntokenTypeuser
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 时:
- 从
Authorizationheader 读取 Bearer token。 - 使用 JWKS 校验 JWT 签名。
- 校验 issuer。
- 校验 audience。
- 校验
client_id或azp是否是预期 client。 - 读取
permissions。 - 按接口要求判断权限。
业务 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-read | demo-app.user |
GET /demo-api/permission/admin | demo-app.admin |
如果用户只有 demo-app.user,就不能访问 admin 接口。如果用户拥有 demo-app.admin,则可以覆盖 demo-app.user 这类应用内权限。
用户注册示例
demo 还提供了一个注册接口:
POST /demo-api/register它的实现方式是:
- demo API 使用配置中的 SSO 管理员账号登录 SSO。
- 获取管理后台 access token。
- 调用
/api/users创建用户,并传入app=demo-app。 - SSO 后端创建用户后自动分配 demo-app 默认用户角色。
这个实现只适合本地演示。生产系统不应该让业务应用持有 SSO 管理员账号。
更合理的生产方案有几种:
- SSO 自己提供公开注册流程。
- SSO 提供邀请注册。
- 业务系统调用受限的注册 API,而不是完整管理 API。
- 通过企业身份源同步用户。
- 对机器间调用使用 client credentials 和最小服务权限。
退出登录
demo 的退出逻辑:
- 清理业务系统本地 token。
- 跳转到 SSO 的
/oauth/logout。 - 传入
client_id和post_logout_redirect_uri。 - SSO 清理
sso_sessioncookie 并跳回业务系统。
这能退出 SSO 会话,但业务系统仍需要自己清理本地状态。对多业务系统单点退出,更完整的方案还需要前端通道、后端通道或集中 session 通知机制。
Token 刷新
demo 前端支持 refresh token:
POST /oauth/tokenContent-Type: application/x-www-form-urlencoded
grant_type=refresh_tokenclient_id=demo-apprefresh_token=<refresh_token>SSO 后端使用 refresh token rotation。业务系统实现刷新时要注意并发问题:
- 同一时间只允许一个 refresh 请求。
- 刷新成功后用新 refresh token 覆盖旧值。
- 旧 refresh token 不能继续使用。
管理后台前端已经做了 refreshing Promise 复用,业务系统也建议采用同样策略。
本地运行
初始化依赖:
npm install配置后端环境:
cp backend/.env.example backend/.env初始化数据库:
npm run db:pushnpm run db:seed启动 SSO 后端和管理后台:
npm run dev同时启动 demo:
npm run dev:all默认端口:
| 服务 | 地址 |
|---|---|
| SSO Backend | http://localhost:9999 |
| SSO Admin Frontend | http://localhost:9090 |
| Demo Frontend | http://localhost:9091 |
| Demo API | http://localhost:9092 |
生产部署建议
生产部署建议把 SSO 前后端放在同一域名下,例如:
https://sso.example.com推荐结构:
Internet ↓Nginx / Caddy / Traefik ↓SSO frontend static filesSSO backend on 127.0.0.1:9999反向代理至少需要转发:
/api/oauth/.well-known/healthz/readyz
必须设置的生产环境变量:
NODE_ENV=productionCOOKIE_SECRET=<openssl rand -base64 32>APP_BASE_URL=https://sso.example.comAPI_BASE_URL=https://sso.example.comJWT_ISSUER=https://sso.example.comDATABASE_URL=file:/opt/sso/data/prod.dbSQLite 备份建议使用绝对路径:
SQLITE_BACKUP_DIR=/opt/sso/backups/sqliteSQLITE_BACKUP_RETENTION_DAYS=7SQLITE_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 必须校验
iss、aud、client_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 权限系统
记录我为个人多项目搭建统一登录和权限管理系统的完整过程,覆盖架构、OAuth、安全闭环、权限模型、接入和部署。
March7th