
为什么“使用 Google 登录”比看起来更复杂
每次你点击“继续使用 Google”或“使用 GitHub 登录”时,背后都会发生一次精心编排的握手。OAuth 2.0 是实现这一功能的授权协议,而 OpenID Connect (OIDC) 是构建在其之上的身份层。它们共同驱动着网络上几乎所有现代认证流程。
本指南将厘清其中的困惑。OAuth 和 OIDC 经常被误解——即使是每天使用它们的开发者也是如此。

OAuth 2.0:授权,而非认证
需要理解的最重要一点是:OAuth 2.0 是一个授权协议,而不是认证协议。 它旨在让用户授予第三方应用程序对其资源的有限访问权限——而无需共享密码。
经典示例:一个照片编辑应用想要访问你的 Google Photos。你不想将 Google 密码交给该应用。OAuth 允许 Google 向该应用颁发一个有时间限制的访问令牌,该令牌仅具有对照片的只读访问权限。
四个 OAuth 2.0 角色
| 角色 | 描述 | 示例 |
|---|---|---|
| 资源所有者 (Resource Owner) | 拥有数据的用户 | 你 |
| 客户端 (Client) | 请求访问的应用 | 照片编辑应用 |
| 授权服务器 (Authorization Server) | 认证后颁发令牌 | Google 的认证服务器 |
| 资源服务器 (Resource Server) | 托管受保护数据 | Google Photos API |
授权码流程(最常见)
这是你应该用于带有后端的 Web 应用和移动应用的流程。
用户 → 客户端应用 → 授权服务器 → 用户登录 → 重定向并携带授权码
→ 客户端用授权码交换令牌 → 客户端用令牌调用资源服务器
逐步说明:
# 步骤 1:客户端将用户重定向到授权服务器
GET https://accounts.google.com/o/oauth2/v2/auth?
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=https://yourapp.com/callback&
scope=openid%20email%20profile&
state=RANDOM_STATE_VALUE
# 步骤 2:用户认证并批准
# 授权服务器重定向回来:
GET https://yourapp.com/callback?code=AUTH_CODE&state=RANDOM_STATE_VALUE
# 步骤 3:用授权码交换令牌(服务器到服务器,绝不在浏览器中)
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://yourapp.com/callback&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET
# 响应:
{
"access_token": "ya29.a0AfH6...",
"id_token": "eyJhbGciOiJSUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 3599,
"refresh_token": "1//0gLh..."
}
state 参数至关重要——它可以防止 CSRF 攻击。生成一个随机值,存储在会话中,并在回调到达时验证它是否匹配。
PKCE:适用于 SPA 和移动应用的授权码流程
单页应用和移动应用无法安全地存储 client_secret(它对用户可见)。PKCE(Proof Key for Code Exchange,发音为“pixy”)解决了这个问题。
// 生成 PKCE code_verifier(随机字符串)
const codeVerifier = generateRandomString(64);
// sha256("verifier") → base64url
const codeChallenge = base64url(sha256(codeVerifier));
// 步骤 1:授权请求包含 code_challenge
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// ... 其他参数
// 步骤 2:令牌交换包含 code_verifier
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
code_verifier: codeVerifier, // 证明我们发起了请求
// 不需要 client_secret!
})
});
PKCE 之所以有效,是因为拦截了授权码的攻击者无法在没有 code_verifier 的情况下交换令牌,而 code_verifier 只有合法客户端拥有。

其他 OAuth 2.0 授权类型
客户端凭证(机器对机器)
用于没有用户参与的服务器到服务器 API 调用:
// 你的服务直接使用自己的凭证进行认证
const response = await fetch('https://api.example.com/oauth/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
scope: 'read:data write:data'
})
});
const { access_token } = await response.json();
// 使用令牌调用 API
await fetch('https://api.example.com/data', {
headers: { Authorization: `Bearer ${access_token}` }
});
刷新令牌流程
访问令牌是短期的。当它们过期时,使用刷新令牌:
async function getValidToken(refreshToken) {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
})
});
const data = await response.json();
if (!response.ok) {
throw new Error('刷新失败: ' + data.error);
}
return data.access_token;
}
OpenID Connect:为 OAuth 添加认证
OAuth 只表示“这是调用此 API 的访问令牌”。它没有说明用户是谁。OpenID Connect 添加了一个 ID Token——一个包含用户身份声明的 JWT。
当你将 openid 添加到 OAuth 作用域时,授权服务器会返回一个 ID Token 以及访问令牌:
// ID Token 是一个包含用户声明的 JWT
// 解码它(但务必先验证签名!)
const idTokenPayload = {
"iss": "https://accounts.google.com", // 颁发者
"sub": "110169484474386276334", // 主题(用户 ID)
"aud": "YOUR_CLIENT_ID", // 受众
"exp": 1516239022, // 过期时间
"iat": 1516235422, // 颁发时间
"nonce": "abc123", // 重放保护
// 带有 profile 作用域的标准 OIDC 声明:
"name": "Jane Doe",
"email": "jane@example.com",
"picture": "https://...",
"email_verified": true
}

验证 ID Token
切勿在不验证的情况下信任 ID Token:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://accounts.google.com/.well-known/openid-configuration')
// Jose 会自动从发现文档中获取实际的 jwks_uri
);
async function verifyIdToken(idToken) {
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://accounts.google.com',
audience: YOUR_CLIENT_ID,
});
// 检查 nonce 以防止重放攻击
if (payload.nonce !== sessionNonce) {
throw new Error('Nonce 不匹配');
}
return payload; // 可信的用户信息
}
OIDC 发现文档
每个 OIDC 提供者都会在 /.well-known/openid-configuration 暴露一个发现文档:
curl https://accounts.google.com/.well-known/openid-configuration | jq .
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"scopes_supported": ["openid", "email", "profile"],
"response_types_supported": ["code", "token", "id_token"],
...
}
使用发现文档而不是硬编码端点——它们可能会更改。
常见安全错误
1. 将令牌存储在 localStorage 中
// ❌ 容易受到 XSS 攻击
localStorage.setItem('access_token', token);
// ✅ 使用 httpOnly cookie(JavaScript 无法访问)
// 或者存储在内存中,并在 httpOnly cookie 中使用刷新令牌
2. 未验证 state 参数
// ❌ CSRF 漏洞
router.get('/callback', async (req) => {
const { code } = req.query;
await exchangeCode(code); // 未检查 state!
});
// ✅ 始终验证 state
router.get('/callback', async (req) => {
const { code, state } = req.query;
if (state !== req.session.oauthState) {
return res.status(400).send('State 不匹配');
}
await exchangeCode(code);
});
3. 使用隐式流程(已弃用) 隐式流程将令牌直接返回在 URL 片段中——它们会出现在浏览器历史记录和服务器日志中。始终使用授权码 + PKCE 替代。
4. 未检查令牌过期
function isTokenExpired(token) {
const { exp } = JSON.parse(atob(token.split('.')[1]));
return Date.now() >= exp * 1000;
}
使用 Passport.js 的实际实现
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback',
scope: ['openid', 'email', 'profile']
}, async (accessToken, refreshToken, profile, done) => {
// profile 包含来自 Google 的已验证用户信息
let user = await User.findOne({ googleId: profile.id });
if (!user) {
user = await User.create({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
});
}
return done(null, user);
}));
// 路由
app.get('/auth/google', passport.authenticate('google'));
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => res.redirect('/dashboard')
);
OAuth 与 API 密钥与会话的对比
| 方法 | 最适合 | 安全性 | 用户体验 |
|---|---|---|---|
| OAuth 2.0 | 第三方访问、SSO | 高(有限作用域) | 无缝 |
| API 密钥 | 服务器到服务器、M2M | 中(无过期) | 简单 |
| 会话 Cookie | 自有 Web 应用 | 高(httpOnly) | 透明 |
| JWT(无状态) | 微服务 | 中(难以撤销) | 可扩展 |
→ 使用 JWT 解析器 解码和检查 OAuth ID Token 和访问令牌。