PKCE接入指南

核心流程

1. PKCE 参数生成

1.1 Code Verifier 生成

// 生成43-128字符的随机字符串
function generateCodeVerifier(length = 128) {
  const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~';
  const array = new Uint8Array(length);
  window.crypto.getRandomValues(array);
  return Array.from(array, byte => chars[byte % 66]).join('');
}

1.2 Code Challenge 生成

// 使用SHA-256哈希并Base64URL编码
async function generateCodeChallenge(codeVerifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const hash = await window.crypto.subtle.digest('SHA-256', data);
  return base64URLEncode(String.fromCharCode(...new Uint8Array(hash)));
}

function base64URLEncode(str) {
  return btoa(str)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

2. 授权请求

2.1 构建授权URL

GET {issuer}/

请求参数:

参数名 类型 必填 说明
client_id string 客户端ID
redirect_uri string 回调地址
response_type string 固定值: “code”
scope string 权限范围,如: “openid”
state string 随机状态参数,防CSRF攻击
code_challenge string PKCE代码挑战
code_challenge_method string 固定值: “S256”

示例:

http://login.mindoffice.lan:8888/?client_id=4df1390c2b5e90e3c782&redirect_uri=http://localhost:3000/callback&response_type=code&scope=openid+profile+email&state=abc123&code_challenge=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&code_challenge_method=S256

2.2 用户授权

用户访问授权URL,完成登录和授权操作。

2.3 授权回调

授权成功后,服务器重定向到回调地址:

{redirect_uri}?code={authorization_code}&state={state}

回调参数:

参数名 类型 说明
code string 授权码
state string 状态参数,需与请求时一致

3. Token交换

3.1 请求Token

POST {issuer}:8888/account/api/token
Content-Type: application/x-www-form-urlencoded

请求参数:

参数名 类型 必填 说明
grant_type string 固定值: “authorization_code”
client_id string 客户端ID
code string 授权码
redirect_uri string 回调地址
code_verifier string PKCE代码验证器

请求示例:

grant_type=authorization_code&client_id=4df1390c2b5e90e3c782&code=abc123&redirect_uri=http://localhost:3000/callback&code_verifier=mKoBdDPrVmr84ZR6WsAwUq0sw_dv9nCdgxlWK-u_u7c

3.2 Token响应格式

标准OAuth 2.0格式:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "scope": "openid profile email"
}

包装格式(推荐):

{
  "code": 0,
  "data": {
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "token_type": "Bearer",
    "id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "expires_in": 2592000,
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "scope": "openid profile email",
    "uid": "30403123228430913"
  },
  "msg": "操作成功",
  "trace_id": "fa4cb500-06b2-410c-a682-7d88e534e420"
}

4. 用户信息获取

4.1 请求用户信息

POST {issuer}:8888/account/api/userInfo
Authorization: Bearer {access_token}
Content-Type: application/json

请求体:

{
  "userId": ""
}

4.2 用户信息响应格式

包装格式:

{
  "code": 0,
  "data": {
    "sub": "bskj3qdn93d73mft_qjr9jox5fifctnp5",
    "name": "测试用户",
    "email": "test@example.com",
    "preferred_username": "testuser",
    "given_name": "测试",
    "family_name": "用户",
    "phone_number": "+86-138-0000-0000",
    "picture": "https://example.com/avatar.jpg",
    "department": "技术部",
    "position": "软件工程师",
    "company": "示例公司",
    "updated_at": 1640995200
  },
  "msg": "操作成功"
}

标准OIDC格式:

{
  "sub": "bskj3qdn93d73mft_qjr9jox5fifctnp5",
  "name": "测试用户",
  "email": "test@example.com",
  "preferred_username": "testuser",
  "email_verified": true,
  "updated_at": 1640995200
}

完整实现示例

JavaScript/TypeScript 实现

class OIDCPKCEValidator {
  constructor(config) {
    this.issuer = config.issuer;
    this.clientId = config.clientId;
    this.redirectUri = config.redirectUri;
    this.scope = config.scope || 'openid profile email';
  }

  // 生成PKCE参数
  async generatePKCEParams() {
    const codeVerifier = this.generateCodeVerifier(128);
    const codeChallenge = await this.generateCodeChallenge(codeVerifier);

    return {
      codeVerifier,
      codeChallenge,
      codeChallengeMethod: 'S256'
    };
  }

  // 生成Code Verifier
  generateCodeVerifier(length = 128) {
    const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~';
    const array = new Uint8Array(length);
    window.crypto.getRandomValues(array);
    return Array.from(array, byte => chars[byte % 66]).join('');
  }

  // 生成Code Challenge
  async generateCodeChallenge(codeVerifier) {
    const encoder = new TextEncoder();
    const data = encoder.encode(codeVerifier);
    const hash = await window.crypto.subtle.digest('SHA-256', data);
    return this.base64URLEncode(String.fromCharCode(...new Uint8Array(hash)));
  }

  // Base64URL编码
  base64URLEncode(str) {
    return btoa(str)
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=/g, '');
  }

  // 构建授权URL
  buildAuthorizationUrl(pkceParams) {
    const state = this.generateRandomString(32);
    const params = new URLSearchParams({
      client_id: this.clientId,
      redirect_uri: this.redirectUri,
      response_type: 'code',
      scope: this.scope,
      state: state,
      code_challenge: pkceParams.codeChallenge,
      code_challenge_method: pkceParams.codeChallengeMethod
    });

    return {
      url: `${this.issuer}/auth?${params.toString()}`,
      state: state
    };
  }

  // 交换Token
  async exchangeToken(authorizationCode, codeVerifier) {
    const tokenEndpoint = `${this.issuer}:8888/account/api/token`;

    const params = new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: this.clientId,
      code: authorizationCode,
      redirect_uri: this.redirectUri,
      code_verifier: codeVerifier
    });

    const response = await fetch(tokenEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      body: params.toString()
    });

    const data = await response.json();

    if (!response.ok) {
      throw new Error(data.error_description || data.error || 'Token交换失败');
    }

    return this.parseTokenResponse(data);
  }

  // 解析Token响应
  parseTokenResponse(response) {
    // 包装格式
    if (response.code !== undefined && response.data) {
      if (response.code === 0) {
        return response.data;
      }
      throw new Error(response.msg || 'Token获取失败');
    }

    // 标准格式
    return response;
  }

  // 获取用户信息
  async fetchUserInfo(accessToken) {
    const userInfoEndpoint = `${this.issuer}:8888/account/api/userInfo`;

    const response = await fetch(userInfoEndpoint, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        userId: ""
      })
    });

    const data = await response.json();

    if (!response.ok) {
      throw new Error(data.msg || data.error || '用户信息获取失败');
    }

    return this.parseUserInfoResponse(data);
  }

  // 解析用户信息响应
  parseUserInfoResponse(response) {
    // 包装格式
    if (response.code !== undefined && response.data) {
      if (response.code === 0) {
        return response.data;
      }
      throw new Error(response.msg || '用户信息获取失败');
    }

    // 标准格式
    return response;
  }

  // 生成随机字符串
  generateRandomString(length) {
    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    let result = '';
    for (let i = 0; i < length; i++) {
      result += chars.charAt(Math.floor(Math.random() * chars.length));
    }
    return result;
  }
}

// 使用示例
const validator = new OIDCPKCEValidator({
  issuer: 'http://login.mindoffice.lan:8888',
  clientId: '4df1390c2b5e90e3c782',
  redirectUri: 'http://localhost:3000/callback',
  scope: 'openid profile email'
});

// 完整流程示例
async function completePKCEFlow() {
  try {
    // 1. 生成PKCE参数
    const pkceParams = await validator.generatePKCEParams();
    console.log('PKCE参数:', pkceParams);

    // 2. 构建授权URL
    const authData = validator.buildAuthorizationUrl(pkceParams);
    console.log('授权URL:', authData.url);

    // 3. 用户访问授权URL进行授权
    // window.location.href = authData.url;

    // 4. 从回调中获取授权码
    const authorizationCode = '从回调URL中获取的code';

    // 5. 交换Token
    const tokenData = await validator.exchangeToken(authorizationCode, pkceParams.codeVerifier);
    console.log('Token数据:', tokenData);

    // 6. 获取用户信息
    const userInfo = await validator.fetchUserInfo(tokenData.access_token);
    console.log('用户信息:', userInfo);

    return { tokenData, userInfo };
  } catch (error) {
    console.error('PKCE流程失败:', error);
    throw error;
  }
}

配置参数

必需配置

参数名 类型 说明 示例
issuer string OIDC服务器地址 http://login.mindoffice.lan:8888
clientId string 客户端ID 4df1390c2b5e90e3c782
redirectUri string 回调地址 http://localhost:3000/callback

可选配置

参数名 类型 默认值 说明
scope string openid 权限范围
responseFormat string auto Token响应格式

错误处理

常见错误码

错误码 说明 解决方案
400 请求参数错误 检查请求参数格式
401 未授权 检查客户端ID和密钥
403 禁止访问 检查权限配置
404 接口不存在 检查接口地址
500 服务器错误 联系服务器管理员

错误响应格式

{
  "code": 400,
  "msg": "参数错误",
  "error": "invalid_request",
  "error_description": "缺少必需参数"
}

安全注意事项

1. PKCE安全

  • Code Verifier必须是43-128字符的随机字符串
  • 使用密码学安全的随机数生成器
  • Code Challenge必须使用SHA-256算法

2. 状态参数

  • 每次授权请求必须生成唯一的state参数
  • 验证回调中的state参数与请求时一致
  • 防止CSRF攻击

3. Token安全

  • 安全存储access_token
  • 设置合适的token过期时间
  • 使用HTTPS传输

4. 回调地址

  • 使用HTTPS协议
  • 验证回调地址的域名
  • 防止开放重定向攻击

总结

OIDC PKCE验证工具提供了完整的身份验证解决方案,支持:

  1. 标准协议: 完全符合OAuth 2.0和OIDC标准
  2. 安全机制: PKCE扩展防止授权码拦截攻击
  3. 灵活配置: 支持多种响应格式和自定义配置
  4. 完整流程: 从授权到用户信息获取的完整实现
  5. 错误处理: 详细的错误信息和异常处理
  6. 易于集成: 提供完整的代码示例和文档

核心接口总结

接口 方法 地址 说明
授权 GET {issuer}/ 用户授权登录
Token POST {issuer}:8888/account/api/token 交换访问令牌
用户信息 POST {issuer}:8888/account/api/userInfo 获取用户详细信息

通过本文档,您可以轻松地将OIDC PKCE验证功能集成到您的应用程序中。