# ArcTower-OAuth2接入文档

- URL: https://blog.xiaoxian.org/articles/arctower-oauth2
- Author: Darf
- Published: 2026-06-01
- Tags: 知识, OAuth2, OIDC, ArcTower

> OAuth 2.0 / OpenID Connect 认证流程文档展路径

---

# OAuth 2.0 / OpenID Connect 认证流程文档

## 1. 概述

AxT 的 SSO 基于 **OAuth 2.0 授权码模式（Authorization Code Flow）** + **OpenID Connect (OIDC)** 实现。第三方客户端通过此流程获取 **accessToken**、**refreshToken** 以及（可选）**idToken**，用于访问受保护的 API 资源和获取用户身份信息。

### 角色说明
- **客户端（Client）**: 在 ArcTower 创建 OAuth App 的开发者/应用
- **用户（Resource Owner）**: 登录并授权的最终用户
- **授权服务器（Authorization Server）**: ArcTower SSO Provider，部署于 `auth.axtrk.com`
- **资源服务器（Resource Server）**: ArcTower 用户 API，部署于 `api.axtrk.com`

### 部署域划分

| 域名 | 职责 |
|------|------|
| `auth.axtrk.com` | SSO Issuer —— OIDC Discovery / JWKS / 授权 / Token / UserInfo / 撤销 / 内省 |
| `api.axtrk.com` | 业务 API（含 OAuth App CRUD、第一方用户 API） |
| `account.axtrk.com` | 前端授权页面（承载登录 UI） |

### Token 有效期与签名

| Token | 有效期 | 签名算法 | 说明 |
|-------|--------|----------|------|
| code | 5 分钟 | —— | 临时授权码，**一次性使用** |
| accessToken | 10 分钟 | RS256 | 访问令牌，用于请求 API 资源 |
| refreshToken | 14 天 | RS256 | 刷新令牌，**原子轮换 + 重放检测** |
| idToken | 10 分钟 | RS256 | OIDC 身份令牌，请求 scope 含 `openid` 时签发 |

所有 Token 均使用 **RS256 非对称签名**，按 token 类型隔离密钥。资源服务器可通过 JWKS 端点获取公钥独立验证，无需联系授权服务器。

### 支持的安全特性
- ✅ **PKCE (RFC 7636)** ——  S256 / plain 两种方法
- ✅ **state 参数** —— 服务端存储并强制校验（防 CSRF）
- ✅ **Nonce（OIDC）** —— 绑定到 idToken，防重放
- ✅ **Refresh Token Rotation** —— 每次刷新签发新 RT，旧 RT 失效，检测到重放自动撤销整条链
- ✅ **单点退出（SLO）** —— 第一方账户登出/改密时级联撤销所有 OAuth 会话
- ✅ **Token 内省（RFC 7662）** / **Token 撤销（RFC 7009）**

---

## 2. OIDC Discovery

所有协议端点和公钥信息可通过标准 Discovery 端点动态发现，**客户端应首选使用 Discovery 而非硬编码 URL**。

### 2.1 获取 OIDC 配置

**请求:**
```
GET https://auth.axtrk.com/.well-known/openid-configuration
```

**返回示例:**
```json
{
  "issuer": "https://auth.axtrk.com",
  "authorization_endpoint": "https://account.axtrk.com/oauth/authorize",
  "token_endpoint": "https://auth.axtrk.com/api/v1/oauth/access",
  "userinfo_endpoint": "https://auth.axtrk.com/api/v1/o/me",
  "jwks_uri": "https://auth.axtrk.com/.well-known/jwks.json",
  "revocation_endpoint": "https://auth.axtrk.com/api/v1/oauth/revoke-token",
  "introspection_endpoint": "https://auth.axtrk.com/api/v1/oauth/introspect",
  "end_session_endpoint": "https://auth.axtrk.com/api/v1/oauth/logout",
  "response_types_supported": ["code"],
  "response_modes_supported": ["query"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "subject_types_supported": ["pairwise"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "scopes_supported": ["openid", "profile", "email", "phone", "identity"],
  "token_endpoint_auth_methods_supported": ["client_secret_post"],
  "revocation_endpoint_auth_methods_supported": ["client_secret_post"],
  "introspection_endpoint_auth_methods_supported": ["client_secret_post"],
  "code_challenge_methods_supported": ["S256", "plain"],
  "claims_supported": ["sub", "iss", "aud", "exp", "iat", "nonce", "auth_time",
                       "preferred_username", "picture", "email", "email_verified",
                       "phone_number", "phone_number_verified", "identity"]
}
```

### 2.2 获取公钥集 (JWKS)

资源服务器用公钥离线验证 Token 签名。

**请求:**
```
GET https://auth.axtrk.com/.well-known/jwks.json
```

**返回示例:**
```json
{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "oauth-v1",
      "n": "...(modulus)...",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "id-v1",
      "n": "...(modulus)...",
      "e": "AQAB"
    }
  ]
}
```

> JWT Header 中的 `kid` 字段决定使用哪把公钥验证；`oauth-v1` 用于 accessToken，`id-v1` 用于 idToken。

---

## 3. 标准授权码流程（含 PKCE）

```ascii
+-------------------+
|       客户端       |
+-------------------+
        |
        | 1. 生成 code_verifier + code_challenge = BASE64URL(SHA256(verifier))
        |    将用户跳转至 AxT 授权页
        |    GET https://account.axtrk.com/oauth/authorize
        |      ?clientId={clientId}
        |      &redirect_uri={uri}
        |      &state={state}
        |      &nonce={nonce}            (OIDC 可选)
        |      &scope=openid+profile+email   (含 openid 时触发 OIDC)
        |      &code_challenge={challenge}
        |      &code_challenge_method=S256
        v
+----------------------+
|    用户登录 + 授权     |
+----------------------+
        |
        | 2. 授权通过后重定向
        |    {redirect_uri}?code={code}&state={state}
        v
+-------------------+
|       客户端       |
+-------------------+
        |
        | 3. 校验 state == 原值 (否则终止)
        |
        | 4. 使用 code + code_verifier 换取 Token
        |    POST https://auth.axtrk.com/api/v1/oauth/access
        |    Body: {clientId, clientSecret, code, redirectUri, state, codeVerifier}
        v
+-------------------+
|   授权服务器       |
| (auth.axtrk.com)    |
+-------------------+
        |
        | 5. 校验 code / state / PKCE / clientSecret
        |    颁发 accessToken + refreshToken (+ idToken 若 scope 含 openid)
        v
+-------------------+
|       客户端       |
+-------------------+
        |
        | 6. 访问受保护 API
        |    GET https://auth.axtrk.com/api/v1/o/me
        |    Header: Authorization: Bearer {accessToken}
        v
+-------------------+
|   授权服务器       |
+-------------------+
        |
        | 7. 返回 UserInfo
        v
        ...
        |
        | 8. accessToken 过期后使用 refreshToken 轮换
        |    POST https://auth.axtrk.com/api/v1/oauth/refresh
        |    Body: {clientId, clientSecret, refreshToken}
        |    → 返回全新的 accessToken + refreshToken（旧 RT 立即失效）
        v
        ...
```

---

## 4. 第三方快捷登录（tpo_provider）

未登录用户可附加 `tpo_provider` 参数，让用户通过 Google/GitHub/QQ 一键登录或自动注册为 **FastUser**，无需手动跳转登录页。

```ascii
客户端
  │ 1. 跳转授权页（附 tpo_provider=github）
  │    GET https://account.axtrk.com/oauth/authorize?...&tpo_provider=github
  ▼
前端检测未登录 + tpo_provider
  │ 2. 自动跳转至 GitHub OAuth
  ▼
GitHub 登录授权
  │ 3. 回调 /auth/tpo/callback/{platform}?code=...
  ▼
AxT 回调处理
  │ 4. POST /api/v1/tpo-auth/login
  │    查找已有用户 or 创建 FastUser
  │    返回第一方 accessToken
  ▼
前端恢复 OAuth 上下文
  │ 5. 自动跳回 /oauth/authorize 完成授权
  ▼
后续与标准流程一致
```

---

## 5. 详细接口

### 5.1 授权端点

**请求:** `GET https://account.axtrk.com/oauth/authorize`

前端授权页本身不是后端接口，它会调用后端 `GET https://auth.axtrk.com/api/v1/oauth/authorize/{clientId}` 生成授权码并重定向。

**Query 参数:**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| clientId | string | 是 | 客户端 ID（路径参数） |
| redirect_uri | string | 否 | 回调 URL，未传则使用配置中第一个 |
| state | string | **强烈建议** | 防 CSRF 的随机串，服务端会存储并在换 token 时校验 |
| nonce | string | OIDC 建议 | 防 idToken 重放，会写入 idToken 的 `nonce` claim |
| scope | string | OIDC 必填 | 空格分隔，含 `openid` 时触发 OIDC 并签发 idToken。可选：`profile` / `email` / `phone` |
| code_challenge | string | 公共客户端必填 | PKCE 挑战值 = `BASE64URL(SHA256(code_verifier))` |
| code_challenge_method | string | 配合 challenge | `S256`（推荐）或 `plain` |
| tpo_provider | string | 否 | `google` / `github` / `qq` —— 未登录时自动走第三方登录 |

**PKCE 生成示例（JavaScript）:**
```js
// 1. 生成 code_verifier: 43-128 字符的随机串
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));

// 2. 生成 code_challenge
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest));
// 存储 verifier，跳转授权页并传 challenge
```

**跳转回调:** 授权成功后浏览器被重定向至：
```
{redirect_uri}?code={code}&state={state}
```

| 参数 | 说明 |
|------|------|
| code | 临时授权码，5 分钟有效，**一次性** |
| state | 原始传入值，**客户端必须校验** |

---

### 5.2 Token 端点

用 code 换 accessToken（+ idToken）+ refreshToken。

**请求:**
```
POST https://auth.axtrk.com/api/v1/oauth/access
Content-Type: application/json
```

**Body:**
```json
{
  "clientId": "your_client_id",
  "clientSecret": "your_client_secret",
  "code": "abc123",
  "redirectUri": "https://your-app.com/callback",
  "state": "random123",
  "codeVerifier": "your_code_verifier"
}
```

| 字段 | 必填 | 说明 |
|------|------|------|
| clientId | 是 | —— |
| clientSecret | 是 | —— |
| code | 是 | 步骤 2 获得的授权码 |
| redirectUri | 条件 | 若授权时传了必须一致 |
| state | 条件 | 若授权时传了必须一致（服务端校验） |
| codeVerifier | 条件 | 若授权时传了 code_challenge 必须提供 |

**返回示例（含 OIDC）:**
```json
{
  "code": 200,
  "data": {
    "accessToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6Im9hdXRoLXYxIn0...",
    "refreshToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6Im9hdXRoX3Jmcy12MSJ9...",
    "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImlkLXYxIn0...",
    "tokenType": "Bearer",
    "expiresIn": 600,
    "refreshExpiresIn": 1209600,
    "scope": "profile"
  },
  "message": "Success"
}
```

| 字段 | 说明 |
|------|------|
| accessToken | RS256 JWT，用于访问 API |
| refreshToken | RS256 JWT，用于刷新（原子轮换） |
| idToken | 仅当请求 scope 含 `openid` 时返回 |
| tokenType | 固定 `Bearer` |
| expiresIn | accessToken 秒数（10m） |
| refreshExpiresIn | refreshToken 秒数（14d） |
| scope | 实际授权的权限列表（空格分隔） |

---

### 5.3 刷新 Token

**请求:**
```
POST https://auth.axtrk.com/api/v1/oauth/refresh
Content-Type: application/json
```

**Body:**
```json
{
  "clientId": "your_client_id",
  "clientSecret": "your_client_secret",
  "refreshToken": "old_refresh_token"
}
```

**返回:** 结构同 5.2，但不含 `idToken`。

**重要机制：**
- 每次刷新都会签发全新的 `accessToken + refreshToken`，旧 RT **立即失效**
- 若检测到同一个 RT 被重复使用（重放攻击），服务器会撤销当前整条令牌链，要求用户重新登录
- 客户端必须在本地立即用新 RT 替换旧 RT

---

### 5.4 UserInfo 端点

**请求:**
```
GET https://auth.axtrk.com/api/v1/o/me
Authorization: Bearer {accessToken}
```

**返回示例:**
```json
{
  "code": 200,
  "data": {
    "user": {
      "openId": "axt_15fd483903e9198109206c92866c9f33",
      "username": "Darf",
      "country": "CN",
      "active": true,
      "avatarUrl": "https://q2.qlogo.cn/headimg_dl?dst_uin=1680839&spec=640",
      "registrationTime": "2025-07-17T16:18:19.179092Z",
      "twoFactorAuth": false
    },
    "role": {
      "prefix": "AT",
      "role": "USER",
      "expireTime": null
    }
  },
  "message": "Success"
}
```

> `openId` 是用户 UUID + 应用 `oidHashKey` 的 SHA256 哈希，**同一用户对不同应用的 openId 不同**（pairwise）。

---

### 5.5 Token 撤销（RFC 7009）

用于客户端主动注销单个 accessToken 或 refreshToken。

**请求:**
```
POST https://auth.axtrk.com/api/v1/oauth/revoke-token
Content-Type: application/json
```

**Body:**
```json
{
  "clientId": "your_client_id",
  "clientSecret": "your_client_secret",
  "token": "token_to_revoke"
}
```

传入 accessToken 或 refreshToken 均可。按 RFC 7009，即使 token 已失效也会返回 200。

---

### 5.6 Token 内省（RFC 7662）

资源服务器/客户端可查询 Token 是否仍然有效及其 claims。

**请求:**
```
POST https://auth.axtrk.com/api/v1/oauth/introspect
Content-Type: application/json
```

**Body:**
```json
{
  "clientId": "your_client_id",
  "clientSecret": "your_client_secret",
  "token": "token_to_inspect"
}
```

**返回（活跃）:**
```json
{
  "code": 200,
  "data": {
    "active": true,
    "sub": "axt_15fd483903e9198109206c92866c9f33",
    "client_id": "your_client_id",
    "token_type": "Bearer",
    "exp": 1745678901,
    "iat": 1745635701,
    "scope": ["profile"]
  }
}
```

**返回（失效）:**
```json
{
  "code": 200,
  "data": { "active": false }
}
```

---

### 5.7 结束会话（OIDC End Session）

通知授权服务器用户已从客户端登出，清除对应 Token。

**请求:**
```
POST https://auth.axtrk.com/api/v1/oauth/logout
Content-Type: application/json
```

**Body:**
```json
{
  "clientId": "your_client_id",
  "clientSecret": "your_client_secret",
  "token": "access_or_refresh_token"
}
```

**按 `clientTier` 分流：**

| Tier | 行为 |
|------|------|
| `STANDARD` | 仅撤销本次传入的 token（RFC 7009 / OIDC 标准行为）|
| `ADVANCED` / `INTERNAL` | 撤销本次 token + **反向单点退出**：清空该用户在 SSO 端的所有第一方 access token，并级联撤销该用户在所有 OAuth 客户端的会话（等同于用户主动 `POST /api/v1/auth/logout`）|

接口签名对调用方完全透明，是否级联取决于该 OAuth App 注册时的 tier。普通客户端无法通过此端点触发反向单点退出。

---

## 6. idToken 结构（OIDC）

请求 scope 含 `openid` 时 `/access` 返回的 idToken 为标准 JWT，包含：

| Claim | 说明 |
|-------|------|
| iss | `https://auth.axtrk.com` |
| sub | 用户 openId（pairwise，按 client 隔离） |
| aud | clientId |
| exp / iat | 有效期 / 签发时间（epoch 秒） |
| nonce | 授权请求传入的值（防重放） |
| auth_time | 用户实际认证时间（epoch 秒） |
| preferred_username | `profile` scope 时返回 |
| picture | `profile` scope 时返回（头像 URL） |
| email / email_verified | `email` scope 且用户绑定了邮箱时返回 |
| phone_number / phone_number_verified | `phone` scope 且用户绑定了手机时返回 |

客户端应使用 JWKS 中 `id-v1` 公钥验证 idToken 签名，并校验 `iss`、`aud`、`exp`、`nonce`。

---

## 7. 单点退出（SLO）

### 7.1 SSO → 客户端（向下级联）

以下场景会触发用户在**所有** OAuth 客户端的会话失效（后端自动级联）：

1. 用户在第一方 `POST /api/v1/auth/logout` 登出
2. 用户在第一方 `POST /api/v1/user/password/change` 修改密码
3. 用户在授权列表中 `POST /api/v1/oauth/revoke/{clientId}` 撤销单个应用（仅该应用）

客户端收到 API `401` 后应当视为会话已失效，引导用户重新授权。

### 7.2 客户端 → SSO（反向级联，仅 ADVANCED / INTERNAL）

受信任 tier 的客户端可通过 `POST /api/v1/oauth/logout`（5.7）发起反向单点退出，效果等同于用户在第一方主动登出：

- 撤销该用户在 SSO 端的所有第一方 access token
- 级联撤销该用户在所有 OAuth 客户端的会话

`STANDARD` 客户端调用同一端点只会撤销本次 token，不会触发反向级联。

`INTERNAL` 客户端因获得的是第一方 access/refresh token，也可以直接调 `POST /api/v1/auth/logout`，效果一致。

---

## 8. 第三方快捷登录集成

### 8.1 支持的平台

| tpo_provider | 平台 |
|--------------|------|
| google | Google |
| github | GitHub |
| qq | QQ 互联 |

### 8.2 集成示例

```html
<!-- 使用 GitHub 快捷登录 -->
<a href="https://account.axtrk.com/oauth/authorize
  ?clientId=YOUR_CLIENT_ID
  &redirect_uri=https://your-app.com/callback
  &state=random123
  &scope=openid+profile+email
  &code_challenge=YOUR_CHALLENGE
  &code_challenge_method=S256
  &tpo_provider=github">
  使用 GitHub 登录
</a>
```

### 8.3 FastUser 说明

通过 `tpo_provider` 自动注册的用户为 **FastUser**：
- 仅持有第三方平台信息，无邮箱/手机/密码
- 可在 AxT 面板绑定邮箱或手机号升级为完整用户
- 部分敏感功能对 FastUser 不可用

---

## 9. 错误处理

| HTTP | 常见场景 |
|------|---------|
| 400 | 参数缺失、code 无效、state/PKCE 校验失败、redirect_uri 不匹配 |
| 401 | clientSecret 错误、Token 失效、refreshToken 重放 |
| 403 | 用户账户被封禁或未激活 |
| 429 | 触发限流（登录失败过多 / 请求频次超限） |

---

## 10. 安全建议

- **必须** 为每次授权生成新的 `state` 和 `nonce`，并在回调校验
- **强烈建议** 使用 PKCE（S256），即使是 confidential 客户端
- **必须** 使用 HTTPS 承载所有端点
- **必须** 在服务端存储 `clientSecret`，不要在前端暴露
- **建议** 资源服务器通过 JWKS 离线验证签名，避免每次请求回调授权服务器
- 检测到 `refreshToken` 重放时立即引导用户重新登录
