OAuth2 授权服务器

让第三方应用向你的用户申请授权。这和「用 Google 登录你的网站」不是同一件事。

Saavo 内置了 OAuth 2.0 授权服务器,支持你将产品打造成开放平台。它的核心机制是第三方应用引导你的用户登录并授权,然后通过授权码换取访问令牌,从而调用你开放的 API 接口。

注意,这不同于社交账号登录(如 Google 登录)。社交登录是“外部身份进入你的系统”,而 Saavo 解决的是“外部应用访问你的用户资源”。

使用的时候,你需要在那些开放的 API 接口中进行校验,确保请求的 token 权限有效且在允许的授权范围内。

已有功能

模板初始化后,以下功能已经开箱即用:

用途默认入口默认状态
授权/oauth/authorizeoauth2.enableService 默认为 true
登录后继续/oauth/authorize/continue已启用
同意页/oauth/authorize/consent已启用
换取令牌POST /oauth/token已挂载
撤销令牌POST /oauth/token/revoke已挂载
获取授权后的用户信息GET /oauth/current-user需要 user scope
管理第三方客户端/dashboard/oauth-server管理员可创建客户端

该模板默认支持授权码、刷新令牌和客户端凭证三种授权模式。其中,授权码模式支持 PKCE 扩展,并强制要求使用 S256 算法来生成 Code Challenge 。管理员在创建 OAuth 客户端时,可以配置回调地址,并指定该客户端允许使用的授权模式(Grant Type)和权限范围(Scope)。

默认作用域在 config/scopes.ts:

Scope含义
user访问用户信息
basic示例基础访问

角色描述的是用户本身拥有的权限,而 Scope 描述的是某个访问令牌被允许执行的操作。即使当前用户是管理员,第三方应用获得的 Token 也可能只包含 basic Scope,因此只能访问该 basic Scope 允许的 API。

OAuth 授权同意页

与 Google 登录有什么不同

授权服务器社交登录
目的第三方应用访问你的 API用户登录你的网站
页面/oauth/authorize*/auth/login 等
回调 / 换取令牌/oauth/token/api/auth/oauth2/google、/github
配置deploy.oauth2、scopes.tsGOOGLE_* / GITHUB_* 环境变量

/api/auth/current-user 读的是网站 Session。/oauth/current-user 读的是第三方应用的访问令牌。不要混用。

先体验授权流程

如果你的产品暂时不开放给第三方,仍建议先在后台看一眼客户端管理页,避免上线后才发现默认开关是打开的。

客户端管理页面

完整走通一次授权码流程:

管理员创建客户端,填写 Redirect URI 和允许的 scope
↓
第三方应用打开 /oauth/authorize
↓
用户登录(如尚未登录)
↓
在同意页确认权限
↓
带回授权码
↓
POST /oauth/token 换取 access token 和 refresh token

默认授权码有效期 15 分钟,访问令牌 1 天,刷新令牌 30 天。刷新令牌会轮换。

提示

oauth2.enableService 只决定是否开放授权相关页面,关闭后 /oauth/token 等 API 接口依然可用。如果你的产品暂不对第三方开放,建议不要创建任何客户端,也不要在业务 API 中依赖这些令牌。

配置 OAuth 2.0 授权服务器

你需要在 config/deploy.ts 文件的 oauth2 配置项和 config/scopes.ts 文件里进行相关授权服务器的配置。

是否开放 OAuth 2.0 服务

默认:

oauth2: {
    enableService: true,
    issuer: 'saavo',
    requiresPKCE: true,
    requiresS256: true,
}

产品初始化后,建议尽早修改 issuer,它会写入访问令牌的声明中,用于标识令牌的签发方。

如果产品不需要提供开放平台能力,可以将 enableService 设置为 false,并且不创建 OAuth 客户端。需要注意,当前这个开关只控制相关页面是否启用,并不会停止 OAuth API 本身。

如果希望彻底关闭授权服务器,还需要同时停止挂载 /oauth 相关 API 路由。

定义第三方可申请的授权范围

在 config/scopes.ts 增加自己的 scope,并写好名称和说明。这些文案会显示在同意页。

第三方实际能拿到的权限,是请求 scope 与客户端允许 scope 的交集。只在配置里新增 scope 还不够,还要在后台把该 scope 授给对应客户端。

配置授权范围升级提示

config/upgrade.ts 默认是空对象。当用户缺少某个权益时,可以在这里配置升级卡片,让同意页引导购买更高套餐。

没有升级需求就保持为空。不要把计费逻辑写进授权码换取令牌接口。

准备授权服务

授权码、访问令牌和刷新令牌的签名都依赖 SAAS_SECRET 环境变量。不同环境应该使用独立的密钥,尤其不要将开发或测试环境中的 SAAS_SECRET 直接用于生产环境。

第三方客户端使用的 Redirect URI 必须与管理后台中登记的地址完全一致。由于本地开发和生产环境通常使用不同域名,可以分别创建对应环境的 OAuth 客户端,也可以为同一个客户端配置多组允许的回调地址。

在业务 API 中验证访问令牌

只有当你主动把 API 开放给第三方时,才需要在业务路由里检查 OAuth token。

保护资源的基本原则:

先检查 token 的 scope,再按需检查该用户是否仍具备某个角色或权益。不要把用户的 admin 角色写进 token。

const result = await authz.authorizeOAuthRequest(c, {
    scope: 'basic',
});

if (!result.success) {
    return c.json(result, 401);
}

如果这个接口还要求用户买过某个产品:

const result = await authz.authorizeOAuthRequest(c, {
    scope: 'user',
    entitlement: 'starter_download',
});

也可以单独使用:

await authz.hasScopes(c, 'user')
await authz.hasAnyScope(c, ['user', 'basic'])

第三方应用通过 OAuth2 访问资源时,应只暴露与当前 Scope 相匹配的数据。模板提供的 GET /oauth/current-user 只是一个示例,即使通过 user Scope 后会返回部分用户权益信息,也不代表自己的资源接口应该照搬全部字段;正式开放 API 时,应根据实际业务决定哪些用户信息、角色或权益可以提供给第三方。

OAuth2 主要用于第三方客户端访问 API,网站自身的页面和第一方 API 仍然继续使用 Session 和 authenticatedGuard,不需要改成 Bearer Token 认证。两套认证方式解决的是不同场景,不建议混用。

另外,client_credentials 获取的令牌不代表任何具体用户,因此也不能依赖用户的角色、权益或其他个人状态。它更适合服务与服务之间的机器访问,这类接口应仅根据 Token 的 Scope 决定是否允许访问。

提示

在 SaaS 业务中,只推荐使用两种授权模式,授权码模式和客户端凭证模式。其它授权模式在安全性上存在明显缺陷,不推荐使用。

上线检查

授权服务器会把用户权限交给第三方,上线前建议至少确认:

  • issuer 已改成自己的产品标识。
  • 生产环境使用独立的 SAAS_SECRET。
  • 如不开放平台,未创建正式客户端且已关闭授权页面;如要求 API 也不可访问,已停止挂载 /oauth 路由。
  • 如开放平台,已用授权码 + PKCE 走通登录、同意、换票、刷新和撤销。
  • 客户端 Redirect URI 已指向生产回调,而不是本地地址。
  • 业务 API 检查的是 scope,而不是用户是否为管理员。
  • 令牌拿不到未申请的 scope。
  • 作用域文案已经换成自己的产品语言。

建议用一个专门的测试客户端完成上述流程,不要拿网站管理员 Session 代替 token 测试。

常见问题

接下来

根据接下来要开发的功能,可以继续阅读:

大多数产品如果只是自己的 SaaS,并不需要把授权服务器当成核心功能。

只有确实要做开放平台时,再基于 scope 保护第三方 API。