OAuth 和 OpenID Connect 是现代开发者不断使用的两项极其基础的技术,然而他们却经常对其进行不准确的描述。人们会随口说出诸如“OAuth 登录”或“使用 OAuth 安全登录”之类的话。有时这种轻松的措辞只是一种无害的行业行话,但在其他时候,它却掩盖了一个极其重要的架构区别。OAuth 2.0 从根本上讲是一个完全围绕授权构建的框架。而 OpenID Connect(几乎总是被简称为 OIDC)是一个严格为身份验证而构建的身份层。
这种语义上的差异听起来纯粹是学术性的,直到你实际构建一个真正的、生产级别的应用程序。如果你带着错误的心智模型去操作,你很容易就会把标准的访问令牌(access token)当作用户身份的决定性证明,危险地跳过关键的令牌验证步骤,将高度敏感的令牌不安全地存储在移动设备上,或者愚蠢地假设每一个身份提供商(Identity Provider)的行为都跟 Google 完全一样。
关于这种区别,最简单直接的说法是这样的:OAuth 坚定地回答了这样一个关键问题:“这个特定的应用程序到底被允许做什么?”与此同时,OIDC 则清晰地回答了一个完全不同的问题:“这个特定的用户究竟是谁?”毫不夸张地说,OIDC 是直接构建在既有的 OAuth 2.0 框架之上的,它无缝地为 OAuth 现有的授权流程添加了一个健壮的、高度标准化的身份层。
腕带与身份证的类比
想象一下试图进入一家高级夜总会。
OAuth 本质上就像在门口收到一条色彩鲜艳的腕带。它清楚地告诉保安和场地工作人员,你正式被允许进入哪些区域。也许你的腕带意味着你可以进入用绳子隔开的 VIP 区,或者它可能标志着你可以直接在高级吧台点单。从各个意义上讲,腕带仅仅代表了被授予的权限。但至关重要的是,腕带绝对无法证明你到底是谁。它没有显示你的法定姓名,没有你的照片,没有确认你的年龄,也没有包含任何官方的身份号码。
OIDC 就像是在收到完全相同的那条腕带的同时,还被要求携带一张高度可信的政府身份证。你仍然保留着通过腕带获得的所有权限,但你现在还拥有了一份被广泛信任的、标准化的身份文件,明确证明了你究竟是谁。
这种情况与软件世界中发生的技术情况如出一辙:
OAuth 2.0 gives you:
Access token
Used to call APIs
OIDC gives you:
Access token
Used to call APIs
ID token
Used to identify the user访问令牌和身份令牌在功能上是兄弟关系。一个令牌绝对不是另一个令牌的替代品。
OAuth 2.0 实际上做了什么
在其最核心的层面,OAuth 2.0 仅仅是让用户安全地授予第三方应用程序明确的权限,以代表用户访问受到严密保护的资源。一个非常常见的现实示例是将一个新应用程序连接到你现有的 GitHub 账户。该应用程序无缝地将用户直接重定向到 GitHub,并明确请求特定仓库的访问权限。
https://github.com/login/oauth/authorize?
client_id=abc123&
scope=repo&
redirect_uri=https://myapp.com/callback随后,GitHub 会向用户展示一个清晰的同意界面。如果用户明确批准了该请求,GitHub 会安全地将他们重定向回原始应用程序,并携带一个临时的授权码。
https://myapp.com/callback?code=xyz789在幕后,该应用程序随后会安全地将该临时代码交换为一个更持久的访问令牌。
POST https://github.com/login/oauth/access_token
Content-Type: application/x-www-form-urlencoded
client_id=abc123&
client_secret=secret456&
code=xyz789&
redirect_uri=https://myapp.com/callback提供商的响应中重要的是包含新生成的访问令牌。
{
"access_token": "gho_16C7e42F292c6912E7710c838347Ae178B4a",
"token_type": "bearer",
"scope": "repo"
}现在,这个特定的令牌可以被重复使用,以代表用户调用受到高度保护的 GitHub API。
GET https://api.github.com/user/repos
Authorization: Bearer gho_16C7e42F292c6912E7710c838347Ae178B4a请仔细注意,在整个过程中,OAuth 根本没有给我们什么。它绝对没有给我们任何类型的标准身份令牌。它严格意义上只给了我们调用 API 的限定范围的权限。特别是对于 GitHub,如果你实际上想要确定用户的身份,你将被迫使用该访问令牌手动调用显式的身份 API,例如 GET /user。这个额外步骤的存在,正是因为 GitHub 最常见的登录流程利用了纯粹的 OAuth 2.0,而不是 OIDC。
OIDC 增加了什么
OIDC 巧妙地从完全相同的基础 OAuth 流程开始,但明确添加了一个高度需要的、标准化的身份作用域(scope),名为 openid。例如,一个典型的 Google 登录请求几乎总是包含一组如下所示的作用域:
scope=openid profile email这个简单的 openid 作用域向提供商发出了一个决定性的信号,表明这在形式上是一个 OpenID Connect 流程。请求应用明确表示,它不仅仅是在要求标准的 API 访问权限;它还在积极要求底层提供商严格对用户进行身份验证,然后以高度标准化、全球认可的格式返回其身份信息。
在标准代码交换完成之后,一个兼容 OIDC 的提供商会可预见地同时返回一个访问令牌和一个身份令牌(ID token)。
{
"access_token": "ya29.a0AfH6SMBxC...",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE2...",
"refresh_token": "1//04dGKC8...",
"expires_in": 3600,
"token_type": "Bearer"
}访问令牌坚决保持专用于调用 API。新引入的身份令牌则充当高度可信的身份文档。至关重要的是,身份令牌几乎总是被格式化为 JWT(JSON Web Token),这严格规定了它拥有三个不同的部分:标头(header)、有效载荷(payload)和加密签名(signature)。
header.payload.signature核心有效载荷中大量包含了关于特定用户以及身份验证事件本身确切情况的详细声明(claims)。
{
"sub": "109651783456723954281",
"name": "John Doe",
"email": "john.doe@gmail.com",
"email_verified": true,
"picture": "https://lh3.googleusercontent.com/a-/abc123",
"iss": "https://accounts.google.com",
"aud": "1234567890-abcd1234.apps.googleusercontent.com",
"iat": 1712345678,
"exp": 1712349278
}sub 声明始终作为高度稳定的、提供商端唯一的最终用户 ID。iss 声明可靠地告诉你究竟是谁安全地颁发了该令牌。aud 声明明确指出该令牌是有意为哪个特定的客户端应用程序颁发的。exp 声明清晰地定义了该令牌在数学上何时过期。然而,除非你首先主动且正确地验证令牌的加密完整性,否则这些嵌入的声明不仅完全没用,还具有潜在的危险。
永远不要只对 ID Token 进行解码
一个极其常见且非常危险的新手错误是,偷懒地拆分 JWT,解码 base64 有效载荷,然后盲目相信里面碰巧包含的任何文本。这种鲁莽的过程绝对不是验证;它仅仅是文本解析。
JWT 只有在它被颁发者加密签名的情况下才真正有用。你的安全后端必须对照身份提供商公开托管的密钥,严格验证那个嵌入的签名。此外,它必须严格验证颁发者(issuer),严格检查受众(audience),强制执行过期时间,并积极验证你的应用程序严重依赖的任何其他特定声明。
标准后端验证清单的核心概念非常简单:
| 检查项 | 为什么重要 |
|---|---|
| 签名 (Signature) | 证明令牌确实来自受信任的提供商 |
| 颁发者 (Issuer) | 防止接受完全来自错误提供商的伪造令牌 |
| 受众 (Audience) | 防止意外接受旨在用于另一个应用的有效令牌 |
| 过期时间 (Expiration) | 严格防止恶意重复使用旧的、已过期的令牌 |
| 邮箱验证 (Email verification) | 有效防止盲目相信未经证实且可能是伪造的电子邮件地址 |
安全后端验证功能的一个高度简化版本通常看起来像这样:
def verify_token(token):
public_key = fetch_provider_public_key(token)
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"],
audience=YOUR_CLIENT_ID,
issuer="https://accounts.google.com",
)
if not payload.get("email_verified"):
raise ValueError("Email is not verified")
return payload在真实的生产环境中,你不可避免地还需要强大的 JWKS 缓存机制、复杂的密钥轮换处理逻辑,以及深度特定于提供商的验证规则。最重要的总体思路是,稳健的身份验证从根本上属于安全的后端,绝对不能仅仅停留在前端解码、容易被操纵的令牌载荷中。
提供商之间的差异很重要
至关重要的是要明白,并非每一个“使用 X 登录”按钮实际上都在底层利用了 OIDC。像 Google、Apple、Microsoft、Auth0、Keycloak 和 Zitadel 这样的大型提供商,以及众多现代身份平台,都完全支持 OIDC 标准。在这些高度现代化的流程中,你可以可靠地期望接收到一个标准的、可验证的身份令牌。
然而,GitHub 最常用的 OAuth 应用程序流程有着明显的不同。它通常只提供一个访问令牌。为了实际获取用户的配置文件信息,你将被迫使用该令牌手动调用 GitHub API。
const userResponse = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const user = await userResponse.json();这种方法不一定天生就更糟,但它不可否认是不同的。纯 OAuth 绝对仍然可以成功用于现代登录流程中,但实际的身份检索机制将完全特定于提供商,除非所选的提供商明确支持 OIDC 标准。这种细微的区别也极大地影响了账户链接策略。在真正的 OIDC 下,sub 声明始终充当该特定提供商的高度稳定的身份密钥。然而,对于仅支持 OAuth 的提供商,你必须手动检索并仔细存储直接从其自定义 API 响应中派生的提供商的唯一用户 ID。
托管身份验证 vs 自托管 OIDC
在应用程序开发的现实世界中,你几乎总是面临着这样的选择:是利用完全托管的身份验证提供商,还是完全靠自己辛苦实现一个稳健的 OIDC 集成。
像 Clerk、Auth0 和 Firebase Auth 这样的托管服务,其存在就是专门为了抽象并隐藏绝大多数痛苦的协议细节。它们能毫不费力地处理密集的提供商配置、复杂的令牌交换、积极的令牌刷新、安全的会话管理、持久的用户记录,以及深度集成的 SDK。当纯粹的开发速度真正重要时,这种“开箱即用”的方法极具吸引力。
相反,像 Zitadel 或 Keycloak 这样的自托管身份提供商则提供了显著更细粒度的控制。对于严格的数据主权要求、密集的合规性检查、深度的业务流程定制,或者在企业规模下大规模节省成本而言,它们通常是好得多的选择。然而,代价是你必须对更大部分的集成工作承担重大责任。
使用托管提供商时,你的后端可能只需依赖于验证由该提供商的自定义 SDK 显式生成的轻量级会话令牌。
async def require_auth(request):
request_state = clerk.authenticate_request(
request={"headers": dict(request.headers), "url": str(request.url)}
)
if not request_state.is_signed_in:
raise HTTPException(status_code=401)
return request_state.payload如果是自托管的 OIDC 提供商,你通常要完全从零开始负责手动配置授权端点、令牌端点、客户端 ID、重定向 URI、请求的作用域,以及繁重的后端 JWT 验证逻辑。这两种特定的方法都没有绝对的对错。托管身份验证大大减少了运营负担,而自托管则极大地增强了长期的控制权。
移动应用需要 PKCE
绝对关键的一点是要意识到,移动应用程序在根本上与传统的、服务端渲染的 Web 应用程序并不完全相同。传统的 Web 应用程序可以安全且永久地将高度敏感的客户端密钥(client secret)完全存储在后端服务器上。原生移动应用程序绝对不能这样做。毫不夸张地说,任何被编译并发布在公共 APK 或 IPA 文件中的东西,都很容易被稍微有些决心的用户或自动化脚本提取出来。这一严酷的现实意味着,原生移动应用必须毫不含糊地被视为高度脆弱的“公共客户端(public clients)”。
这个显而易见的漏洞,正是为什么所有移动端 OAuth 和 OIDC 流程都应该严格使用 PKCE(Proof Key for Code Exchange,代码交换证明密钥)的原因。PKCE 巧妙地为流程添加了一个临时的、高度动态的、每次登录专属的密钥。移动应用会在本地生成一个随机的 code_verifier,将其安全地哈希化为一个 code_challenge 并在初始授权请求期间发送,然后在最终将代码交换为实际令牌时,确凿地证明它知道最初的验证器。
1. App creates a random code_verifier
2. App hashes it into a code_challenge
3. Authorization request includes the code_challenge
4. Provider returns an authorization code
5. Token request includes the original code_verifier
6. Provider checks that it matches the challenge在流行的 Expo 生态系统中,这种复杂的舞蹈几乎总是直接通过 expo-auth-session 库干净利落地处理掉。
const [request, response, promptAsync] = AuthSession.useAuthRequest(
{
clientId: CLIENT_ID,
scopes: ["openid", "profile", "email"],
redirectUri,
usePKCE: true,
},
discovery
);此外,移动应用程序迫切需要极其安全的本地令牌存储。你绝对不能将高度敏感的令牌倾倒进未加密、易于读取的异步存储(async storage)中。在 Expo 环境中,你应始终严格使用 expo-secure-store 来锁定任何敏感的令牌素材。
import * as SecureStore from "expo-secure-store";
await SecureStore.setItemAsync("id_token", idToken);
const savedToken = await SecureStore.getItemAsync("id_token");三重令牌层
一个造成巨大且持久困惑的根源在于这样一个简单的事实:现代应用程序几乎总是同时处理远不止一种类型的令牌。外部身份提供商可能随时颁发一个身份令牌和一个单独的访问令牌。作为回应,你的内部后端随后可能会动态创建自己专属的专有会话令牌。为了让事情变得更复杂,提供商可能还会同时颁发一个长效的刷新令牌(refresh token)。
这些变化多端的令牌在架构中服务于完全不同、高度专业的目的。
| 令牌 | 颁发者 | 目的 |
|---|---|---|
| 身份令牌 (ID token) | 身份提供商 | 决定性地证明用户确切是谁 |
| 访问令牌 (Access token) | 身份或 API 提供商 | 严格授权下游 API 调用 |
| 应用会话令牌 (App session) | 你的应用或 Auth 平台 | 直接针对你的后端验证持续的请求 |
| 刷新令牌 (Refresh token) | 提供商 | 在旧令牌过期时安全地获取新的、短期的令牌 |
一个非常常见、非常稳健的架构流程通常看起来有些像这样:
Google ID token -> Your backend verifies it -> Your backend creates an app session在最初的交换之后,你的客户端应用程序可以简单地利用自己深度定制的会话令牌来高效调用你的内部 API。它绝对不需要在每一个网络请求上都不断地传输庞大的 Google 身份令牌,除非你奇特的架构本来就是这么有意设计的。
常见的误解
业内绝对最常见、最普遍的错误就是随口声称 OAuth 字面意思就是“登录”。OAuth 当然可以被大量利用来安全地参与到更广泛的登录流程中,但 OAuth 本身严格且完全是关于委派授权的。OIDC 才是实际上为了让人们登录而专门构建的、高度标准化的身份层。
另一个大量遇到且频繁发生的错误是试图使用身份令牌来直接授权 API 调用。身份令牌的存在完全是为了前端客户端或后端服务器来决定性地理解用户的身份。访问令牌的存在则完全是为了进行稳健的 API 授权。模糊这两条界限会不断导致严重的架构缺陷。
登出(Logout)行为代表了另一个极其微妙的困惑领域。将用户从你的特定应用中登出,通常只是清除你本地的应用会话并愤怒地删除本地令牌。它绝对不一定会强迫用户退出 Google、Apple,或者存在于他们物理设备上的每一个其他相连的应用程序。这种会话状态的坚决分离是完全正常的,也是预期之中的。
最后,请求的权限作用域应该始终保持在人力可能的最小范围内。如果你的应用程序真的只需要知道用户的身份,你永远应该只请求 openid profile email 作用域。你绝对不应该随口要求广泛访问他们的日历、私人网盘、个人照片或其他广泛的权限,除非你的应用程序实际上从根本上需要它们才能发挥作用。
实用总结
OAuth 2.0 从根本上讲是权限框架。它决定性地允许给定的应用程序代表用户做一些极其特定的事情。OIDC 是直接坐在其顶部的标准化身份层。它以通用标准的方式安全地让应用程序可靠地知道用户到底是谁。
专门使用访问令牌来调用 API。专门使用身份令牌来验证身份。始终在后端严格验证令牌。始终对原生移动应用程序积极实施 PKCE。始终将敏感令牌存储在高度安全的加密存储中。始终将身份提供商的差异视为高度真实的架构约束,而不仅仅是表面的变化。
如果你从本文中绝对记不住其他任何东西,请强迫自己记住这条铁律:OAuth 仅仅给了你的应用权限,但 OIDC 决定性地给了你的应用身份。
