oauth2-authentication
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseOAuth2 Authentication
OAuth2 认证
A comprehensive skill for implementing secure authentication and authorization using OAuth2 and OpenID Connect. This skill covers all major authorization flows, token management strategies, security best practices, and real-world implementation patterns for web, mobile, and API applications.
这是一项使用OAuth2和OpenID Connect实现安全认证与授权的全面技能。该技能涵盖了Web、移动和API应用的所有主要授权流程、令牌管理策略、安全最佳实践以及实际落地模式。
When to Use This Skill
何时使用此技能
Use this skill when:
- Implementing user authentication in web applications, SPAs, or mobile apps
- Building API authorization with access tokens and refresh tokens
- Integrating social login (Google, GitHub, Facebook, Twitter, etc.)
- Creating secure machine-to-machine (M2M) authentication
- Implementing single sign-on (SSO) across multiple applications
- Building an OAuth2 authorization server or identity provider
- Adding delegated authorization to allow third-party access
- Securing APIs with token-based authentication
- Implementing passwordless authentication flows
- Adding multi-tenant authentication with organization-specific rules
- Migrating from session-based to token-based authentication
- Implementing fine-grained access control with OAuth2 scopes
在以下场景中使用此技能:
- 在Web应用、单页应用(SPA)或移动应用中实现用户认证
- 使用访问令牌和刷新令牌构建API授权
- 集成社交登录(Google、GitHub、Facebook、Twitter等)
- 创建安全的机器对机器(M2M)认证
- 在多个应用间实现单点登录(SSO)
- 构建OAuth2授权服务器或身份提供商
- 添加委托授权以允许第三方访问
- 使用基于令牌的认证保护API
- 实现无密码认证流程
- 添加带有组织特定规则的多租户认证
- 从基于会话的认证迁移到基于令牌的认证
- 使用OAuth2范围实现细粒度访问控制
Core Concepts
核心概念
OAuth2 Fundamentals
OAuth2 基础
OAuth2 is an authorization framework that enables applications to obtain limited access to user accounts on an HTTP service. It works by delegating user authentication to the service that hosts the user account and authorizing third-party applications to access that account.
Key Terminology:
- Resource Owner: The user who owns the data or resources
- Client: The application requesting access to resources (web app, mobile app, SPA)
- Authorization Server: Issues access tokens after authenticating the resource owner
- Resource Server: Hosts protected resources, accepts and validates access tokens
- Access Token: Short-lived credential used to access protected resources
- Refresh Token: Long-lived credential used to obtain new access tokens
- Scope: Permission granted to access specific resources or perform actions
- Authorization Code: Temporary code exchanged for an access token
- State Parameter: Prevents CSRF attacks during authorization flow
- Redirect URI: Callback URL where the user is redirected after authorization
OAuth2是一个授权框架,允许应用在HTTP服务上获取用户账户的有限访问权限。它通过将用户认证委托给托管用户账户的服务,并授权第三方应用访问该账户来工作。
关键术语:
- 资源所有者:拥有数据或资源的用户
- 客户端:请求访问资源的应用(Web应用、移动应用、SPA)
- 授权服务器:在认证资源所有者后颁发访问令牌
- 资源服务器:托管受保护资源,接受并验证访问令牌
- 访问令牌:用于访问受保护资源的短期凭证
- 刷新令牌:用于获取新访问令牌的长期凭证
- 范围:授予的访问特定资源或执行操作的权限
- 授权码:用于交换访问令牌的临时代码
- State参数:在授权流程中防止CSRF攻击
- 重定向URI:用户授权后被重定向到的回调URL
OAuth2 Grant Types (Authorization Flows)
OAuth2 授权类型(授权流程)
OAuth2 defines several grant types for different use cases:
OAuth2为不同用例定义了多种授权类型:
1. Authorization Code Flow
1. 授权码流程
Most Secure Flow - Recommended for Server-Side Applications
The authorization code flow is the most secure and widely used OAuth2 flow. It involves exchanging an authorization code for an access token on the server side.
Flow Steps:
- Client redirects user to authorization server with client_id, redirect_uri, scope, and state
- User authenticates and consents to requested permissions
- Authorization server redirects back to client with authorization code
- Client exchanges code for access token using client secret (server-side)
- Client uses access token to access protected resources
When to Use:
- Traditional server-side web applications
- Applications that can securely store client secrets
- When you need maximum security
- When refresh tokens are required
Security Benefits:
- Access token never exposed to browser
- Client authentication via client secret
- Authorization code is single-use and short-lived
- State parameter prevents CSRF attacks
最安全的流程 - 推荐用于服务器端应用
授权码流程是最安全且应用最广泛的OAuth2流程。它涉及在服务器端将授权码交换为访问令牌。
流程步骤:
- 客户端将用户重定向到授权服务器,携带client_id、redirect_uri、scope和state
- 用户进行认证并同意请求的权限
- 授权服务器将用户重定向回客户端,并携带授权码
- 客户端使用客户端密钥(服务器端)将代码交换为访问令牌
- 客户端使用访问令牌访问受保护资源
适用场景:
- 传统服务器端Web应用
- 可以安全存储客户端密钥的应用
- 需要最高安全性的场景
- 需要刷新令牌的场景
安全优势:
- 访问令牌不会暴露给浏览器
- 通过客户端密钥进行客户端认证
- 授权码是一次性且短期有效的
- State参数防止CSRF攻击
2. Authorization Code Flow with PKCE
2. 带PKCE的授权码流程
Secure Flow for Public Clients (SPAs and Mobile Apps)
PKCE (Proof Key for Code Exchange, pronounced "pixy") is an extension to the authorization code flow designed for public clients that cannot securely store client secrets.
Flow Steps:
- Client generates code_verifier (random string) and code_challenge (SHA256 hash)
- Client redirects to authorization server with code_challenge
- User authenticates and consents
- Authorization server returns authorization code
- Client exchanges code + code_verifier for access token
- Server validates code_verifier matches code_challenge
When to Use:
- Single Page Applications (SPAs)
- Mobile applications (iOS, Android)
- Desktop applications
- Any public client that cannot store secrets
Security Benefits:
- Prevents authorization code interception attacks
- No client secret required
- Protects against malicious apps intercepting redirect
- Recommended by OAuth2 security best practices (RFC 8252)
面向公共客户端(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 Credentials Flow
3. 客户端凭证流程
Machine-to-Machine Authentication
The client credentials flow is used when the client itself is the resource owner, typically for service-to-service communication.
Flow Steps:
- Client authenticates with client_id and client_secret
- Authorization server validates credentials
- Authorization server issues access token
- Client uses token to access protected resources
When to Use:
- Backend services communicating with APIs
- Cron jobs or scheduled tasks
- Microservices authentication
- CI/CD pipelines accessing APIs
- System-level operations without user context
Characteristics:
- No user involvement
- Client is the resource owner
- No refresh tokens (just request new access token)
- Typically long-lived or cached tokens
机器对机器认证
当客户端本身就是资源所有者时,通常用于服务间通信,会使用客户端凭证流程。
流程步骤:
- 客户端使用client_id和client_secret进行认证
- 授权服务器验证凭证
- 授权服务器颁发访问令牌
- 客户端使用令牌访问受保护资源
适用场景:
- 后端服务与API通信
- 定时任务或计划任务
- 微服务认证
- CI/CD流水线访问API
- 无用户上下文的系统级操作
特点:
- 无用户参与
- 客户端即为资源所有者
- 无刷新令牌(直接请求新的访问令牌)
- 通常是长期有效的令牌或缓存令牌
4. Implicit Flow (Deprecated)
4. 隐式流程(已弃用)
Legacy Flow - No Longer Recommended
The implicit flow returns tokens directly in the URL fragment without an authorization code exchange. This flow is now considered insecure and should be avoided.
Why Deprecated:
- Access tokens exposed in browser history
- No client authentication
- No refresh token support
- Vulnerable to token theft
- Use Authorization Code Flow with PKCE instead
遗留流程 - 不再推荐使用
隐式流程直接在URL片段中返回令牌,无需授权码交换。该流程现在被认为是不安全的,应避免使用。
为何弃用:
- 访问令牌暴露在浏览器历史中
- 无客户端认证
- 不支持刷新令牌
- 易受令牌窃取攻击
- 请改用带PKCE的授权码流程
5. Resource Owner Password Credentials (ROPC)
5. 资源所有者密码凭证(ROPC)
Legacy Flow - Avoid Unless Necessary
The resource owner password credentials flow allows the client to collect username and password directly, then exchange them for tokens.
Flow Steps:
- User provides username and password to client
- Client sends credentials to authorization server
- Authorization server validates and issues tokens
When to Use (Rarely):
- First-party mobile apps migrating from legacy authentication
- Trusted first-party applications only
- When no browser/redirect flow is possible
Why to Avoid:
- Client handles user credentials directly (security risk)
- No multi-factor authentication support
- Phishing vulnerability
- Violates OAuth2 principle of delegated authorization
- Use Authorization Code Flow with PKCE instead when possible
遗留流程 - 除非必要否则避免使用
资源所有者密码凭证流程允许客户端直接收集用户名和密码,然后交换为令牌。
流程步骤:
- 用户向客户端提供用户名和密码
- 客户端将凭证发送到授权服务器
- 授权服务器验证并颁发令牌
适用场景(极少情况):
- 从遗留认证迁移的第一方移动应用
- 仅受信任的第一方应用
- 无法使用浏览器/重定向流程的场景
为何避免使用:
- 客户端直接处理用户凭证(安全风险)
- 不支持多因素认证
- 存在钓鱼风险
- 违反OAuth2委托授权原则
- 尽可能使用带PKCE的授权码流程
6. Device Authorization Flow
6. 设备授权流程
For Input-Constrained Devices
The device flow is designed for devices with limited input capabilities (smart TVs, IoT devices, CLI tools).
Flow Steps:
- Device requests device code and user code from authorization server
- Device displays user code and instructs user to visit URL
- User visits URL on another device and enters user code
- User authenticates and authorizes device
- Device polls authorization server for access token
- Authorization server issues access token when user completes authorization
When to Use:
- Smart TVs and streaming devices
- IoT devices without keyboards
- CLI tools and command-line applications
- Gaming consoles
- Any device where typing is difficult
适用于输入受限的设备
设备流程专为输入能力有限的设备设计(智能电视、IoT设备、CLI工具)。
流程步骤:
- 设备向授权服务器请求device code和user code
- 设备显示user code并指示用户访问指定URL
- 用户在另一设备上访问URL并输入user code
- 用户进行认证并授权设备
- 设备轮询授权服务器以获取访问令牌
- 用户完成授权后,授权服务器颁发访问令牌
适用场景:
- 智能电视和流媒体设备
- 无键盘的IoT设备
- CLI工具和命令行应用
- 游戏机
- 任何输入困难的设备
Token Types and Management
令牌类型与管理
Access Tokens
访问令牌
Short-lived credentials for accessing protected resources
Characteristics:
- Typically expire in 15 minutes to 1 hour
- Bearer token format:
Authorization: Bearer <access_token> - Can be opaque tokens or JWTs (JSON Web Tokens)
- Should be treated as sensitive credentials
- Never log or expose in URLs
- Validate on every API request
JWT Structure (when using JWTs):
Header.Payload.SignatureJWT Payload Claims:
- : Subject (user ID)
sub - : Issued at time
iat - : Expiration time
exp - : Issuer (authorization server)
iss - : Audience (resource server)
aud - : Granted permissions
scope - Custom claims (user metadata, roles, etc.)
Token Validation:
- Verify signature using public key
- Check expiration (exp claim)
- Verify issuer (iss claim)
- Validate audience (aud claim)
- Check token has required scopes
用于访问受保护资源的短期凭证
特点:
- 通常在15分钟到1小时内过期
- Bearer令牌格式:
Authorization: Bearer <access_token> - 可以是不透明令牌或JWT(JSON Web Tokens)
- 应视为敏感凭证
- 切勿记录或在URL中暴露
- 在每个API请求上验证
JWT结构(使用JWT时):
Header.Payload.SignatureJWT负载声明:
- : 主题(用户ID)
sub - : 颁发时间
iat - : 过期时间
exp - : 颁发者(授权服务器)
iss - : 受众(资源服务器)
aud - : 授予的权限
scope - 自定义声明(用户元数据、角色等)
令牌验证:
- 使用公钥验证签名
- 检查过期时间(exp声明)
- 验证颁发者(iss声明)
- 验证受众(aud声明)
- 检查令牌是否具有所需的范围
Refresh Tokens
刷新令牌
Long-lived credentials for obtaining new access tokens
Characteristics:
- Typically expire in days, weeks, or months
- Single-use or reusable (depending on implementation)
- Must be stored securely (never in localStorage in browsers)
- Should be encrypted at rest
- Can be revoked by authorization server
- Subject to rotation policies
Refresh Token Rotation:
- Each refresh issues a new refresh token
- Old refresh token is invalidated
- Prevents token replay attacks
- Detects token theft (multiple refresh attempts)
- Recommended security practice
Token Storage Best Practices:
Web Applications:
- Access tokens: Memory (React context, Vuex, Redux)
- Refresh tokens: HttpOnly, Secure, SameSite cookies
- Alternative: Store refresh token on backend, use session
Mobile Applications:
- Use platform secure storage
- iOS: Keychain Services
- Android: EncryptedSharedPreferences or Keystore
- Never store in plaintext files
SPAs:
- Store access tokens in memory only
- Use BFF (Backend for Frontend) pattern for refresh tokens
- Consider Token Handler pattern
- Avoid localStorage (XSS vulnerability)
用于获取新访问令牌的长期凭证
特点:
- 通常在数天、数周或数月内过期
- 一次性或可重复使用(取决于实现)
- 必须安全存储(切勿在浏览器的localStorage中存储)
- 应在静态存储时加密
- 可被授权服务器吊销
- 受轮换策略约束
刷新令牌轮换:
- 每次刷新都会颁发新的刷新令牌
- 旧的刷新令牌失效
- 防止令牌重放攻击
- 检测令牌窃取(多次刷新尝试)
- 推荐的安全实践
令牌存储最佳实践:
Web应用:
- 访问令牌:内存(React上下文、Vuex、Redux)
- 刷新令牌:HttpOnly、Secure、SameSite cookies
- 替代方案:在后端存储刷新令牌,使用会话
移动应用:
- 使用平台安全存储
- iOS: Keychain Services
- Android: EncryptedSharedPreferences或Keystore
- 切勿存储在明文文件中
SPA:
- 仅在内存中存储访问令牌
- 使用BFF(Backend for Frontend)模式处理刷新令牌
- 考虑Token Handler模式
- 避免使用localStorage(XSS漏洞)
ID Tokens (OpenID Connect)
ID令牌(OpenID Connect)
Tokens containing user identity information
Characteristics:
- Always JWT format
- Contains user profile information
- Used for authentication (not authorization)
- Returned alongside access tokens
- Should be validated before use
Standard Claims:
- : Subject (unique user ID)
sub - : Full name
name - : Email address
email - : Email verification status
email_verified - : Profile picture URL
picture - ,
iat: Issued/expiration timesexp
包含用户身份信息的令牌
特点:
- 始终为JWT格式
- 包含用户配置文件信息
- 用于认证(而非授权)
- 与访问令牌一起返回
- 使用前应验证
标准声明:
- : 主题(唯一用户ID)
sub - : 全名
name - : 电子邮件地址
email - : 电子邮件验证状态
email_verified - : 个人资料图片URL
picture - ,
iat: 颁发/过期时间exp
OAuth2 Scopes
OAuth2 范围
Fine-grained permissions for access control
Scopes define what access the client is requesting and what the access token permits.
Scope Naming Conventions:
- - Read user data
read:users - - Create/update users
write:users - - Delete users
delete:users - - Full administrative access
admin:all - - Request OpenID Connect ID token
openid - - Access user profile information
profile - - Access user email address
email
Best Practices:
- Request minimum required scopes (principle of least privilege)
- Separate read and write permissions
- Create resource-specific scopes
- Document all available scopes
- Allow users to see and understand requested permissions
- Implement scope-based access control in APIs
Dynamic Scopes:
read:organization:{org_id}
write:project:{project_id}
admin:tenant:{tenant_id}用于访问控制的细粒度权限
范围定义了客户端请求的访问权限以及访问令牌允许的操作。
范围命名约定:
- - 读取用户数据
read:users - - 创建/更新用户
write:users - - 删除用户
delete:users - - 完整的管理员访问权限
admin:all - - 请求OpenID Connect ID令牌
openid - - 访问用户配置文件信息
profile - - 访问用户电子邮件地址
email
最佳实践:
- 请求最小必要范围(最小权限原则)
- 分离读和写权限
- 创建特定于资源的范围
- 记录所有可用范围
- 允许用户查看并理解请求的权限
- 在API中实现基于范围的访问控制
动态范围:
read:organization:{org_id}
write:project:{project_id}
admin:tenant:{tenant_id}Security Considerations
安全考虑
State Parameter
State参数
Prevents Cross-Site Request Forgery (CSRF)
The state parameter is a random value that the client includes in the authorization request and validates in the callback.
Implementation:
- Generate random state value (cryptographically secure)
- Store state in session or encrypted cookie
- Include state in authorization URL
- Validate state matches when receiving callback
- Reject mismatched or missing state
Example State Value:
state: crypto.randomBytes(32).toString('hex')
// "7f8a3d9e2b1c4f5a6d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"防止跨站请求伪造(CSRF)
State参数是客户端在授权请求中包含的随机值,并在回调中验证。
实现:
- 生成随机State值(加密安全)
- 将State存储在会话或加密cookie中
- 在授权URL中包含State
- 接收回调时验证State是否匹配
- 拒绝不匹配或缺失的State
示例State值:
state: crypto.randomBytes(32).toString('hex')
// "7f8a3d9e2b1c4f5a6d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"PKCE Implementation
PKCE实现
Proof Key for Code Exchange - Prevents Code Interception
PKCE protects the authorization code flow against authorization code interception attacks.
Code Verifier:
- Random string, 43-128 characters
- Characters: A-Z, a-z, 0-9, -, ., _, ~
- Stored securely on client
Code Challenge:
- SHA256 hash of code verifier (recommended)
- Or plain code verifier (not recommended)
- Sent in authorization request
Code Challenge Methods:
- : SHA256 hash (use this)
S256 - : Plaintext verifier (legacy only)
plain
Implementation Example:
javascript
// 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`;授权码交换证明密钥 - 防止代码拦截
PKCE保护授权码流程免受授权码拦截攻击。
Code Verifier:
- 随机字符串,43-128个字符
- 字符:A-Z, a-z, 0-9, -, ., _, ~
- 在客户端安全存储
Code Challenge:
- Code Verifier的SHA256哈希值(推荐)
- 或原始Code Verifier(不推荐)
- 在授权请求中发送
Code Challenge方法:
- : SHA256哈希(使用此方法)
S256 - : 明文Verifier(仅遗留场景)
plain
实现示例:
javascript
// 生成code verifier
const codeVerifier = generateRandomString(64);
// 生成code challenge
const codeChallenge = base64UrlEncode(
sha256(codeVerifier)
);
// 存储code verifier用于令牌交换
sessionStorage.setItem('code_verifier', codeVerifier);
// 包含在授权URL中
const authUrl = `${authEndpoint}?` +
`client_id=${clientId}` +
`&redirect_uri=${redirectUri}` +
`&response_type=code` +
`&scope=${scopes}` +
`&state=${state}` +
`&code_challenge=${codeChallenge}` +
`&code_challenge_method=S256`;Token Security
令牌安全
Protecting Access and Refresh Tokens
Do:
- Use HTTPS for all OAuth2 endpoints
- Store refresh tokens in secure storage only
- Implement token rotation
- Set appropriate token expiration times
- Validate tokens on every API request
- Use JWTs with strong signing algorithms (RS256, ES256)
- Implement token revocation
- Monitor for suspicious token usage
Don't:
- Store tokens in localStorage (XSS risk)
- Include tokens in URLs or query parameters
- Log tokens in application logs
- Use weak signing algorithms (HS256 with shared secrets)
- Share tokens between applications
- Extend access token lifetime unnecessarily
- Ignore token expiration
保护访问令牌和刷新令牌
应该做:
- 所有OAuth2端点使用HTTPS
- 仅在安全存储中存储刷新令牌
- 实现令牌轮换
- 设置适当的令牌过期时间
- 在每个API请求上验证令牌
- 使用带有强签名算法的JWT(RS256、ES256)
- 实现令牌吊销
- 监控可疑令牌使用
不应该做:
- 在localStorage中存储令牌(XSS风险)
- 在URL或查询参数中包含令牌
- 在应用日志中记录令牌
- 使用弱签名算法(HS256与共享密钥)
- 在应用之间共享令牌
- 不必要地延长访问令牌的生命周期
- 忽略令牌过期
Redirect URI Validation
重定向URI验证
Prevent Open Redirect Vulnerabilities
Strict Validation Rules:
- Exact match required (no wildcards)
- Protocol must match exactly (https://)
- Host must match exactly
- Port must match (if specified)
- Path must match (if specified)
- Register all redirect URIs in advance
Mobile Deep Links:
- Use custom URL schemes:
com.example.app://callback - Or universal links (iOS):
https://example.com/auth/callback - Or app links (Android):
https://example.com/auth/callback - Register schemes with authorization server
Localhost Development:
- Allow http://localhost for development only
- Specify exact port:
http://localhost:3000/callback - Use 127.0.0.1 if localhost doesn't work
- Never use localhost redirects in production
防止开放重定向漏洞
严格验证规则:
- 需要精确匹配(无通配符)
- 协议必须完全匹配(https://)
- 主机必须完全匹配
- 端口必须匹配(如果指定)
- 路径必须匹配(如果指定)
- 预先注册所有重定向URI
移动深度链接:
- 使用自定义URL方案:
com.example.app://callback - 或通用链接(iOS):
https://example.com/auth/callback - 或应用链接(Android):
https://example.com/auth/callback - 向授权服务器注册方案
Localhost开发:
- 仅在开发环境中允许http://localhost
- 指定精确端口:
http://localhost:3000/callback - 如果localhost不工作,使用127.0.0.1
- 切勿在生产环境中使用localhost重定向
OpenID Connect (OIDC)
OpenID Connect(OIDC)
Identity Layer Built on OAuth2
OpenID Connect adds an identity layer on top of OAuth2, providing authentication in addition to authorization.
Key Differences from OAuth2:
- Returns ID token in addition to access token
- ID token contains user identity information
- Standardized user info endpoint
- Standardized discovery endpoint (.well-known/openid-configuration)
- Session management capabilities
OIDC Flows:
-
Authorization Code Flow (recommended)
- Same as OAuth2 but returns id_token
- Most secure for web apps
-
Implicit Flow (deprecated)
- Returns id_token directly
- Insecure, use Code Flow with PKCE instead
-
Hybrid Flow
- Combines Code and Implicit flows
- Complex, rarely needed
OIDC Scopes:
- (required) - Enables OIDC
openid - - Name, picture, locale, etc.
profile - - Email address and verification status
email - - Physical address
address - - Phone number
phone
UserInfo Endpoint:
GET /userinfo
Authorization: Bearer <access_token>
Response:
{
"sub": "248289761001",
"name": "Jane Doe",
"email": "jane@example.com",
"email_verified": true,
"picture": "https://example.com/photo.jpg"
}ID Token Validation:
- Verify signature using provider's public key
- Validate issuer (iss claim)
- Validate audience (aud claim - should match client_id)
- Check expiration (exp claim)
- Validate nonce (if provided in request)
- Check token was issued recently (iat claim)
基于OAuth2的身份层
OpenID Connect在OAuth2之上添加了身份层,除了授权之外还提供认证功能。
与OAuth2的主要区别:
- 除访问令牌外还返回ID令牌
- ID令牌包含用户身份信息
- 标准化的用户信息端点
- 标准化的发现端点(.well-known/openid-configuration)
- 会话管理功能
OIDC流程:
-
授权码流程(推荐)
- 与OAuth2相同,但返回id_token
- 对Web应用最安全
-
隐式流程(已弃用)
- 直接返回id_token
- 不安全,请改用带PKCE的授权码流程
-
混合流程
- 结合了授权码和隐式流程
- 复杂,很少需要
OIDC范围:
- (必填)- 启用OIDC
openid - - 姓名、图片、区域设置等
profile - - 电子邮件地址和验证状态
email - - 物理地址
address - - 电话号码
phone
用户信息端点:
GET /userinfo
Authorization: Bearer <access_token>
响应:
{
"sub": "248289761001",
"name": "Jane Doe",
"email": "jane@example.com",
"email_verified": true,
"picture": "https://example.com/photo.jpg"
}ID令牌验证:
- 使用提供商的公钥验证签名
- 验证颁发者(iss声明)
- 验证受众(aud声明 - 应匹配client_id)
- 检查过期时间(exp声明)
- 验证nonce(如果在请求中提供)
- 检查令牌是否是近期颁发的(iat声明)
Multi-Tenancy Patterns
多租户模式
Organization-Specific Authentication
Many SaaS applications require users to authenticate within the context of an organization or tenant.
Patterns:
-
Organization Parameter
- Include organization ID in authorization request
scope=openid profile organization:acme-corp- Tokens scoped to specific organization
-
Organization Selector
- User selects organization after initial auth
- Exchange token for organization-specific token
- Support switching organizations
-
Custom Domain per Tenant
- vs
acme.example.comglobex.example.com - Separate OAuth2 configuration per tenant
- White-label authentication experience
-
Organization in Token Claims
- Include org_id in access token
- API validates organization access
- Support users in multiple organizations
组织特定的认证
许多SaaS应用要求用户在组织或租户的上下文中进行认证。
模式:
-
组织参数
- 在授权请求中包含组织ID
scope=openid profile organization:acme-corp- 令牌限定到特定组织
-
组织选择器
- 用户在初始认证后选择组织
- 将令牌交换为特定于组织的令牌
- 支持切换组织
-
每个租户的自定义域名
- vs
acme.example.comglobex.example.com - 每个租户有单独的OAuth2配置
- 白标认证体验
-
令牌声明中的组织
- 在访问令牌中包含org_id
- API验证组织访问权限
- 支持用户属于多个组织
Authorization Code Flow Implementation
授权码流程实现
Server-Side Web Application Flow
服务器端Web应用流程
Complete implementation for traditional web applications with backend
针对带有后端的传统Web应用的完整实现
Step 1: Configuration
步骤1:配置
javascript
// 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
};javascript
// OAuth2 配置
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'],
// 可选OIDC端点
userInfoEndpoint: 'https://auth.example.com/oauth/userinfo',
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
// 安全设置
useStateParameter: true,
usePKCE: false, // 机密客户端不需要
};Step 2: Generate Authorization URL
步骤2:生成授权URL
javascript
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);
});javascript
const crypto = require('crypto');
function generateAuthorizationUrl(req) {
// 生成并存储state以防止CSRF
const state = crypto.randomBytes(32).toString('hex');
req.session.oauth2State = state;
// 构建授权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路由
app.get('/auth/login', (req, res) => {
const authUrl = generateAuthorizationUrl(req);
res.redirect(authUrl);
});Step 3: Handle Callback and Exchange Code
步骤3:处理回调并交换代码
javascript
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');
}
});javascript
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;
// 返回: { access_token, refresh_token, token_type, expires_in, id_token }
}
// 回调路由
app.get('/auth/callback', async (req, res) => {
const { code, state, error, error_description } = req.query;
// 检查授权错误
if (error) {
console.error('授权错误:', error, error_description);
return res.redirect('/auth/error?message=' + error_description);
}
// 验证state参数(CSRF保护)
if (state !== req.session.oauth2State) {
console.error('State不匹配 - 可能存在CSRF攻击');
return res.status(403).send('无效的state参数');
}
// 清除会话中的state
delete req.session.oauth2State;
try {
// 将授权码交换为令牌
const tokens = await exchangeCodeForToken(code);
// 在会话中安全存储令牌
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
req.session.tokenExpiry = Date.now() + (tokens.expires_in * 1000);
// 可选:获取用户信息
if (tokens.id_token) {
const userInfo = await getUserInfo(tokens.access_token);
req.session.user = userInfo;
}
// 重定向到应用
res.redirect('/dashboard');
} catch (error) {
console.error('令牌交换失败:', error);
res.redirect('/auth/error?message=认证失败');
}
});Step 4: Use Access Token for API Requests
步骤4:使用访问令牌进行API请求
javascript
// 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');
}
});javascript
// 检查认证的中间件
function requireAuth(req, res, next) {
if (!req.session.accessToken) {
return res.redirect('/auth/login');
}
// 检查令牌是否过期
if (Date.now() > req.session.tokenExpiry) {
// 令牌过期,尝试刷新
return refreshAccessToken(req, res, next);
}
next();
}
// 带访问令牌的API请求
async function fetchUserData(accessToken) {
const response = await axios.get('https://api.example.com/user/data', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
return response.data;
}
// 受保护的路由
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请求失败:', error);
res.status(500).send('获取数据失败');
}
});Step 5: Implement Token Refresh
步骤5:实现令牌刷新
javascript
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');
}
}javascript
async function refreshAccessToken(req, res, next) {
if (!req.session.refreshToken) {
// 没有刷新令牌,需要重新认证
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',
},
}
);
// 更新会话中的令牌
req.session.accessToken = response.data.access_token;
req.session.tokenExpiry = Date.now() + (response.data.expires_in * 1000);
// 如果启用了轮换,更新刷新令牌
if (response.data.refresh_token) {
req.session.refreshToken = response.data.refresh_token;
}
next();
} catch (error) {
console.error('令牌刷新失败:', error);
// 刷新失败,需要重新认证
delete req.session.accessToken;
delete req.session.refreshToken;
res.redirect('/auth/login');
}
}Step 6: Implement Logout
步骤6:实现登出
javascript
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',
},
}
);
}javascript
app.post('/auth/logout', async (req, res) => {
// 可选:在授权服务器上吊销令牌
if (req.session.accessToken) {
try {
await revokeToken(req.session.accessToken);
} catch (error) {
console.error('令牌吊销失败:', error);
}
}
// 清除会话
req.session.destroy((err) => {
if (err) {
console.error('会话销毁失败:', 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 Implementation (SPAs and Mobile Apps)
PKCE实现(SPA和移动应用)
Single Page Application (React)
单页应用(React)
Complete OAuth2 with PKCE implementation for React SPAs
针对React SPA的完整OAuth2 + PKCE实现
Setup and Configuration
设置与配置
javascript
// 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, '');
}javascript
// 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工具函数
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, '');
}Auth Context Provider
认证上下文提供器
javascript
// 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>;
}javascript
// 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(() => {
// 检查现有会话
checkAuth();
}, []);
async function checkAuth() {
// 尝试从内存或刷新中恢复访问令牌
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 {
// 令牌过期或不存在,尝试刷新
await tryRefresh();
}
setLoading(false);
}
async function login() {
// 生成PKCE参数
const codeVerifier = generateRandomString(64);
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateRandomString(32);
// 存储用于回调
sessionStorage.setItem('code_verifier', codeVerifier);
sessionStorage.setItem('oauth2_state', state);
// 构建授权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',
});
// 重定向到授权服务器
window.location.href = `${oauth2Config.authorizationEndpoint}?${params}`;
}
async function handleCallback(code, state) {
// 验证state
const savedState = sessionStorage.getItem('oauth2_state');
if (state !== savedState) {
throw new Error('无效的state参数 - 可能存在CSRF攻击');
}
// 获取code verifier
const codeVerifier = sessionStorage.getItem('code_verifier');
if (!codeVerifier) {
throw new Error('未找到code verifier');
}
// 将代码交换为令牌
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('令牌交换失败');
}
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));
// 通过后端将刷新令牌存储在httpOnly cookie中
if (tokens.refresh_token) {
await storeRefreshToken(tokens.refresh_token);
}
// 获取用户信息
await fetchUserInfo(tokens.access_token);
// 清理
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) {
// 通过后端存储刷新令牌(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 {
// 调用后端使用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('令牌刷新失败:', error);
}
return false;
}
async function logout() {
// 吊销令牌
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
// 清除状态
setUser(null);
setAccessToken(null);
sessionStorage.clear();
}
const value = {
user,
accessToken,
loading,
login,
logout,
handleCallback,
isAuthenticated: !!accessToken,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}Callback Component
回调组件
javascript
// 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>
);
}javascript
// 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('认证失败:', err);
setError(err.message);
});
} else {
setError('缺少授权码或state');
}
}, [searchParams, handleCallback, navigate]);
if (error) {
return (
<div className="auth-error">
<h2>认证失败</h2>
<p>{error}</p>
<button onClick={() => navigate('/')}>返回首页</button>
</div>
);
}
return (
<div className="auth-loading">
<h2>正在完成认证...</h2>
<div className="spinner"></div>
</div>
);
}Protected Route Component
受保护路由组件
javascript
// 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;
}javascript
// 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>加载中...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
return children;
}API Client with Token Management
带令牌管理的API客户端
javascript
// 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();javascript
// 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');
// 检查令牌是否需要刷新
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) {
// 令牌无效,尝试刷新一次
await this.refreshToken();
const newToken = sessionStorage.getItem('access_token');
// 重试请求
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请求失败');
}
return retryResponse.json();
}
if (!response.ok) {
throw new Error(`API错误: ${response.status}`);
}
return response.json();
}
async refreshToken() {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
// 刷新失败,重定向到登录
window.location.href = '/login';
throw new Error('令牌刷新失败');
}
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();Client Credentials Flow
客户端凭证流程
Backend Service Authentication
后端服务认证
Machine-to-machine authentication for services and APIs
针对服务和API的机器对机器认证
Node.js Implementation
Node.js实现
javascript
// 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();
}javascript
// 服务到服务的认证
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() {
// 如果令牌仍然有效,返回缓存的令牌
if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
return this.accessToken;
}
// 请求新令牌
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('获取访问令牌失败');
}
const data = await response.json();
// 缓存令牌
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) {
// 令牌可能无效,强制刷新并重试
this.accessToken = null;
const newToken = await this.getAccessToken();
const retryResponse = await fetch(endpoint, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newToken}`,
},
});
return retryResponse;
}
return response;
}
}
// 使用示例
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'],
});
// 发起API调用
async function fetchData() {
const response = await oauth2Client.callApi('https://api.example.com/data');
return response.json();
}Python Implementation
Python实现
python
undefinedpython
undefinedclient_credentials_oauth2.py
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 responseimport 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):
"""获取缓存令牌或请求新令牌"""
# 如果令牌有效,返回缓存的令牌
if self.access_token and datetime.now() < self.token_expiry - timedelta(minutes=1):
return self.access_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'获取访问令牌失败: {response.text}')
token_data = response.json()
# 缓存令牌
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):
"""发起认证后的API请求"""
token = self.get_access_token()
headers = kwargs.pop('headers', {})
headers['Authorization'] = f'Bearer {token}'
response = requests.request(method, url, headers=headers, **kwargs)
# 处理令牌过期
if response.status_code == 401:
# 强制刷新令牌并重试
self.access_token = None
token = self.get_access_token()
headers['Authorization'] = f'Bearer {token}'
response = requests.request(method, url, headers=headers, **kwargs)
return responseUsage
使用示例
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']
)
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
发起API请求
response = client.call_api('https://api.example.com/data')
data = response.json()
undefinedresponse = client.call_api('https://api.example.com/data')
data = response.json()
undefinedOpenID Connect Implementation
OpenID Connect实现
Complete OIDC Integration
完整OIDC集成
Authentication with identity verification
带身份验证的认证
ID Token Validation
ID令牌验证
javascript
// 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;
}
}javascript
// 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客户端,用于获取公钥
this.client = jwksClient({
jwksUri: this.jwksUri,
cache: true,
cacheMaxAge: 86400000, // 24小时
});
}
async getSigningKey(kid) {
const key = await this.client.getSigningKey(kid);
return key.getPublicKey();
}
async validate(idToken) {
try {
// 解码令牌头以获取密钥ID
const decoded = jwt.decode(idToken, { complete: true });
if (!decoded) {
throw new Error('无效的令牌格式');
}
// 获取公钥
const publicKey = await this.getSigningKey(decoded.header.kid);
// 验证并解码令牌
const payload = jwt.verify(idToken, publicKey, {
issuer: this.issuer,
audience: this.audience,
algorithms: ['RS256', 'ES256'],
});
// 额外验证
this.validateClaims(payload);
return payload;
} catch (error) {
throw new Error(`ID令牌验证失败: ${error.message}`);
}
}
validateClaims(payload) {
// 检查必填声明
if (!payload.sub) {
throw new Error('缺少sub声明');
}
if (!payload.iat || !payload.exp) {
throw new Error('缺少iat或exp声明');
}
// 检查令牌是否过期(jwt.verify已检查,但再次确认)
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) {
throw new Error('令牌已过期');
}
// 检查令牌是否在未来颁发
if (payload.iat > now + 60) {
throw new Error('令牌在未来颁发');
}
// 可选:如果提供了nonce,检查它
// if (payload.nonce && payload.nonce !== expectedNonce) {
// throw new Error('Nonce不匹配');
// }
return true;
}
}
// 使用示例
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('用户ID:', payload.sub);
console.log('电子邮件:', payload.email);
console.log('姓名:', payload.name);
return payload;
} catch (error) {
console.error('ID令牌验证失败:', error);
throw error;
}
}UserInfo Endpoint Integration
用户信息端点集成
javascript
// 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,
};
}javascript
// 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(`用户信息请求失败: ${response.status}`);
}
return response.json();
}
async getUserInfoWithValidation(accessToken, idTokenPayload) {
const userInfo = await this.getUserInfo(accessToken);
// 验证sub是否与ID令牌匹配
if (userInfo.sub !== idTokenPayload.sub) {
throw new Error('用户信息sub与ID令牌sub不匹配');
}
return userInfo;
}
}
// 使用示例
const userInfoClient = new UserInfoClient('https://auth.example.com/oauth/userinfo');
async function getCompleteUserProfile(accessToken, idToken, validator) {
// 验证ID令牌
const idTokenPayload = await validator.validate(idToken);
// 获取额外的用户信息
const userInfo = await userInfoClient.getUserInfoWithValidation(accessToken, idTokenPayload);
// 合并信息
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,
// 任何自定义声明
...userInfo,
};
}Token Management Best Practices
令牌管理最佳实践
Secure Token Storage
安全令牌存储
Browser Storage Comparison
浏览器存储对比
javascript
// 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 browserjavascript
// Web应用的令牌存储策略
// ❌ 不良:localStorage(易受XSS攻击)
localStorage.setItem('access_token', token); // 不要这样做
// ❌ 不良:sessionStorage(同样易受XSS攻击)
sessionStorage.setItem('access_token', token); // 不要这样做
// ✅ 良好:内存存储(页面刷新时清除)
class TokenStore {
constructor() {
this.accessToken = null;
this.refreshToken = null;
}
setTokens(accessToken, refreshToken) {
this.accessToken = accessToken;
// 刷新令牌通过后端存储在httpOnly cookie中
// this.refreshToken = refreshToken; // 不要存储在内存中
}
getAccessToken() {
return this.accessToken;
}
clear() {
this.accessToken = null;
this.refreshToken = null;
}
}
// ✅ 最佳:前端后端(BFF)模式
// - 所有令牌存储在后端
// - 会话cookie标识用户
// - 没有令牌暴露给浏览器Mobile Token Storage (React Native)
移动令牌存储(React Native)
javascript
// 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();javascript
// React Native的安全令牌存储
import * as SecureStore from 'expo-secure-store';
// 或
// 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('存储令牌失败:', error);
throw error;
}
}
async getAccessToken() {
try {
return await SecureStore.getItemAsync('access_token');
} catch (error) {
console.error('获取访问令牌失败:', error);
return null;
}
}
async getRefreshToken() {
try {
return await SecureStore.getItemAsync('refresh_token');
} catch (error) {
console.error('获取刷新令牌失败:', 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('清除令牌失败:', 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();Token Refresh Strategies
令牌刷新策略
Automatic Token Refresh (Proactive)
自动令牌刷新(主动式)
javascript
// 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();javascript
// 令牌过期前主动刷新
class TokenRefreshManager {
constructor(refreshCallback, expiresIn) {
this.refreshCallback = refreshCallback;
this.expiresIn = expiresIn;
this.refreshTimer = null;
}
scheduleRefresh() {
// 过期前5分钟刷新
const refreshIn = (this.expiresIn - 300) * 1000;
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(async () => {
try {
await this.refreshCallback();
} catch (error) {
console.error('令牌刷新失败:', error);
// 可选:重定向到登录
}
}, refreshIn);
}
cancelRefresh() {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}
}
// 使用示例
const refreshManager = new TokenRefreshManager(
async () => {
// 刷新令牌逻辑
const newTokens = await refreshTokens();
setAccessToken(newTokens.access_token);
refreshManager.expiresIn = newTokens.expires_in;
refreshManager.scheduleRefresh();
},
3600 // 初始expires_in
);
// 启动自动刷新
refreshManager.scheduleRefresh();On-Demand Token Refresh (Reactive)
按需令牌刷新(响应式)
javascript
// 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;
}javascript
// 仅当访问令牌过期时刷新
class ReactiveTokenManager {
constructor() {
this.accessToken = null;
this.expiresAt = null;
this.refreshPromise = null; // 防止并发刷新
}
async getValidToken() {
// 检查令牌是否仍然有效
if (this.accessToken && Date.now() < this.expiresAt - 60000) {
return this.accessToken;
}
// 令牌过期,刷新它
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', // 发送刷新令牌cookie
});
if (!response.ok) {
throw new Error('令牌刷新失败');
}
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;
}
}
// 在API客户端中使用
const tokenManager = new ReactiveTokenManager();
async function makeApiRequest(endpoint) {
const token = await tokenManager.getValidToken();
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
return response;
}Token Revocation
令牌吊销
javascript
// 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();
}
}javascript
// 实现令牌吊销
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' 或 'refresh_token'
client_id: oauth2Config.clientId,
client_secret: oauth2Config.clientSecret, // 如果是机密客户端
}),
});
if (!response.ok) {
throw new Error('令牌吊销失败');
}
return true;
}
// 带令牌吊销的登出
async function logout() {
try {
// 吊销访问令牌
if (accessToken) {
await revokeToken(accessToken, 'access_token');
}
// 吊销刷新令牌
if (refreshToken) {
await revokeToken(refreshToken, 'refresh_token');
}
// 清除本地状态
clearTokens();
// 可选:重定向到登出端点以清理会话
window.location.href = 'https://auth.example.com/logout?redirect_uri=' +
encodeURIComponent(window.location.origin);
} catch (error) {
console.error('登出失败:', error);
// 无论如何都清除令牌
clearTokens();
}
}OAuth2 Server Implementation
OAuth2服务器实现
Building an Authorization Server
构建授权服务器
Implementing OAuth2 provider functionality
实现OAuth2提供商功能
Authorization Endpoint
授权端点
javascript
// 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());
}javascript
// Express.js授权端点
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;
// 验证必填参数
if (!client_id || !redirect_uri || !response_type) {
return res.status(400).json({
error: 'invalid_request',
error_description: '缺少必填参数',
});
}
// 验证client_id和redirect_uri
const client = await getClient(client_id);
if (!client) {
return res.status(400).json({
error: 'invalid_client',
error_description: '未知客户端',
});
}
if (!client.redirect_uris.includes(redirect_uri)) {
return res.status(400).json({
error: 'invalid_request',
error_description: '无效的redirect_uri',
});
}
// 验证response_type
if (response_type !== 'code') {
return redirectWithError(redirect_uri, state, 'unsupported_response_type',
'仅支持授权码流程');
}
// 验证PKCE参数
if (code_challenge) {
if (!code_challenge_method) {
return redirectWithError(redirect_uri, state, 'invalid_request',
'使用PKCE时需要code_challenge_method');
}
if (code_challenge_method !== 'S256' && code_challenge_method !== 'plain') {
return redirectWithError(redirect_uri, state, 'invalid_request',
'不支持的code_challenge_method');
}
}
// 检查用户是否已认证
if (!req.session.userId) {
// 存储授权请求并重定向到登录
req.session.authRequest = {
client_id,
redirect_uri,
scope,
state,
code_challenge,
code_challenge_method,
};
return res.redirect('/login?continue=' + encodeURIComponent(req.originalUrl));
}
// 用户已认证,显示同意页面
res.render('consent', {
client: client,
scopes: scope ? scope.split(' ') : [],
state: state,
});
});
// 同意端点
router.post('/oauth/consent', async (req, res) => {
const { approved } = req.body;
const authRequest = req.session.authRequest;
if (!authRequest) {
return res.status(400).send('没有待处理的授权请求');
}
if (approved !== 'true') {
// 用户拒绝同意
return redirectWithError(
authRequest.redirect_uri,
authRequest.state,
'access_denied',
'用户拒绝同意'
);
}
// 生成授权码
const authorizationCode = crypto.randomBytes(32).toString('hex');
// 存储授权码和元数据
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分钟
});
// 清除会话中的授权请求
delete req.session.authRequest;
// 重定向到客户端并携带授权码
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
令牌端点
javascript
// 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,
};
}javascript
// 令牌端点实现
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}'`,
});
}
res.json(tokens);
} catch (error) {
console.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;
// 验证必填参数
if (!code || !redirect_uri || !client_id) {
throw { code: 'invalid_request', message: '缺少必填参数' };
}
// 获取授权码
const authCode = await getAuthorizationCode(code);
if (!authCode) {
throw { code: 'invalid_grant', message: '无效的授权码' };
}
// 检查过期时间
if (Date.now() > authCode.expires_at) {
await deleteAuthorizationCode(code);
throw { code: 'invalid_grant', message: '授权码已过期' };
}
// 验证客户端
if (authCode.client_id !== client_id) {
throw { code: 'invalid_grant', message: '客户端不匹配' };
}
// 验证重定向URI
if (authCode.redirect_uri !== redirect_uri) {
throw { code: 'invalid_grant', message: '重定向URI不匹配' };
}
// 认证客户端
const client = await getClient(client_id);
if (client.client_type === 'confidential') {
// 机密客户端必须提供client_secret
if (client.client_secret !== client_secret) {
throw { code: 'invalid_client', message: '无效的客户端凭证' };
}
}
// 如果使用了PKCE,验证它
if (authCode.code_challenge) {
if (!code_verifier) {
throw { code: 'invalid_request', message: '需要code_verifier' };
}
const isValid = validatePKCE(
code_verifier,
authCode.code_challenge,
authCode.code_challenge_method
);
if (!isValid) {
throw { code: 'invalid_grant', message: '无效的code_verifier' };
}
}
// 删除授权码(一次性使用)
await deleteAuthorizationCode(code);
// 生成令牌
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,
});
// 如果请求了openid范围,生成ID令牌
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;
// 验证刷新令牌
const storedToken = await getRefreshToken(refresh_token);
if (!storedToken) {
throw { code: 'invalid_grant', message: '无效的刷新令牌' };
}
// 检查是否已吊销
if (storedToken.revoked) {
throw { code: 'invalid_grant', message: '刷新令牌已被吊销' };
}
// 验证客户端
if (storedToken.client_id !== client_id) {
throw { code: 'invalid_grant', message: '客户端不匹配' };
}
const client = await getClient(client_id);
if (client.client_type === 'confidential' && client.client_secret !== client_secret) {
throw { code: 'invalid_client', message: '无效的客户端凭证' };
}
// 验证范围(请求的范围必须是原始范围的子集)
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: '请求的范围超出原始授权' };
}
// 生成新的访问令牌
const accessToken = await generateAccessToken({
user_id: storedToken.user_id,
client_id: client_id,
scope: requestedScopes.join(' '),
});
// 可选:刷新令牌轮换
let newRefreshToken = refresh_token;
if (shouldRotateRefreshToken()) {
// 吊销旧的刷新令牌
await revokeRefreshToken(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;
// 认证客户端
const client = await getClient(client_id);
if (!client || client.client_secret !== client_secret) {
throw { code: 'invalid_client', message: '无效的客户端凭证' };
}
// 验证范围
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: '请求的范围对此客户端不允许' };
}
}
// 生成访问令牌(客户端凭证流程没有刷新令牌)
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,
};
}Token Generation (JWT)
令牌生成(JWT)
javascript
// 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;
}javascript
// JWT令牌生成
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小时
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');
// 在数据库中存储刷新令牌
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天
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),
// 标准声明
name: user.name,
email: user.email,
email_verified: user.email_verified,
picture: user.picture,
// 基于范围的自定义声明
...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;
}Best Practices and Security
最佳实践与安全
Security Best Practices
安全最佳实践
-
Always Use HTTPS
- All OAuth2 endpoints must use HTTPS
- Tokens transmitted over encrypted connections only
- No OAuth2 over HTTP (except localhost development)
-
Validate Redirect URIs Strictly
- Exact match required (no wildcards)
- Register all redirect URIs in advance
- Validate on every authorization request
-
Use PKCE for Public Clients
- Required for SPAs and mobile apps
- Use S256 code challenge method (not plain)
- Prevents authorization code interception
-
Implement State Parameter
- Prevents CSRF attacks
- Generate cryptographically secure random value
- Validate on callback
-
Short-Lived Access Tokens
- Typically 15 minutes to 1 hour
- Reduces impact of token theft
- Use refresh tokens for long-lived sessions
-
Secure Token Storage
- Never store tokens in localStorage (XSS risk)
- Use httpOnly cookies for refresh tokens
- Mobile: Use platform secure storage (Keychain, Keystore)
-
Implement Token Rotation
- Rotate refresh tokens on each use
- Detect token theft through multiple refresh attempts
- Revoke all tokens for compromised refresh token
-
Validate All Tokens
- Verify JWT signatures
- Check expiration
- Validate issuer and audience
- Verify required scopes
-
Scope-Based Access Control
- Request minimum required scopes
- Validate scopes on API requests
- Document all available scopes
-
Monitor and Log
- Log all authentication attempts
- Monitor for suspicious patterns
- Alert on multiple failed attempts
- Track token usage and revocations
-
始终使用HTTPS
- 所有OAuth2端点必须使用HTTPS
- 仅通过加密连接传输令牌
- 除了localhost开发环境,不要使用HTTP进行OAuth2
-
严格验证重定向URI
- 需要精确匹配(无通配符)
- 预先注册所有重定向URI
- 在每个授权请求上验证
-
对公共客户端使用PKCE
- SPA和移动应用必需
- 使用S256 code challenge方法(不要使用plain)
- 防止授权码拦截
-
实现State参数
- 防止CSRF攻击
- 生成加密安全的随机值
- 在回调时验证
-
短期访问令牌
- 通常15分钟到1小时
- 减少令牌被盗的影响
- 使用刷新令牌实现长期会话
-
安全令牌存储
- 切勿在localStorage中存储令牌(XSS风险)
- 对刷新令牌使用httpOnly cookies
- 移动应用:使用平台安全存储(Keychain、Keystore)
-
实现令牌轮换
- 每次使用时轮换刷新令牌
- 通过多次刷新尝试检测令牌窃取
- 吊销被盗刷新令牌对应的所有令牌
-
验证所有令牌
- 验证JWT签名
- 检查过期时间
- 验证颁发者和受众
- 验证所需的范围
-
基于范围的访问控制
- 请求最小必要范围
- 在API请求上验证范围
- 记录所有可用范围
-
监控与日志
- 记录所有认证尝试
- 监控可疑模式
- 对多次失败尝试发出警报
- 跟踪令牌使用和吊销
Common Pitfalls to Avoid
应避免的常见陷阱
-
Using Implicit Flow
- Deprecated and insecure
- Use Authorization Code Flow with PKCE instead
-
Storing Tokens in localStorage
- Vulnerable to XSS attacks
- Use memory or secure storage instead
-
Not Validating Redirect URIs
- Enables open redirect attacks
- Always validate against registered URIs
-
Ignoring Token Expiration
- Expired tokens should be rejected
- Implement proper refresh logic
-
Not Using State Parameter
- Vulnerable to CSRF attacks
- Always generate and validate state
-
Long-Lived Access Tokens
- Increases security risk
- Keep access tokens short-lived (15-60 minutes)
-
Sharing Tokens Between Applications
- Tokens should be application-specific
- Each app should have its own client_id
-
Weak Code Verifiers (PKCE)
- Use cryptographically secure random generation
- Minimum 43 characters
-
Not Implementing Token Revocation
- Users can't revoke access
- Compromised tokens remain valid
-
Insufficient Logging
- Can't detect or investigate security incidents
- Log all authentication and authorization events
-
使用隐式流程
- 已弃用且不安全
- 请改用带PKCE的授权码流程
-
在localStorage中存储令牌
- 易受XSS攻击
- 改用内存或安全存储
-
不验证重定向URI
- 启用开放重定向漏洞
- 始终针对注册的URI进行验证
-
忽略令牌过期
- 应拒绝过期令牌
- 实现适当的刷新逻辑
-
不使用State参数
- 易受CSRF攻击
- 始终生成并验证State
-
长期访问令牌
- 增加安全风险
- 保持访问令牌短期有效(15-60分钟)
-
在应用之间共享令牌
- 令牌应特定于应用
- 每个应用应有自己的client_id
-
弱Code Verifiers(PKCE)
- 使用加密安全的随机生成
- 最少43个字符
-
不实现令牌吊销
- 用户无法撤销访问权限
- 被盗令牌仍然有效
-
日志不足
- 无法检测或调查安全事件
- 记录所有认证和授权事件
Performance Optimization
性能优化
Token Caching
令牌缓存
javascript
// 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();
}
}
}javascript
// 带自动刷新的高效令牌缓存
class OptimizedTokenManager {
constructor() {
this.tokenCache = new Map();
this.refreshPromises = new Map();
}
async getToken(cacheKey, fetchCallback) {
// 检查缓存
const cached = this.tokenCache.get(cacheKey);
if (cached && Date.now() < cached.expiresAt - 60000) {
return cached.token;
}
// 检查是否正在刷新
if (this.refreshPromises.has(cacheKey)) {
return this.refreshPromises.get(cacheKey);
}
// 获取新令牌
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();
}
}
}Connection Pooling
连接池
javascript
// 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;
}javascript
// 为令牌请求重用HTTP连接
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,
});
// 用于所有OAuth2请求
async function fetchTokens(params) {
const response = await apiClient.post(tokenEndpoint, params);
return response.data;
}Resources and References
资源与参考
Official Specifications
官方规范
Popular OAuth2 Libraries
流行的OAuth2库
JavaScript/Node.js:
- - Authentication middleware
passport - - Modern OAuth2 client
oauth4webapi - - OAuth2 server implementation
node-oauth2-server - - JWT creation and validation
jsonwebtoken - - JWKS key retrieval
jwks-rsa
Python:
- - Comprehensive OAuth library
authlib - - OAuth2 provider toolkit
python-oauth2 - - JWT implementation
PyJWT
PHP:
- - OAuth2 client
league/oauth2-client - - OAuth2 server
league/oauth2-server
Java:
- Spring Security OAuth
- Apache Oltu
JavaScript/Node.js:
- - 认证中间件
passport - - 现代OAuth2客户端
oauth4webapi - - OAuth2服务器实现
node-oauth2-server - - JWT创建与验证
jsonwebtoken - - JWKS密钥检索
jwks-rsa
Python:
- - 全面的OAuth库
authlib - - OAuth2提供工具包
python-oauth2 - - JWT实现
PyJWT
PHP:
- - OAuth2客户端
league/oauth2-client - - OAuth2服务器
league/oauth2-server
Java:
- Spring Security OAuth
- Apache Oltu
OAuth2 Providers
OAuth2提供商
- Auth0
- Okta
- Amazon Cognito
- Google Identity Platform
- Azure Active Directory
- Keycloak (open source)
- ORY Hydra (open source)
Skill Version: 1.0.0
Last Updated: October 2025
Skill Category: Authentication, Authorization, Security
Compatible With: Web Applications, Mobile Apps, APIs, Microservices
- Auth0
- Okta
- Amazon Cognito
- Google Identity Platform
- Azure Active Directory
- Keycloak(开源)
- ORY Hydra(开源)
技能版本: 1.0.0
最后更新: 2025年10月
技能类别: 认证、授权、安全
兼容对象: Web应用、移动应用、API、微服务