mcpField
O
oauth2-authentication

oauth2-authentication

github.com/manutej安全62602 次安装39 周前更新
代码仓库

关于 oauth2-authentication

oauth2-authentication 是帮助开发者实现 OAuth2 与 OpenID Connect 身份验证和授权的技能,适用于 Web、移动应用和 API。内容涵盖多种授权流程、令牌管理、权限范围及安全实践。

主要功能

  1. 01涵盖多种 OAuth2 授权流程
  2. 02说明访问令牌与刷新令牌管理
  3. 03介绍 OpenID Connect ID 令牌
  4. 04按权限范围实施访问控制
  5. 05提供状态参数与 PKCE 安全建议

适用场景

  1. 01为 SPA 或移动应用实现登录
  2. 02为服务间通信配置令牌授权
  3. 03集成社交登录或单点登录
  4. 04使用范围保护 API 资源

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/manutej/luxor-claude-marketplace --skill oauth2-authentication

安装后在 Claude Code 中输入/oauth2-authentication确认已加载。

附带文件README.md

详细介绍

OAuth2 身份验证

一项全面的技能,涵盖如何使用 OAuth2 和 OpenID Connect 实现安全的身份验证与授权。本技能介绍所有主要授权流程、令牌管理策略、安全最佳实践,以及适用于 Web、移动端和 API 应用的实际实现模式。

何时使用此技能

在以下情况下使用此技能:

  • 在 Web 应用、SPA 或移动应用中实现用户身份验证
  • 使用访问令牌和刷新令牌构建 API 授权
  • 集成社交登录(Google、GitHub、Facebook、Twitter 等)
  • 创建安全的机器对机器(M2M)身份验证
  • 在多个应用之间实现单点登录(SSO)
  • 构建 OAuth2 授权服务或身份提供商
  • 添加委托授权,以允许第三方访问
  • 使用基于令牌的身份验证保护 API
  • 实现无密码身份验证流程
  • 添加具有组织特定规则的多租户身份验证
  • 从基于会话的身份验证迁移到基于令牌的身份验证
  • 使用 OAuth2 作用域实现细粒度访问控制

核心概念

OAuth2 基础知识

OAuth2 是一种授权框架,可让应用获得对 HTTP 服务上用户帐户的有限访问权限。它将用户身份验证委托给托管用户帐户的服务,并授权第三方应用访问该帐户。

关键术语:

  • 资源所有者:拥有数据或资源的用户
  • 客户端:请求访问资源的应用(Web 应用、移动应用、SPA)
  • 授权服务:在对资源所有者进行身份验证后颁发访问令牌
  • 资源服务:托管受保护的资源,并接受和验证访问令牌
  • 访问令牌:用于访问受保护资源的短期凭据
  • 刷新令牌:用于获取新访问令牌的长期凭据
  • 作用域:访问特定资源或执行操作所授予的权限
  • 授权码:用于换取访问令牌的临时代码
  • 状态参数:用于防止授权流程中的 CSRF 攻击
  • 重定向 URI:授权后将用户重定向到的回调 URL
OAuth2 授权类型(授权流程)

OAuth2 为不同用例定义了多种授权类型:

1. 授权码流程

最安全的流程——推荐用于服务端应用

授权码流程是最安全、使用最广泛的 OAuth2 流程。它涉及在服务端将授权码交换为访问令牌。

流程步骤:

  1. 客户端将用户重定向到授权服务,并附带 client_id、redirect_uri、scope 和 state
  2. 用户进行身份验证,并同意所请求的权限
  3. 授权服务将用户重定向回客户端,并附带授权码
  4. 客户端使用客户端密钥在服务端将授权码换取访问令牌
  5. 客户端使用访问令牌访问受保护的资源

适用场景:

  • 传统的服务端 Web 应用
  • 能够安全存储客户端密钥的应用
  • 需要最高安全性的场景
  • 需要刷新令牌的场景

安全优势:

  • 访问令牌不会暴露给浏览器
  • 通过客户端密钥进行客户端身份验证
  • 授权码只能使用一次,且有效期较短
  • 状态参数可防止 CSRF 攻击
2. 使用 PKCE 的授权码流程

面向公共客户端(SPA 和移动应用)的安全流程

PKCE(Proof Key for Code Exchange,读作“pixy”)是授权码流程的扩展,专为无法安全存储客户端密钥的公共客户端设计。

流程步骤:

  1. 客户端生成 code_verifier(随机字符串)和 code_challenge(SHA256 哈希值)
  2. 客户端携带 code_challenge 重定向到授权服务器
  3. 用户进行身份验证并授权
  4. 授权服务器返回授权码
  5. 客户端使用 code 和 code_verifier 交换访问令牌
  6. 服务器验证 code_verifier 是否与 code_challenge 匹配

适用场景:

  • 单页应用(SPA)
  • 移动应用(iOS、Android)
  • 桌面应用
  • 任何无法存储密钥的公共客户端

安全优势:

  • 防止授权码拦截攻击
  • 无需客户端密钥
  • 防止恶意应用拦截重定向
  • OAuth2 安全最佳实践(RFC 8252)推荐使用
3. 客户端凭证流程

机器间身份验证

客户端凭证流程适用于客户端本身就是资源所有者的场景,通常用于服务间通信。

流程步骤:

  1. 客户端使用 client_id 和 client_secret 进行身份验证
  2. 授权服务器验证凭证
  3. 授权服务器签发访问令牌
  4. 客户端使用令牌访问受保护的资源

适用场景:

  • 与 API 通信的后端服务
  • Cron 作业或计划任务
  • 微服务身份验证
  • 访问 API 的 CI/CD 流水线
  • 无用户上下文的系统级操作

特点:

  • 无需用户参与
  • 客户端是资源所有者
  • 没有刷新令牌(只需重新请求访问令牌)
  • 通常使用有效期较长或缓存的令牌
4. 隐式流程(已弃用)

旧版流程——不再推荐

隐式流程会直接在 URL 片段中返回令牌,而不进行授权码交换。此流程现被认为不安全,应避免使用。

弃用原因:

  • 访问令牌会暴露在浏览器历史记录中
  • 无客户端身份验证
  • 不支持刷新令牌
  • 容易遭受令牌窃取
  • 请改用使用 PKCE 的授权码流程
5. 资源所有者密码凭证(ROPC)

旧版流程——除非必要,否则避免使用

资源所有者密码凭证流程允许客户端直接收集用户名和密码,然后将其交换为令牌。

适用场景(较少):

  • 从旧版身份验证迁移的第一方移动应用
  • 仅限受信任的第一方应用
  • 无法使用浏览器/重定向流程时

避免使用的原因:

  • 客户端直接处理用户凭证(安全风险)
  • 不支持多重身份验证
  • 存在网络钓鱼风险
  • 违背 OAuth2 委托授权原则
  • 条件允许时,请改用使用 PKCE 的授权码流程
6. 设备授权流程

适用于输入受限的设备

设备流程专为输入能力有限的设备设计(智能电视、IoT 设备、CLI 工具)。

流程步骤:

  1. 设备向授权服务器请求设备码和用户码
  2. 设备显示用户码,并提示用户访问 URL
  3. 用户在另一台设备上访问该 URL 并输入用户码
  4. 用户进行身份验证并授权设备
  5. 设备轮询授权服务器以获取访问令牌
  6. 用户完成授权后,授权服务器签发访问令牌

适用场景:

  • 智能电视和流媒体设备
  • 没有键盘的 IoT 设备
  • CLI 工具和命令行应用
  • 游戏主机
  • 任何难以输入文字的设备
令牌类型与管理
访问令牌

用于访问受保护资源的短期凭据

特征:

  • 通常在 15 分钟至 1 小时后过期
  • Bearer 令牌格式:Authorization: Bearer <access_token>
  • 可以是不透明令牌,也可以是 JWT(JSON Web Token)
  • 应视为敏感凭据
  • 切勿记录或暴露在 URL 中
  • 每次 API 请求都应验证

JWT 结构(使用 JWT 时):

Header.Payload.Signature

JWT 载荷声明:

  • sub:主体(用户 ID)
  • iat:签发时间
  • exp:过期时间
  • iss:签发方(授权服务)
  • aud:受众(资源服务)
  • scope:授予的权限
  • 自定义声明(用户元数据、角色等)

令牌验证:

  • 使用公钥验证签名
  • 检查过期时间(exp 声明)
  • 验证签发方(iss 声明)
  • 验证受众(aud 声明)
  • 检查令牌是否具有所需的范围
刷新令牌

用于获取新访问令牌的长期凭据

特征:

  • 通常在数天、数周或数月后过期
  • 可单次使用或重复使用(取决于实现)
  • 必须安全存储(浏览器中切勿存储在 localStorage)
  • 静态存储时应加密
  • 授权服务可以撤销
  • 受轮换策略约束

刷新令牌轮换:

  • 每次刷新都会签发一个新的刷新令牌
  • 旧刷新令牌会失效
  • 防止令牌重放攻击
  • 检测令牌被盗(多次刷新尝试)
  • 推荐的安全实践

令牌存储最佳实践:

Web 应用:

  • 访问令牌:内存(React context、Vuex、Redux)
  • 刷新令牌:HttpOnly、Secure、SameSite Cookie
  • 替代方案:在后端存储刷新令牌,并使用会话

移动应用:

  • 使用平台安全存储
  • iOS:Keychain Services
  • Android:EncryptedSharedPreferences 或 Keystore
  • 切勿存储在明文文件中

SPA:

  • 仅在内存中存储访问令牌
  • 对刷新令牌使用 BFF(Backend for Frontend)模式
  • 考虑使用 Token Handler 模式
  • 避免使用 localStorage(存在 XSS 漏洞)
ID 令牌(OpenID Connect)

包含用户身份信息的令牌

特征:

  • 始终采用 JWT 格式
  • 包含用户个人资料信息
  • 用于身份验证(而非授权)
  • 与访问令牌一同返回
  • 使用前应进行验证

标准声明:

  • sub:主体(唯一用户 ID)
  • name:全名
  • email:电子邮件地址
  • email_verified:电子邮件验证状态
  • picture:个人资料图片 URL
  • iat、exp:签发时间/过期时间
OAuth2 范围

用于访问控制的细粒度权限

范围定义客户端请求的访问权限,以及访问令牌允许的操作。

范围命名约定:

  • read:users - 读取用户数据
  • write:users - 创建/更新用户
  • delete:users - 删除用户
  • admin:all - 完全管理权限
  • openid - 请求 OpenID Connect ID 令牌
  • profile - 访问用户个人资料信息
  • email - 访问用户电子邮件地址

最佳实践:

  • 仅请求所需的最小范围(最小权限原则)
  • 分离读写权限
  • 创建特定于资源的范围
  • 记录所有可用范围
  • 允许用户查看并理解所请求的权限
  • 在 API 中实施基于范围的访问控制

动态范围:

read:organization:{org_id}
write:project:{project_id}
admin:tenant:{tenant_id}
安全注意事项
State 参数

防止跨站请求伪造(CSRF)

state 参数是一个随机值,客户端在授权请求中包含该值,并在回调时进行验证。

实现:

  1. 生成随机 state 值(使用密码学安全的方式)
  2. 将 state 存储在会话或加密 Cookie 中
  3. 在授权 URL 中包含 state
  4. 收到回调时,验证 state 是否匹配
  5. 拒绝不匹配或缺失的 state

State 值示例:

state: crypto.randomBytes(32).toString('hex')
// "7f8a3d9e2b1c4f5a6d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
PKCE 实现

代码交换证明密钥(Proof Key for Code Exchange)——防止代码拦截

PKCE 可保护授权码流程免受授权码拦截攻击。

代码验证器:

  • 随机字符串,长度为 43–128 个字符
  • 字符:A-Z、a-z、0-9、-、.、_、~
  • 在客户端安全存储

代码质询:

  • 代码验证器的 SHA256 哈希(推荐)
  • 或使用原始代码验证器(不推荐)
  • 在授权请求中发送

代码质询方法:

  • S256:SHA256 哈希(使用此方法)
  • plain:明文验证器(仅用于旧版)

实现示例:

// Generate code verifier
const codeVerifier = generateRandomString(64);

// Generate code challenge
const codeChallenge = base64UrlEncode(
  sha256(codeVerifier)
);

// Store code verifier for token exchange
sessionStorage.setItem('code_verifier', codeVerifier);

// Include in authorization URL
const authUrl = `${authEndpoint}?` +
  `client_id=${clientId}` +
  `&redirect_uri=${redirectUri}` +
  `&response_type=code` +
  `&scope=${scopes}` +
  `&state=${state}` +
  `&code_challenge=${codeChallenge}` +
  `&code_challenge_method=S256`;
令牌安全

保护访问令牌和刷新令牌

应做事项:

  • 对所有 OAuth2 端点使用 HTTPS
  • 仅在安全存储中保存刷新令牌
  • 实现令牌轮换
  • 设置适当的令牌过期时间
  • 在每次 API 请求时验证令牌
  • 使用强签名算法(RS256、ES256)的 JWT
  • 实现令牌撤销
  • 监控可疑的令牌使用情况

不应做事项:

  • 将令牌存储在 localStorage 中(存在 XSS 风险)
  • 在 URL 或查询参数中包含令牌
  • 在应用程序日志中记录令牌
  • 使用弱签名算法(使用共享密钥的 HS256)
  • 在应用程序之间共享令牌
  • 不必要地延长访问令牌的有效期
  • 忽略令牌过期
重定向 URI 验证

防止开放重定向漏洞

严格验证规则:

  • 必须完全匹配(不允许通配符)
  • 协议必须完全匹配(https://)
  • 主机必须完全匹配
  • 端口必须匹配(如果已指定)
  • 路径必须匹配(如果已指定)
  • 提前注册所有重定向 URI

移动设备深层链接:

  • 使用自定义 URL 方案:com.example.app://callback
  • 或使用通用链接(iOS):https://example.com/auth/callback
  • 或使用应用链接(Android):https://example.com/auth/callback
  • 在授权服务器中注册这些方案

本地主机开发:

  • 仅在开发环境中允许 http://localhost
  • 指定确切端口:http://localhost:3000/callback
  • 如果 localhost 不可用,请使用 127.0.0.1
  • 绝不要在生产环境中使用 localhost 重定向
OpenID Connect (OIDC)

基于 OAuth2 构建的身份层

OpenID Connect 在 OAuth2 之上增加了身份层,除了授权之外,还提供身份验证。

与 OAuth2 的主要区别:

  • 除访问令牌外,还会返回 ID 令牌
  • ID 令牌包含用户身份信息
  • 标准化的用户信息端点
  • 标准化的发现端点(.well-known/openid-configuration)
  • 会话管理功能

OIDC 流程:

  1. 授权码流程(推荐)

    • 与 OAuth2 相同,但会返回 id_token
    • 对 Web 应用最安全
  2. 隐式流程(已弃用)

    • 直接返回 id_token
    • 不安全,请改用带 PKCE 的授权码流程
  3. 混合流程

    • 结合授权码流程和隐式流程
    • 较复杂,极少需要

OIDC 作用域:

  • openid(必需)- 启用 OIDC
  • profile - 姓名、图片、区域设置等
  • email - 电子邮件地址和验证状态
  • address - 物理地址
  • phone - 电话号码

UserInfo 端点:

GET /userinfo
Authorization: Bearer <access_token>

Response:
{
  "sub": "248289761001",
  "name": "Jane Doe",
  "email": "[email protected]",
  "email_verified": true,
  "picture": "https://example.com/photo.jpg"
}

ID 令牌验证:

  1. 使用提供方的公钥验证签名
  2. 验证颁发者(iss 声明)
  3. 验证受众(aud 声明——应与 client_id 匹配)
  4. 检查过期时间(exp 声明)
  5. 验证 nonce(如果请求中提供)
  6. 检查令牌是否为近期颁发(iat 声明)
多租户模式

组织专属身份验证

许多 SaaS 应用要求用户在组织或租户的上下文中进行身份验证。

模式:

  1. 组织参数

    • 在授权请求中包含组织 ID
    • scope=openid profile organization:acme-corp
    • 令牌限定于特定组织
  2. 组织选择器

    • 用户在初次身份验证后选择组织
    • 将令牌交换为组织专属令牌
    • 支持切换组织
  3. 每个租户使用自定义域名

    • acme.example.com 与 globex.example.com
    • 每个租户使用独立的 OAuth2 配置
    • 白标身份验证体验
  4. 令牌声明中包含组织信息

    • 在访问令牌中包含 org_id
    • API 验证组织访问权限
    • 支持用户加入多个组织

授权码流程实现

服务端 Web 应用流程

适用于传统 Web 应用后端的完整实现

步骤 1:配置
// OAuth2 Configuration
const oauth2Config = {
  clientId: process.env.OAUTH2_CLIENT_ID,
  clientSecret: process.env.OAUTH2_CLIENT_SECRET,
  authorizationEndpoint: 'https://auth.example.com/oauth/authorize',
  tokenEndpoint: 'https://auth.example.com/oauth/token',
  redirectUri: 'https://yourapp.com/auth/callback',
  scopes: ['openid', 'profile', 'email', 'read:data'],

  // Optional OIDC endpoints
  userInfoEndpoint: 'https://auth.example.com/oauth/userinfo',
  jwksUri: 'https://auth.example.com/.well-known/jwks.json',

  // Security settings
  useStateParameter: true,
  usePKCE: false, // Not needed for confidential clients
};
步骤 2:生成授权 URL
const crypto = require('crypto');

function generateAuthorizationUrl(req) {
  // Generate and store state for CSRF protection
  const state = crypto.randomBytes(32).toString('hex');
  req.session.oauth2State = state;

  // Build authorization URL
  const params = new URLSearchParams({
    client_id: oauth2Config.clientId,
    redirect_uri: oauth2Config.redirectUri,
    response_type: 'code',
    scope: oauth2Config.scopes.join(' '),
    state: state,
  });

  return `${oauth2Config.authorizationEndpoint}?${params.toString()}`;
}

// Express.js route
app.get('/auth/login', (req, res) => {
  const authUrl = generateAuthorizationUrl(req);
  res.redirect(authUrl);
});
步骤 3:处理回调并交换授权码
const axios = require('axios');

async function exchangeCodeForToken(code) {
  const response = await axios.post(
    oauth2Config.tokenEndpoint,
    new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: oauth2Config.redirectUri,
      client_id: oauth2Config.clientId,
      client_secret: oauth2Config.clientSecret,
    }),
    {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
    }
  );

  return response.data;
  // Returns: { access_token, refresh_token, token_type, expires_in, id_token }
}

// Callback route
app.get('/auth/callback', async (req, res) => {
  const { code, state, error, error_description } = req.query;

  // Check for authorization errors
  if (error) {
    console.error('Authorization error:', error, error_description);
    return res.redirect('/auth/error?message=' + error_description);
  }

  // Validate state parameter (CSRF protection)
  if (state !== req.session.oauth2State) {
    console.error('State mismatch - possible CSRF attack');
    return res.status(403).send('Invalid state parameter');
  }

  // Clear state from session
  delete req.session.oauth2State;

  try {
    // Exchange authorization code for tokens
    const tokens = await exchangeCodeForToken(code);

    // Store tokens securely in session
    req.session.accessToken = tokens.access_token;
    req.session.refreshToken = tokens.refresh_token;
    req.session.tokenExpiry = Date.now() + (tokens.expires_in * 1000);

    // Optional: Fetch user info
    if (tokens.id_token) {
      const userInfo = await getUserInfo(tokens.access_token);
      req.session.user = userInfo;
    }

    // Redirect to application
    res.redirect('/dashboard');
  } catch (error) {
    console.error('Token exchange failed:', error);
    res.redirect('/auth/error?message=Authentication failed');
  }
});
步骤 4:使用访问令牌发送 API 请求
// Middleware to check authentication
function requireAuth(req, res, next) {
  if (!req.session.accessToken) {
    return res.redirect('/auth/login');
  }

  // Check if token is expired
  if (Date.now() > req.session.tokenExpiry) {
    // Token expired, try to refresh
    return refreshAccessToken(req, res, next);
  }

  next();
}

// API request with access token
async function fetchUserData(accessToken) {
  const response = await axios.get('https://api.example.com/user/data', {
    headers: {
      'Authorization': `Bearer ${accessToken}`,
    },
  });

  return response.data;
}

// Protected route
app.get('/dashboard', requireAuth, async (req, res) => {
  try {
    const userData = await fetchUserData(req.session.accessToken);
    res.render('dashboard', { user: req.session.user, data: userData });
  } catch (error) {
    console.error('API request failed:', error);
    res.status(500).send('Failed to fetch data');
  }
});
步骤 5:实现令牌刷新
async function refreshAccessToken(req, res, next) {
  if (!req.session.refreshToken) {
    // No refresh token, require re-authentication
    return res.redirect('/auth/login');
  }

  try {
    const response = await axios.post(
      oauth2Config.tokenEndpoint,
      new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: req.session.refreshToken,
        client_id: oauth2Config.clientId,
        client_secret: oauth2Config.clientSecret,
      }),
      {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
        },
      }
    );

    // Update tokens in session
    req.session.accessToken = response.data.access_token;
    req.session.tokenExpiry = Date.now() + (response.data.expires_in * 1000);

    // Update refresh token if rotation is enabled
    if (response.data.refresh_token) {
      req.session.refreshToken = response.data.refresh_token;
    }

    next();
  } catch (error) {
    console.error('Token refresh failed:', error);
    // Refresh failed, require re-authentication
    delete req.session.accessToken;
    delete req.session.refreshToken;
    res.redirect('/auth/login');
  }
}
步骤 6:实现注销
app.post('/auth/logout', async (req, res) => {
  // Optional: Revoke tokens on authorization server
  if (req.session.accessToken) {
    try {
      await revokeToken(req.session.accessToken);
    } catch (error) {
      console.error('Token revocation failed:', error);
    }
  }

  // Clear session
  req.session.destroy((err) => {
    if (err) {
      console.error('Session destruction failed:', err);
    }
    res.redirect('/');
  });
});

async function revokeToken(token) {
  await axios.post(
    'https://auth.example.com/oauth/revoke',
    new URLSearchParams({
      token: token,
      client_id: oauth2Config.clientId,
      client_secret: oauth2Config.clientSecret,
    }),
    {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
    }
  );
}

PKCE 实现(SPA 和移动应用)

单页应用(React)

适用于 React SPA 的完整 OAuth2 PKCE 实现

设置和配置
// src/config/oauth2.js
export const oauth2Config = {
  clientId: process.env.REACT_APP_OAUTH2_CLIENT_ID,
  authorizationEndpoint: 'https://auth.example.com/oauth/authorize',
  tokenEndpoint: 'https://auth.example.com/oauth/token',
  redirectUri: window.location.origin + '/auth/callback',
  scopes: ['openid', 'profile', 'email', 'read:data'],
  audience: 'https://api.example.com',
};

// PKCE utility functions
export function generateRandomString(length) {
  const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
  const values = crypto.getRandomValues(new Uint8Array(length));
  return Array.from(values)
    .map(v => charset[v % charset.length])
    .join('');
}

export async function generateCodeChallenge(codeVerifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(digest);
}

export function base64UrlEncode(buffer) {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (let i = 0; i < bytes.byteLength; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}
身份验证上下文提供程序
// src/contexts/AuthContext.js
import React, { createContext, useState, useContext, useEffect } from 'react';
import { oauth2Config, generateRandomString, generateCodeChallenge } from '../config/oauth2';

const AuthContext = createContext();

export function useAuth() {
  return useContext(AuthContext);
}

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [accessToken, setAccessToken] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Check for existing session
    checkAuth();
  }, []);

  async function checkAuth() {
    // Try to restore access token from memory or refresh
    const storedToken = sessionStorage.getItem('access_token');
    const expiresAt = sessionStorage.getItem('expires_at');

    if (storedToken && expiresAt && Date.now() < parseInt(expiresAt)) {
      setAccessToken(storedToken);
      await fetchUserInfo(storedToken);
    } else {
      // Token expired or doesn't exist, try refresh
      await tryRefresh();
    }

    setLoading(false);
  }

  async function login() {
    // Generate PKCE parameters
    const codeVerifier = generateRandomString(64);
    const codeChallenge = await generateCodeChallenge(codeVerifier);
    const state = generateRandomString(32);

    // Store for callback
    sessionStorage.setItem('code_verifier', codeVerifier);
    sessionStorage.setItem('oauth2_state', state);

    // Build authorization URL
    const params = new URLSearchParams({
      client_id: oauth2Config.clientId,
      redirect_uri: oauth2Config.redirectUri,
      response_type: 'code',
      scope: oauth2Config.scopes.join(' '),
      state: state,
      code_challenge: codeChallenge,
      code_challenge_method: 'S256',
    });

    // Redirect to authorization server
    window.location.href = `${oauth2Config.authorizationEndpoint}?${params}`;
  }

  async function handleCallback(code, state) {
    // Validate state
    const savedState = sessionStorage.getItem('oauth2_state');
    if (state !== savedState) {
      throw new Error('Invalid state parameter - possible CSRF attack');
    }

    // Get code verifier
    const codeVerifier = sessionStorage.getItem('code_verifier');
    if (!codeVerifier) {
      throw new Error('Code verifier not found');
    }

    // Exchange code for tokens
    const response = await fetch(oauth2Config.tokenEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: code,
        redirect_uri: oauth2Config.redirectUri,
        client_id: oauth2Config.clientId,
        code_verifier: codeVerifier,
      }),
    });

    if (!response.ok) {
      throw new Error('Token exchange failed');
    }

    const tokens = await response.json();

    // Store tokens
    setAccessToken(tokens.access_token);
    sessionStorage.setItem('access_token', tokens.access_token);
    sessionStorage.setItem('expires_at', Date.now() + (tokens.expires_in * 1000));

    // Store refresh token in httpOnly cookie via backend
    if (tokens.refresh_token) {
      await storeRefreshToken(tokens.refresh_token);
    }

    // Fetch user info
    await fetchUserInfo(tokens.access_token);

    // Clean up
    sessionStorage.removeItem('code_verifier');
    sessionStorage.removeItem('oauth2_state');
  }

  async function fetchUserInfo(token) {
    const response = await fetch('https://auth.example.com/oauth/userinfo', {
      headers: {
        'Authorization': `Bearer ${token}`,
      },
    });

    if (response.ok) {
      const userInfo = await response.json();
      setUser(userInfo);
    }
  }

  async function storeRefreshToken(refreshToken) {
    // Store refresh token via backend (httpOnly cookie)
    await fetch('/api/auth/store-refresh-token', {
      method: 'POST',
      credentials: 'include',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ refreshToken }),
    });
  }

  async function tryRefresh() {
    try {
      // Call backend to refresh using httpOnly cookie
      const response = await fetch('/api/auth/refresh', {
        method: 'POST',
        credentials: 'include',
      });

      if (response.ok) {
        const tokens = await response.json();
        setAccessToken(tokens.access_token);
        sessionStorage.setItem('access_token', tokens.access_token);
        sessionStorage.setItem('expires_at', Date.now() + (tokens.expires_in * 1000));
        await fetchUserInfo(tokens.access_token);
        return true;
      }
    } catch (error) {
      console.error('Token refresh failed:', error);
    }
    return false;
  }

  async function logout() {
    // Revoke tokens
    await fetch('/api/auth/logout', {
      method: 'POST',
      credentials: 'include',
    });

    // Clear state
    setUser(null);
    setAccessToken(null);
    sessionStorage.clear();
  }

  const value = {
    user,
    accessToken,
    loading,
    login,
    logout,
    handleCallback,
    isAuthenticated: !!accessToken,
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
回调组件
// src/components/AuthCallback.js
import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';

export function AuthCallback() {
  const [searchParams] = useSearchParams();
  const navigate = useNavigate();
  const { handleCallback } = useAuth();
  const [error, setError] = useState(null);

  useEffect(() => {
    const code = searchParams.get('code');
    const state = searchParams.get('state');
    const errorParam = searchParams.get('error');
    const errorDescription = searchParams.get('error_description');

    if (errorParam) {
      setError(errorDescription || errorParam);
      return;
    }

    if (code && state) {
      handleCallback(code, state)
        .then(() => {
          navigate('/dashboard');
        })
        .catch((err) => {
          console.error('Authentication failed:', err);
          setError(err.message);
        });
    } else {
      setError('Missing authorization code or state');
    }
  }, [searchParams, handleCallback, navigate]);

  if (error) {
    return (
      <div className="auth-error">
        <h2>Authentication Failed</h2>
        <p>{error}</p>
        <button onClick={() => navigate('/')}>Return Home</button>
      </div>
    );
  }

  return (
    <div className="auth-loading">
      <h2>Completing authentication...</h2>
      <div className="spinner"></div>
    </div>
  );
}
受保护路由组件
// src/components/ProtectedRoute.js
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';

export function ProtectedRoute({ children }) {
  const { isAuthenticated, loading } = useAuth();

  if (loading) {
    return <div>Loading...</div>;
  }

  if (!isAuthenticated) {
    return <Navigate to="/login" />;
  }

  return children;
}
带令牌管理的 API 客户端
// src/utils/apiClient.js
import { oauth2Config } from '../config/oauth2';

class ApiClient {
  constructor() {
    this.baseUrl = 'https://api.example.com';
  }

  async request(endpoint, options = {}) {
    const accessToken = sessionStorage.getItem('access_token');
    const expiresAt = sessionStorage.getItem('expires_at');

    // Check if token needs refresh
    if (!accessToken || Date.now() >= parseInt(expiresAt)) {
      await this.refreshToken();
    }

    const token = sessionStorage.getItem('access_token');

    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
    });

    if (response.status === 401) {
      // Token invalid, try refresh once
      await this.refreshToken();
      const newToken = sessionStorage.getItem('access_token');

      // Retry request
      const retryResponse = await fetch(`${this.baseUrl}${endpoint}`, {
        ...options,
        headers: {
          ...options.headers,
          'Authorization': `Bearer ${newToken}`,
          'Content-Type': 'application/json',
        },
      });

      if (!retryResponse.ok) {
        throw new Error('API request failed after token refresh');
      }

      return retryResponse.json();
    }

    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }

    return response.json();
  }

  async refreshToken() {
    const response = await fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include',
    });

    if (!response.ok) {
      // Refresh failed, redirect to login
      window.location.href = '/login';
      throw new Error('Token refresh failed');
    }

    const tokens = await response.json();
    sessionStorage.setItem('access_token', tokens.access_token);
    sessionStorage.setItem('expires_at', Date.now() + (tokens.expires_in * 1000));
  }

  async get(endpoint) {
    return this.request(endpoint);
  }

  async post(endpoint, data) {
    return this.request(endpoint, {
      method: 'POST',
      body: JSON.stringify(data),
    });
  }

  async put(endpoint, data) {
    return this.request(endpoint, {
      method: 'PUT',
      body: JSON.stringify(data),
    });
  }

  async delete(endpoint) {
    return this.request(endpoint, {
      method: 'DELETE',
    });
  }
}

export const apiClient = new ApiClient();

客户端凭据流程

后端服务身份验证

适用于服务和 API 的机器间身份验证

Node.js 实现
// Service-to-service authentication
class OAuth2Client {
  constructor(config) {
    this.clientId = config.clientId;
    this.clientSecret = config.clientSecret;
    this.tokenEndpoint = config.tokenEndpoint;
    this.audience = config.audience;
    this.scopes = config.scopes || [];

    this.accessToken = null;
    this.tokenExpiry = null;
  }

  async getAccessToken() {
    // Return cached token if still valid
    if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
      return this.accessToken;
    }

    // Request new token
    const response = await fetch(this.tokenEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: this.clientId,
        client_secret: this.clientSecret,
        audience: this.audience,
        scope: this.scopes.join(' '),
      }),
    });

    if (!response.ok) {
      throw new Error('Failed to obtain access token');
    }

    const data = await response.json();

    // Cache token
    this.accessToken = data.access_token;
    this.tokenExpiry = Date.now() + (data.expires_in * 1000);

    return this.accessToken;
  }

  async callApi(endpoint, options = {}) {
    const token = await this.getAccessToken();

    const response = await fetch(endpoint, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${token}`,
      },
    });

    if (response.status === 401) {
      // Token might be invalid, force refresh and retry
      this.accessToken = null;
      const newToken = await this.getAccessToken();

      const retryResponse = await fetch(endpoint, {
        ...options,
        headers: {
          ...options.headers,
          'Authorization': `Bearer ${newToken}`,
        },
      });

      return retryResponse;
    }

    return response;
  }
}

// Usage
const oauth2Client = new OAuth2Client({
  clientId: process.env.CLIENT_ID,
  clientSecret: process.env.CLIENT_SECRET,
  tokenEndpoint: 'https://auth.example.com/oauth/token',
  audience: 'https://api.example.com',
  scopes: ['read:data', 'write:data'],
});

// Make API calls
async function fetchData() {
  const response = await oauth2Client.callApi('https://api.example.com/data');
  return response.json();
}
Python 实现
# client_credentials_oauth2.py
import requests
import time
from datetime import datetime, timedelta

class OAuth2Client:
    def __init__(self, client_id, client_secret, token_endpoint, audience=None, scopes=None):
        self.client_id = client_id
        self.client_secret = client_secret
        self.token_endpoint = token_endpoint
        self.audience = audience
        self.scopes = scopes or []

        self.access_token = None
        self.token_expiry = None

    def get_access_token(self):
        """Get cached token or request new one"""
        # Return cached token if valid
        if self.access_token and datetime.now() < self.token_expiry - timedelta(minutes=1):
            return self.access_token

        # Request new token
        data = {
            'grant_type': 'client_credentials',
            'client_id': self.client_id,
            'client_secret': self.client_secret,
        }

        if self.audience:
            data['audience'] = self.audience

        if self.scopes:
            data['scope'] = ' '.join(self.scopes)

        response = requests.post(
            self.token_endpoint,
            data=data,
            headers={'Content-Type': 'application/x-www-form-urlencoded'}
        )

        if not response.ok:
            raise Exception(f'Failed to obtain access token: {response.text}')

        token_data = response.json()

        # Cache token
        self.access_token = token_data['access_token']
        self.token_expiry = datetime.now() + timedelta(seconds=token_data['expires_in'])

        return self.access_token

    def call_api(self, url, method='GET', **kwargs):
        """Make authenticated API request"""
        token = self.get_access_token()

        headers = kwargs.pop('headers', {})
        headers['Authorization'] = f'Bearer {token}'

        response = requests.request(method, url, headers=headers, **kwargs)

        # Handle token expiration
        if response.status_code == 401:
            # Force token refresh and retry
            self.access_token = None
            token = self.get_access_token()
            headers['Authorization'] = f'Bearer {token}'
            response = requests.request(method, url, headers=headers, **kwargs)

        return response

# Usage
client = OAuth2Client(
    client_id='your_client_id',
    client_secret='your_client_secret',
    token_endpoint='https://auth.example.com/oauth/token',
    audience='https://api.example.com',
    scopes=['read:data', 'write:data']
)

# Make API requests
response = client.call_api('https://api.example.com/data')
data = response.json()

OpenID Connect 实现

完整的 OIDC 集成

带身份验证的身份验证

ID 令牌验证
// id-token-validator.js
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

class IDTokenValidator {
  constructor(config) {
    this.issuer = config.issuer;
    this.audience = config.audience;
    this.jwksUri = config.jwksUri;

    // JWKS client for fetching public keys
    this.client = jwksClient({
      jwksUri: this.jwksUri,
      cache: true,
      cacheMaxAge: 86400000, // 24 hours
    });
  }

  async getSigningKey(kid) {
    const key = await this.client.getSigningKey(kid);
    return key.getPublicKey();
  }

  async validate(idToken) {
    try {
      // Decode token header to get key ID
      const decoded = jwt.decode(idToken, { complete: true });

      if (!decoded) {
        throw new Error('Invalid token format');
      }

      // Get public key
      const publicKey = await this.getSigningKey(decoded.header.kid);

      // Verify and decode token
      const payload = jwt.verify(idToken, publicKey, {
        issuer: this.issuer,
        audience: this.audience,
        algorithms: ['RS256', 'ES256'],
      });

      // Additional validations
      this.validateClaims(payload);

      return payload;
    } catch (error) {
      throw new Error(`ID token validation failed: ${error.message}`);
    }
  }

  validateClaims(payload) {
    // Check required claims
    if (!payload.sub) {
      throw new Error('Missing sub claim');
    }

    if (!payload.iat || !payload.exp) {
      throw new Error('Missing iat or exp claim');
    }

    // Check token is not expired (already checked by jwt.verify, but double-check)
    const now = Math.floor(Date.now() / 1000);
    if (payload.exp < now) {
      throw new Error('Token expired');
    }

    // Check token was not issued in the future
    if (payload.iat > now + 60) {
      throw new Error('Token issued in the future');
    }

    // Optional: Check nonce if provided
    // if (payload.nonce && payload.nonce !== expectedNonce) {
    //   throw new Error('Nonce mismatch');
    // }

    return true;
  }
}

// Usage
const validator = new IDTokenValidator({
  issuer: 'https://auth.example.com',
  audience: 'your_client_id',
  jwksUri: 'https://auth.example.com/.well-known/jwks.json',
});

async function validateIDToken(idToken) {
  try {
    const payload = await validator.validate(idToken);
    console.log('User ID:', payload.sub);
    console.log('Email:', payload.email);
    console.log('Name:', payload.name);
    return payload;
  } catch (error) {
    console.error('ID token validation failed:', error);
    throw error;
  }
}
UserInfo 端点集成
// userinfo-client.js
class UserInfoClient {
  constructor(userInfoEndpoint) {
    this.endpoint = userInfoEndpoint;
  }

  async getUserInfo(accessToken) {
    const response = await fetch(this.endpoint, {
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/json',
      },
    });

    if (!response.ok) {
      throw new Error(`UserInfo request failed: ${response.status}`);
    }

    return response.json();
  }

  async getUserInfoWithValidation(accessToken, idTokenPayload) {
    const userInfo = await this.getUserInfo(accessToken);

    // Validate that sub matches ID token
    if (userInfo.sub !== idTokenPayload.sub) {
      throw new Error('UserInfo sub does not match ID token sub');
    }

    return userInfo;
  }
}

// Usage
const userInfoClient = new UserInfoClient('https://auth.example.com/oauth/userinfo');

async function getCompleteUserProfile(accessToken, idToken, validator) {
  // Validate ID token
  const idTokenPayload = await validator.validate(idToken);

  // Fetch additional user info
  const userInfo = await userInfoClient.getUserInfoWithValidation(accessToken, idTokenPayload);

  // Combine information
  return {
    userId: userInfo.sub,
    email: userInfo.email,
    emailVerified: userInfo.email_verified,
    name: userInfo.name,
    givenName: userInfo.given_name,
    familyName: userInfo.family_name,
    picture: userInfo.picture,
    locale: userInfo.locale,
    // Any custom claims
    ...userInfo,
  };
}

令牌管理最佳实践

安全的令牌存储
浏览器存储方式对比
// Token storage strategies for web applications

// ❌ BAD: localStorage (vulnerable to XSS)
localStorage.setItem('access_token', token); // DON'T DO THIS

// ❌ BAD: sessionStorage (also vulnerable to XSS)
sessionStorage.setItem('access_token', token); // DON'T DO THIS

// ✅ GOOD: In-memory storage (cleared on page refresh)
class TokenStore {
  constructor() {
    this.accessToken = null;
    this.refreshToken = null;
  }

  setTokens(accessToken, refreshToken) {
    this.accessToken = accessToken;
    // Refresh token stored in httpOnly cookie via backend
    // this.refreshToken = refreshToken; // Don't store in memory
  }

  getAccessToken() {
    return this.accessToken;
  }

  clear() {
    this.accessToken = null;
    this.refreshToken = null;
  }
}

// ✅ BEST: Backend For Frontend (BFF) Pattern
// - All tokens stored on backend
// - Session cookie identifies user
// - No tokens exposed to browser
移动令牌存储(React Native)
// Secure token storage for React Native
import * as SecureStore from 'expo-secure-store';
// or
// import EncryptedStorage from 'react-native-encrypted-storage';

class MobileTokenStore {
  async saveTokens(accessToken, refreshToken) {
    try {
      await SecureStore.setItemAsync('access_token', accessToken);
      await SecureStore.setItemAsync('refresh_token', refreshToken);
      await SecureStore.setItemAsync('token_expiry', Date.now().toString());
    } catch (error) {
      console.error('Failed to store tokens:', error);
      throw error;
    }
  }

  async getAccessToken() {
    try {
      return await SecureStore.getItemAsync('access_token');
    } catch (error) {
      console.error('Failed to retrieve access token:', error);
      return null;
    }
  }

  async getRefreshToken() {
    try {
      return await SecureStore.getItemAsync('refresh_token');
    } catch (error) {
      console.error('Failed to retrieve refresh token:', error);
      return null;
    }
  }

  async clear() {
    try {
      await SecureStore.deleteItemAsync('access_token');
      await SecureStore.deleteItemAsync('refresh_token');
      await SecureStore.deleteItemAsync('token_expiry');
    } catch (error) {
      console.error('Failed to clear tokens:', error);
    }
  }

  async isTokenExpired() {
    try {
      const expiry = await SecureStore.getItemAsync('token_expiry');
      if (!expiry) return true;
      return Date.now() > parseInt(expiry);
    } catch (error) {
      return true;
    }
  }
}

export const tokenStore = new MobileTokenStore();
令牌刷新策略
自动令牌刷新(主动)
// Proactive token refresh before expiration
class TokenRefreshManager {
  constructor(refreshCallback, expiresIn) {
    this.refreshCallback = refreshCallback;
    this.expiresIn = expiresIn;
    this.refreshTimer = null;
  }

  scheduleRefresh() {
    // Refresh 5 minutes before expiration
    const refreshIn = (this.expiresIn - 300) * 1000;

    if (this.refreshTimer) {
      clearTimeout(this.refreshTimer);
    }

    this.refreshTimer = setTimeout(async () => {
      try {
        await this.refreshCallback();
      } catch (error) {
        console.error('Token refresh failed:', error);
        // Optionally: Redirect to login
      }
    }, refreshIn);
  }

  cancelRefresh() {
    if (this.refreshTimer) {
      clearTimeout(this.refreshTimer);
      this.refreshTimer = null;
    }
  }
}

// Usage
const refreshManager = new TokenRefreshManager(
  async () => {
    // Refresh token logic
    const newTokens = await refreshTokens();
    setAccessToken(newTokens.access_token);
    refreshManager.expiresIn = newTokens.expires_in;
    refreshManager.scheduleRefresh();
  },
  3600 // Initial expires_in
);

// Start automatic refresh
refreshManager.scheduleRefresh();
按需令牌刷新(被动)
// Refresh token only when access token is expired
class ReactiveTokenManager {
  constructor() {
    this.accessToken = null;
    this.expiresAt = null;
    this.refreshPromise = null; // Prevent concurrent refreshes
  }

  async getValidToken() {
    // Check if token is still valid
    if (this.accessToken && Date.now() < this.expiresAt - 60000) {
      return this.accessToken;
    }

    // Token expired, refresh it
    if (!this.refreshPromise) {
      this.refreshPromise = this.refreshToken()
        .finally(() => {
          this.refreshPromise = null;
        });
    }

    await this.refreshPromise;
    return this.accessToken;
  }

  async refreshToken() {
    const response = await fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include', // Send refresh token cookie
    });

    if (!response.ok) {
      throw new Error('Token refresh failed');
    }

    const tokens = await response.json();
    this.accessToken = tokens.access_token;
    this.expiresAt = Date.now() + (tokens.expires_in * 1000);

    return this.accessToken;
  }

  clearTokens() {
    this.accessToken = null;
    this.expiresAt = null;
    this.refreshPromise = null;
  }
}

// Usage in API client
const tokenManager = new ReactiveTokenManager();

async function makeApiRequest(endpoint) {
  const token = await tokenManager.getValidToken();

  const response = await fetch(endpoint, {
    headers: {
      'Authorization': `Bearer ${token}`,
    },
  });

  return response;
}
令牌撤销
// Implementing token revocation
async function revokeToken(token, tokenTypeHint = 'access_token') {
  const response = await fetch('https://auth.example.com/oauth/revoke', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      token: token,
      token_type_hint: tokenTypeHint, // 'access_token' or 'refresh_token'
      client_id: oauth2Config.clientId,
      client_secret: oauth2Config.clientSecret, // If confidential client
    }),
  });

  if (!response.ok) {
    throw new Error('Token revocation failed');
  }

  return true;
}

// Logout with token revocation
async function logout() {
  try {
    // Revoke access token
    if (accessToken) {
      await revokeToken(accessToken, 'access_token');
    }

    // Revoke refresh token
    if (refreshToken) {
      await revokeToken(refreshToken, 'refresh_token');
    }

    // Clear local state
    clearTokens();

    // Optional: Redirect to logout endpoint for session cleanup
    window.location.href = 'https://auth.example.com/logout?redirect_uri=' +
      encodeURIComponent(window.location.origin);
  } catch (error) {
    console.error('Logout failed:', error);
    // Clear tokens anyway
    clearTokens();
  }
}

OAuth2 服务端实现

构建授权服务

实现 OAuth2 提供方功能

授权端点
// Express.js authorization endpoint
const express = require('express');
const crypto = require('crypto');

const router = express.Router();

router.get('/oauth/authorize', async (req, res) => {
  const {
    client_id,
    redirect_uri,
    response_type,
    scope,
    state,
    code_challenge,
    code_challenge_method,
  } = req.query;

  // Validate required parameters
  if (!client_id || !redirect_uri || !response_type) {
    return res.status(400).json({
      error: 'invalid_request',
      error_description: 'Missing required parameters',
    });
  }

  // Validate client_id and redirect_uri
  const client = await getClient(client_id);
  if (!client) {
    return res.status(400).json({
      error: 'invalid_client',
      error_description: 'Unknown client',
    });
  }

  if (!client.redirect_uris.includes(redirect_uri)) {
    return res.status(400).json({
      error: 'invalid_request',
      error_description: 'Invalid redirect_uri',
    });
  }

  // Validate response_type
  if (response_type !== 'code') {
    return redirectWithError(redirect_uri, state, 'unsupported_response_type',
      'Only authorization code flow is supported');
  }

  // Validate PKCE parameters
  if (code_challenge) {
    if (!code_challenge_method) {
      return redirectWithError(redirect_uri, state, 'invalid_request',
        'code_challenge_method is required when using PKCE');
    }
    if (code_challenge_method !== 'S256' && code_challenge_method !== 'plain') {
      return redirectWithError(redirect_uri, state, 'invalid_request',
        'Unsupported code_challenge_method');
    }
  }

  // Check if user is authenticated
  if (!req.session.userId) {
    // Store authorization request and redirect to login
    req.session.authRequest = {
      client_id,
      redirect_uri,
      scope,
      state,
      code_challenge,
      code_challenge_method,
    };
    return res.redirect('/login?continue=' + encodeURIComponent(req.originalUrl));
  }

  // User is authenticated, show consent screen
  res.render('consent', {
    client: client,
    scopes: scope ? scope.split(' ') : [],
    state: state,
  });
});

// Consent endpoint
router.post('/oauth/consent', async (req, res) => {
  const { approved } = req.body;
  const authRequest = req.session.authRequest;

  if (!authRequest) {
    return res.status(400).send('No pending authorization request');
  }

  if (approved !== 'true') {
    // User denied consent
    return redirectWithError(
      authRequest.redirect_uri,
      authRequest.state,
      'access_denied',
      'User denied consent'
    );
  }

  // Generate authorization code
  const authorizationCode = crypto.randomBytes(32).toString('hex');

  // Store authorization code with metadata
  await storeAuthorizationCode(authorizationCode, {
    client_id: authRequest.client_id,
    redirect_uri: authRequest.redirect_uri,
    user_id: req.session.userId,
    scope: authRequest.scope,
    code_challenge: authRequest.code_challenge,
    code_challenge_method: authRequest.code_challenge_method,
    expires_at: Date.now() + 600000, // 10 minutes
  });

  // Clear auth request from session
  delete req.session.authRequest;

  // Redirect to client with authorization code
  const redirectUrl = new URL(authRequest.redirect_uri);
  redirectUrl.searchParams.set('code', authorizationCode);
  if (authRequest.state) {
    redirectUrl.searchParams.set('state', authRequest.state);
  }

  res.redirect(redirectUrl.toString());
});

function redirectWithError(redirectUri, state, error, errorDescription) {
  const url = new URL(redirectUri);
  url.searchParams.set('error', error);
  url.searchParams.set('error_description', errorDescription);
  if (state) {
    url.searchParams.set('state', state);
  }
  return res.redirect(url.toString());
}
令牌端点
// Token endpoint implementation
router.post('/oauth/token', async (req, res) => {
  const { grant_type } = req.body;

  try {
    let tokens;

    switch (grant_type) {
      case 'authorization_code':
        tokens = await handleAuthorizationCodeGrant(req.body);
        break;
      case 'refresh_token':
        tokens = await handleRefreshTokenGrant(req.body);
        break;
      case 'client_credentials':
        tokens = await handleClientCredentialsGrant(req.body);
        break;
      default:
        return res.status(400).json({
          error: 'unsupported_grant_type',
          error_description: `Grant type '${grant_type}' is not supported`,
        });
    }

    res.json(tokens);
  } catch (error) {
    console.error('Token endpoint error:', error);
    res.status(400).json({
      error: error.code || 'invalid_request',
      error_description: error.message,
    });
  }
});

async function handleAuthorizationCodeGrant(params) {
  const {
    code,
    redirect_uri,
    client_id,
    client_secret,
    code_verifier,
  } = params;

  // Validate required parameters
  if (!code || !redirect_uri || !client_id) {
    throw { code: 'invalid_request', message: 'Missing required parameters' };
  }

  // Retrieve authorization code
  const authCode = await getAuthorizationCode(code);
  if (!authCode) {
    throw { code: 'invalid_grant', message: 'Invalid authorization code' };
  }

  // Check expiration
  if (Date.now() > authCode.expires_at) {
    await deleteAuthorizationCode(code);
    throw { code: 'invalid_grant', message: 'Authorization code expired' };
  }

  // Validate client
  if (authCode.client_id !== client_id) {
    throw { code: 'invalid_grant', message: 'Client mismatch' };
  }

  // Validate redirect URI
  if (authCode.redirect_uri !== redirect_uri) {
    throw { code: 'invalid_grant', message: 'Redirect URI mismatch' };
  }

  // Authenticate client
  const client = await getClient(client_id);
  if (client.client_type === 'confidential') {
    // Confidential client must provide client_secret
    if (client.client_secret !== client_secret) {
      throw { code: 'invalid_client', message: 'Invalid client credentials' };
    }
  }

  // Validate PKCE if used
  if (authCode.code_challenge) {
    if (!code_verifier) {
      throw { code: 'invalid_request', message: 'code_verifier is required' };
    }

    const isValid = validatePKCE(
      code_verifier,
      authCode.code_challenge,
      authCode.code_challenge_method
    );

    if (!isValid) {
      throw { code: 'invalid_grant', message: 'Invalid code_verifier' };
    }
  }

  // Delete authorization code (single use)
  await deleteAuthorizationCode(code);

  // Generate tokens
  const accessToken = await generateAccessToken({
    user_id: authCode.user_id,
    client_id: client_id,
    scope: authCode.scope,
  });

  const refreshToken = await generateRefreshToken({
    user_id: authCode.user_id,
    client_id: client_id,
    scope: authCode.scope,
  });

  // Generate ID token if openid scope was requested
  let idToken = null;
  if (authCode.scope && authCode.scope.includes('openid')) {
    idToken = await generateIDToken({
      user_id: authCode.user_id,
      client_id: client_id,
      scope: authCode.scope,
    });
  }

  const response = {
    access_token: accessToken,
    token_type: 'Bearer',
    expires_in: 3600,
    refresh_token: refreshToken,
  };

  if (idToken) {
    response.id_token = idToken;
  }

  return response;
}

function validatePKCE(codeVerifier, codeChallenge, method) {
  if (method === 'plain') {
    return codeVerifier === codeChallenge;
  }

  if (method === 'S256') {
    const hash = crypto.createHash('sha256').update(codeVerifier).digest();
    const computed = base64UrlEncode(hash);
    return computed === codeChallenge;
  }

  return false;
}

async function handleRefreshTokenGrant(params) {
  const { refresh_token, client_id, client_secret, scope } = params;

  // Validate refresh token
  const storedToken = await getRefreshToken(refresh_token);
  if (!storedToken) {
    throw { code: 'invalid_grant', message: 'Invalid refresh token' };
  }

  // Check if revoked
  if (storedToken.revoked) {
    throw { code: 'invalid_grant', message: 'Refresh token has been revoked' };
  }

  // Validate client
  if (storedToken.client_id !== client_id) {
    throw { code: 'invalid_grant', message: 'Client mismatch' };
  }

  const client = await getClient(client_id);
  if (client.client_type === 'confidential' && client.client_secret !== client_secret) {
    throw { code: 'invalid_client', message: 'Invalid client credentials' };
  }

  // Validate scope (requested scope must be subset of original)
  const requestedScopes = scope ? scope.split(' ') : storedToken.scope.split(' ');
  const originalScopes = storedToken.scope.split(' ');
  const isValidScope = requestedScopes.every(s => originalScopes.includes(s));

  if (!isValidScope) {
    throw { code: 'invalid_scope', message: 'Requested scope exceeds original grant' };
  }

  // Generate new access token
  const accessToken = await generateAccessToken({
    user_id: storedToken.user_id,
    client_id: client_id,
    scope: requestedScopes.join(' '),
  });

  // Optional: Refresh token rotation
  let newRefreshToken = refresh_token;
  if (shouldRotateRefreshToken()) {
    // Revoke old refresh token
    await revokeRefreshToken(refresh_token);

    // Generate new refresh token
    newRefreshToken = await generateRefreshToken({
      user_id: storedToken.user_id,
      client_id: client_id,
      scope: requestedScopes.join(' '),
    });
  }

  return {
    access_token: accessToken,
    token_type: 'Bearer',
    expires_in: 3600,
    refresh_token: newRefreshToken,
    scope: requestedScopes.join(' '),
  };
}

async function handleClientCredentialsGrant(params) {
  const { client_id, client_secret, scope } = params;

  // Authenticate client
  const client = await getClient(client_id);
  if (!client || client.client_secret !== client_secret) {
    throw { code: 'invalid_client', message: 'Invalid client credentials' };
  }

  // Validate scope
  if (scope) {
    const requestedScopes = scope.split(' ');
    const allowedScopes = client.allowed_scopes || [];
    const isValidScope = requestedScopes.every(s => allowedScopes.includes(s));

    if (!isValidScope) {
      throw { code: 'invalid_scope', message: 'Requested scope not allowed for this client' };
    }
  }

  // Generate access token (no refresh token for client credentials)
  const accessToken = await generateAccessToken({
    client_id: client_id,
    scope: scope || client.default_scope,
  });

  return {
    access_token: accessToken,
    token_type: 'Bearer',
    expires_in: 3600,
    scope: scope || client.default_scope,
  };
}
令牌生成(JWT)
// JWT token generation
const jwt = require('jsonwebtoken');
const fs = require('fs');

const privateKey = fs.readFileSync('path/to/private-key.pem');
const publicKey = fs.readFileSync('path/to/public-key.pem');

async function generateAccessToken(payload) {
  const token = jwt.sign(
    {
      sub: payload.user_id || payload.client_id,
      client_id: payload.client_id,
      scope: payload.scope,
      iat: Math.floor(Date.now() / 1000),
      exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour
      iss: 'https://auth.example.com',
      aud: 'https://api.example.com',
    },
    privateKey,
    {
      algorithm: 'RS256',
      keyid: 'key-id-1',
    }
  );

  return token;
}

async function generateRefreshToken(payload) {
  const refreshToken = crypto.randomBytes(32).toString('hex');

  // Store refresh token in database
  await storeRefreshToken(refreshToken, {
    user_id: payload.user_id,
    client_id: payload.client_id,
    scope: payload.scope,
    created_at: Date.now(),
    expires_at: Date.now() + (30 * 24 * 60 * 60 * 1000), // 30 days
    revoked: false,
  });

  return refreshToken;
}

async function generateIDToken(payload) {
  const user = await getUser(payload.user_id);

  const idToken = jwt.sign(
    {
      iss: 'https://auth.example.com',
      sub: payload.user_id,
      aud: payload.client_id,
      exp: Math.floor(Date.now() / 1000) + 3600,
      iat: Math.floor(Date.now() / 1000),
      // Standard claims
      name: user.name,
      email: user.email,
      email_verified: user.email_verified,
      picture: user.picture,
      // Custom claims based on scope
      ...getScopedClaims(user, payload.scope),
    },
    privateKey,
    {
      algorithm: 'RS256',
      keyid: 'key-id-1',
    }
  );

  return idToken;
}

function getScopedClaims(user, scope) {
  const claims = {};
  const scopes = scope ? scope.split(' ') : [];

  if (scopes.includes('profile')) {
    claims.given_name = user.given_name;
    claims.family_name = user.family_name;
    claims.locale = user.locale;
    claims.updated_at = user.updated_at;
  }

  if (scopes.includes('phone')) {
    claims.phone_number = user.phone_number;
    claims.phone_number_verified = user.phone_number_verified;
  }

  return claims;
}

最佳实践与安全

安全最佳实践
  1. 始终使用 HTTPS

    • 所有 OAuth2 端点都必须使用 HTTPS
    • 仅通过加密连接传输令牌
    • 不得通过 HTTP 使用 OAuth2(本地主机开发除外)
  2. 严格验证重定向 URI

    • 必须完全匹配(不允许使用通配符)
    • 提前注册所有重定向 URI
    • 在每次授权请求时进行验证
  3. 公共客户端使用 PKCE

    • SPA 和移动应用必须使用
    • 使用 S256 代码质询方法(而非 plain)
    • 防止授权码被截获
  4. 实现 State 参数

    • 防止 CSRF 攻击
    • 生成在密码学上安全的随机值
    • 在回调时进行验证
  5. 使用短时效访问令牌

    • 通常为 15 分钟到 1 小时
    • 降低令牌被盗造成的影响
    • 使用刷新令牌维持长时效会话
  6. 安全存储令牌

    • 切勿将令牌存储在 localStorage 中(有 XSS 风险)
    • 使用 httpOnly Cookie 存储刷新令牌
    • 移动端:使用平台的安全存储(Keychain、Keystore)
  7. 实现令牌轮换

    • 每次使用时轮换刷新令牌
    • 通过多次刷新尝试检测令牌盗用
    • 撤销与被盗刷新令牌关联的所有令牌
  8. 验证所有令牌

    • 验证 JWT 签名
    • 检查过期时间
    • 验证签发者和受众
    • 验证必需的作用域
  9. 基于作用域的访问控制

    • 仅请求最低限度的必需作用域
    • 验证 API 请求中的作用域
    • 记录所有可用作用域
  10. 监控并记录日志

    • 记录所有身份验证尝试
    • 监控可疑模式
    • 多次失败尝试时发出警报
    • 跟踪令牌的使用和撤销情况
应避免的常见问题
  1. 使用 Implicit Flow

    • 已弃用且不安全
    • 改用带 PKCE 的 Authorization Code Flow
  2. 将令牌存储在 localStorage 中

    • 易受 XSS 攻击
    • 改用内存或安全存储
  3. 不验证重定向 URI

    • 会导致开放重定向攻击
    • 始终根据已注册的 URI 进行验证
  4. 忽略令牌过期

    • 应拒绝过期令牌
    • 实现正确的刷新逻辑
  5. 不使用 State 参数

    • 易受 CSRF 攻击
    • 始终生成并验证 state
  6. 访问令牌时效过长

    • 增加安全风险
    • 缩短访问令牌的有效期(15-60 分钟)
  7. 在应用之间共享令牌

    • 令牌应针对特定应用
    • 每个应用都应有自己的 client_id
  8. 代码验证器(PKCE)强度不足

    • 使用在密码学上安全的随机生成方式
    • 长度至少为 43 个字符
  9. 未实现令牌撤销

    • 用户无法撤销访问权限
    • 被盗令牌仍然有效
  10. 日志记录不足

    • 无法检测或调查安全事件
    • 记录所有身份验证和授权事件

性能优化

令牌缓存
// Efficient token caching with automatic refresh
class OptimizedTokenManager {
  constructor() {
    this.tokenCache = new Map();
    this.refreshPromises = new Map();
  }

  async getToken(cacheKey, fetchCallback) {
    // Check cache
    const cached = this.tokenCache.get(cacheKey);
    if (cached && Date.now() < cached.expiresAt - 60000) {
      return cached.token;
    }

    // Check if refresh is in progress
    if (this.refreshPromises.has(cacheKey)) {
      return this.refreshPromises.get(cacheKey);
    }

    // Fetch new token
    const promise = fetchCallback()
      .then(({ token, expiresIn }) => {
        this.tokenCache.set(cacheKey, {
          token,
          expiresAt: Date.now() + (expiresIn * 1000),
        });
        this.refreshPromises.delete(cacheKey);
        return token;
      })
      .catch(error => {
        this.refreshPromises.delete(cacheKey);
        throw error;
      });

    this.refreshPromises.set(cacheKey, promise);
    return promise;
  }

  clearCache(cacheKey) {
    if (cacheKey) {
      this.tokenCache.delete(cacheKey);
    } else {
      this.tokenCache.clear();
    }
  }
}
连接池
// Reuse HTTP connections for token requests
const https = require('https');
const axios = require('axios');

const httpsAgent = new https.Agent({
  keepAlive: true,
  maxSockets: 50,
  maxFreeSockets: 10,
  timeout: 60000,
});

const apiClient = axios.create({
  httpsAgent,
  timeout: 30000,
});

// Use for all OAuth2 requests
async function fetchTokens(params) {
  const response = await apiClient.post(tokenEndpoint, params);
  return response.data;
}

资源与参考资料

官方规范
常用 OAuth2 库

JavaScript/Node.js:

  • passport - 身份验证中间件
  • oauth4webapi - 现代 OAuth2 客户端
  • node-oauth2-server - OAuth2 服务实现
  • jsonwebtoken - JWT 的创建与验证
  • jwks-rsa - JWKS 密钥检索

Python:

  • authlib - 功能全面的 OAuth 库
  • python-oauth2 - OAuth2 提供方工具包
  • PyJWT - JWT 实现

PHP:

  • league/oauth2-client - OAuth2 客户端
  • league/oauth2-server - OAuth2 服务

Java:

  • Spring Security OAuth
  • Apache Oltu
OAuth2 提供商
  • Auth0
  • Okta
  • Amazon Cognito
  • Google Identity Platform
  • Azure Active Directory
  • Keycloak(开源)
  • ORY Hydra(开源)

技能版本:1.0.0 最后更新:2025 年 10 月 技能类别:身份验证、授权、安全 兼容对象:Web 应用、移动应用、API、微服务

评论

支持 Markdown