给 Web、CLI 和远程服务器做一套统一授权
古董级程序员,大厂出来后一直在创业公司,现在仍活跃在一线做 AI 相关的开发。更完整的更新写在微信公众号「字与码」:工作经历、对新技术的想法,以及这些年走弯路的记录,会不定期发在那里。若觉得博客对你有用,欢迎顺手关注。
很多系统的账号体系都是一点点长出来的。
网站先有邮箱密码登录,后来接入 Google;桌面工具嫌网页登录麻烦,增加一串 API Key;命令行工具再复制一套 Token;远程服务器无法打开浏览器,又写一个临时验证码。等到需要统一权限、统一计费和统一撤销时,才发现“同一个用户”在不同入口里已经变成了四种身份。
这篇文章用一个完整但不绑定具体业务的例子,做一套统一授权系统。目标不是实现另一个账号密码页面,而是让这些客户端共享同一个身份和授权中心:
- Web 应用;
- 浏览器中的单页应用;
- Windows、macOS、Linux 桌面程序;
- WSL 中运行的 CLI;
- SSH 登录的远程 CLI;
- 后台服务和自动化任务;
- 多个独立部署的资源 API。
示例域名统一使用 example.com,代码只展示协议边界和关键逻辑,不依赖某个现成项目。

统一的究竟是什么
“统一登录”很容易被理解成所有页面共用一个登录框。真正要统一的至少有四层。
身份统一
无论用户从网站、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 |
|---|---|---|---|
| 服务端 Web | confidential | Authorization Code + PKCE | 可以,保存在服务端 |
| 浏览器 SPA | public | Authorization Code + PKCE | 不可以 |
| 桌面/本地 CLI | public | Authorization Code + PKCE | 不可以 |
| 远程/无头 CLI | public | Device Authorization | 不可以 |
| 后台服务 | confidential | Client 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 应用,后端生成随机
state、nonce和 PKCE verifier,把必要的事务状态保存在服务端 Session。 - 后端把浏览器重定向到授权中心。
client_secret不出现在浏览器中。 - 用户在授权中心登录并确认资源与权限范围。
- 授权中心通过浏览器把一次性 code 返回 Web 后端的 HTTPS callback。
- 后端使用 code、verifier 和自身 client 认证兑换 token,把 refresh token 加密存储,再向浏览器签发
HttpOnly + Secure + SameSiteSession 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 在浏览器内存中生成
state、nonce、verifier 和 challenge,不生成 client secret。 - 浏览器跳转到授权中心,redirect URI 必须是预注册的 HTTPS 地址。
- 用户登录并批准 scope。
- SPA callback 校验
state,使用 code + verifier 兑换 token;短期 access token 优先保存在内存,而不是localStorage。 - SPA 携带 access token 调用资源 API;需要长期会话时使用受控的 refresh token rotation,或者改成 BFF 架构。
浏览器刷新会清空内存,这是安全性与体验之间的真实取舍。可以通过静默 Session 检查或 BFF 恢复登录,不要因为怕用户重新登录就把长期 token 永久塞进可被任意脚本读取的存储。
SPA 还需要严格配置 CORS,并确保授权回调页不加载广告、统计脚本等无关第三方资源,避免 code 出现在 Referer 或前端日志里。
本地桌面程序和 CLI:Loopback Callback
桌面应用和本地 CLI 同样是 public client,但可以临时监听本机回环地址,让系统浏览器把授权码交回来。

- CLI 生成 PKCE 和
state,启动只存活几分钟的 loopback listener,例如127.0.0.1:8765。 - CLI 调用操作系统默认浏览器打开授权页,密码始终输入可信浏览器。
- 用户登录并确认应用、resource 和 scope。
- 浏览器访问 loopback callback;CLI 只接受预期路径和当前
state,旧授权页面的回调必须忽略。 - 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 endpoint 申请
device_code、短user_code、验证地址和轮询间隔。 - 用户在自己的电脑或手机打开验证地址,输入短码;浏览器不需要连接远程主机。
- 授权页展示客户端、设备、scope 和短码,用户核对后批准。
- CLI 按
interval轮询 token endpoint,正确处理 pending、slow_down、拒绝和过期。 - 批准后 CLI 获得 token,安全保存凭证并调用同一个资源 API。
Device Flow 解决的是“用户和客户端不在同一台设备”,不是给所有客户端提供一个更省事的验证码登录。能可靠使用浏览器回调的本地客户端,通常仍应优先使用 Authorization Code + PKCE。
后台任务:工作负载身份优先
定时任务、队列 Worker 和服务间调用没有用户坐在终端前,不应使用人工登录,也不应长期复用某个员工的 refresh token。

- 运行平台为 Worker 提供工作负载身份或非对称密钥,私钥不离开受控运行环境。
- Worker 使用短期签名 assertion 请求 token,不打开浏览器。
- 授权中心验证 workload identity、client、resource、scope 和环境策略。
- 授权中心签发 audience-restricted 的短期 access token,不默认签发长期 refresh token。
- 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
授权服务器依次检查:
- client 是否启用;
- redirect URI 是否精确注册;
- resource 是否允许;
- scope 是否为 client 允许集合的子集;
- 是否使用
S256PKCE; - 用户是否登录;
- 用户和策略是否允许访问;
- 是否需要展示同意页。
授权码兑换
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 再回来标记,否则并发请求可能重复兑换。

第五步:为远程 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 后无条件接受。

第七步:把身份、授权与计费上下文分开
资源服务通过 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;
- 修改密码、账号冻结或高风险事件触发撤销。
“退出登录”也要区分三件事:
- 清除网站登录 Session;
- 删除当前客户端本地凭证;
- 撤销服务器上的 grant/refresh token。
只删除本地文件不等于撤销授权;只清网站 Cookie 也不影响已经发给 CLI 的 refresh token。
第十步:密钥轮换与多副本部署
签名密钥需要版本化,kid 必须唯一。一次平滑轮换可以这样做:
- 生成新密钥并安全存储;
- JWKS 同时发布旧公钥和新公钥;
- 新 token 改用新私钥签名;
- 等旧 access token 最大寿命过去;
- 再从 JWKS 移除旧公钥;
- 保留审计记录和紧急回滚能力。
授权服务器多副本必须共享:
- 稳定签名密钥;
- 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_client | client 认证失败或 client 不存在 |
invalid_grant | code/refresh token 无效、过期或已使用 |
invalid_scope | 请求了未注册 scope |
unauthorized_client | client 不允许使用这个 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 只能使用一次;
state、nonce校验;- 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”,而不是再复制一套账号系统。
参考资料
- RFC 6749:The OAuth 2.0 Authorization Framework
- RFC 7636:Proof Key for Code Exchange
- RFC 8252:OAuth 2.0 for Native Apps
- RFC 8414:OAuth 2.0 Authorization Server Metadata
- RFC 8628:OAuth 2.0 Device Authorization Grant
- RFC 9396:OAuth 2.0 Rich Authorization Requests
- RFC 9449:OAuth 2.0 Demonstrating Proof of Possession
- RFC 9700:Best Current Practice for OAuth 2.0 Security
- RFC 9728:OAuth 2.0 Protected Resource Metadata
- OpenID Connect Discovery 1.0
微信公众号
欢迎关注「字与码」
如果这篇文章对你有用,也欢迎在微信里继续关注后续更新。
X / Twitter
关注 @ax2_zicode
更即时的技术观察、新文章提醒和一些短想法会发在 X 上。