Kaynağa Gözat

更新oidc文档

liuq 1 ay önce
ebeveyn
işleme
1564c7762c

+ 53 - 1
backend/app/api/v1/endpoints/oidc.py

@@ -7,7 +7,7 @@ from app.api.v1 import deps
 from app.core import security
 from app.models.user import User
 from app.models.mapping import AppUserMapping
-from app.schemas.token import Token, LoginRequest
+from app.schemas.token import Token, LoginRequest, RejectRequest
 from app.services.hydra_service import hydra_service
 
 router = APIRouter()
@@ -57,6 +57,32 @@ def accept_login(
         logger.exception(f"Failed to accept login request for challenge: {challenge}")
         raise HTTPException(status_code=500, detail=str(e))
 
+@router.post("/login/reject", summary="拒绝登录请求 (OIDC)")
+def reject_login(
+    challenge: str,
+    reject_data: RejectRequest
+):
+    """
+    拒绝登录请求。
+    用于在用户取消登录或发生错误时拒绝 OIDC 登录请求。
+    
+    标准 OAuth2 错误码:
+    - access_denied: 用户拒绝访问
+    - invalid_request: 请求无效
+    - server_error: 服务器错误
+    """
+    try:
+        logger.info(f"Rejecting login request for challenge: {challenge}, error: {reject_data.error}")
+        result = hydra_service.reject_login_request(
+            challenge=challenge,
+            error=reject_data.error,
+            error_description=reject_data.error_description or ""
+        )
+        return result
+    except Exception as e:
+        logger.exception(f"Failed to reject login request for challenge: {challenge}")
+        raise HTTPException(status_code=400, detail=str(e))
+
 @router.get("/consent-request", summary="获取同意请求信息 (OIDC)")
 def get_consent_request(
     challenge: str,
@@ -119,3 +145,29 @@ def get_consent_request(
     except Exception as e:
         logger.exception(f"Failed to process consent request for challenge: {challenge}")
         raise HTTPException(status_code=400, detail=str(e))
+
+@router.post("/consent/reject", summary="拒绝同意请求 (OIDC)")
+def reject_consent(
+    challenge: str,
+    reject_data: RejectRequest
+):
+    """
+    拒绝同意请求。
+    用于在用户拒绝授权或发生错误时拒绝 OIDC 同意请求。
+    
+    标准 OAuth2 错误码:
+    - access_denied: 用户拒绝访问
+    - invalid_request: 请求无效
+    - server_error: 服务器错误
+    """
+    try:
+        logger.info(f"Rejecting consent request for challenge: {challenge}, error: {reject_data.error}")
+        result = hydra_service.reject_consent_request(
+            challenge=challenge,
+            error=reject_data.error,
+            error_description=reject_data.error_description or ""
+        )
+        return result
+    except Exception as e:
+        logger.exception(f"Failed to reject consent request for challenge: {challenge}")
+        raise HTTPException(status_code=400, detail=str(e))

+ 6 - 0
backend/app/schemas/token.py

@@ -18,3 +18,9 @@ class LoginRequest(BaseModel):
 class AppLoginRequest(BaseModel):
     app_id: str
     app_secret: str
+
+
+class RejectRequest(BaseModel):
+    """拒绝登录/同意请求的模型"""
+    error: str = "access_denied"  # OAuth2 标准错误码
+    error_description: Optional[str] = None  # 可选的错误描述

+ 28 - 0
frontend/nginx.conf

@@ -24,6 +24,20 @@ server {
         proxy_set_header Upgrade $http_upgrade;
         proxy_set_header Connection "upgrade";
     }
+
+    # Proxy OIDC (Hydra public) with /hydra prefix
+    location /hydra/ {
+        # Strip /hydra prefix before forwarding to Hydra
+        rewrite ^/hydra/(.*)$ /$1 break;
+
+        proxy_pass         http://hydra:4444;
+        proxy_http_version 1.1;
+
+        proxy_set_header   Host              $host;
+        proxy_set_header   X-Real-IP         $remote_addr;
+        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
+        proxy_set_header   X-Forwarded-Proto $scheme;
+    }
 }
 
 server {
@@ -63,4 +77,18 @@ server {
         proxy_set_header Upgrade $http_upgrade;
         proxy_set_header Connection "upgrade";
     }
+
+    # Proxy OIDC (Hydra public) with /hydra prefix over HTTPS
+    location /hydra/ {
+        # Strip /hydra prefix before forwarding to Hydra
+        rewrite ^/hydra/(.*)$ /$1 break;
+
+        proxy_pass         http://hydra:4444;
+        proxy_http_version 1.1;
+
+        proxy_set_header   Host              $host;
+        proxy_set_header   X-Real-IP         $remote_addr;
+        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
+        proxy_set_header   X-Forwarded-Proto $scheme;
+    }
 }

+ 596 - 0
frontend/public/docs/oidc_integration.md

@@ -0,0 +1,596 @@
+# OIDC (OpenID Connect) 集成指南
+
+## 1. 概述
+
+OIDC (OpenID Connect) 是基于 OAuth 2.0 的现代身份认证协议,本平台基于 [Ory Hydra](https://www.ory.sh/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`
+   - **支持的 Scopes**:`openid offline profile`
+4. 保存后获取 `App ID` 和 `App Secret`
+
+### 3.2 配置说明
+
+**Redirect URIs** 必须与您的应用实际回调地址完全匹配(包括协议、域名、端口、路径)。
+
+**Scopes 说明**:
+- `openid`: 必需,标识 OIDC 请求
+- `offline`: 可选,用于获取 Refresh Token
+- `profile`: 可选,用于获取用户基本信息
+
+## 4. 前端集成(作为 OIDC Client)
+
+### 4.1 启动授权流程
+
+当检测到用户未登录时,重定向用户到授权端点:
+
+```javascript
+// 构建授权 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
+```
+
+**处理回调的代码**:
+
+```javascript
+// 从 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
+
+```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
+
+```javascript
+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)
+
+```java
+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 响应示例
+
+```json
+{
+  "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
+// 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}
+```
+
+**代码示例**:
+
+```python
+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 |
+
+**代码示例**:
+
+```python
+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`
+
+**响应示例**:
+
+```json
+{
+  "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 拒绝请求示例
+
+```javascript
+// 拒绝登录
+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)
+
+```jsx
+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. 参考资源
+
+- [OIDC 规范](https://openid.net/specs/openid-connect-core-1_0.html)
+- [OAuth 2.0 规范](https://oauth.net/2/)
+- [Ory Hydra 文档](https://www.ory.sh/docs/hydra/)
+- [JWT.io](https://jwt.io/) - JWT 调试工具
+
+---
+
+**最后更新**: 2026-01-XX

+ 7 - 0
frontend/src/api/oidc.ts

@@ -12,3 +12,10 @@ export const getConsentRequest = (challenge: string) => {
   return api.get(`/oidc/consent-request?challenge=${challenge}`)
 }
 
+export const rejectLogin = (challenge: string, rejectData: { error: string; error_description?: string }) => {
+  return api.post(`/oidc/login/reject?challenge=${challenge}`, rejectData)
+}
+
+export const rejectConsent = (challenge: string, rejectData: { error: string; error_description?: string }) => {
+  return api.post(`/oidc/consent/reject?challenge=${challenge}`, rejectData)
+}

+ 3 - 0
frontend/src/views/Help.vue

@@ -35,6 +35,7 @@
       <MessageIntegration v-if="activeTab === 'message-integration'" />
       <MinIOFilePermissions v-if="activeTab === 'minio-file-permissions'" />
       <AccountManagement v-if="activeTab === 'account-management'" />
+      <OidcIntegration v-if="activeTab === 'oidc-integration'" />
       <Tbd v-if="activeTab === 'tbd'" />
     </div>
   </div>
@@ -56,6 +57,7 @@ import UserSyncPull from './help/UserSyncPull.vue'
 import MessageIntegration from './help/MessageIntegration.vue'
 import MinIOFilePermissions from './help/MinIOFilePermissions.vue'
 import AccountManagement from './help/AccountManagement.vue'
+import OidcIntegration from './help/OidcIntegration.vue'
 import Tbd from './help/Tbd.vue'
 
 const activeTab = ref('integration-overview')
@@ -73,6 +75,7 @@ const tabOptions = [
   { label: '消息中心对接', value: 'message-integration' },
   { label: '文件存储权限控制', value: 'minio-file-permissions' },
   { label: '平台账号管理', value: 'account-management' },
+  { label: 'OIDC 集成指南', value: 'oidc-integration' },
   { label: '其他帮助 (待定)', value: 'tbd' }
 ]
 </script>

+ 450 - 0
frontend/src/views/help/OidcIntegration.vue

@@ -0,0 +1,450 @@
+<template>
+  <div class="help-content">
+    <div class="content-header">
+      <h2>OIDC (OpenID Connect) 集成指南</h2>
+      <el-button type="primary" size="small" plain @click="downloadDoc('/docs/oidc_integration.md', 'OIDC_Integration_Guide.md')">
+        <el-icon style="margin-right: 5px"><Download /></el-icon>
+        下载 开发文档
+      </el-button>
+    </div>
+    
+    <p class="intro">
+      OIDC (OpenID Connect) 是基于 OAuth 2.0 的现代身份认证协议,本平台基于 <strong>Ory Hydra</strong> 实现了完整的 OIDC 标准流程。
+      适用于需要标准 OIDC 协议支持、Token 管理、长期会话等场景。
+    </p>
+
+    <div class="section">
+      <h3>1. 概述</h3>
+      
+      <div class="feature-card">
+        <h4>✅ 适用场景</h4>
+        <ul>
+          <li>需要标准 OIDC 协议支持的应用</li>
+          <li>需要获取 ID Token 和 Access Token 的场景</li>
+          <li>需要支持 Refresh Token 的长期会话</li>
+          <li>需要标准化的用户信息获取(UserInfo 端点)</li>
+          <li>需要与第三方 OIDC 库/框架集成</li>
+        </ul>
+      </div>
+
+      <div class="feature-card">
+        <h4>🔄 OIDC vs Simple API</h4>
+        <el-table :data="comparisonTable" border style="width: 100%">
+          <el-table-column prop="feature" label="特性" width="200" />
+          <el-table-column prop="oidc" label="OIDC" />
+          <el-table-column prop="simple" label="Simple API" />
+        </el-table>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>2. 核心流程</h3>
+      <div class="flow-chart">
+        <div class="step">
+          <div class="self-action" style="margin-left: 0; width: auto; display: inline-block;">
+            1. 应用重定向用户到授权端点
+          </div>
+          <div
+            class="arrow client-to-uap"
+            style="position: relative; top: 0; left: 10px; width: 120px;"
+          ></div>
+          <div class="self-action" style="margin-left: 10px; width: auto; display: inline-block;">
+            2. 用户在 UAP 完成登录
+          </div>
+        </div>
+
+        <div class="step" style="margin-top: 10px;">
+          <div class="self-action" style="margin-left: 0; width: auto; display: inline-block;">
+            3. 用户确认授权(自动)
+          </div>
+          <div
+            class="arrow uap-to-client"
+            style="position: relative; top: 0; left: 10px; width: 120px;"
+          ></div>
+          <div class="self-action" style="margin-left: 10px; width: auto; display: inline-block;">
+            4. 平台返回授权码
+          </div>
+        </div>
+
+        <div class="step" style="margin-top: 10px;">
+          <div class="self-action" style="margin-left: 0; width: auto; display: inline-block;">
+            5. 应用用授权码换取 Token
+          </div>
+          <div
+            class="arrow client-to-user"
+            style="position: relative; top: 0; left: 10px; width: 120px; border-style: dashed;"
+          ></div>
+          <div class="self-action" style="margin-left: 10px; width: auto; display: inline-block;">
+            6. 使用 Token 获取用户信息
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>3. 准备工作</h3>
+      
+      <h4>创建 OIDC 应用</h4>
+      <ol>
+        <li>登录 UAP 管理后台,进入「应用管理」</li>
+        <li>点击「创建应用」,<strong>协议类型选择 <code>OIDC</code></strong></li>
+        <li><strong>关键配置</strong>:
+          <ul>
+            <li><strong>回调地址 (Redirect URIs)</strong>:填写您的应用回调地址,例如:
+              <code>http://your-app.com/callback</code>
+            </li>
+            <li><strong>支持的 Scopes</strong>:<code>openid offline profile</code></li>
+          </ul>
+        </li>
+        <li>保存后获取 <code>App ID</code> 和 <code>App Secret</code></li>
+      </ol>
+      
+      <el-alert title="重要提示" type="warning" :closable="false" show-icon style="margin-top: 15px;">
+        <div>
+          <strong>Redirect URIs</strong> 必须与您的应用实际回调地址完全匹配(包括协议、域名、端口、路径)。
+        </div>
+      </el-alert>
+    </div>
+
+    <div class="section">
+      <h3>4. 前端集成</h3>
+      
+      <h4>启动授权流程</h4>
+      <div class="code-block">
+        <pre>
+const clientId = 'YOUR_APP_ID'
+const redirectUri = encodeURIComponent('http://your-app.com/callback')
+const scope = 'openid offline profile'
+const state = generateRandomString() // 用于防止 CSRF 攻击
+
+const authUrl = `http://localhost/hydra/oauth2/auth?` +
+  `client_id=${clientId}&` +
+  `response_type=code&` +
+  `scope=${scope}&` +
+  `redirect_uri=${redirectUri}&` +
+  `state=${state}`
+
+window.location.href = authUrl
+        </pre>
+      </div>
+
+      <h4>处理回调</h4>
+      <p>用户完成授权后,平台会重定向回您的回调地址:</p>
+      <code>http://your-app.com/callback?code=AUTHORIZATION_CODE&state=YOUR_STATE</code>
+    </div>
+
+    <div class="section">
+      <h3>5. 后端集成(Token 交换)</h3>
+      
+      <h4>接口信息</h4>
+      <ul>
+        <li><strong>接口地址</strong>: <code>POST http://localhost/hydra/oauth2/token</code></li>
+        <li><strong>Content-Type</strong>: <code>application/x-www-form-urlencoded</code></li>
+      </ul>
+
+      <h4>请求参数</h4>
+      <el-table :data="tokenParams" border style="width: 100%">
+        <el-table-column prop="param" label="参数" width="150" />
+        <el-table-column prop="type" label="类型" width="100" />
+        <el-table-column prop="required" label="必填" width="80" />
+        <el-table-column prop="desc" label="说明" />
+      </el-table>
+
+      <el-tabs type="border-card" class="code-tabs" style="margin-top: 20px;">
+        <el-tab-pane label="Python">
+          <div class="code-block">
+            <pre>
+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"),
+            "expires_in": token_data["expires_in"]
+        }
+    else:
+        raise Exception(f"Token exchange failed: {response.text}")
+            </pre>
+          </div>
+        </el-tab-pane>
+
+        <el-tab-pane label="Node.js">
+          <div class="code-block">
+            <pre>
+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')
+  
+  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
+  }
+}
+            </pre>
+          </div>
+        </el-tab-pane>
+
+        <el-tab-pane label="Java">
+          <div class="code-block">
+            <pre>
+import org.springframework.web.client.RestTemplate;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+public TokenResponse exchangeCodeForToken(String code, String redirectUri) {
+    RestTemplate restTemplate = new RestTemplate();
+    
+    MultiValueMap&lt;String, String&gt; params = new LinkedMultiValueMap&lt;&gt;();
+    params.add("grant_type", "authorization_code");
+    params.add("code", code);
+    params.add("redirect_uri", redirectUri);
+    params.add("client_id", "YOUR_APP_ID");
+    params.add("client_secret", "YOUR_APP_SECRET");
+    
+    return restTemplate.postForObject(
+        "http://localhost/hydra/oauth2/token",
+        params,
+        TokenResponse.class
+    );
+}
+            </pre>
+          </div>
+        </el-tab-pane>
+      </el-tabs>
+    </div>
+
+    <div class="section">
+      <h3>6. 获取用户信息</h3>
+      
+      <h4>从 ID Token 获取(推荐)</h4>
+      <p>ID Token 是 JWT 格式,可以直接解析:</p>
+      <div class="code-block">
+        <pre>
+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 claims = parseJwt(idToken)
+console.log('User ID:', claims.sub)
+console.log('Email:', claims.email)
+console.log('Preferred Username:', claims.preferred_username)
+        </pre>
+      </div>
+
+      <h4>ID Token Claims</h4>
+      <el-table :data="idTokenClaims" border style="width: 100%">
+        <el-table-column prop="claim" label="Claim" width="200" />
+        <el-table-column prop="desc" label="说明" />
+        <el-table-column prop="example" label="示例" />
+      </el-table>
+    </div>
+
+    <div class="section">
+      <h3>7. Refresh Token 使用</h3>
+      <p>如果请求了 <code>offline</code> scope,可以使用 Refresh Token 刷新 Access Token:</p>
+      <div class="code-block">
+        <pre>
+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)
+    return response.json()
+        </pre>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>8. 标准端点</h3>
+      <ul>
+        <li><strong>授权端点</strong>: <code>http://localhost/hydra/oauth2/auth</code></li>
+        <li><strong>Token 端点</strong>: <code>http://localhost/hydra/oauth2/token</code></li>
+        <li><strong>UserInfo 端点</strong>: <code>http://localhost/hydra/userinfo</code></li>
+        <li><strong>发现端点</strong>: <code>http://localhost/hydra/.well-known/openid-configuration</code></li>
+      </ul>
+      <el-alert title="注意" type="info" :closable="false" show-icon style="margin-top: 15px;">
+        本地开发默认通过 <code>http://localhost/hydra/...</code> 访问 Hydra,
+        生产环境请将 <code>http://localhost</code> 替换为实际的前端网关域名(例如 <code>https://sso.example.com</code>)。
+      </el-alert>
+    </div>
+
+    <div class="section">
+      <h3>9. 常见问题</h3>
+      <el-collapse>
+        <el-collapse-item title="授权码有效期是多久?" name="1">
+          <p>授权码通常有效期为 10 分钟,建议立即交换 Token。</p>
+        </el-collapse-item>
+        <el-collapse-item title="如何判断用户是否已登录?" name="2">
+          <p>检查是否有有效的 Access Token,或调用 UserInfo 端点验证。</p>
+        </el-collapse-item>
+        <el-collapse-item title="Refresh Token 会过期吗?" name="3">
+          <p>Refresh Token 通常长期有效,但可能被撤销。建议在刷新失败时引导用户重新登录。</p>
+        </el-collapse-item>
+        <el-collapse-item title="ID Token 和 Access Token 的区别?" name="4">
+          <p><strong>ID Token</strong>: 包含用户身份信息,用于标识用户。<br>
+          <strong>Access Token</strong>: 用于访问受保护的资源(如 UserInfo 端点)。</p>
+        </el-collapse-item>
+      </el-collapse>
+    </div>
+
+    <div class="section">
+      <h3>10. 安全建议</h3>
+      <ul>
+        <li>✅ 生产环境必须使用 HTTPS</li>
+        <li>✅ 始终验证授权回调中的 <code>state</code> 参数</li>
+        <li>✅ 验证 ID Token 的签名和过期时间</li>
+        <li>✅ App Secret 必须安全存储,不要暴露在前端代码中</li>
+        <li>✅ Access Token 和 Refresh Token 应安全存储(如 httpOnly Cookie)</li>
+      </ul>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Download } from '@element-plus/icons-vue'
+import { useHelpDocs } from '../../composables/useHelpDocs'
+
+const { downloadDoc } = useHelpDocs()
+
+const comparisonTable = [
+  {
+    feature: '协议标准',
+    oidc: 'OAuth 2.0 / OIDC',
+    simple: '自定义 Ticket 机制'
+  },
+  {
+    feature: 'Token 类型',
+    oidc: 'ID Token, Access Token, Refresh Token',
+    simple: 'Ticket (一次性)'
+  },
+  {
+    feature: '用户信息获取',
+    oidc: 'UserInfo 端点',
+    simple: '验证接口返回'
+  },
+  {
+    feature: '会话管理',
+    oidc: 'Refresh Token',
+    simple: '需重新登录'
+  },
+  {
+    feature: '适用场景',
+    oidc: '现代应用、第三方集成',
+    simple: '传统系统、快速对接'
+  }
+]
+
+const tokenParams = [
+  {
+    param: 'grant_type',
+    type: 'string',
+    required: '是',
+    desc: '固定值: authorization_code'
+  },
+  {
+    param: 'code',
+    type: 'string',
+    required: '是',
+    desc: '从回调 URL 获取的授权码'
+  },
+  {
+    param: 'redirect_uri',
+    type: 'string',
+    required: '是',
+    desc: '必须与授权请求中的 redirect_uri 完全一致'
+  },
+  {
+    param: 'client_id',
+    type: 'string',
+    required: '是',
+    desc: '您的 App ID'
+  },
+  {
+    param: 'client_secret',
+    type: 'string',
+    required: '是',
+    desc: '您的 App Secret'
+  }
+]
+
+const idTokenClaims = [
+  {
+    claim: 'sub',
+    desc: '用户唯一标识(User ID)',
+    example: '"123"'
+  },
+  {
+    claim: 'preferred_username',
+    desc: '映射的用户名',
+    example: '"user_zhangsan"'
+  },
+  {
+    claim: 'email',
+    desc: '映射的邮箱',
+    example: '"zhangsan@example.com"'
+  },
+  {
+    claim: 'phone_number',
+    desc: '用户手机号',
+    example: '"13800138000"'
+  },
+  {
+    claim: 'exp',
+    desc: 'Token 过期时间(Unix 时间戳)',
+    example: '1704067200'
+  },
+  {
+    claim: 'iat',
+    desc: 'Token 签发时间',
+    example: '1704063600'
+  }
+]
+</script>
+
+<style scoped>
+@import './help.css';
+</style>