oidc_integration.md 18 KB

OIDC (OpenID Connect) 集成指南

1. 概述

OIDC (OpenID Connect) 是基于 OAuth 2.0 的现代身份认证协议,本平台基于 Ory Hydra 实现了完整的 OIDC 标准流程。

1.1 适用场景

  • ✅ 需要标准 OIDC 协议支持的应用
  • ✅ 需要获取 ID Token 和 Access Token 的场景
  • ✅ 需要支持 Refresh Token 的长期会话
  • ✅ 需要标准化的用户信息获取(UserInfo 端点)
  • ✅ 需要与第三方 OIDC 库/框架集成

1.2 与 Simple API 的区别

特性 OIDC Simple API
协议标准 OAuth 2.0 / OIDC 自定义 Ticket 机制
Token 类型 ID Token, Access Token, Refresh Token Ticket (一次性)
用户信息获取 UserInfo 端点 验证接口返回
会话管理 Refresh Token 需重新登录
适用场景 现代应用、第三方集成 传统系统、快速对接

2. 核心概念

2.1 OIDC 流程

OIDC 使用标准的 Authorization Code Flow(授权码流程):

  1. 授权请求:应用重定向用户到授权端点
  2. 用户认证:用户在 UAP 平台完成登录
  3. 授权同意:用户确认授权(通常自动完成)
  4. 授权码返回:平台重定向回应用,携带授权码
  5. Token 交换:应用用授权码换取 Token
  6. 用户信息获取:使用 Access Token 获取用户信息

2.2 端点说明

本平台基于 Ory Hydra,并通过网关统一暴露以下标准端点(假设前端网关地址为 http://localhost,生产环境请替换为实际域名,例如 https://sso.example.com):

  • 授权端点: http://localhost/hydra/oauth2/auth
  • Token 端点: http://localhost/hydra/oauth2/token
  • UserInfo 端点: http://localhost/hydra/userinfo
  • 发现端点: http://localhost/hydra/.well-known/openid-configuration

注意: 生产环境请将 http://localhost 替换为实际的前端网关域名,例如 https://sso.example.com

3. 准备工作

3.1 创建 OIDC 应用

  1. 登录 UAP 管理后台
  2. 进入「应用管理」,点击「创建应用」
  3. 关键配置
    • 协议类型:选择 OIDC
    • 回调地址 (Redirect URIs):填写您的应用回调地址,例如:
      • http://your-app.com/callback
      • http://localhost:3000/auth/callback
    • 支持的 Scopesopenid offline profile
  4. 保存后获取 App IDApp Secret

3.2 配置说明

Redirect URIs 必须与您的应用实际回调地址完全匹配(包括协议、域名、端口、路径)。

Scopes 说明

  • openid: 必需,标识 OIDC 请求
  • offline: 可选,用于获取 Refresh Token
  • profile: 可选,用于获取用户基本信息

4. 前端集成(作为 OIDC Client)

4.1 启动授权流程

当检测到用户未登录时,重定向用户到授权端点:

// 构建授权 URL
const clientId = 'YOUR_APP_ID'
const redirectUri = encodeURIComponent('http://your-app.com/callback')
const scope = 'openid offline profile'
const state = generateRandomString() // 用于防止 CSRF 攻击
const nonce = generateRandomString() // 用于验证 ID Token

const authUrl = `http://localhost/hydra/oauth2/auth?` +
  `client_id=${clientId}&` +
  `response_type=code&` +
  `scope=${scope}&` +
  `redirect_uri=${redirectUri}&` +
  `state=${state}&` +
  `nonce=${nonce}`

// 重定向用户
window.location.href = authUrl

4.2 处理回调

用户完成授权后,平台会重定向回您的回调地址,URL 格式:

http://your-app.com/callback?code=AUTHORIZATION_CODE&state=YOUR_STATE

处理回调的代码

// 从 URL 中提取授权码
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
const state = urlParams.get('state')
const error = urlParams.get('error')

// 验证 state(防止 CSRF)
if (state !== storedState) {
  console.error('State mismatch')
  return
}

// 检查错误
if (error) {
  console.error('Authorization error:', error)
  return
}

// 用授权码换取 Token(见下一节)

5. 后端集成(Token 交换)

5.1 用授权码换取 Token

接口地址: POST http://localhost/hydra/oauth2/token

请求参数 (application/x-www-form-urlencoded):

参数 类型 必填 说明
grant_type string 固定值: authorization_code
code string 从回调 URL 获取的授权码
redirect_uri string 必须与授权请求中的 redirect_uri 完全一致
client_id string 您的 App ID
client_secret string 您的 App Secret

代码示例

Python

import requests

def exchange_code_for_token(code: str, redirect_uri: str):
    token_url = "http://localhost/hydra/oauth2/token"
    
    data = {
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": redirect_uri,
        "client_id": "YOUR_APP_ID",
        "client_secret": "YOUR_APP_SECRET"
    }
    
    response = requests.post(token_url, data=data)
    if response.status_code == 200:
        token_data = response.json()
        return {
            "access_token": token_data["access_token"],
            "id_token": token_data["id_token"],
            "refresh_token": token_data.get("refresh_token"),  # 如果请求了 offline scope
            "expires_in": token_data["expires_in"],
            "token_type": token_data["token_type"]
        }
    else:
        raise Exception(f"Token exchange failed: {response.text}")

Node.js

const axios = require('axios')

async function exchangeCodeForToken(code, redirectUri) {
  const tokenUrl = 'http://localhost/hydra/oauth2/token'
  
  const params = new URLSearchParams()
  params.append('grant_type', 'authorization_code')
  params.append('code', code)
  params.append('redirect_uri', redirectUri)
  params.append('client_id', 'YOUR_APP_ID')
  params.append('client_secret', 'YOUR_APP_SECRET')
  
  try {
    const response = await axios.post(tokenUrl, params, {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      }
    })
    
    return {
      access_token: response.data.access_token,
      id_token: response.data.id_token,
      refresh_token: response.data.refresh_token,
      expires_in: response.data.expires_in,
      token_type: response.data.token_type
    }
  } catch (error) {
    throw new Error(`Token exchange failed: ${error.message}`)
  }
}

Java (Spring Boot)

import org.springframework.web.client.RestTemplate;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

public class OidcClient {
    private static final String TOKEN_URL = "http://localhost/hydra/oauth2/token";
    private static final String CLIENT_ID = "YOUR_APP_ID";
    private static final String CLIENT_SECRET = "YOUR_APP_SECRET";
    
    public TokenResponse exchangeCodeForToken(String code, String redirectUri) {
        RestTemplate restTemplate = new RestTemplate();
        
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("code", code);
        params.add("redirect_uri", redirectUri);
        params.add("client_id", CLIENT_ID);
        params.add("client_secret", CLIENT_SECRET);
        
        TokenResponse response = restTemplate.postForObject(
            TOKEN_URL,
            params,
            TokenResponse.class
        );
        
        return response;
    }
}

class TokenResponse {
    public String access_token;
    public String id_token;
    public String refresh_token;
    public Integer expires_in;
    public String token_type;
}

5.2 Token 响应示例

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "def50200a1b2c3d4e5f6...",
  "expires_in": 3600,
  "token_type": "Bearer",
  "scope": "openid offline profile"
}

6. 获取用户信息

6.1 从 ID Token 获取(推荐)

ID Token 是 JWT 格式,可以直接解析获取用户信息:

// JavaScript 示例
function parseJwt(token) {
  const base64Url = token.split('.')[1]
  const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
  const jsonPayload = decodeURIComponent(
    atob(base64)
      .split('')
      .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
      .join('')
  )
  return JSON.parse(jsonPayload)
}

const idToken = tokenResponse.id_token
const claims = parseJwt(idToken)

console.log('User ID:', claims.sub)
console.log('Email:', claims.email)
console.log('Preferred Username:', claims.preferred_username)
console.log('Phone:', claims.phone_number)

ID Token Claims 说明

Claim 说明 示例
sub 用户唯一标识(User ID) "123"
preferred_username 映射的用户名 "user_zhangsan"
email 映射的邮箱 "zhangsan@example.com"
phone_number 用户手机号 "13800138000"
exp Token 过期时间(Unix 时间戳) 1704067200
iat Token 签发时间 1704063600
nonce 随机字符串(用于验证) "abc123"

6.2 从 UserInfo 端点获取

接口地址: GET http://localhost/hydra/userinfo

请求头:

Authorization: Bearer {access_token}

代码示例

import requests

def get_userinfo(access_token: str):
    userinfo_url = "http://localhost/hydra/userinfo"
    headers = {
        "Authorization": f"Bearer {access_token}"
    }
    
    response = requests.get(userinfo_url, headers=headers)
    if response.status_code == 200:
        return response.json()
    else:
        raise Exception(f"Failed to get userinfo: {response.text}")

7. Refresh Token 使用

如果请求了 offline scope,您会获得 refresh_token,可用于刷新 Access Token。

7.1 刷新 Token

接口地址: POST http://localhost/hydra/oauth2/token

请求参数:

参数 类型 必填 说明
grant_type string 固定值: refresh_token
refresh_token string 之前获取的 Refresh Token
client_id string 您的 App ID
client_secret string 您的 App Secret

代码示例

def refresh_access_token(refresh_token: str):
    token_url = "http://localhost/hydra/oauth2/token"
    
    data = {
        "grant_type": "refresh_token",
        "refresh_token": refresh_token,
        "client_id": "YOUR_APP_ID",
        "client_secret": "YOUR_APP_SECRET"
    }
    
    response = requests.post(token_url, data=data)
    if response.status_code == 200:
        return response.json()  # 包含新的 access_token 和 refresh_token
    else:
        raise Exception(f"Token refresh failed: {response.text}")

8. 发现端点(Discovery)

OIDC 提供发现端点,可以自动获取所有端点配置:

接口地址: GET http://localhost/hydra/.well-known/openid-configuration

响应示例

{
  "issuer": "http://localhost/hydra",
  "authorization_endpoint": "http://localhost/hydra/oauth2/auth",
  "token_endpoint": "http://localhost/hydra/oauth2/token",
  "userinfo_endpoint": "http://localhost/hydra/userinfo",
  "jwks_uri": "http://localhost/hydra/.well-known/jwks.json",
  "response_types_supported": ["code", "code id_token"],
  "scopes_supported": ["openid", "offline", "profile"],
  "subject_types_supported": ["public", "pairwise"],
  "id_token_signing_alg_values_supported": ["RS256"]
}

9. 自定义 Login/Consent Provider

本平台提供了自定义的 Login 和 Consent Provider,支持账号映射注入。

9.1 Login Provider 接口

  • 获取登录请求: GET {{API_BASE_URL}}/oidc/login-request?challenge={challenge}
  • 接受登录: POST {{API_BASE_URL}}/oidc/login/accept?challenge={challenge}
  • 拒绝登录: POST {{API_BASE_URL}}/oidc/login/reject?challenge={challenge}

9.2 Consent Provider 接口

  • 获取同意请求: GET {{API_BASE_URL}}/oidc/consent-request?challenge={challenge}
  • 拒绝同意: POST {{API_BASE_URL}}/oidc/consent/reject?challenge={challenge}

注意: Consent 通常自动接受,无需手动调用接受接口。

9.3 拒绝请求示例

// 拒绝登录
await fetch(`${API_BASE_URL}/oidc/login/reject?challenge=${challenge}`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    error: 'access_denied',
    error_description: '用户取消了登录'
  })
})

10. 账号映射

OIDC 支持将平台用户映射到应用特定的账号标识,映射信息会自动注入到 ID Token 中。

10.1 映射配置

在 UAP 管理后台配置账号映射:

  • 进入「应用管理」→ 选择应用 → 「账号映射」
  • 添加映射关系:平台用户 → 应用账号标识

10.2 映射信息在 ID Token 中的位置

映射的账号标识会出现在以下 Claims:

  • preferred_username: 映射的用户名
  • email: 映射的邮箱(如果配置了邮箱映射)

11. 安全建议

  1. 使用 HTTPS: 生产环境必须使用 HTTPS
  2. 验证 State: 始终验证授权回调中的 state 参数
  3. 验证 ID Token: 验证 ID Token 的签名和过期时间
  4. 安全存储 Secret: App Secret 必须安全存储,不要暴露在前端代码中
  5. Token 存储: Access Token 和 Refresh Token 应安全存储(如 httpOnly Cookie)
  6. 验证 Nonce: 如果使用了 nonce,验证 ID Token 中的 nonce 值

12. 常见问题

Q1: 授权码有效期是多久?

A: 授权码通常有效期为 10 分钟,建议立即交换 Token。

Q2: 如何判断用户是否已登录?

A: 检查是否有有效的 Access Token,或调用 UserInfo 端点验证。

Q3: Refresh Token 会过期吗?

A: Refresh Token 通常长期有效,但可能被撤销。建议在刷新失败时引导用户重新登录。

Q4: ID Token 和 Access Token 的区别?

A:

  • ID Token: 包含用户身份信息,用于标识用户
  • Access Token: 用于访问受保护的资源(如 UserInfo 端点)

Q5: 如何实现登出?

A: OIDC 支持 RP-Initiated Logout,可以调用 Hydra 的登出端点。

13. 完整示例

13.1 前端完整流程(React)

import React, { useEffect, useState } from 'react'
import axios from 'axios'

function OidcLogin() {
  const [loading, setLoading] = useState(false)
  
  const handleLogin = () => {
    const clientId = 'YOUR_APP_ID'
    const redirectUri = encodeURIComponent('http://localhost:3000/callback')
    const state = Math.random().toString(36).substring(7)
    const nonce = Math.random().toString(36).substring(7)
    
    // 保存 state 用于验证
    sessionStorage.setItem('oidc_state', state)
    sessionStorage.setItem('oidc_nonce', nonce)
    
    const authUrl = `http://localhost:4444/oauth2/auth?` +
      `client_id=${clientId}&` +
      `response_type=code&` +
      `scope=openid offline profile&` +
      `redirect_uri=${redirectUri}&` +
      `state=${state}&` +
      `nonce=${nonce}`
    
    window.location.href = authUrl
  }
  
  return (
    <button onClick={handleLogin} disabled={loading}>
      {loading ? '登录中...' : '使用 OIDC 登录'}
    </button>
  )
}

// 回调处理组件
function Callback() {
  useEffect(() => {
    const handleCallback = async () => {
      const params = new URLSearchParams(window.location.search)
      const code = params.get('code')
      const state = params.get('state')
      const error = params.get('error')
      
      // 验证 state
      const storedState = sessionStorage.getItem('oidc_state')
      if (state !== storedState) {
        console.error('State mismatch')
        return
      }
      
      if (error) {
        console.error('Authorization error:', error)
        return
      }
      
      // 交换 Token
      try {
        const tokenParams = new URLSearchParams()
        tokenParams.append('grant_type', 'authorization_code')
        tokenParams.append('code', code)
        tokenParams.append('redirect_uri', 'http://localhost:3000/callback')
        tokenParams.append('client_id', 'YOUR_APP_ID')
        tokenParams.append('client_secret', 'YOUR_APP_SECRET')
        
        const response = await axios.post(
          'http://localhost:4444/oauth2/token',
          tokenParams,
          { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
        )
        
        // 保存 Token
        localStorage.setItem('access_token', response.data.access_token)
        localStorage.setItem('id_token', response.data.id_token)
        if (response.data.refresh_token) {
          localStorage.setItem('refresh_token', response.data.refresh_token)
        }
        
        // 解析 ID Token 获取用户信息
        const idToken = response.data.id_token
        const claims = parseJwt(idToken)
        console.log('User logged in:', claims)
        
        // 重定向到应用首页
        window.location.href = '/'
      } catch (error) {
        console.error('Token exchange failed:', error)
      }
    }
    
    handleCallback()
  }, [])
  
  return <div>处理登录回调...</div>
}

function parseJwt(token) {
  const base64Url = token.split('.')[1]
  const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
  const jsonPayload = decodeURIComponent(
    atob(base64)
      .split('')
      .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
      .join('')
  )
  return JSON.parse(jsonPayload)
}

14. 参考资源


最后更新: 2026-01-XX