# OpenViking 原生 OAuth 2.1（MCP 客户端授权）实施方案

> **更新（Studio 迁移）**：本文档保留 Phase 1 的设计与术语作为历史记录。当前
> 默认授权 UI 已经从独立的 `/console` (端口 8020) 迁移到主服务上的 OpenViking
> Studio（同源、挂载在 `/studio`）。要点：
>
> - `provider.authorize()` 默认 redirect 到 `/studio/oauth/consent?pending=<id>`
>   而不是 `/oauth/authorize/page`。consent SPA 跑在 Studio 自己的 tab 里，
>   直接复用 `sessionStorage` 里已有的 API Key 调
>   `POST /api/v1/auth/oauth-verify`（请求体改用 `pending_id` 取代
>   `display_code`，并通过新增的公开端点
>   `GET /api/v1/auth/oauth/pending/{pending_id}` 拿 client_name /
>   redirect_host / scopes 渲染 consent 卡片）。
> - 服务端 HTML 页面 `/oauth/authorize/page` 退化为**跨设备 fallback**：
>   显示 6 字符 `display_code` + 引导文案，让用户在另一台已登录 Studio 的设备
>   打开 `/studio/oauth/verify` 输入码完成授权。同设备的 quick-authorize
>   面板（依赖 `sessionStorage.ov_console_api_key` 跨 tab 探测）已移除，
>   不再需要把 API Key 写到 `localStorage`。
> - OTP push 流程（`POST /api/v1/auth/otp` 签发、Studio 里"OAuth client OTP"
>   区块生成短期码交给客户端）从未接通消费端，已整体移除（后端端点 + 前端入口 +
>   `otp_ttl_seconds` 配置）。Studio 侧边栏底部的那个槽位改造成"OAuth 验证"入口，
>   直接打开跨设备 `display_code` 验证表单。
> - 旧的 `/console` 独立服务（8020 + `openviking.console` Python 包 +
>   `python -m openviking.console.bootstrap`）已在 #2160 整体下线，本次迁移
>   是该 PR commit message 里明确指向的 OAuth follow-up。Caddy 仍保留
>   `:1934 → openviking:1933` 的 legacy 单上游反代以兼容旧书签，公网 HTTPS
>   按 12-public-access 指南在 Caddyfile 里 append `:443` domain block。
>
> 下文的 "Phase 1" 描述仍然刻画了 device-flow 的核心思路（含已移除的 OTP push
> 端点，仅作历史记录）；新迁移在此之上把 UI 层从 `/console` 替换为 `/studio`，
> 并把 Studio consent 作为同设备主路径。

## Context

**问题**：Claude.ai / Claude Desktop / ChatGPT 等只接受 OAuth 2.1 的 MCP 客户端，必须经由社区项目 [MCP-Key2OAuth](https://github.com/t0saki/MCP-Key2OAuth) 的 Cloudflare Workers 代理才能连接 OpenViking 的 `/mcp`。痛点：

1. **额外部署单元** — 自建 CF Worker + 2 个 KV namespace，运维成本高
2. **生态绑定** — `@cloudflare/workers-oauth-provider` + KV 强绑定 CF Workers，无法脱离 CF 生态
3. **体验差与信任风险** — 用户在浏览器手动粘贴 API Key，且 Worker 部署方有解密 Key 的能力

**目标**：在 OpenViking 服务端原生实现 OAuth 2.1（MCP 子集），消除中间代理；保留 API Key 认证向后兼容；提供顺手的浏览器授权 UX。

**最终决策（与设计早期不同）**：

- **协议层用 `mcp.server.auth` SDK**（已在依赖中）。SDK 提供完整的 RFC 6749 / 7591 / 8414 实现：DCR、authorize 解析、token endpoint、metadata、PKCE S256 校验、redirect_uri 校验、错误码格式化。
- **Token 用 opaque + SQLite，不用 JWT**。Access / refresh / auth_code / OTP 全部是 `secrets.token_urlsafe()` 随机串，按 SHA-256 哈希存表，每次校验做一次 SQLite 查询。**OpenViking 侧零密码学代码**。
- **不做 redirect_uri 白名单**，但 SDK 会强制 strict-equal 校验防 code injection。
- **Phase 1 用 device-flow 风格的 OTP 流程**：authorize page **显示** 6 字符码，用户在 console（已登录环境）**输入** 该码确认授权。比早期的"console 取码、page 输入"流程少一次 tab 切换，且符合 RFC 8628 的心理模型。

---

## 架构

```
┌─────────────────────────────────────────────────────────────────┐
│                    OpenViking 1933                              │
│                                                                 │
│  ┌────────────────────┐   ┌──────────────────────────┐          │
│  │ mcp.server.auth    │   │ openviking.server.oauth  │          │
│  │ (SDK, 协议层)      │   │ (适配 + 自定义路由)      │          │
│  ├────────────────────┤   ├──────────────────────────┤          │
│  │ /.well-known/      │   │ /.well-known/            │          │
│  │   oauth-auth-server│   │   oauth-protected-       │          │
│  │ /register (DCR)    │   │   resource (PRM 9728)    │          │
│  │ /authorize         │   │ /oauth/authorize/page    │          │
│  │ /token             │   │ /oauth/authorize/page/   │          │
│  │ /revoke            │   │   status (轮询)          │          │
│  └─────────┬──────────┘   │ /api/v1/auth/oauth-      │          │
│            │              │   verify (确认入口)       │          │
│            │              │ /api/v1/auth/otp         │          │
│            │              │   (legacy push)           │          │
│            │              └──────────┬───────────────┘          │
│            │                          │                          │
│            ↓ load_access_token()     ↓ DELETE/INSERT            │
│  ┌────────────────────┐   ┌──────────────────────────┐          │
│  │ auth.py            │   │ workspace/oauth.db       │          │
│  │ resolve_identity   │   │  oauth_clients           │          │
│  │ 识别 ovat_ →       │   │  oauth_codes (otp+code)  │          │
│  │ provider 查找      │   │  oauth_refresh_tokens    │          │
│  │ → ResolvedIdentity │   │  oauth_access_tokens     │          │
│  └────────────────────┘   │  oauth_pending_authorizations       │
│                           │   (display_code, verified, ...)     │
│                           └──────────────────────────┘          │
└─────────────────────────────────────────────────────────────────┘
                                ↑ verify (Bearer)
┌─────────────────────────────────────────────────────────────────┐
│                    OpenViking Console 8020                      │
│  Settings → "Authorize an MCP client" 表单                      │
│   - 输入 6 字符 display_code → 调 /console/api/v1/ov/auth/      │
│     oauth-verify (proxy → 1933 /api/v1/auth/oauth-verify)       │
│  浏览器 sessionStorage 存 API Key (key=ov_console_api_key)      │
└─────────────────────────────────────────────────────────────────┘
```

---

## 设计要点

### 1. 认证模式

不引入新的 `AuthMode.OAUTH`。OAuth 叠加在现有 `AuthMode.API_KEY` 之上：当 `oauth.enabled = true` 时，`Authorization: Bearer <token>` 优先按 OAuth 处理：

- 若 token 以 `ovat_` 前缀开头 → 走 `provider.load_access_token()` 路径，**fail-closed**（前缀正确但查不到不会回退到 API Key 路径）
- 否则 → 走现有 APIKeyManager 路径，行为与改动前完全一致

`ResolvedIdentity` 新增 `from_oauth: bool` 标记位；`get_request_context` 对 OAuth 身份跳过 ROOT-tenant-headers 强校验（claims 已钉死 account/user）。

### 2. Token 权限范围与撤销

OAuth token = API Key 等效，能调任何当前用户身份能调的 REST 端点（不仅 `/mcp`）。

- **不是权限放大**：opaque token 都钉死 `(account_id, user_id, role)`
- **撤销粒度**：以 `(account, user)` 为单位 — 删除某 user 的 API Key 时一刀切撤销该 user 名下所有 OAuth token，见 `OAuthStore.revoke_user_tokens()`
- Phase 2 计划引入 OAuth scope 做更细收紧

### 3. Token 形态（全部 opaque）

| 类型 | 形态 | 前缀 | TTL | 存储 |
|---|---|---|---|---|
| access_token | `secrets.token_urlsafe(40)` | `ovat_` | 1h | SQLite (SHA-256 哈希) |
| refresh_token | `secrets.token_urlsafe(40)` | `ovrt_` | 30d | SQLite (SHA-256 哈希) |
| authorization_code | `secrets.token_urlsafe(40)` | `ovac_` | 5min | SQLite (SHA-256 哈希) |
| display_code (人类可读) | 6 字符（去歧义字母+数字） | — | 10min | pending_authorizations |
| OTP（legacy push） | 同上 | — | 5min | oauth_codes |

前缀是 fast-path discriminator（不参与鉴权决策）— 让 `auth.py` 在每次请求只对 `ovat_` 开头的 bearer 做 DB 查询，普通 API Key 不受影响。

### 4. 公网 URL 解析（issuer / PRM resource / WWW-Authenticate / page 链接）

统一 4 级回退：

1. `OPENVIKING_PUBLIC_BASE_URL` 环境变量（最高优先级，部署 override）
2. `oauth.issuer` 配置项
3. `X-Forwarded-Proto` + `X-Forwarded-Host`（反代场景）
4. 请求 scheme + `Host` 头（直连）

非 localhost 部署强烈建议显式设置 (1) 或 (2)，因为 SDK 强制 issuer 必须是 HTTPS（除 loopback）。

### 5. PKCE / redirect_uri / 错误格式

由 SDK 强制 S256，`plain` 拒绝；`code_verifier` 长度 43–128。SDK 在 `TokenHandler` 中验证。`/authorize` 时 `OAuthClientMetadata.validate_redirect_uri` 做 strict-equal；`/token` 时再次比对（防 code injection）。RFC 6749 错误码由 SDK 返回。

### 6. WWW-Authenticate 401 头

`/mcp` 鉴权失败时 `_IdentityASGIMiddleware` 注入：
```
WWW-Authenticate: Bearer resource_metadata="https://<host>/.well-known/oauth-protected-resource"
```
URL 走 §4 的 4 级回退。RFC 9728 客户端发现入口。

---

## 端到端流程（device-flow / pull 模式）

```
1. 用户输入 https://my.ov/mcp 到 Claude.ai
2. Claude POST /mcp → 401 + WWW-Authenticate: Bearer resource_metadata="..."
3. Claude GET /.well-known/oauth-protected-resource     → 拿 issuer
4. Claude GET /.well-known/oauth-authorization-server   → 拿 endpoint        [SDK]
5. Claude POST /register {redirect_uris}                → 拿 client_id      [SDK]
6. Claude 浏览器跳到 /authorize → SDK 校验 → 调 provider.authorize() →
   server 生成 display_code (e.g. "AB3X7K") + pending → 302 → 
   /oauth/authorize/page?pending=...                                          [SDK→OV]
7. Page 显示大字 "AB3X7K" + 链接 https://my.ov/console，
   并启动 JS 轮询 /oauth/authorize/page/status?pending=...
8. 用户切到 console (sessionStorage 已经在登录) → Settings →
   "Authorize an MCP client" 输入 AB3X7K → 点 Authorize
9. Console JS POST /console/api/v1/ov/auth/oauth-verify {code, decision}
   ↓ proxy
   1933 POST /api/v1/auth/oauth-verify
   → server 找 pending by display_code → mark verified, 写入 caller 身份
10. Page 下次轮询命中 status=approved → response.redirect_url 含 auth_code
11. Page JS window.location.replace(redirect_url) → Claude 收到 ?code=...&state=...
12. Claude POST /token (PKCE) → ovat_ + ovrt_                                [SDK]
13. Claude POST /mcp (Authorization: Bearer ovat_...) → 通过                  [SDK→auth.py]
```

**同源加速（可选）**：第 7 步 page JS 检测到 `sessionStorage.ov_console_api_key` 存在（即与 console 同域 + 已登录）时，显示绿色 "Quick authorize" 面板。点击该按钮等价于在 console 输入码 → 直接跳到第 10 步。**仍要点击确认**，不会自动一步跳转。要让同源生效，nginx 反代把 8020 与 1933 放到同一域名（`/console/...` → 8020，`/...` → 1933）。

---

## 模块清单

### 新增 / 改写

| 文件 | 用途 | 行数 |
|---|---|---|
| `openviking/server/oauth/storage.py` | SQLite 5 张表 + CRUD + GC + verify/find_pending_by_display_code | ~620 |
| `openviking/server/oauth/provider.py` | `OAuthAuthorizationServerProvider` Protocol 适配；子类化 SDK 的 `AuthorizationCode/RefreshToken/AccessToken` 嵌入 `(account, user, role)`；`authorize()` 自动生成 display_code | ~280 |
| `openviking/server/oauth/router.py` | PRM、authorize page (HTML+JS)、page/status 轮询、`/api/v1/auth/oauth-verify` | ~440 |
| `openviking/server/oauth/otp.py` | `generate_otp`（cross-device display_code）/ `hash_secret`（stdlib） | ~30 |
| `openviking_cli/utils/config/oauth_config.py` | `OAuthConfig` pydantic | ~70 |

### 修改

| 文件 | 改动 |
|---|---|
| `openviking/server/auth.py` | `_try_resolve_oauth_token`：识别 `ovat_` → `provider.load_access_token` → `ResolvedIdentity(from_oauth=True)` |
| `openviking/server/identity.py` | `ResolvedIdentity.from_oauth: bool` |
| `openviking/server/mcp_endpoint.py` | 401 注入 `WWW-Authenticate` 头；`_scope_to_origin` 4 级回退含 env |
| `openviking/server/app.py` | lifespan 初始化 `OAuthStore` + GC 任务；`create_app` 用 `mcp.server.auth.routes.create_auth_routes()` 挂 SDK routes + 自定义 router；issuer 优先读 `OPENVIKING_PUBLIC_BASE_URL` env |
| `openviking_cli/utils/config/open_viking_config.py` | 接入 `OAuthConfig` |
| `openviking/console/app.py` | 加 `POST /console/api/v1/ov/auth/otp` 和 `POST /console/api/v1/ov/auth/oauth-verify` 转发路由 |
| `openviking/console/static/index.html` | Settings 面板加 "Authorize an MCP client" 表单（device flow 入口）和折叠的 legacy "Get OTP" 入口 |
| `openviking/console/static/app.js` | 表单事件 handler；调 verify 端点；keydown=Enter 触发授权 |

### 删除（vs 早期 JWT 方案）

- `openviking/server/oauth/jwt.py`（手搓 HS256）
- `tests/server/oauth/test_jwt.py`

---

## 端点全表

| 端点 | 方法 | 由谁实现 | 鉴权 | 说明 |
|---|---|---|---|---|
| `/.well-known/oauth-authorization-server` | GET | SDK | 无 | RFC 8414 |
| `/.well-known/oauth-protected-resource` | GET | OpenViking | 无 | RFC 9728，列出 issuer 和 bearer_methods |
| `/register` | POST | SDK | 无 | DCR (RFC 7591)，SDK 生成 client_id/secret，调 `provider.register_client()` |
| `/authorize` | GET/POST | SDK → provider.authorize() | 无 | SDK 校验 client + redirect_uri + PKCE，调 `provider.authorize()` 生成 display_code + pending_id；返回 302 → `/oauth/authorize/page?pending=...` |
| `/oauth/authorize/page` | GET | OpenViking | 无 | 显示 display_code + console 链接 + 同源 quick-authorize 面板（如检测到 sessionStorage 中的 API key）；JS 轮询 status |
| `/oauth/authorize/page/status` | GET | OpenViking | 无 | 返回 `{status: pending\|approved\|expired, redirect_url?}`；status=approved 时原子签发 auth_code 并删除 pending |
| `/token` | POST | SDK | client auth | SDK 验 PKCE / redirect_uri / client，调 `provider.exchange_authorization_code()` 或 `exchange_refresh_token()` |
| `/revoke` | POST | SDK | client auth | SDK 调 `provider.revoke_token()` |
| `POST /api/v1/auth/oauth-verify` | POST | OpenViking | 现有 API Key（`Depends(get_request_context)`） | 接受 `{pending_id 或 code, decision: approve\|deny}`；approve 时把 caller 身份写入 pending；deny 时删除 pending |

---

## 部署运维

### 启动

| 服务 | 命令 | 默认端口 |
|---|---|---|
| 主服务（API + MCP + OAuth） | `openviking-server [--host --port --config --workers]` | 1933 |
| Web Console | `python -m openviking.console.bootstrap [--host --port --openviking-url --write-enabled]` | 8020 |

> Console 当前没有 `openviking-console` entry point，只能 `python -m`。后续可加。

### 关键配置

| 项 | 何处 | 说明 |
|---|---|---|
| 存储路径 | `ov.conf:storage.workspace`（默认 `./data`） | `oauth.db` 落在 `<workspace>/oauth.db` |
| OAuth 启用 | `ov.conf:oauth.enabled = true` | 默认 false，关闭时所有 OAuth 路径不挂载 |
| Issuer URL | `OPENVIKING_PUBLIC_BASE_URL` env > `ov.conf:oauth.issuer` | 非 localhost 必须 HTTPS |
| TTL | `ov.conf:oauth.{access,refresh,auth_code,otp}_ttl_seconds` | 默认 1h / 30d / 5min / 5min |
| Console 上游 | `--openviking-url`（默认 `http://127.0.0.1:1933`） | 反代后改成 `http://127.0.0.1:1933` 即可 |

### nginx 反代模板（推荐部署形态，**未在仓库**）

```nginx
server {
  listen 443 ssl;
  server_name my.ov;

  # 8020 console
  location /console {
    proxy_pass http://127.0.0.1:8020;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host  $host;
  }

  # 其他都走 1933 (REST + MCP + OAuth + .well-known)
  location / {
    proxy_pass http://127.0.0.1:1933;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host  $host;
  }
}
```

这种部署下 console 和 OAuth page 同源，page 的"quick-authorize"面板自动可用。后续可在 `deploy/` 下落一份正式模板。

---

## 实施进度

### ✅ M1 — 基础设施
- `OAuthConfig` 接入 `OpenVikingConfig`（默认 disabled）
- `OAuthStore` 5 张表 + CRUD + 原子一次性消费 + revoke_user_tokens
- `oauth/otp.py` OTP 生成
- `app.py` lifespan 注入 store + provider + GC

### ✅ M2 — Bearer 路径与 401 头
- `auth.py` `_try_resolve_oauth_token` 识别 `ovat_` 前缀走 OAuth 路径
- `ResolvedIdentity.from_oauth` 标记位 + `get_request_context` 跳过 ROOT-tenant 强校验
- `mcp_endpoint.py` 401 注入 `WWW-Authenticate` 头（含 4 级 origin 回退）

### ✅ M3 — SDK 接入与完整流程
- `OpenVikingOAuthProvider`（8 个 Protocol 方法）
- 自定义 authorize HTML 页 + display_code 显示 + JS 轮询
- `POST /api/v1/auth/oauth-verify`（device flow 确认入口）
- `POST /api/v1/auth/otp`（legacy push flow，**已移除**——从未接通消费端）
- `GET /.well-known/oauth-protected-resource`（RFC 9728）
- `app.py` 用 `create_auth_routes()` 挂 SDK 路由

### ✅ M4 — Console 集成
- console proxy 加 `oauth-verify` / `otp` 转发
- Settings 加 "Authorize an MCP client" 表单（device flow）
- legacy "Get OTP" 折叠在 details 内
- 同源 quick-authorize 面板（page JS 检测 sessionStorage）

### ✅ M5 — `OPENVIKING_PUBLIC_BASE_URL` 环境变量
- 4 级 origin 回退在 `_public_origin` (router) 和 `_scope_to_origin` (mcp_endpoint) 共用
- SDK issuer 启动时读

### ⏳ Phase 1 剩余
1. **Claude.ai / Claude Desktop / Cursor 端到端实测**（需要起 server，等 benchmark 跑完）
2. **`deploy/nginx.conf.example`** + 部署文档（同源 quick-authorize 的运维侧条件）
3. **`openviking-console` entry point script**（让 8020 启动统一为 `openviking-console`）

### 🔜 Phase 2 / 3（不在本 PR 范围）
- OAuth scope 机制（`mcp` / `fs.read` / `fs.write` / `admin`）
- GitHub / Google 第三方登录（`identity_links` 表）
- 邮件 OTP 投递（SMTP 集成）
- `ov otp` Rust CLI 子命令

---

## 验证

### 单元 / 集成测试
```bash
pytest tests/server/oauth/ -v   # 38 通过（含完整 device flow happy path）
pytest tests/server/test_auth.py tests/server/test_mcp_endpoint.py -v   # 回归
```

### 端到端 curl（device flow）
```bash
# 1. 注册客户端
curl -X POST -H "Content-Type: application/json" \
  -d '{"redirect_uris":["http://127.0.0.1:9999/cb"],"client_name":"test","token_endpoint_auth_method":"none"}' \
  http://127.0.0.1:1933/register

# 2. PKCE
VERIFIER=$(openssl rand -base64 64 | tr -d '=+/' | head -c 64)
CHALLENGE=$(printf "%s" "$VERIFIER" | openssl dgst -sha256 -binary | basenc --base64url | tr -d '=')

# 3. 浏览器: GET /authorize?... → 跳到 /oauth/authorize/page → 显示 6 字符码 e.g. "AB3X7K"

# 4. 用户在 console 输入 AB3X7K（或直接 curl）
curl -X POST -H "X-Api-Key: $ROOT_KEY" -H "Content-Type: application/json" \
  -d '{"code":"AB3X7K","decision":"approve"}' \
  http://127.0.0.1:1933/api/v1/auth/oauth-verify

# 5. Page 自动 302 回 redirect_uri，从中取 auth_code

# 6. 换 token
curl -X POST -d "grant_type=authorization_code&code=ovac_...&client_id=...&code_verifier=$VERIFIER&redirect_uri=..." \
  http://127.0.0.1:1933/token
# → {"access_token":"ovat_...","refresh_token":"ovrt_...","expires_in":3600}

# 7. 调 MCP
curl -X POST -H "Authorization: Bearer ovat_..." \
  http://127.0.0.1:1933/mcp -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
```

### 向后兼容
- `oauth.enabled=false`（默认）：`auth.py` 中 `oauth_provider is None`，OAuth 分流被跳过；行为与改动前一致
- `oauth.enabled=true`：`Authorization: Bearer <api_key>`（无 `ovat_` 前缀）仍走 APIKeyManager；现有客户端无感知

---

## 关键文件路径速查

**新增**：
- `openviking/server/oauth/{provider,storage,router,otp,__init__}.py`
- `openviking_cli/utils/config/oauth_config.py`
- `tests/server/oauth/test_{storage,router,auth_integration,mcp_www_authenticate}.py`

**修改**：
- `openviking/server/auth.py`（`_try_resolve_oauth_token`）
- `openviking/server/mcp_endpoint.py`（`WWW-Authenticate` + `_scope_to_origin`）
- `openviking/server/identity.py`（`from_oauth` 字段）
- `openviking/server/app.py`（lifespan + `create_auth_routes` + env-aware issuer）
- `openviking_cli/utils/config/open_viking_config.py`（接入 `OAuthConfig`）
- `openviking/console/app.py`（proxy `/auth/otp`, `/auth/oauth-verify`）
- `openviking/console/static/{index.html,app.js}`（device flow 表单 + 同源 quick-authorize）

**复用**（不改）：
- `openviking/server/identity.py:AuthMode/Role/ResolvedIdentity`
- `openviking_cli/utils/config/storage_config.py:StorageConfig.workspace`
- `mcp.server.auth.*`（官方 SDK，无新依赖）

---

## 风险与已识别问题

| 风险 | 处理 |
|---|---|
| 反代后 `issuer` 派生错（HTTPS 终结于代理） | `OPENVIKING_PUBLIC_BASE_URL` env 或 `oauth.issuer` 配置；非 localhost 部署强烈建议显式设 |
| 同源 quick-authorize 是隐式确认 | 即使检测到 sessionStorage，**仍需点击 "Authorize" 按钮**才生效，不会一步跳转 |
| display_code 暴力枚举 | 6 字符 × 32 字母表 = ~1B 组合；TTL 10min；pending 一次性消费；建议在反代层加每 IP 速率限制 |
| Refresh token 重放 | 实现：检测重放→`store.revoke_user_tokens(account, user)` 一并撤销该 user 名下所有 OAuth state |
| Token 权限范围 = 整个 REST API | 已与用户确认 Phase 1 不限制；Phase 2 引入 scope 机制收紧 |
| API Key → 撤销 OAuth token 的精度 | 当前粒度 `(account, user)`：删 user 时调 `revoke_user_tokens` cascade；满足需求 |
| Console 与 OAuth page 同源依赖反代 | 文档提供 nginx 模板；不反代时退化为"console 复制 OTP，page 输入"流程仍可用 |
