OAuth 与 OpenID Connect:最重要的视角转换,是 Web/App 才是 Client
理解 OAuth/OIDC 最容易卡住的地方,是把“正在点登录按钮的人”误认为 OAuth client。其实不是:浏览器里的 Web portal、移动 App、CLI,甚至代表自己请求 token 的后台服务或 API,才是 Client;人是 Resource Owner / End-User。
一旦角色放对,很多看似绕的跳转就自然了:授权服务器不是把 token 发给用户,而是在用户同意后,给注册过的 Client 一份可受限、可撤销的授权凭据。
End User(人)
使用
Client(Web portal / App / CLI)
向 Authorization Server 请求授权
再携带 access token 调用
Resource Server(API)

图的上半部分是 OAuth 2.0:Client 先把用户带到 Authorization Server;用户在 IdP / 授权服务器完成登录与同意;Client 收到短期 authorization code,再用它兑换 access token,最后带着 token 调用 Resource Server。下半部分是在同一授权码主线中加入 OIDC:当请求 scope 包含 openid,Client 还能得到 ID Token,用来验证“本次登录的用户是谁”。
它也说明了 OAuth 要避免的事:第三方应用不该收集或保存用户在 IdP 的密码,而应把认证交给 IdP,再取得范围受限的授权结果。图中的 Client 是 Web portal、移动 App 或 CLI;浏览器只是帮助重定向的 User Agent,不是 OAuth Client。
| 图中的直观名称 | OAuth / OIDC 术语 | 负责什么 |
|---|---|---|
| User | Resource Owner / End-User | 登录、同意授权,拥有资源或身份 |
| Web portal / App / CLI | Client | 发起授权请求、接收 code、调用 API;不是正在操作的人 |
| IdP | Authorization Server;OIDC 场景也提供身份能力 | 认证用户、征求同意、签发 code 与 token |
/my-data 的后端 API | Resource Server | 验证 access token 与 scope,决定是否返回资源 |
IdP、Authorization Server 和 Resource Server 在小型系统中可以由同一个产品承担,但概念上不应混为一谈:Authorization Server 签发凭据,Resource Server 消费 access token 并保护数据。正因为如此,OAuth 的关键不是“把登录页嵌进应用”,而是让不同系统能在不共享用户密码的前提下委托访问权限。
OAuth 解决授权,不负责定义“用户是谁”
OAuth 2.0 的目标是 delegated authorization:用户不必把密码交给第三方应用,而是让授权服务器向该应用签发范围受限的 access token。token 面向 resource server;它回答的是“这个 client 能否代表某个授权上下文访问这个 API”。
OpenID Connect(OIDC)在 OAuth 之上增加身份层:当请求含 scope=openid,Client 可以获得 ID Token,并据此验证 End-User 的身份。OIDC Core 将其定义为建立在 OAuth 2.0 之上的身份层。
因此不要拿 access token 当本地登录凭据,也不要让前端仅凭“能 decode JWT”就相信用户身份。token 的用途和接收方决定其验证方式。
Authorization Code + PKCE:把前信道与后信道分开
现代 Web、SPA 和移动应用的默认选择是 Authorization Code Flow;public client 还应使用 PKCE。流程如下:
1. Client 生成 state、nonce、code_verifier
2. Client → Authorization Endpoint
client_id, redirect_uri, scope, state, code_challenge
3. User Agent 跳转,Authorization Server 认证用户并征求同意
4. Authorization Server → registered redirect_uri?code=...&state=...
5. Client → Token Endpoint
code + code_verifier(confidential client 还需 client auth)
6. Token Endpoint → access_token (+ id_token / refresh_token)
这里的两个边界特别重要:浏览器跳转属于 front channel,容易暴露给用户代理和历史记录,所以只返回短寿命、一次性的 authorization code;兑换 token 的请求始终通过 TLS 发送到 token endpoint。public client 依赖 PKCE,让截获 code 的攻击者无法在没有 code_verifier 的情况下兑换 token;confidential client 还应在兑换时完成 client authentication。现代实践建议所有 OAuth client 都使用 PKCE,而不只是 public client。
state 是不可预测、与发起浏览器会话绑定、回调时严格比对并一次性消费的值;它主要用于关联请求和抵御 CSRF。OIDC 的 nonce 用来将 ID Token 绑定到本次认证请求并降低重放风险。authorization-server mix-up 还需要将预期 issuer 绑定到请求/回调处理,例如使用 issuer 标识或按 issuer 隔离回调处理;仅有 state 不足以覆盖它。redirect URI 必须预注册并严格匹配,不能靠“startsWith”或通配符糊弄。
OIDC 的 ID Token 必须被验证,而不是被“读取”
ID Token 通常是签名 JWT。Client 至少应验证:签名(使用 issuer 的 JWKS、预期算法白名单和正确的 kid)、iss 是否精确匹配期望 issuer、aud 是否包含自己的 client_id、exp 是否未过期,以及请求中使用了 nonce 时 nonce 是否匹配。若 aud 有多个值,OIDC 还要求 azp 存在且等于自己的 client_id;若使用 max_age,还要验证 auth_time。OIDC 对 Authorization Code Flow 的 token response 与验证步骤有明确规定。
sub 才是 issuer 范围内稳定的用户标识;email、name 是可变 profile claim。以 (iss, sub) 建立本地账户关联,比拿 email 当主键稳得多。
先问“谁在调用谁”,再选 OAuth 用法
OAuth 的常见场景可以先按两个问题划分:调用方是不是一个人发起的?下游服务是否需要知道并执行这个人的权限?这比从 grant type 的名字反推架构更不容易走偏。
可以先用一句话快速判断:有人交互时从 Authorization Code + PKCE 开始;没有用户参与时考虑 Client Credentials;下游还要按用户权限判断时,再评估 Token Exchange。

图中的中心是 Authorization Server;周围五种调用关系的差别,不在于“有没有 token”,而在于 token 需要代表谁、发给哪个 API,以及是否需要保留用户授权上下文。下面逐一展开。
| 场景 | token 代表谁 | 常见选择 | 关键点 |
|---|---|---|---|
| 用户登录自己的 Web/App | 面向特定 API 的用户授权上下文 | Authorization Code + PKCE;需要身份时加 OIDC | App 是 Client,ID Token 给 Client 验证登录,API 看 access token |
| 用户授权应用访问另一个 API | 用户委托给 Client 的权限 | Authorization Code + PKCE + consent / scope | 例如日历 App 读取用户的日历;最小 scope,token 要发给目标 API |
| 后台服务调用后台服务 | 调用服务自身 | Client Credentials | 没有用户;权限是服务权限,不应假装成某个用户 |
| 服务继续处理一位用户发起的请求 | 用户上下文,可能还有调用服务身份 | Token Exchange / delegation,或目标 API 可接受的原 token | 不能默认把上游 token 原样转发给所有下游 API |
| 用户发起的长任务 | 开始时的用户意图 + 后台服务身份 | 创建任务时做授权;执行时用服务身份 | 不要把短期 bearer token 当作长期任务凭据保存 |
下面几个案例比“Web / SPA / mobile”这种客户端外形分类更有助于理解边界。
用户 → 服务:登录和访问自己的产品
用户在 Web portal、移动 App 或 CLI 中点击登录,Client 使用 Authorization Code + PKCE 让用户在 Authorization Server 完成认证。请求 openid 时,Client 还会拿到 ID Token,用它建立自己的登录态;当 Client 调用 /my-data 一类 API 时,发送的是 access token。
传统 Web portal 是 confidential client:后端可以安全持有客户端凭据,回调后由后端兑换 token,并向浏览器建立自己的 session cookie。SPA 和 mobile 则是 public client,不能把 client_secret 藏在前端包里,仍应使用 Authorization Code + PKCE。
很多团队会再加一层 BFF(Backend for Frontend):浏览器只持有第一方 session cookie,BFF 作为 confidential client 管理 token 并调用后端 API。这不是 OAuth 的新 flow,而是把浏览器的 token 暴露面收窄的一种部署选择;是否采用取决于产品形态和浏览器端的风险模型。
用户 → 第三方 API:委托访问,而不是“共享登录”
另一个典型场景是用户授权某个应用访问外部资源:例如日历 App 读取用户的日历、报销系统读取企业网盘中的文件。这里 Client 不是资源服务器的前端,而是独立注册的应用;用户在授权服务器看到同意页面,授权的是这个 Client 在限定 scope 内访问目标 Resource Server 的能力。
这个场景最容易忽略 audience:access token 应当面向将要接收它的 API。拿到“日历 API 的 token”并不意味着可以调用“文件 API”。Resource Server 需要按授权服务器的验证约定检查 token 的 issuer、有效期、scope,以及自己是否是预期 resource。JWT access token 常以 aud 表达这层资源绑定;opaque token 则通常由 introspection endpoint 或可信网关给出等价的验证结果。RFC 9700 也明确建议授权服务器将 token 与特定 resource server 关联。RFC 9700
服务 → 服务:没有用户时,用服务自身身份
订单服务调用库存服务、定时任务调用报表 API、CI/CD 调用部署 API,这类请求没有浏览器、没有同意页面,也没有某个用户正在委托权限。Client Credentials 正是为此准备的:Client 向 token endpoint 认证自己,取得一个代表调用服务自身的 access token。
Order Service ── client authentication ──→ Authorization Server
Order Service ←─ access token (inventory.write) ──
Order Service ── access token ──→ Inventory API
这类 token 的 scope 应是服务级别权限,例如 inventory.write,而不是 user:alice。资源服务器也不应因为 token 中出现某个 sub 就假设它代表最终用户:client credentials 的身份语义由授权服务器的具体实现定义,业务代码应根据已注册 client 的身份、授权服务器明确约定的服务主体 claim,或 introspection 结果做服务授权。azp 是 OIDC ID Token 的标准 claim,不能假设它一定出现在 access token 中。
对高价值服务间调用,静态 client_secret 往往不是终点。可以使用 Kubernetes workload identity、SPIFFE 一类工作负载身份、private_key_jwt 或 mTLS 做 client authentication;RFC 8705 还定义了将 access token 绑定到客户端 mTLS 证书的方式,降低 bearer token 被窃取后可被任意重放的风险。使用证书绑定 token 时,Resource Server 还必须验证 token 的 cnf 绑定与当前 TLS 客户端证书一致。RFC 8705 对公网或 public client,DPoP 是另一种可考虑的 sender-constrained token 方案。RFC 9449
服务 → 服务,但保留用户上下文:不要盲目透传 token
更容易出错的是这条链路:浏览器带着用户 token 调用订单 API,订单 API 又需要调用优惠、库存或支付 API。此时下游究竟需要什么?
- 如果下游只做服务内部工作,不需要按用户分别授权,订单服务应使用自己的 service token;用户身份可以作为受审计的业务字段或 trace context 传播。
- 如果下游必须按用户权限决定能否访问,且需要保留委托关系,就不能随手改用 client credentials,否则用户上下文会丢失。
- 也不应默认把收到的 bearer token 原样转给任意下游:它可能只签发给订单 API,
aud和 scope 并不允许库存或支付 API 接受它。
需要为目标 API 获得一个受限 token 时,可以考虑 OAuth 2.0 Token Exchange。订单 API 作为 client,将收到的 token 交换为 audience 指向下游 API、scope 更窄的新 token;RFC 8693 提供了 token exchange 框架,并允许表达 subject/actor 等关系。不过这是一项扩展能力:哪些服务能交换、哪些 subject token 可接受、目标 audience、最大 scope、委托链长度,以及 actor/subject 的审计方式,都应由授权服务器策略明确限制并记录。RFC 8693
User → Order API (token audience = order-api)
Order API → Authorization Server (token exchange)
Order API ← narrower token (audience = inventory-api)
Order API → Inventory API
这条链路的原则是:每个 API 只接受发给自己的 token;每次下游调用只带完成该调用所需的最小权限。它既避免 token 混用,也让审计能够区分“哪个用户触发”与“哪个服务执行”。
Token Exchange 的核心:凭什么从一个 token 换到另一个?
Token Exchange 可以先用一句话理解:Client 拿一个已有 token 去请求另一个 token;但“能不能换、换成谁、能访问什么”永远由 Authorization Server 的策略决定。 它不是把 JWT 解码后改几个 claim 再重新签名,也不是任何持有 token 的人都能获得更高权限。
Authorization Server 在签发新 token 前,至少需要同时确认四类证据:
1. 谁在请求交换?
→ client authentication:mTLS、private_key_jwt、workload identity 等
2. 输入 token 可信吗?
→ 签名 / introspection、issuer、有效期、撤销状态、token type
3. 它为什么能代表这个 subject?
→ 用户登录或同意、已有 delegation、设备与用户绑定、租户策略
4. 新 token 最多能做什么?
→ 允许的 resource / audience、scope 上限、有效期、actor chain
因此,所谓“映射关系”不是两个 token 天然携带的数学关系,而是一条由授权服务器维护的授权链。输入 token 提供上下文和可验证事实;已认证的 Client 提出请求;授权服务器根据用户、设备、服务、租户和目标 API 的策略,决定能否签发一个权限不扩大、audience 更精确、生命周期更短的新 token。
一个容易遇到的例子是 bootstrap。某个设备或安装客户端刚启动时,只有一枚短期 bootstrap token;它可以完成注册、展示配对码或发起绑定,但还不能冒充任何用户。用户随后在浏览器中完成登录和同意,Authorization Server 才把这次登录与设备/安装会话绑定:
Device / Installer
│ bootstrap token:device.bootstrap
▼
Authorization Server ── 创建配对 / 绑定会话 ──→ User 登录并同意
│
│ 验证:设备身份 + 一次性绑定会话 + 用户认证 + tenant policy
▼
短期 user access token
sub = user-123
aud = target-api
scope = profile.read
act = device-456(可选,用于审计)
这里真正证明“bootstrap token 可以映射到 user token”的,不是 bootstrap token 本身含有用户 ID,而是授权服务器持有的绑定事实:该设备身份创建了这次一次性会话;该会话由用户 user-123 完成认证/同意;该设备和该用户属于允许绑定的租户;策略允许它只换取发给 target-api 的最小 scope。缺少其中任何一项,授权服务器就应该拒绝交换。
这也解释了为什么 bootstrap token 应当短期、一次性且权限极窄,例如只允许 device.bind-user;交换完成后应消费或失效。新发出的 user access token 也不应继承 bootstrap token 的广泛能力,而应按照目标 API 的 audience、scope 和短生命周期重新签发。若需要审计“谁代表谁执行”,可以在授权服务器定义的 token 格式中记录 subject 与 actor;Resource Server 再根据自身策略决定是否接受这种 delegated call。
用户发起、后台很久才执行:把授权决定持久化,而不是保存 bearer token
导出报表、异步导入、视频转码等任务可能运行几十分钟甚至数小时。创建任务时可以用用户 access token 做一次授权,并把任务创建者、资源范围、授权决定和审计信息持久化;worker 真正执行时使用自己的服务身份访问内部 API。不要为了“以后还能代表用户”而把短寿命 bearer token 存进任务表,也不要默认靠 refresh token 让后台无限续命。
这里还要选清楚权限变化策略:资源被删除、成员被移除或用户撤销授权后,worker 是在执行点重新鉴权,还是遵循创建时冻结、可审计的权限快照?两种都可能合理,但不能没有定义。如果业务确实要求长时间的 delegated access,需要 refresh-token rotation、重用检测、绑定 client 或 sender-constrained key、最短可行生命周期、撤销和再次同意机制;它是产品授权模型的一部分,不只是把 token 字段保存得更久。
图中列出的 Implicit Flow 与 Resource Owner Password Credentials Flow 是 OAuth 2.0 早期常见的分类,新系统不应使用:前者容易使 token 暴露在前信道,后者让 client 接触用户密码,均不符合现代安全最佳实践。浏览器、SPA、移动端通常统一采用 Authorization Code + PKCE;没有用户参与的服务间调用再考虑 client credentials;需要向下游保留用户授权边界时,再评估 token exchange。对没有浏览器输入能力的 CLI、TV 或设备,还可考虑 Device Authorization Grant,而不是强行套浏览器重定向。
OAuth 2.0 的最新安全最佳实践明确强调重定向 URI、PKCE、token 泄露与客户端认证等问题。
最后可以用两句自检来收束:这个 API 接收的 token 是发给它的吗?这次调用代表用户,还是服务自身? 登录页上的人完成的是认证与同意;Web/App 或后台服务作为 Client 获得的是可验证、受范围约束的结果;API 只接受发给自己的 access token。把这三者混在一起,认证系统就会变得既不安全也难以解释。
References
相关文档
- OpenID Connect Core 1.0
- RFC 6749:The OAuth 2.0 Authorization Framework
- RFC 7636:Proof Key for Code Exchange by OAuth Public Clients
- RFC 9700:OAuth 2.0 Security Best Current Practice
- RFC 8693:OAuth 2.0 Token Exchange
- RFC 8705:OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
- RFC 9207:OAuth 2.0 Authorization Server Issuer Identification
- RFC 9449:OAuth 2.0 Demonstrating Proof of Possession (DPoP)