# 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 | 需重新登录 |
| 适用场景 | 现代应用、第三方集成 | 传统系统、快速对接 |

**说明**：应用协议还可选 **无（`NONE`）**，表示仅作快捷导航、未对接 Ticket/OIDC；映射控制可见性，跳转使用 `POST /simple/sso-login`（返回导航 URL，无 ticket）。详见《平台账号管理接口文档》与《客户端接口 API 指南》§7。

## 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
