关于 oauth2-authentication
oauth2-authentication 是帮助开发者实现 OAuth2 与 OpenID Connect 身份验证和授权的技能,适用于 Web、移动应用和 API。内容涵盖多种授权流程、令牌管理、权限范围及安全实践。
主要功能
- 01涵盖多种 OAuth2 授权流程
- 02说明访问令牌与刷新令牌管理
- 03介绍 OpenID Connect ID 令牌
- 04按权限范围实施访问控制
- 05提供状态参数与 PKCE 安全建议
适用场景
- 01为 SPA 或移动应用实现登录
- 02为服务间通信配置令牌授权
- 03集成社交登录或单点登录
- 04使用范围保护 API 资源
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ manutej/ luxor-claude-marketplace --skill oauth2-authentication安装后在 Claude Code 中输入/oauth2-authentication确认已加载。
附带文件README.md
详细介绍
OAuth2 身份验证
一项全面的技能,涵盖如何使用 OAuth2 和 OpenID Connect 实现安全的身份验证与授权。本技能介绍所有主要授权流程、令牌管理策略、安全最佳实践,以及适用于 Web、移动端和 API 应用的实际实现模式。
何时使用此技能
在以下情况下使用此技能:
- 在 Web 应用、SPA 或移动应用中实现用户身份验证
- 使用访问令牌和刷新令牌构建 API 授权
- 集成社交登录(Google、GitHub、Facebook、Twitter 等)
- 创建安全的机器对机器(M2M)身份验证
- 在多个应用之间实现单点登录(SSO)
- 构建 OAuth2 授权服务或身份提供商
- 添加委托授权,以允许第三方访问
- 使用基于令牌的身份验证保护 API
- 实现无密码身份验证流程
- 添加具有组织特定规则的多租户身份验证
- 从基于会话的身份验证迁移到基于令牌的身份验证
- 使用 OAuth2 作用域实现细粒度访问控制
核心概念
OAuth2 基础知识
OAuth2 是一种授权框架,可让应用获得对 HTTP 服务上用户帐户的有限访问权限。它将用户身份验证委托给托管用户帐户的服务,并授权第三方应用访问该帐户。
关键术语:
- 资源所有者:拥有数据或资源的用户
- 客户端:请求访问资源的应用(Web 应用、移动应用、SPA)
- 授权服务:在对资源所有者进行身份验证后颁发访问令牌
- 资源服务:托管受保护的资源,并接受和验证访问令牌
- 访问令牌:用于访问受保护资源的短期凭据
- 刷新令牌:用于获取新访问令牌的长期凭据
- 作用域:访问特定资源或执行操作所授予的权限
- 授权码:用于换取访问令牌的临时代码
- 状态参数:用于防止授权流程中的 CSRF 攻击
- 重定向 URI:授权后将用户重定向到的回调 URL
OAuth2 授权类型(授权流程)
OAuth2 为不同用例定义了多种授权类型:
1. 授权码流程
最安全的流程——推荐用于服务端应用
授权码流程是最安全、使用最广泛的 OAuth2 流程。它涉及在服务端将授权码交换为访问令牌。
流程步骤:
- 客户端将用户重定向到授权服务,并附带 client_id、redirect_uri、scope 和 state
- 用户进行身份验证,并同意所请求的权限
- 授权服务将用户重定向回客户端,并附带授权码
- 客户端使用客户端密钥在服务端将授权码换取访问令牌
- 客户端使用访问令牌访问受保护的资源
适用场景:
- 传统的服务端 Web 应用
- 能够安全存储客户端密钥的应用
- 需要最高安全性的场景
- 需要刷新令牌的场景
安全优势:
- 访问令牌不会暴露给浏览器
- 通过客户端密钥进行客户端身份验证
- 授权码只能使用一次,且有效期较短
- 状态参数可防止 CSRF 攻击
2. 使用 PKCE 的授权码流程
面向公共客户端(SPA 和移动应用)的安全流程
PKCE(Proof Key for Code Exchange,读作“pixy”)是授权码流程的扩展,专为无法安全存储客户端密钥的公共客户端设计。
流程步骤:
- 客户端生成 code_verifier(随机字符串)和 code_challenge(SHA256 哈希值)
- 客户端携带 code_challenge 重定向到授权服务器
- 用户进行身份验证并授权
- 授权服务器返回授权码
- 客户端使用 code 和 code_verifier 交换访问令牌
- 服务器验证 code_verifier 是否与 code_challenge 匹配
适用场景:
- 单页应用(SPA)
- 移动应用(iOS、Android)
- 桌面应用
- 任何无法存储密钥的公共客户端
安全优势:
- 防止授权码拦截攻击
- 无需客户端密钥
- 防止恶意应用拦截重定向
- OAuth2 安全最佳实践(RFC 8252)推荐使用
3. 客户端凭证流程
机器间身份验证
客户端凭证流程适用于客户端本身就是资源所有者的场景,通常用于服务间通信。
流程步骤:
- 客户端使用 client_id 和 client_secret 进行身份验证
- 授权服务器验证凭证
- 授权服务器签发访问令牌
- 客户端使用令牌访问受保护的资源
适用场景:
- 与 API 通信的后端服务
- Cron 作业或计划任务
- 微服务身份验证
- 访问 API 的 CI/CD 流水线
- 无用户上下文的系统级操作
特点:
- 无需用户参与
- 客户端是资源所有者
- 没有刷新令牌(只需重新请求访问令牌)
- 通常使用有效期较长或缓存的令牌
4. 隐式流程(已弃用)
旧版流程——不再推荐
隐式流程会直接在 URL 片段中返回令牌,而不进行授权码交换。此流程现被认为不安全,应避免使用。
弃用原因:
- 访问令牌会暴露在浏览器历史记录中
- 无客户端身份验证
- 不支持刷新令牌
- 容易遭受令牌窃取
- 请改用使用 PKCE 的授权码流程
5. 资源所有者密码凭证(ROPC)
旧版流程——除非必要,否则避免使用
资源所有者密码凭证流程允许客户端直接收集用户名和密码,然后将其交换为令牌。
适用场景(较少):
- 从旧版身份验证迁移的第一方移动应用
- 仅限受信任的第一方应用
- 无法使用浏览器/重定向流程时
避免使用的原因:
- 客户端直接处理用户凭证(安全风险)
- 不支持多重身份验证
- 存在网络钓鱼风险
- 违背 OAuth2 委托授权原则
- 条件允许时,请改用使用 PKCE 的授权码流程
6. 设备授权流程
适用于输入受限的设备
设备流程专为输入能力有限的设备设计(智能电视、IoT 设备、CLI 工具)。
流程步骤:
- 设备向授权服务器请求设备码和用户码
- 设备显示用户码,并提示用户访问 URL
- 用户在另一台设备上访问该 URL 并输入用户码
- 用户进行身份验证并授权设备
- 设备轮询授权服务器以获取访问令牌
- 用户完成授权后,授权服务器签发访问令牌
适用场景:
- 智能电视和流媒体设备
- 没有键盘的 IoT 设备
- CLI 工具和命令行应用
- 游戏主机
- 任何难以输入文字的设备
令牌类型与管理
访问令牌
用于访问受保护资源的短期凭据
特征:
- 通常在 15 分钟至 1 小时后过期
- Bearer 令牌格式:
Authorization: Bearer <access_token> - 可以是不透明令牌,也可以是 JWT(JSON Web Token)
- 应视为敏感凭据
- 切勿记录或暴露在 URL 中
- 每次 API 请求都应验证
JWT 结构(使用 JWT 时):
Header.Payload.Signature
JWT 载荷声明:
sub:主体(用户 ID)iat:签发时间exp:过期时间iss:签发方(授权服务)aud:受众(资源服务)scope:授予的权限- 自定义声明(用户元数据、角色等)
令牌验证:
- 使用公钥验证签名
- 检查过期时间(exp 声明)
- 验证签发方(iss 声明)
- 验证受众(aud 声明)
- 检查令牌是否具有所需的范围
刷新令牌
用于获取新访问令牌的长期凭据
特征:
- 通常在数天、数周或数月后过期
- 可单次使用或重复使用(取决于实现)
- 必须安全存储(浏览器中切勿存储在 localStorage)
- 静态存储时应加密
- 授权服务可以撤销
- 受轮换策略约束
刷新令牌轮换:
- 每次刷新都会签发一个新的刷新令牌
- 旧刷新令牌会失效
- 防止令牌重放攻击
- 检测令牌被盗(多次刷新尝试)
- 推荐的安全实践
令牌存储最佳实践:
Web 应用:
- 访问令牌:内存(React context、Vuex、Redux)
- 刷新令牌:HttpOnly、Secure、SameSite Cookie
- 替代方案:在后端存储刷新令牌,并使用会话
移动应用:
- 使用平台安全存储
- iOS:Keychain Services
- Android:EncryptedSharedPreferences 或 Keystore
- 切勿存储在明文文件中
SPA:
- 仅在内存中存储访问令牌
- 对刷新令牌使用 BFF(Backend for Frontend)模式
- 考虑使用 Token Handler 模式
- 避免使用 localStorage(存在 XSS 漏洞)
ID 令牌(OpenID Connect)
包含用户身份信息的令牌
特征:
- 始终采用 JWT 格式
- 包含用户个人资料信息
- 用于身份验证(而非授权)
- 与访问令牌一同返回
- 使用前应进行验证
标准声明:
sub:主体(唯一用户 ID)name:全名email:电子邮件地址email_verified:电子邮件验证状态picture:个人资料图片 URLiat、exp:签发时间/过期时间
OAuth2 范围
用于访问控制的细粒度权限
范围定义客户端请求的访问权限,以及访问令牌允许的操作。
范围命名约定:
read:users- 读取用户数据write:users- 创建/更新用户delete:users- 删除用户admin:all- 完全管理权限openid- 请求 OpenID Connect ID 令牌profile- 访问用户个人资料信息email- 访问用户电子邮件地址
最佳实践:
- 仅请求所需的最小范围(最小权限原则)
- 分离读写权限
- 创建特定于资源的范围
- 记录所有可用范围
- 允许用户查看并理解所请求的权限
- 在 API 中实施基于范围的访问控制
动态范围:
read:organization:{org_id}
write:project:{project_id}
admin:tenant:{tenant_id}
安全注意事项
State 参数
防止跨站请求伪造(CSRF)
state 参数是一个随机值,客户端在授权请求中包含该值,并在回调时进行验证。
实现:
- 生成随机 state 值(使用密码学安全的方式)
- 将 state 存储在会话或加密 Cookie 中
- 在授权 URL 中包含 state
- 收到回调时,验证 state 是否匹配
- 拒绝不匹配或缺失的 state
State 值示例:
state: crypto.randomBytes(32).toString('hex')
// "7f8a3d9e2b1c4f5a6d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
PKCE 实现
代码交换证明密钥(Proof Key for Code Exchange)——防止代码拦截
PKCE 可保护授权码流程免受授权码拦截攻击。
代码验证器:
- 随机字符串,长度为 43–128 个字符
- 字符:A-Z、a-z、0-9、-、.、_、~
- 在客户端安全存储
代码质询:
- 代码验证器的 SHA256 哈希(推荐)
- 或使用原始代码验证器(不推荐)
- 在授权请求中发送
代码质询方法:
S256:SHA256 哈希(使用此方法)plain:明文验证器(仅用于旧版)
实现示例:
// Generate code verifier
const codeVerifier = generateRandomString(64);
// Generate code challenge
const codeChallenge = base64UrlEncode(
sha256(codeVerifier)
);
// Store code verifier for token exchange
sessionStorage.setItem('code_verifier', codeVerifier);
// Include in authorization URL
const authUrl = `${authEndpoint}?` +
`client_id=${clientId}` +
`&redirect_uri=${redirectUri}` +
`&response_type=code` +
`&scope=${scopes}` +
`&state=${state}` +
`&code_challenge=${codeChallenge}` +
`&code_challenge_method=S256`;
令牌安全
保护访问令牌和刷新令牌
应做事项:
- 对所有 OAuth2 端点使用 HTTPS
- 仅在安全存储中保存刷新令牌
- 实现令牌轮换
- 设置适当的令牌过期时间
- 在每次 API 请求时验证令牌
- 使用强签名算法(RS256、ES256)的 JWT
- 实现令牌撤销
- 监控可疑的令牌使用情况
不应做事项:
- 将令牌存储在 localStorage 中(存在 XSS 风险)
- 在 URL 或查询参数中包含令牌
- 在应用程序日志中记录令牌
- 使用弱签名算法(使用共享密钥的 HS256)
- 在应用程序之间共享令牌
- 不必要地延长访问令牌的有效期
- 忽略令牌过期
重定向 URI 验证
防止开放重定向漏洞
严格验证规则:
- 必须完全匹配(不允许通配符)
- 协议必须完全匹配(https://)
- 主机必须完全匹配
- 端口必须匹配(如果已指定)
- 路径必须匹配(如果已指定)
- 提前注册所有重定向 URI
移动设备深层链接:
- 使用自定义 URL 方案:
com.example.app://callback - 或使用通用链接(iOS):
https://example.com/auth/callback - 或使用应用链接(Android):
https://example.com/auth/callback - 在授权服务器中注册这些方案
本地主机开发:
- 仅在开发环境中允许 http://localhost
- 指定确切端口:
http://localhost:3000/callback - 如果 localhost 不可用,请使用 127.0.0.1
- 绝不要在生产环境中使用 localhost 重定向
OpenID Connect (OIDC)
基于 OAuth2 构建的身份层
OpenID Connect 在 OAuth2 之上增加了身份层,除了授权之外,还提供身份验证。
与 OAuth2 的主要区别:
- 除访问令牌外,还会返回 ID 令牌
- ID 令牌包含用户身份信息
- 标准化的用户信息端点
- 标准化的发现端点(.well-known/openid-configuration)
- 会话管理功能
OIDC 流程:
-
授权码流程(推荐)
- 与 OAuth2 相同,但会返回 id_token
- 对 Web 应用最安全
-
隐式流程(已弃用)
- 直接返回 id_token
- 不安全,请改用带 PKCE 的授权码流程
-
混合流程
- 结合授权码流程和隐式流程
- 较复杂,极少需要
OIDC 作用域:
openid(必需)- 启用 OIDCprofile- 姓名、图片、区域设置等email- 电子邮件地址和验证状态address- 物理地址phone- 电话号码
UserInfo 端点:
GET /userinfo
Authorization: Bearer <access_token>
Response:
{
"sub": "248289761001",
"name": "Jane Doe",
"email": "[email protected]",
"email_verified": true,
"picture": "https://example.com/photo.jpg"
}
ID 令牌验证:
- 使用提供方的公钥验证签名
- 验证颁发者(iss 声明)
- 验证受众(aud 声明——应与 client_id 匹配)
- 检查过期时间(exp 声明)
- 验证 nonce(如果请求中提供)
- 检查令牌是否为近期颁发(iat 声明)
多租户模式
组织专属身份验证
许多 SaaS 应用要求用户在组织或租户的上下文中进行身份验证。
模式:
-
组织参数
- 在授权请求中包含组织 ID
scope=openid profile organization:acme-corp- 令牌限定于特定组织
-
组织选择器
- 用户在初次身份验证后选择组织
- 将令牌交换为组织专属令牌
- 支持切换组织
-
每个租户使用自定义域名
acme.example.com与globex.example.com- 每个租户使用独立的 OAuth2 配置
- 白标身份验证体验
-
令牌声明中包含组织信息
- 在访问令牌中包含 org_id
- API 验证组织访问权限
- 支持用户加入多个组织
授权码流程实现
服务端 Web 应用流程
适用于传统 Web 应用后端的完整实现
步骤 1:配置
// OAuth2 Configuration
const oauth2Config = {
clientId: process.env.OAUTH2_CLIENT_ID,
clientSecret: process.env.OAUTH2_CLIENT_SECRET,
authorizationEndpoint: 'https://auth.example.com/oauth/authorize',
tokenEndpoint: 'https://auth.example.com/oauth/token',
redirectUri: 'https://yourapp.com/auth/callback',
scopes: ['openid', 'profile', 'email', 'read:data'],
// Optional OIDC endpoints
userInfoEndpoint: 'https://auth.example.com/oauth/userinfo',
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
// Security settings
useStateParameter: true,
usePKCE: false, // Not needed for confidential clients
};
步骤 2:生成授权 URL
const crypto = require('crypto');
function generateAuthorizationUrl(req) {
// Generate and store state for CSRF protection
const state = crypto.randomBytes(32).toString('hex');
req.session.oauth2State = state;
// Build authorization URL
const params = new URLSearchParams({
client_id: oauth2Config.clientId,
redirect_uri: oauth2Config.redirectUri,
response_type: 'code',
scope: oauth2Config.scopes.join(' '),
state: state,
});
return `${oauth2Config.authorizationEndpoint}?${params.toString()}`;
}
// Express.js route
app.get('/auth/login', (req, res) => {
const authUrl = generateAuthorizationUrl(req);
res.redirect(authUrl);
});
步骤 3:处理回调并交换授权码
const axios = require('axios');
async function exchangeCodeForToken(code) {
const response = await axios.post(
oauth2Config.tokenEndpoint,
new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: oauth2Config.redirectUri,
client_id: oauth2Config.clientId,
client_secret: oauth2Config.clientSecret,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
return response.data;
// Returns: { access_token, refresh_token, token_type, expires_in, id_token }
}
// Callback route
app.get('/auth/callback', async (req, res) => {
const { code, state, error, error_description } = req.query;
// Check for authorization errors
if (error) {
console.error('Authorization error:', error, error_description);
return res.redirect('/auth/error?message=' + error_description);
}
// Validate state parameter (CSRF protection)
if (state !== req.session.oauth2State) {
console.error('State mismatch - possible CSRF attack');
return res.status(403).send('Invalid state parameter');
}
// Clear state from session
delete req.session.oauth2State;
try {
// Exchange authorization code for tokens
const tokens = await exchangeCodeForToken(code);
// Store tokens securely in session
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
req.session.tokenExpiry = Date.now() + (tokens.expires_in * 1000);
// Optional: Fetch user info
if (tokens.id_token) {
const userInfo = await getUserInfo(tokens.access_token);
req.session.user = userInfo;
}
// Redirect to application
res.redirect('/dashboard');
} catch (error) {
console.error('Token exchange failed:', error);
res.redirect('/auth/error?message=Authentication failed');
}
});
步骤 4:使用访问令牌发送 API 请求
// Middleware to check authentication
function requireAuth(req, res, next) {
if (!req.session.accessToken) {
return res.redirect('/auth/login');
}
// Check if token is expired
if (Date.now() > req.session.tokenExpiry) {
// Token expired, try to refresh
return refreshAccessToken(req, res, next);
}
next();
}
// API request with access token
async function fetchUserData(accessToken) {
const response = await axios.get('https://api.example.com/user/data', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
return response.data;
}
// Protected route
app.get('/dashboard', requireAuth, async (req, res) => {
try {
const userData = await fetchUserData(req.session.accessToken);
res.render('dashboard', { user: req.session.user, data: userData });
} catch (error) {
console.error('API request failed:', error);
res.status(500).send('Failed to fetch data');
}
});
步骤 5:实现令牌刷新
async function refreshAccessToken(req, res, next) {
if (!req.session.refreshToken) {
// No refresh token, require re-authentication
return res.redirect('/auth/login');
}
try {
const response = await axios.post(
oauth2Config.tokenEndpoint,
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: req.session.refreshToken,
client_id: oauth2Config.clientId,
client_secret: oauth2Config.clientSecret,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
// Update tokens in session
req.session.accessToken = response.data.access_token;
req.session.tokenExpiry = Date.now() + (response.data.expires_in * 1000);
// Update refresh token if rotation is enabled
if (response.data.refresh_token) {
req.session.refreshToken = response.data.refresh_token;
}
next();
} catch (error) {
console.error('Token refresh failed:', error);
// Refresh failed, require re-authentication
delete req.session.accessToken;
delete req.session.refreshToken;
res.redirect('/auth/login');
}
}
步骤 6:实现注销
app.post('/auth/logout', async (req, res) => {
// Optional: Revoke tokens on authorization server
if (req.session.accessToken) {
try {
await revokeToken(req.session.accessToken);
} catch (error) {
console.error('Token revocation failed:', error);
}
}
// Clear session
req.session.destroy((err) => {
if (err) {
console.error('Session destruction failed:', err);
}
res.redirect('/');
});
});
async function revokeToken(token) {
await axios.post(
'https://auth.example.com/oauth/revoke',
new URLSearchParams({
token: token,
client_id: oauth2Config.clientId,
client_secret: oauth2Config.clientSecret,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
}
PKCE 实现(SPA 和移动应用)
单页应用(React)
适用于 React SPA 的完整 OAuth2 PKCE 实现
设置和配置
// src/config/oauth2.js
export const oauth2Config = {
clientId: process.env.REACT_APP_OAUTH2_CLIENT_ID,
authorizationEndpoint: 'https://auth.example.com/oauth/authorize',
tokenEndpoint: 'https://auth.example.com/oauth/token',
redirectUri: window.location.origin + '/auth/callback',
scopes: ['openid', 'profile', 'email', 'read:data'],
audience: 'https://api.example.com',
};
// PKCE utility functions
export function generateRandomString(length) {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const values = crypto.getRandomValues(new Uint8Array(length));
return Array.from(values)
.map(v => charset[v % charset.length])
.join('');
}
export async function generateCodeChallenge(codeVerifier) {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(digest);
}
export function base64UrlEncode(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
身份验证上下文提供程序
// src/contexts/AuthContext.js
import React, { createContext, useState, useContext, useEffect } from 'react';
import { oauth2Config, generateRandomString, generateCodeChallenge } from '../config/oauth2';
const AuthContext = createContext();
export function useAuth() {
return useContext(AuthContext);
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [accessToken, setAccessToken] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check for existing session
checkAuth();
}, []);
async function checkAuth() {
// Try to restore access token from memory or refresh
const storedToken = sessionStorage.getItem('access_token');
const expiresAt = sessionStorage.getItem('expires_at');
if (storedToken && expiresAt && Date.now() < parseInt(expiresAt)) {
setAccessToken(storedToken);
await fetchUserInfo(storedToken);
} else {
// Token expired or doesn't exist, try refresh
await tryRefresh();
}
setLoading(false);
}
async function login() {
// Generate PKCE parameters
const codeVerifier = generateRandomString(64);
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateRandomString(32);
// Store for callback
sessionStorage.setItem('code_verifier', codeVerifier);
sessionStorage.setItem('oauth2_state', state);
// Build authorization URL
const params = new URLSearchParams({
client_id: oauth2Config.clientId,
redirect_uri: oauth2Config.redirectUri,
response_type: 'code',
scope: oauth2Config.scopes.join(' '),
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
// Redirect to authorization server
window.location.href = `${oauth2Config.authorizationEndpoint}?${params}`;
}
async function handleCallback(code, state) {
// Validate state
const savedState = sessionStorage.getItem('oauth2_state');
if (state !== savedState) {
throw new Error('Invalid state parameter - possible CSRF attack');
}
// Get code verifier
const codeVerifier = sessionStorage.getItem('code_verifier');
if (!codeVerifier) {
throw new Error('Code verifier not found');
}
// Exchange code for tokens
const response = await fetch(oauth2Config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: oauth2Config.redirectUri,
client_id: oauth2Config.clientId,
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
throw new Error('Token exchange failed');
}
const tokens = await response.json();
// Store tokens
setAccessToken(tokens.access_token);
sessionStorage.setItem('access_token', tokens.access_token);
sessionStorage.setItem('expires_at', Date.now() + (tokens.expires_in * 1000));
// Store refresh token in httpOnly cookie via backend
if (tokens.refresh_token) {
await storeRefreshToken(tokens.refresh_token);
}
// Fetch user info
await fetchUserInfo(tokens.access_token);
// Clean up
sessionStorage.removeItem('code_verifier');
sessionStorage.removeItem('oauth2_state');
}
async function fetchUserInfo(token) {
const response = await fetch('https://auth.example.com/oauth/userinfo', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const userInfo = await response.json();
setUser(userInfo);
}
}
async function storeRefreshToken(refreshToken) {
// Store refresh token via backend (httpOnly cookie)
await fetch('/api/auth/store-refresh-token', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
});
}
async function tryRefresh() {
try {
// Call backend to refresh using httpOnly cookie
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (response.ok) {
const tokens = await response.json();
setAccessToken(tokens.access_token);
sessionStorage.setItem('access_token', tokens.access_token);
sessionStorage.setItem('expires_at', Date.now() + (tokens.expires_in * 1000));
await fetchUserInfo(tokens.access_token);
return true;
}
} catch (error) {
console.error('Token refresh failed:', error);
}
return false;
}
async function logout() {
// Revoke tokens
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
// Clear state
setUser(null);
setAccessToken(null);
sessionStorage.clear();
}
const value = {
user,
accessToken,
loading,
login,
logout,
handleCallback,
isAuthenticated: !!accessToken,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
回调组件
// src/components/AuthCallback.js
import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export function AuthCallback() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { handleCallback } = useAuth();
const [error, setError] = useState(null);
useEffect(() => {
const code = searchParams.get('code');
const state = searchParams.get('state');
const errorParam = searchParams.get('error');
const errorDescription = searchParams.get('error_description');
if (errorParam) {
setError(errorDescription || errorParam);
return;
}
if (code && state) {
handleCallback(code, state)
.then(() => {
navigate('/dashboard');
})
.catch((err) => {
console.error('Authentication failed:', err);
setError(err.message);
});
} else {
setError('Missing authorization code or state');
}
}, [searchParams, handleCallback, navigate]);
if (error) {
return (
<div className="auth-error">
<h2>Authentication Failed</h2>
<p>{error}</p>
<button onClick={() => navigate('/')}>Return Home</button>
</div>
);
}
return (
<div className="auth-loading">
<h2>Completing authentication...</h2>
<div className="spinner"></div>
</div>
);
}
受保护路由组件
// src/components/ProtectedRoute.js
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export function ProtectedRoute({ children }) {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
return children;
}
带令牌管理的 API 客户端
// src/utils/apiClient.js
import { oauth2Config } from '../config/oauth2';
class ApiClient {
constructor() {
this.baseUrl = 'https://api.example.com';
}
async request(endpoint, options = {}) {
const accessToken = sessionStorage.getItem('access_token');
const expiresAt = sessionStorage.getItem('expires_at');
// Check if token needs refresh
if (!accessToken || Date.now() >= parseInt(expiresAt)) {
await this.refreshToken();
}
const token = sessionStorage.getItem('access_token');
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (response.status === 401) {
// Token invalid, try refresh once
await this.refreshToken();
const newToken = sessionStorage.getItem('access_token');
// Retry request
const retryResponse = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newToken}`,
'Content-Type': 'application/json',
},
});
if (!retryResponse.ok) {
throw new Error('API request failed after token refresh');
}
return retryResponse.json();
}
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
async refreshToken() {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
// Refresh failed, redirect to login
window.location.href = '/login';
throw new Error('Token refresh failed');
}
const tokens = await response.json();
sessionStorage.setItem('access_token', tokens.access_token);
sessionStorage.setItem('expires_at', Date.now() + (tokens.expires_in * 1000));
}
async get(endpoint) {
return this.request(endpoint);
}
async post(endpoint, data) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
}
async put(endpoint, data) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async delete(endpoint) {
return this.request(endpoint, {
method: 'DELETE',
});
}
}
export const apiClient = new ApiClient();
客户端凭据流程
后端服务身份验证
适用于服务和 API 的机器间身份验证
Node.js 实现
// Service-to-service authentication
class OAuth2Client {
constructor(config) {
this.clientId = config.clientId;
this.clientSecret = config.clientSecret;
this.tokenEndpoint = config.tokenEndpoint;
this.audience = config.audience;
this.scopes = config.scopes || [];
this.accessToken = null;
this.tokenExpiry = null;
}
async getAccessToken() {
// Return cached token if still valid
if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
return this.accessToken;
}
// Request new token
const response = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret,
audience: this.audience,
scope: this.scopes.join(' '),
}),
});
if (!response.ok) {
throw new Error('Failed to obtain access token');
}
const data = await response.json();
// Cache token
this.accessToken = data.access_token;
this.tokenExpiry = Date.now() + (data.expires_in * 1000);
return this.accessToken;
}
async callApi(endpoint, options = {}) {
const token = await this.getAccessToken();
const response = await fetch(endpoint, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
},
});
if (response.status === 401) {
// Token might be invalid, force refresh and retry
this.accessToken = null;
const newToken = await this.getAccessToken();
const retryResponse = await fetch(endpoint, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newToken}`,
},
});
return retryResponse;
}
return response;
}
}
// Usage
const oauth2Client = new OAuth2Client({
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
tokenEndpoint: 'https://auth.example.com/oauth/token',
audience: 'https://api.example.com',
scopes: ['read:data', 'write:data'],
});
// Make API calls
async function fetchData() {
const response = await oauth2Client.callApi('https://api.example.com/data');
return response.json();
}
Python 实现
# client_credentials_oauth2.py
import requests
import time
from datetime import datetime, timedelta
class OAuth2Client:
def __init__(self, client_id, client_secret, token_endpoint, audience=None, scopes=None):
self.client_id = client_id
self.client_secret = client_secret
self.token_endpoint = token_endpoint
self.audience = audience
self.scopes = scopes or []
self.access_token = None
self.token_expiry = None
def get_access_token(self):
"""Get cached token or request new one"""
# Return cached token if valid
if self.access_token and datetime.now() < self.token_expiry - timedelta(minutes=1):
return self.access_token
# Request new token
data = {
'grant_type': 'client_credentials',
'client_id': self.client_id,
'client_secret': self.client_secret,
}
if self.audience:
data['audience'] = self.audience
if self.scopes:
data['scope'] = ' '.join(self.scopes)
response = requests.post(
self.token_endpoint,
data=data,
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
if not response.ok:
raise Exception(f'Failed to obtain access token: {response.text}')
token_data = response.json()
# Cache token
self.access_token = token_data['access_token']
self.token_expiry = datetime.now() + timedelta(seconds=token_data['expires_in'])
return self.access_token
def call_api(self, url, method='GET', **kwargs):
"""Make authenticated API request"""
token = self.get_access_token()
headers = kwargs.pop('headers', {})
headers['Authorization'] = f'Bearer {token}'
response = requests.request(method, url, headers=headers, **kwargs)
# Handle token expiration
if response.status_code == 401:
# Force token refresh and retry
self.access_token = None
token = self.get_access_token()
headers['Authorization'] = f'Bearer {token}'
response = requests.request(method, url, headers=headers, **kwargs)
return response
# Usage
client = OAuth2Client(
client_id='your_client_id',
client_secret='your_client_secret',
token_endpoint='https://auth.example.com/oauth/token',
audience='https://api.example.com',
scopes=['read:data', 'write:data']
)
# Make API requests
response = client.call_api('https://api.example.com/data')
data = response.json()
OpenID Connect 实现
完整的 OIDC 集成
带身份验证的身份验证
ID 令牌验证
// id-token-validator.js
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
class IDTokenValidator {
constructor(config) {
this.issuer = config.issuer;
this.audience = config.audience;
this.jwksUri = config.jwksUri;
// JWKS client for fetching public keys
this.client = jwksClient({
jwksUri: this.jwksUri,
cache: true,
cacheMaxAge: 86400000, // 24 hours
});
}
async getSigningKey(kid) {
const key = await this.client.getSigningKey(kid);
return key.getPublicKey();
}
async validate(idToken) {
try {
// Decode token header to get key ID
const decoded = jwt.decode(idToken, { complete: true });
if (!decoded) {
throw new Error('Invalid token format');
}
// Get public key
const publicKey = await this.getSigningKey(decoded.header.kid);
// Verify and decode token
const payload = jwt.verify(idToken, publicKey, {
issuer: this.issuer,
audience: this.audience,
algorithms: ['RS256', 'ES256'],
});
// Additional validations
this.validateClaims(payload);
return payload;
} catch (error) {
throw new Error(`ID token validation failed: ${error.message}`);
}
}
validateClaims(payload) {
// Check required claims
if (!payload.sub) {
throw new Error('Missing sub claim');
}
if (!payload.iat || !payload.exp) {
throw new Error('Missing iat or exp claim');
}
// Check token is not expired (already checked by jwt.verify, but double-check)
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) {
throw new Error('Token expired');
}
// Check token was not issued in the future
if (payload.iat > now + 60) {
throw new Error('Token issued in the future');
}
// Optional: Check nonce if provided
// if (payload.nonce && payload.nonce !== expectedNonce) {
// throw new Error('Nonce mismatch');
// }
return true;
}
}
// Usage
const validator = new IDTokenValidator({
issuer: 'https://auth.example.com',
audience: 'your_client_id',
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
});
async function validateIDToken(idToken) {
try {
const payload = await validator.validate(idToken);
console.log('User ID:', payload.sub);
console.log('Email:', payload.email);
console.log('Name:', payload.name);
return payload;
} catch (error) {
console.error('ID token validation failed:', error);
throw error;
}
}
UserInfo 端点集成
// userinfo-client.js
class UserInfoClient {
constructor(userInfoEndpoint) {
this.endpoint = userInfoEndpoint;
}
async getUserInfo(accessToken) {
const response = await fetch(this.endpoint, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error(`UserInfo request failed: ${response.status}`);
}
return response.json();
}
async getUserInfoWithValidation(accessToken, idTokenPayload) {
const userInfo = await this.getUserInfo(accessToken);
// Validate that sub matches ID token
if (userInfo.sub !== idTokenPayload.sub) {
throw new Error('UserInfo sub does not match ID token sub');
}
return userInfo;
}
}
// Usage
const userInfoClient = new UserInfoClient('https://auth.example.com/oauth/userinfo');
async function getCompleteUserProfile(accessToken, idToken, validator) {
// Validate ID token
const idTokenPayload = await validator.validate(idToken);
// Fetch additional user info
const userInfo = await userInfoClient.getUserInfoWithValidation(accessToken, idTokenPayload);
// Combine information
return {
userId: userInfo.sub,
email: userInfo.email,
emailVerified: userInfo.email_verified,
name: userInfo.name,
givenName: userInfo.given_name,
familyName: userInfo.family_name,
picture: userInfo.picture,
locale: userInfo.locale,
// Any custom claims
...userInfo,
};
}
令牌管理最佳实践
安全的令牌存储
浏览器存储方式对比
// Token storage strategies for web applications
// ❌ BAD: localStorage (vulnerable to XSS)
localStorage.setItem('access_token', token); // DON'T DO THIS
// ❌ BAD: sessionStorage (also vulnerable to XSS)
sessionStorage.setItem('access_token', token); // DON'T DO THIS
// ✅ GOOD: In-memory storage (cleared on page refresh)
class TokenStore {
constructor() {
this.accessToken = null;
this.refreshToken = null;
}
setTokens(accessToken, refreshToken) {
this.accessToken = accessToken;
// Refresh token stored in httpOnly cookie via backend
// this.refreshToken = refreshToken; // Don't store in memory
}
getAccessToken() {
return this.accessToken;
}
clear() {
this.accessToken = null;
this.refreshToken = null;
}
}
// ✅ BEST: Backend For Frontend (BFF) Pattern
// - All tokens stored on backend
// - Session cookie identifies user
// - No tokens exposed to browser
移动令牌存储(React Native)
// Secure token storage for React Native
import * as SecureStore from 'expo-secure-store';
// or
// import EncryptedStorage from 'react-native-encrypted-storage';
class MobileTokenStore {
async saveTokens(accessToken, refreshToken) {
try {
await SecureStore.setItemAsync('access_token', accessToken);
await SecureStore.setItemAsync('refresh_token', refreshToken);
await SecureStore.setItemAsync('token_expiry', Date.now().toString());
} catch (error) {
console.error('Failed to store tokens:', error);
throw error;
}
}
async getAccessToken() {
try {
return await SecureStore.getItemAsync('access_token');
} catch (error) {
console.error('Failed to retrieve access token:', error);
return null;
}
}
async getRefreshToken() {
try {
return await SecureStore.getItemAsync('refresh_token');
} catch (error) {
console.error('Failed to retrieve refresh token:', error);
return null;
}
}
async clear() {
try {
await SecureStore.deleteItemAsync('access_token');
await SecureStore.deleteItemAsync('refresh_token');
await SecureStore.deleteItemAsync('token_expiry');
} catch (error) {
console.error('Failed to clear tokens:', error);
}
}
async isTokenExpired() {
try {
const expiry = await SecureStore.getItemAsync('token_expiry');
if (!expiry) return true;
return Date.now() > parseInt(expiry);
} catch (error) {
return true;
}
}
}
export const tokenStore = new MobileTokenStore();
令牌刷新策略
自动令牌刷新(主动)
// Proactive token refresh before expiration
class TokenRefreshManager {
constructor(refreshCallback, expiresIn) {
this.refreshCallback = refreshCallback;
this.expiresIn = expiresIn;
this.refreshTimer = null;
}
scheduleRefresh() {
// Refresh 5 minutes before expiration
const refreshIn = (this.expiresIn - 300) * 1000;
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(async () => {
try {
await this.refreshCallback();
} catch (error) {
console.error('Token refresh failed:', error);
// Optionally: Redirect to login
}
}, refreshIn);
}
cancelRefresh() {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}
}
// Usage
const refreshManager = new TokenRefreshManager(
async () => {
// Refresh token logic
const newTokens = await refreshTokens();
setAccessToken(newTokens.access_token);
refreshManager.expiresIn = newTokens.expires_in;
refreshManager.scheduleRefresh();
},
3600 // Initial expires_in
);
// Start automatic refresh
refreshManager.scheduleRefresh();
按需令牌刷新(被动)
// Refresh token only when access token is expired
class ReactiveTokenManager {
constructor() {
this.accessToken = null;
this.expiresAt = null;
this.refreshPromise = null; // Prevent concurrent refreshes
}
async getValidToken() {
// Check if token is still valid
if (this.accessToken && Date.now() < this.expiresAt - 60000) {
return this.accessToken;
}
// Token expired, refresh it
if (!this.refreshPromise) {
this.refreshPromise = this.refreshToken()
.finally(() => {
this.refreshPromise = null;
});
}
await this.refreshPromise;
return this.accessToken;
}
async refreshToken() {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // Send refresh token cookie
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const tokens = await response.json();
this.accessToken = tokens.access_token;
this.expiresAt = Date.now() + (tokens.expires_in * 1000);
return this.accessToken;
}
clearTokens() {
this.accessToken = null;
this.expiresAt = null;
this.refreshPromise = null;
}
}
// Usage in API client
const tokenManager = new ReactiveTokenManager();
async function makeApiRequest(endpoint) {
const token = await tokenManager.getValidToken();
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
return response;
}
令牌撤销
// Implementing token revocation
async function revokeToken(token, tokenTypeHint = 'access_token') {
const response = await fetch('https://auth.example.com/oauth/revoke', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
token: token,
token_type_hint: tokenTypeHint, // 'access_token' or 'refresh_token'
client_id: oauth2Config.clientId,
client_secret: oauth2Config.clientSecret, // If confidential client
}),
});
if (!response.ok) {
throw new Error('Token revocation failed');
}
return true;
}
// Logout with token revocation
async function logout() {
try {
// Revoke access token
if (accessToken) {
await revokeToken(accessToken, 'access_token');
}
// Revoke refresh token
if (refreshToken) {
await revokeToken(refreshToken, 'refresh_token');
}
// Clear local state
clearTokens();
// Optional: Redirect to logout endpoint for session cleanup
window.location.href = 'https://auth.example.com/logout?redirect_uri=' +
encodeURIComponent(window.location.origin);
} catch (error) {
console.error('Logout failed:', error);
// Clear tokens anyway
clearTokens();
}
}
OAuth2 服务端实现
构建授权服务
实现 OAuth2 提供方功能
授权端点
// Express.js authorization endpoint
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
router.get('/oauth/authorize', async (req, res) => {
const {
client_id,
redirect_uri,
response_type,
scope,
state,
code_challenge,
code_challenge_method,
} = req.query;
// Validate required parameters
if (!client_id || !redirect_uri || !response_type) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing required parameters',
});
}
// Validate client_id and redirect_uri
const client = await getClient(client_id);
if (!client) {
return res.status(400).json({
error: 'invalid_client',
error_description: 'Unknown client',
});
}
if (!client.redirect_uris.includes(redirect_uri)) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Invalid redirect_uri',
});
}
// Validate response_type
if (response_type !== 'code') {
return redirectWithError(redirect_uri, state, 'unsupported_response_type',
'Only authorization code flow is supported');
}
// Validate PKCE parameters
if (code_challenge) {
if (!code_challenge_method) {
return redirectWithError(redirect_uri, state, 'invalid_request',
'code_challenge_method is required when using PKCE');
}
if (code_challenge_method !== 'S256' && code_challenge_method !== 'plain') {
return redirectWithError(redirect_uri, state, 'invalid_request',
'Unsupported code_challenge_method');
}
}
// Check if user is authenticated
if (!req.session.userId) {
// Store authorization request and redirect to login
req.session.authRequest = {
client_id,
redirect_uri,
scope,
state,
code_challenge,
code_challenge_method,
};
return res.redirect('/login?continue=' + encodeURIComponent(req.originalUrl));
}
// User is authenticated, show consent screen
res.render('consent', {
client: client,
scopes: scope ? scope.split(' ') : [],
state: state,
});
});
// Consent endpoint
router.post('/oauth/consent', async (req, res) => {
const { approved } = req.body;
const authRequest = req.session.authRequest;
if (!authRequest) {
return res.status(400).send('No pending authorization request');
}
if (approved !== 'true') {
// User denied consent
return redirectWithError(
authRequest.redirect_uri,
authRequest.state,
'access_denied',
'User denied consent'
);
}
// Generate authorization code
const authorizationCode = crypto.randomBytes(32).toString('hex');
// Store authorization code with metadata
await storeAuthorizationCode(authorizationCode, {
client_id: authRequest.client_id,
redirect_uri: authRequest.redirect_uri,
user_id: req.session.userId,
scope: authRequest.scope,
code_challenge: authRequest.code_challenge,
code_challenge_method: authRequest.code_challenge_method,
expires_at: Date.now() + 600000, // 10 minutes
});
// Clear auth request from session
delete req.session.authRequest;
// Redirect to client with authorization code
const redirectUrl = new URL(authRequest.redirect_uri);
redirectUrl.searchParams.set('code', authorizationCode);
if (authRequest.state) {
redirectUrl.searchParams.set('state', authRequest.state);
}
res.redirect(redirectUrl.toString());
});
function redirectWithError(redirectUri, state, error, errorDescription) {
const url = new URL(redirectUri);
url.searchParams.set('error', error);
url.searchParams.set('error_description', errorDescription);
if (state) {
url.searchParams.set('state', state);
}
return res.redirect(url.toString());
}
令牌端点
// Token endpoint implementation
router.post('/oauth/token', async (req, res) => {
const { grant_type } = req.body;
try {
let tokens;
switch (grant_type) {
case 'authorization_code':
tokens = await handleAuthorizationCodeGrant(req.body);
break;
case 'refresh_token':
tokens = await handleRefreshTokenGrant(req.body);
break;
case 'client_credentials':
tokens = await handleClientCredentialsGrant(req.body);
break;
default:
return res.status(400).json({
error: 'unsupported_grant_type',
error_description: `Grant type '${grant_type}' is not supported`,
});
}
res.json(tokens);
} catch (error) {
console.error('Token endpoint error:', error);
res.status(400).json({
error: error.code || 'invalid_request',
error_description: error.message,
});
}
});
async function handleAuthorizationCodeGrant(params) {
const {
code,
redirect_uri,
client_id,
client_secret,
code_verifier,
} = params;
// Validate required parameters
if (!code || !redirect_uri || !client_id) {
throw { code: 'invalid_request', message: 'Missing required parameters' };
}
// Retrieve authorization code
const authCode = await getAuthorizationCode(code);
if (!authCode) {
throw { code: 'invalid_grant', message: 'Invalid authorization code' };
}
// Check expiration
if (Date.now() > authCode.expires_at) {
await deleteAuthorizationCode(code);
throw { code: 'invalid_grant', message: 'Authorization code expired' };
}
// Validate client
if (authCode.client_id !== client_id) {
throw { code: 'invalid_grant', message: 'Client mismatch' };
}
// Validate redirect URI
if (authCode.redirect_uri !== redirect_uri) {
throw { code: 'invalid_grant', message: 'Redirect URI mismatch' };
}
// Authenticate client
const client = await getClient(client_id);
if (client.client_type === 'confidential') {
// Confidential client must provide client_secret
if (client.client_secret !== client_secret) {
throw { code: 'invalid_client', message: 'Invalid client credentials' };
}
}
// Validate PKCE if used
if (authCode.code_challenge) {
if (!code_verifier) {
throw { code: 'invalid_request', message: 'code_verifier is required' };
}
const isValid = validatePKCE(
code_verifier,
authCode.code_challenge,
authCode.code_challenge_method
);
if (!isValid) {
throw { code: 'invalid_grant', message: 'Invalid code_verifier' };
}
}
// Delete authorization code (single use)
await deleteAuthorizationCode(code);
// Generate tokens
const accessToken = await generateAccessToken({
user_id: authCode.user_id,
client_id: client_id,
scope: authCode.scope,
});
const refreshToken = await generateRefreshToken({
user_id: authCode.user_id,
client_id: client_id,
scope: authCode.scope,
});
// Generate ID token if openid scope was requested
let idToken = null;
if (authCode.scope && authCode.scope.includes('openid')) {
idToken = await generateIDToken({
user_id: authCode.user_id,
client_id: client_id,
scope: authCode.scope,
});
}
const response = {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: refreshToken,
};
if (idToken) {
response.id_token = idToken;
}
return response;
}
function validatePKCE(codeVerifier, codeChallenge, method) {
if (method === 'plain') {
return codeVerifier === codeChallenge;
}
if (method === 'S256') {
const hash = crypto.createHash('sha256').update(codeVerifier).digest();
const computed = base64UrlEncode(hash);
return computed === codeChallenge;
}
return false;
}
async function handleRefreshTokenGrant(params) {
const { refresh_token, client_id, client_secret, scope } = params;
// Validate refresh token
const storedToken = await getRefreshToken(refresh_token);
if (!storedToken) {
throw { code: 'invalid_grant', message: 'Invalid refresh token' };
}
// Check if revoked
if (storedToken.revoked) {
throw { code: 'invalid_grant', message: 'Refresh token has been revoked' };
}
// Validate client
if (storedToken.client_id !== client_id) {
throw { code: 'invalid_grant', message: 'Client mismatch' };
}
const client = await getClient(client_id);
if (client.client_type === 'confidential' && client.client_secret !== client_secret) {
throw { code: 'invalid_client', message: 'Invalid client credentials' };
}
// Validate scope (requested scope must be subset of original)
const requestedScopes = scope ? scope.split(' ') : storedToken.scope.split(' ');
const originalScopes = storedToken.scope.split(' ');
const isValidScope = requestedScopes.every(s => originalScopes.includes(s));
if (!isValidScope) {
throw { code: 'invalid_scope', message: 'Requested scope exceeds original grant' };
}
// Generate new access token
const accessToken = await generateAccessToken({
user_id: storedToken.user_id,
client_id: client_id,
scope: requestedScopes.join(' '),
});
// Optional: Refresh token rotation
let newRefreshToken = refresh_token;
if (shouldRotateRefreshToken()) {
// Revoke old refresh token
await revokeRefreshToken(refresh_token);
// Generate new refresh token
newRefreshToken = await generateRefreshToken({
user_id: storedToken.user_id,
client_id: client_id,
scope: requestedScopes.join(' '),
});
}
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: newRefreshToken,
scope: requestedScopes.join(' '),
};
}
async function handleClientCredentialsGrant(params) {
const { client_id, client_secret, scope } = params;
// Authenticate client
const client = await getClient(client_id);
if (!client || client.client_secret !== client_secret) {
throw { code: 'invalid_client', message: 'Invalid client credentials' };
}
// Validate scope
if (scope) {
const requestedScopes = scope.split(' ');
const allowedScopes = client.allowed_scopes || [];
const isValidScope = requestedScopes.every(s => allowedScopes.includes(s));
if (!isValidScope) {
throw { code: 'invalid_scope', message: 'Requested scope not allowed for this client' };
}
}
// Generate access token (no refresh token for client credentials)
const accessToken = await generateAccessToken({
client_id: client_id,
scope: scope || client.default_scope,
});
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
scope: scope || client.default_scope,
};
}
令牌生成(JWT)
// JWT token generation
const jwt = require('jsonwebtoken');
const fs = require('fs');
const privateKey = fs.readFileSync('path/to/private-key.pem');
const publicKey = fs.readFileSync('path/to/public-key.pem');
async function generateAccessToken(payload) {
const token = jwt.sign(
{
sub: payload.user_id || payload.client_id,
client_id: payload.client_id,
scope: payload.scope,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour
iss: 'https://auth.example.com',
aud: 'https://api.example.com',
},
privateKey,
{
algorithm: 'RS256',
keyid: 'key-id-1',
}
);
return token;
}
async function generateRefreshToken(payload) {
const refreshToken = crypto.randomBytes(32).toString('hex');
// Store refresh token in database
await storeRefreshToken(refreshToken, {
user_id: payload.user_id,
client_id: payload.client_id,
scope: payload.scope,
created_at: Date.now(),
expires_at: Date.now() + (30 * 24 * 60 * 60 * 1000), // 30 days
revoked: false,
});
return refreshToken;
}
async function generateIDToken(payload) {
const user = await getUser(payload.user_id);
const idToken = jwt.sign(
{
iss: 'https://auth.example.com',
sub: payload.user_id,
aud: payload.client_id,
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
// Standard claims
name: user.name,
email: user.email,
email_verified: user.email_verified,
picture: user.picture,
// Custom claims based on scope
...getScopedClaims(user, payload.scope),
},
privateKey,
{
algorithm: 'RS256',
keyid: 'key-id-1',
}
);
return idToken;
}
function getScopedClaims(user, scope) {
const claims = {};
const scopes = scope ? scope.split(' ') : [];
if (scopes.includes('profile')) {
claims.given_name = user.given_name;
claims.family_name = user.family_name;
claims.locale = user.locale;
claims.updated_at = user.updated_at;
}
if (scopes.includes('phone')) {
claims.phone_number = user.phone_number;
claims.phone_number_verified = user.phone_number_verified;
}
return claims;
}
最佳实践与安全
安全最佳实践
-
始终使用 HTTPS
- 所有 OAuth2 端点都必须使用 HTTPS
- 仅通过加密连接传输令牌
- 不得通过 HTTP 使用 OAuth2(本地主机开发除外)
-
严格验证重定向 URI
- 必须完全匹配(不允许使用通配符)
- 提前注册所有重定向 URI
- 在每次授权请求时进行验证
-
公共客户端使用 PKCE
- SPA 和移动应用必须使用
- 使用 S256 代码质询方法(而非 plain)
- 防止授权码被截获
-
实现 State 参数
- 防止 CSRF 攻击
- 生成在密码学上安全的随机值
- 在回调时进行验证
-
使用短时效访问令牌
- 通常为 15 分钟到 1 小时
- 降低令牌被盗造成的影响
- 使用刷新令牌维持长时效会话
-
安全存储令牌
- 切勿将令牌存储在 localStorage 中(有 XSS 风险)
- 使用 httpOnly Cookie 存储刷新令牌
- 移动端:使用平台的安全存储(Keychain、Keystore)
-
实现令牌轮换
- 每次使用时轮换刷新令牌
- 通过多次刷新尝试检测令牌盗用
- 撤销与被盗刷新令牌关联的所有令牌
-
验证所有令牌
- 验证 JWT 签名
- 检查过期时间
- 验证签发者和受众
- 验证必需的作用域
-
基于作用域的访问控制
- 仅请求最低限度的必需作用域
- 验证 API 请求中的作用域
- 记录所有可用作用域
-
监控并记录日志
- 记录所有身份验证尝试
- 监控可疑模式
- 多次失败尝试时发出警报
- 跟踪令牌的使用和撤销情况
应避免的常见问题
-
使用 Implicit Flow
- 已弃用且不安全
- 改用带 PKCE 的 Authorization Code Flow
-
将令牌存储在 localStorage 中
- 易受 XSS 攻击
- 改用内存或安全存储
-
不验证重定向 URI
- 会导致开放重定向攻击
- 始终根据已注册的 URI 进行验证
-
忽略令牌过期
- 应拒绝过期令牌
- 实现正确的刷新逻辑
-
不使用 State 参数
- 易受 CSRF 攻击
- 始终生成并验证 state
-
访问令牌时效过长
- 增加安全风险
- 缩短访问令牌的有效期(15-60 分钟)
-
在应用之间共享令牌
- 令牌应针对特定应用
- 每个应用都应有自己的 client_id
-
代码验证器(PKCE)强度不足
- 使用在密码学上安全的随机生成方式
- 长度至少为 43 个字符
-
未实现令牌撤销
- 用户无法撤销访问权限
- 被盗令牌仍然有效
-
日志记录不足
- 无法检测或调查安全事件
- 记录所有身份验证和授权事件
性能优化
令牌缓存
// Efficient token caching with automatic refresh
class OptimizedTokenManager {
constructor() {
this.tokenCache = new Map();
this.refreshPromises = new Map();
}
async getToken(cacheKey, fetchCallback) {
// Check cache
const cached = this.tokenCache.get(cacheKey);
if (cached && Date.now() < cached.expiresAt - 60000) {
return cached.token;
}
// Check if refresh is in progress
if (this.refreshPromises.has(cacheKey)) {
return this.refreshPromises.get(cacheKey);
}
// Fetch new token
const promise = fetchCallback()
.then(({ token, expiresIn }) => {
this.tokenCache.set(cacheKey, {
token,
expiresAt: Date.now() + (expiresIn * 1000),
});
this.refreshPromises.delete(cacheKey);
return token;
})
.catch(error => {
this.refreshPromises.delete(cacheKey);
throw error;
});
this.refreshPromises.set(cacheKey, promise);
return promise;
}
clearCache(cacheKey) {
if (cacheKey) {
this.tokenCache.delete(cacheKey);
} else {
this.tokenCache.clear();
}
}
}
连接池
// Reuse HTTP connections for token requests
const https = require('https');
const axios = require('axios');
const httpsAgent = new https.Agent({
keepAlive: true,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 60000,
});
const apiClient = axios.create({
httpsAgent,
timeout: 30000,
});
// Use for all OAuth2 requests
async function fetchTokens(params) {
const response = await apiClient.post(tokenEndpoint, params);
return response.data;
}
资源与参考资料
官方规范
- RFC 6749 - OAuth 2.0 授权框架
- RFC 7636 - PKCE
- RFC 8252 - 面向原生应用的 OAuth 2.0
- OpenID Connect Core 1.0
- OAuth 2.0 安全最佳实践
常用 OAuth2 库
JavaScript/Node.js:
passport- 身份验证中间件oauth4webapi- 现代 OAuth2 客户端node-oauth2-server- OAuth2 服务实现jsonwebtoken- JWT 的创建与验证jwks-rsa- JWKS 密钥检索
Python:
authlib- 功能全面的 OAuth 库python-oauth2- OAuth2 提供方工具包PyJWT- JWT 实现
PHP:
league/oauth2-client- OAuth2 客户端league/oauth2-server- OAuth2 服务
Java:
- Spring Security OAuth
- Apache Oltu
OAuth2 提供商
- Auth0
- Okta
- Amazon Cognito
- Google Identity Platform
- Azure Active Directory
- Keycloak(开源)
- ORY Hydra(开源)
技能版本:1.0.0 最后更新:2025 年 10 月 技能类别:身份验证、授权、安全 兼容对象:Web 应用、移动应用、API、微服务