给 Web、CLI 和远程服务器做一套统一授权
原创 · 约 57 分钟阅读 · 阅读 --

给 Web、CLI 和远程服务器做一套统一授权

作者: Alex Xiang


古董级程序员,大厂出来后一直在创业公司,现在仍活跃在一线做 AI 相关的开发。更完整的更新写在微信公众号「字与码」:工作经历、对新技术的想法,以及这些年走弯路的记录,会不定期发在那里。若觉得博客对你有用,欢迎顺手关注。

很多系统的账号体系都是一点点长出来的。

网站先有邮箱密码登录,后来接入 Google;桌面工具嫌网页登录麻烦,增加一串 API Key;命令行工具再复制一套 Token;远程服务器无法打开浏览器,又写一个临时验证码。等到需要统一权限、统一计费和统一撤销时,才发现“同一个用户”在不同入口里已经变成了四种身份。

这篇文章用一个完整但不绑定具体业务的例子,做一套统一授权系统。目标不是实现另一个账号密码页面,而是让这些客户端共享同一个身份和授权中心:

  • Web 应用;
  • 浏览器中的单页应用;
  • Windows、macOS、Linux 桌面程序;
  • WSL 中运行的 CLI;
  • SSH 登录的远程 CLI;
  • 后台服务和自动化任务;
  • 多个独立部署的资源 API。

示例域名统一使用 example.com,代码只展示协议边界和关键逻辑,不依赖某个现成项目。

Web、CLI、远程服务器和 API 共用一套统一授权中心

统一的究竟是什么

“统一登录”很容易被理解成所有页面共用一个登录框。真正要统一的至少有四层。

身份统一

无论用户从网站、CLI 还是桌面应用登录,最终都对应同一个稳定的用户 ID。邮箱、手机号、GitHub subject 和企业 SSO subject 都只是登录凭据,不应成为业务主键。

user_id = 7f...91
  ├── password identity
  ├── google subject
  ├── github subject
  └── enterprise subject

授权统一

客户端不能因为“已经登录”就自动获得所有能力。授权需要明确回答:

  • 哪个客户端在请求?
  • 请求访问哪个资源?
  • 请求哪些 scope?
  • 用户是否批准?
  • 管理策略是否允许?

凭证统一

资源 API 不应该分别理解网站 Cookie、CLI 配置文件和第三方登录结果。它们只需要验证统一签发的 access token,并检查 issuer、audience、expiry 和 scope。

用量与计费统一

同一用户可能通过多个客户端消费同一种 API。计费不能只记录 token 里的 sub,还要保留客户端、资源、计费归属和授权方式,才能回答“谁通过什么应用消费了什么”。

总体架构:授权中心不等于资源服务器

先给虚构系统划分三个域名:

https://login.example.com     授权服务器与账号中心
https://account.example.com   账号资源 API
https://api.example.com       业务资源 API

授权服务器负责登录、同意页、客户端管理、授权码和 token。资源服务器负责业务数据。两者可以由同一个团队维护,但不能在概念上揉成一个服务。

统一授权系统的客户端、授权中心、资源服务、审计与计费分层

图中可以按三层理解。

客户端层

浏览器、桌面程序、本地 CLI 和远程 CLI 都是 OAuth client。它们的运行环境不同,保密能力也不同,因此不能注册成同一种 client:

客户端类型推荐授权方式能否保存 client secret
服务端 WebconfidentialAuthorization Code + PKCE可以,保存在服务端
浏览器 SPApublicAuthorization Code + PKCE不可以
桌面/本地 CLIpublicAuthorization Code + PKCE不可以
远程/无头 CLIpublicDevice Authorization不可以
后台服务confidentialClient Credentials 或工作负载身份可以,但优先非对称凭证

把一个 secret 写进公开发布的 Python 包、npm 包或桌面安装包,不会让 public client 变成 confidential client。用户总能把它取出来。

五类客户端分别怎么接入

统一授权不是强迫所有客户端走完全相同的页面和回调。统一的是 issuer、身份、grant、scope、resource、token 语义与撤销机制;客户端根据自身运行边界选择合适的 grant。

下面五张图都使用 1–5 编号,图后的清单与编号一一对应。

服务端 Web:Token 留在后端

服务端渲染网站或带 BFF(Backend for Frontend)的 Web 应用,适合使用 confidential client。浏览器只持有网站自己的 Session Cookie,OAuth access token 和 refresh token 留在后端。

服务端 Web 使用 Authorization Code 与 PKCE,Token 保存在后端

  1. 浏览器访问 Web 应用,后端生成随机 statenonce 和 PKCE verifier,把必要的事务状态保存在服务端 Session。
  2. 后端把浏览器重定向到授权中心。client_secret 不出现在浏览器中。
  3. 用户在授权中心登录并确认资源与权限范围。
  4. 授权中心通过浏览器把一次性 code 返回 Web 后端的 HTTPS callback。
  5. 后端使用 code、verifier 和自身 client 认证兑换 token,把 refresh token 加密存储,再向浏览器签发 HttpOnly + Secure + SameSite Session Cookie。

资源 API 调用由后端发起:

Browser --session cookie--> Web/BFF --access token--> Resource API

这种模式的优点是 token 不进入 JavaScript 环境,适合高价值账号和复杂业务。代价是 Web 后端需要管理 Session、Token 刷新和 CSRF 防护。

不要把后端拿到的 refresh token 再返回给浏览器,也不要为了“前后端统一”把 confidential client secret 编译进前端包。

浏览器 SPA:Public Client + PKCE

纯 SPA 没有可信后端,发布到浏览器的任何 secret 都可以被查看,因此必须注册为 public client。

浏览器 SPA 使用 PKCE,短期 Token 仅保存在内存

  1. SPA 在浏览器内存中生成 statenonce、verifier 和 challenge,不生成 client secret。
  2. 浏览器跳转到授权中心,redirect URI 必须是预注册的 HTTPS 地址。
  3. 用户登录并批准 scope。
  4. SPA callback 校验 state,使用 code + verifier 兑换 token;短期 access token 优先保存在内存,而不是 localStorage
  5. SPA 携带 access token 调用资源 API;需要长期会话时使用受控的 refresh token rotation,或者改成 BFF 架构。

浏览器刷新会清空内存,这是安全性与体验之间的真实取舍。可以通过静默 Session 检查或 BFF 恢复登录,不要因为怕用户重新登录就把长期 token 永久塞进可被任意脚本读取的存储。

SPA 还需要严格配置 CORS,并确保授权回调页不加载广告、统计脚本等无关第三方资源,避免 code 出现在 Referer 或前端日志里。

本地桌面程序和 CLI:Loopback Callback

桌面应用和本地 CLI 同样是 public client,但可以临时监听本机回环地址,让系统浏览器把授权码交回来。

本地桌面程序和 CLI 通过系统浏览器与 Loopback Callback 完成 PKCE

  1. CLI 生成 PKCE 和 state,启动只存活几分钟的 loopback listener,例如 127.0.0.1:8765
  2. CLI 调用操作系统默认浏览器打开授权页,密码始终输入可信浏览器。
  3. 用户登录并确认应用、resource 和 scope。
  4. 浏览器访问 loopback callback;CLI 只接受预期路径和当前 state,旧授权页面的回调必须忽略。
  5. CLI 用 code + verifier 换 token,把 refresh token 保存到 Keychain、Credential Manager、Secret Service 等系统凭证库,然后调用 API。

RFC 8252允许原生应用使用 loopback redirect,并允许端口动态变化。实际注册可以允许 127.0.0.1 的动态端口,但 host 和 callback path 仍要受控。

WSL 是一个特殊边界:CLI 运行在 Linux,浏览器可能运行在 Windows,打开浏览器和浏览器回到 localhost 属于两条机制。成熟客户端应在 loopback 不可达时主动切换 Device Flow,而不是让终端无限等待。

远程 CLI:Device Authorization

SSH 服务器、容器和无头 Linux 主机通常没有可用浏览器,即使能打开 URL,浏览器的 localhost 也不是远程主机。这时使用 Device Authorization。

远程 CLI 使用 Device Authorization,在另一台设备完成登录并由 CLI 轮询

  1. 远程 CLI 向 device authorization endpoint 申请 device_code、短 user_code、验证地址和轮询间隔。
  2. 用户在自己的电脑或手机打开验证地址,输入短码;浏览器不需要连接远程主机。
  3. 授权页展示客户端、设备、scope 和短码,用户核对后批准。
  4. CLI 按 interval 轮询 token endpoint,正确处理 pending、slow_down、拒绝和过期。
  5. 批准后 CLI 获得 token,安全保存凭证并调用同一个资源 API。

Device Flow 解决的是“用户和客户端不在同一台设备”,不是给所有客户端提供一个更省事的验证码登录。能可靠使用浏览器回调的本地客户端,通常仍应优先使用 Authorization Code + PKCE。

后台任务:工作负载身份优先

定时任务、队列 Worker 和服务间调用没有用户坐在终端前,不应使用人工登录,也不应长期复用某个员工的 refresh token。

后台服务使用工作负载身份获取短期 Token,并记录审计与用量

  1. 运行平台为 Worker 提供工作负载身份或非对称密钥,私钥不离开受控运行环境。
  2. Worker 使用短期签名 assertion 请求 token,不打开浏览器。
  3. 授权中心验证 workload identity、client、resource、scope 和环境策略。
  4. 授权中心签发 audience-restricted 的短期 access token,不默认签发长期 refresh token。
  5. Worker 调用资源 API;资源服务记录 client、workload、用量和审计事件,token 到期后自动失效。

如果部署平台支持 OIDC workload identity federation,应优先于静态 client secret。确实只能使用 Client Credentials 时,也要将 secret 放入 Secret Manager,设置轮换周期,并限制 client 只能访问必要 resource 和 scope。

这五类客户端最后获得的是同一种资源访问语义:同一个 issuer、明确的 audience、受限 scope、短期 access token 和可追踪的 client 身份。差别只存在于“如何安全证明客户端和用户授权”。

授权中心层

授权中心内部至少包含:

  • 用户认证与多因素认证;
  • OAuth client 注册;
  • redirect URI 精确校验;
  • scope 与 resource 策略;
  • 用户同意记录;
  • authorization code;
  • device code;
  • token 签发、刷新、撤销;
  • 私钥与 JWKS 轮换;
  • 登录和授权审计。

资源与运营层

资源服务验证 token 后执行业务,并把调用事实写入用量账本。策略服务决定用户、客户端、区域和资源之间是否允许访问;审计日志保存授权与撤销轨迹;计费账本保存可重放的用量事实。

第一步:定义 issuer、resource 和 scope

授权系统最先确定的不是数据库表,而是三个命名空间。

Issuer

Issuer 表示“谁签发了这个 token”:

iss = https://login.example.com

资源服务器必须精确比较它,不能接受“域名差不多”或任意可配置 issuer。

Resource 与 audience

Resource 表示 token 准备用在哪里:

https://account.example.com
https://api.example.com

签发给 Account API 的 token 不应被业务 API 接受。JWT 中通常用 aud 表达这个约束。

Scope

Scope 表示客户端被委托的操作范围,而不是用户角色的同义词:

openid
profile
email
data.read
data.write
jobs.execute

admin 这种巨大 scope 很方便,也最容易把权限边界做坏。更实用的做法是 scope 描述客户端可请求的能力,用户角色、资源 ACL 和业务条件继续由服务端策略判断。

第二步:提供标准发现接口

客户端不应该把所有 endpoint 写死。授权服务器可以发布:

GET /.well-known/openid-configuration

响应至少包括:

{
  "issuer": "https://login.example.com",
  "authorization_endpoint": "https://login.example.com/oauth/authorize",
  "token_endpoint": "https://login.example.com/oauth/token",
  "userinfo_endpoint": "https://login.example.com/oauth/userinfo",
  "jwks_uri": "https://login.example.com/.well-known/jwks.json",
  "response_types_supported": ["code"],
  "code_challenge_methods_supported": ["S256"],
  "scopes_supported": ["openid", "profile", "email", "data.read"]
}

RFC 8414定义了授权服务器元数据;OpenID Connect Discovery 在此基础上补充 OIDC endpoint 和能力发现。

资源服务器也可以发布自己的元数据:

GET https://api.example.com/.well-known/oauth-protected-resource
{
  "resource": "https://api.example.com",
  "authorization_servers": ["https://login.example.com"],
  "scopes_supported": ["data.read", "data.write"],
  "bearer_methods_supported": ["header"]
}

这是 2025 年发布的 RFC 9728。它让一个初次接触 API 的通用客户端先发现“这个资源由谁授权、支持哪些 scope”,对 MCP、CLI 插件和多资源平台尤其有用。

第三步:设计最小但完整的数据模型

下面使用 PostgreSQL 风格的表,只保留教程需要的字段。

OAuth client

CREATE TABLE oauth_clients (
  client_id          text PRIMARY KEY,
  name               text NOT NULL,
  client_type        text NOT NULL CHECK (client_type IN ('public', 'confidential')),
  secret_hash        text,
  redirect_uris      jsonb NOT NULL,
  allowed_scopes     jsonb NOT NULL,
  allowed_resources  jsonb NOT NULL,
  enabled            boolean NOT NULL DEFAULT true,
  created_at         timestamptz NOT NULL DEFAULT now()
);

数据库只保存 confidential client secret 的哈希,不保存明文。Public client 不配置有效 secret。

用户授权记录

CREATE TABLE oauth_grants (
  id            uuid PRIMARY KEY,
  user_id       uuid NOT NULL,
  client_id     text NOT NULL REFERENCES oauth_clients(client_id),
  resource      text NOT NULL,
  scopes        jsonb NOT NULL,
  granted_at    timestamptz NOT NULL,
  revoked_at    timestamptz,
  UNIQUE (user_id, client_id, resource)
);

用户撤销某个应用时,撤销的是这条委托关系,而不是删除用户账号。

授权码

CREATE TABLE oauth_authorization_codes (
  code_hash              text PRIMARY KEY,
  user_id                uuid NOT NULL,
  client_id              text NOT NULL,
  redirect_uri           text NOT NULL,
  resource               text NOT NULL,
  scopes                 jsonb NOT NULL,
  code_challenge         text NOT NULL,
  code_challenge_method  text NOT NULL,
  nonce                   text,
  expires_at             timestamptz NOT NULL,
  consumed_at            timestamptz
);

授权码必须短命、一次性使用,并绑定 client、redirect URI、resource、scope 和 PKCE challenge。数据库保存 code hash,泄漏数据库也不能直接兑换。

Refresh token family

CREATE TABLE oauth_refresh_tokens (
  token_hash       text PRIMARY KEY,
  family_id        uuid NOT NULL,
  user_id          uuid NOT NULL,
  client_id        text NOT NULL,
  resource         text NOT NULL,
  scopes           jsonb NOT NULL,
  expires_at       timestamptz NOT NULL,
  rotated_at       timestamptz,
  revoked_at       timestamptz
);

family_id 用于 refresh token rotation。如果一个已经轮换过的旧 token 再次出现,说明可能发生复制或泄漏,可以撤销整个 family。

第四步:实现 Authorization Code + PKCE

这是 Web、桌面程序和本地 CLI 的主流程。

客户端生成 PKCE

import base64
import hashlib
import secrets

verifier = secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode("ascii")).digest()
).rstrip(b"=").decode("ascii")

state = secrets.token_urlsafe(24)
nonce = secrets.token_urlsafe(24)

verifier 只保存在当前客户端进程;授权请求只发送 challenge。即使授权码被另一个进程截获,没有 verifier 也无法兑换。

授权请求

GET /oauth/authorize?
  response_type=code&
  client_id=desktop-cli&
  redirect_uri=http://127.0.0.1:8765/callback&
  resource=https://api.example.com&
  scope=openid%20profile%20data.read&
  state=...&
  nonce=...&
  code_challenge=...&
  code_challenge_method=S256

授权服务器依次检查:

  1. client 是否启用;
  2. redirect URI 是否精确注册;
  3. resource 是否允许;
  4. scope 是否为 client 允许集合的子集;
  5. 是否使用 S256 PKCE;
  6. 用户是否登录;
  7. 用户和策略是否允许访问;
  8. 是否需要展示同意页。

授权码兑换

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
client_id=desktop-cli&
code=...&
redirect_uri=http://127.0.0.1:8765/callback&
code_verifier=...

服务端需要在一个短事务里锁定授权码记录,验证后写入 consumed_at。不要先查出授权码、释放事务、签完 token 再回来标记,否则并发请求可能重复兑换。

Authorization Code + PKCE 与 Device Authorization 两条客户端登录流程

第五步:为远程 CLI 增加 Device Authorization

远程主机没有可用浏览器,localhost 又指向远程服务器。这时不要强迫用户做端口转发,可以增加 RFC 8628 设备授权。

申请设备码

POST /oauth/device/authorize
Content-Type: application/x-www-form-urlencoded

client_id=remote-cli&
resource=https://api.example.com&
scope=openid%20profile%20data.read

响应:

{
  "device_code": "long-secret-value",
  "user_code": "BDQP-MZRT",
  "verification_uri": "https://login.example.com/device",
  "verification_uri_complete": "https://login.example.com/device?user_code=BDQP-MZRT",
  "expires_in": 600,
  "interval": 5
}

CLI 可以显示 URL、短码和 QR code,但不能把 device_code 暴露给用户。随后按照 interval 轮询 token endpoint:

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:device_code&
client_id=remote-cli&
device_code=...

用户尚未批准时返回:

{"error": "authorization_pending"}

轮询太快时返回 slow_down,过期返回 expired_token,拒绝返回 access_denied。客户端不能把这些预期状态都打印成红色异常。

设备码的风险是远程钓鱼:攻击者可能诱导用户输入一串由攻击者发起的代码。确认页应显示应用名称、设备信息、scope 和短码,让用户核对“我是否正在给眼前这台设备授权”。

第六步:签发能被资源服务器独立验证的 Token

Access token 可以使用签名 JWT。一个示例 payload:

{
  "iss": "https://login.example.com",
  "sub": "7f34...91",
  "aud": "https://api.example.com",
  "client_id": "desktop-cli",
  "scope": "data.read jobs.execute",
  "iat": 1783785600,
  "exp": 1783786500,
  "jti": "a0c1..."
}

建议把 access token 控制在 5 到 15 分钟。Token 里只放资源服务实时决策需要的稳定声明,不要塞完整用户对象、价格表或经常变化的权限列表。

使用非对称签名

授权服务器持有 RSA 或 EC 私钥,资源服务只获取公钥:

GET /.well-known/jwks.json
{
  "keys": [
    {
      "kty": "RSA",
      "kid": "auth-2026-07",
      "use": "sig",
      "alg": "RS256",
      "n": "...",
      "e": "AQAB"
    }
  ]
}

生产环境不能在每个进程启动时临时生成签名私钥。多副本会用相同 kid 暴露不同公钥,重启还会让已签发 token 全部失效。缺少稳定私钥时应该启动失败;若确实需要开发环境临时密钥,也要使用显式的 development-only 开关。

资源服务器验证顺序

claims = verify_jwt_signature(token, jwks)

assert claims["iss"] == "https://login.example.com"
assert "https://api.example.com" in normalize_audience(claims["aud"])
assert claims["exp"] > now()
assert "data.read" in claims["scope"].split()

真正实现时还要限制允许的算法、按 kid 选择公钥、处理 JWKS 缓存与轮换。绝不能读取 token 头里的 alg 后无条件接受。

Access Token 签发、JWKS 校验、使用账单、Refresh Token 与密钥轮换

第七步:把身份、授权与计费上下文分开

资源服务通过 token 知道用户和 client,但计费通常还需要一个稳定的 billing owner。例如个人用户、团队或企业组织都可能承担费用。

不要简单规定:

billing_owner = token.sub

更稳妥的调用上下文是:

{
  "subject": "user:7f34...91",
  "client_id": "desktop-cli",
  "credential_type": "oauth_access_token",
  "resource": "https://api.example.com",
  "scopes": ["data.read"],
  "billing_owner": "account:2a18...",
  "grant_id": "grant:91be..."
}

其中 billing_owner 可以由授权中心在签发时确定,也可以由可信网关根据短声明查询。关键是资源调用记录至少保存:

  • 用户 subject;
  • OAuth client;
  • resource;
  • grant 或授权来源;
  • billing owner;
  • 用量、成本和计价版本;
  • request/session 关联 ID。

这样用户撤销某个客户端后,历史账单仍然可解释;同一用户从网站和 CLI 调用,也能按客户端分开分析。

第八步:权限判断不要只靠 Scope

Scope 是委托边界,不是完整 ACL。资源服务可以采用三层判断:

第一层:Token 是否允许访问这个 resource
第二层:Scope 是否允许执行这个动作
第三层:用户、组织、区域与对象级策略是否允许

例如 data.read 只说明客户端可以代表用户读取数据,不代表这个用户能读取所有数据集。最终对象级权限仍应由服务端判断。

授权页显示的权限文案要来自 scope 的固定产品定义,不能直接把内部权限标识拼给用户:

data.read      → 查看你有权访问的数据
jobs.execute   → 代表你创建并运行任务
profile        → 读取基本账号信息

第九步:Refresh、撤销和退出

Access token 短命后,交互式客户端通常需要 refresh token。安全做法包括:

  • refresh token rotation;
  • 数据库只存 token hash;
  • 绑定 client、user、resource 和 scope;
  • 为 token family 设置绝对有效期;
  • 检测旧 refresh token 重放;
  • 用户撤销 grant 时撤销整个 family;
  • 修改密码、账号冻结或高风险事件触发撤销。

“退出登录”也要区分三件事:

  1. 清除网站登录 Session;
  2. 删除当前客户端本地凭证;
  3. 撤销服务器上的 grant/refresh token。

只删除本地文件不等于撤销授权;只清网站 Cookie 也不影响已经发给 CLI 的 refresh token。

第十步:密钥轮换与多副本部署

签名密钥需要版本化,kid 必须唯一。一次平滑轮换可以这样做:

  1. 生成新密钥并安全存储;
  2. JWKS 同时发布旧公钥和新公钥;
  3. 新 token 改用新私钥签名;
  4. 等旧 access token 最大寿命过去;
  5. 再从 JWKS 移除旧公钥;
  6. 保留审计记录和紧急回滚能力。

授权服务器多副本必须共享:

  • 稳定签名密钥;
  • authorization code 与 consumed 状态;
  • device code 状态;
  • refresh token family;
  • client 注册信息;
  • grant 和撤销状态。

登录 Session 可以使用共享存储或经过谨慎设计的加密 Cookie,但一次性授权码不能只放在某个 Pod 的内存里。

第十一步:错误响应也是接口契约

OAuth 错误需要稳定、可机器处理。Authorization endpoint 通过回调返回错误,Token endpoint 使用 JSON:

{
  "error": "invalid_grant",
  "error_description": "authorization code is invalid or expired"
}

常见错误包括:

错误含义
invalid_request缺字段、格式错误或参数冲突
invalid_clientclient 认证失败或 client 不存在
invalid_grantcode/refresh token 无效、过期或已使用
invalid_scope请求了未注册 scope
unauthorized_clientclient 不允许使用这个 grant type
authorization_pending设备码等待用户批准
slow_down设备码轮询过快
access_denied用户或策略拒绝授权

日志中可以记录 request ID、client ID、错误类型和阶段,但不要记录 authorization code、access token、refresh token、client secret、PKCE verifier 或完整回调 URL。

第十二步:测试一条真正的端到端链路

单元测试只验证函数还不够,至少准备以下测试层次。

协议单元测试

  • redirect URI 必须精确匹配;
  • 未注册 resource/scope 被拒绝;
  • PKCE S256 正确与错误 verifier;
  • authorization code 只能使用一次;
  • statenonce 校验;
  • refresh token rotation 与重放;
  • device flow 的 pending、slow_down、expired、denied;
  • JWT issuer、audience、expiry、scope 与算法限制。

多副本测试

  • A 副本创建 code,B 副本兑换;
  • A 副本签发 token,任意资源副本从 JWKS 验证;
  • 授权服务重启后,已签发 token 仍然有效;
  • 新旧 kid 在轮换窗口内都能验证。

客户端端到端测试

桌面/CLI 流程:

启动 CLI → 打开浏览器 → 登录 → 同意 → loopback 回调
→ code exchange → 调用 API → 写入用量账本

远程流程:

启动远程 CLI → 获取 user_code → 另一设备批准
→ CLI 轮询成功 → 调用 API → 撤销授权 → 后续刷新失败

最后查一次业务调用记录,确认保存的是 OAuth 用户、client、resource 和 billing owner,而不是退化成一个无法解释的匿名 Bearer Token。

上线前检查表

协议与安全

  • 只使用 Authorization Code,不使用 Implicit Grant;
  • 所有交互式客户端使用 PKCE S256;
  • public client 不依赖 client secret;
  • redirect URI 精确匹配;
  • access token 校验 issuer、audience、expiry、scope 和算法;
  • authorization code 一次性、短有效期、数据库保存 hash;
  • refresh token rotation 与重放检测;
  • Device Flow 遵守 interval,并展示设备和权限信息;
  • 日志不记录敏感凭证;
  • 授权页不加载不必要的第三方资源。

部署与运维

  • 生产环境使用稳定签名私钥,缺失时启动失败;
  • 多副本共享 code、device code、grant 和 refresh 状态;
  • JWKS 有缓存策略和轮换窗口;
  • 审计日志能查询登录、授权、刷新和撤销;
  • client 可单独禁用;
  • grant 可由用户和管理员撤销;
  • 监控 token 签发失败率、兑换延迟、无效 grant 和异常轮询;
  • 数据库迁移先于启用新授权入口。

产品与客户端

  • 同意页展示应用名称、资源和人类可读权限;
  • Web、本地 CLI、远程 CLI 都有明确登录路径;
  • 本地回调失败时可切换 Device Flow;
  • 用户能查看并撤销已授权应用;
  • CLI 安全保存 refresh token;
  • CI/CD 使用工作负载身份,不复用人工登录 token。

现在的 OAuth 实现应该遵循什么基线

如果今天从头实现,不要只停留在 2012 年的 OAuth 2.0 核心 RFC。2025 年发布的 RFC 9700 已经把多年攻击经验整理成安全最佳实践,几个直接影响实现的结论是:

  • 公共客户端必须使用 PKCE,confidential client 也推荐使用;
  • S256 是应使用的 PKCE challenge method;
  • redirect URI 必须精确匹配,本地 loopback 端口是规范允许的例外;
  • 不推荐使用把 access token 直接放在授权响应里的 Implicit Grant;
  • 应考虑 DPoP 或 mTLS 等 sender-constrained token 机制降低 token 重放风险;
  • 授权码使用后立即失效,重复兑换应被视为安全信号;
  • Refresh Token 应轮换或绑定发送方。

教程为了保持主线清晰,使用了 Bearer JWT。如果系统处理高价值数据、长生命周期授权或不可信客户端,可以继续评估 DPoP(RFC 9449);如果授权需求不仅是 scope 字符串,还要表达对象、动作和约束,可以评估 Rich Authorization Requests(RFC 9396)

结语

统一授权不是把几个登录页面接到一起,而是建立一条稳定的信任链:客户端把用户带到可信浏览器,授权中心签发受 resource 和 scope 限制的短期凭证,资源服务器独立验证,调用事实进入审计与计费,用户可以随时撤销委托。

Web、桌面 CLI 和远程 CLI 的交互方式可以不同,但身份、grant、token、权限和账本必须是同一套语义。做到这一点,后续增加新应用时才是“注册一个 client”,而不是再复制一套账号系统。

参考资料