Files
moeka-project/apps/server/docs/ai-context/auth-and-oidc.md
T
2026-04-02 04:24:04 +08:00

256 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 认证与 OIDC Provider
## 一句话总结
Server 通过 `better-auth` 同时充当**用户认证后端**和 **OIDC ProviderAuthorization Server**,为 Web、Electron Desktop、Capacitor Mobile 三个客户端提供 Authorization Code + PKCE 登录流程。所有客户端通过 OIDC session bridge 将 OIDC access token 换取 better-auth session token,使用 Bearer token 鉴权。
## 架构角色
```
┌─────────────────────────────────┐
│ 社交登录 IdP (Google, GitHub) │
└──────────────┬──────────────────┘
↓ OAuth 2.0
┌──────────────────────────────────────────────────┐
│ AIRI Server (better-auth OIDC Provider) │
│ │
│ /api/auth/oauth2/authorize ← PKCE 授权 │
│ /api/auth/oauth2/token ← Code 换 Token │
│ /api/auth/oidc/session ← OIDC→Session 桥接 │
│ /api/auth/oidc/electron-callback ← 回调中继页 │
│ /api/auth/sign-in/social ← 社交登录入口 │
│ /sign-in ← 登录选择页 │
└──────────────┬──────────────────┬────────────────┘
↓ ↓
┌──────────┐ ┌──────────────┐
│ Stage Web │ │ Stage Electron│
│ /auth/ │ │ 127.0.0.1: │
│ callback │ │ {port}/ │
└──────────┘ │ callback │
└──────────────┘
```
## 核心组件
### Server 端
| 文件 | 职责 |
|------|------|
| `src/libs/auth.ts` | better-auth 配置:社交 provider、OIDC provider 插件、trusted clients 种子数据、session/cookie 策略 |
| `src/routes/auth/index.ts` | 所有鉴权路由的统一入口:sign-in 页、rate limiter、OIDC session bridge、electron callback、well-known metadata、better-auth catch-all |
| `src/routes/oidc/session.ts` | OIDC→Session 桥接端点:验证 OIDC access token → 创建 better-auth session → 返回 session token |
| `src/routes/oidc/electron-callback.ts` | Electron 回调中继页:服务端 HTML 页面通过 JS fetch() 将 auth code 转发到 Electron 本地 loopback |
| `src/utils/sign-in-page.ts` | 渲染 fallback HTML 登录页(Google/GitHub 按钮) |
| `src/utils/origin.ts` | 可信来源配置:`localhost``127.0.0.1``airi.moeru.ai``capacitor://localhost` |
| `src/libs/env.ts` | OIDC 相关环境变量定义(Valibot schema |
### Client 端
| 文件 | 职责 |
|------|------|
| `packages/stage-ui/src/libs/auth-oidc.ts` | OIDC 协议实现:构建 authorize URL、PKCE 生成、code 换 token、token 刷新、flow state 持久化 |
| `packages/stage-ui/src/libs/auth.ts` | 高层鉴权编排:`signInOIDC()` 发起登录、`bridgeOIDCTokens()` 桥接 token、`fetchSession()` 同步会话、自动刷新调度 |
| `packages/stage-ui/src/stores/auth.ts` | Pinia auth store:持久化 `user``session``token``refreshToken` 到 localStorage |
| `packages/stage-shared/src/auth/pkce.ts` | PKCE 工具函数:`generateCodeVerifier()``generateCodeChallenge()``generateState()` |
| `apps/stage-web/src/pages/auth/callback.vue` | Web 回调页:提取 code → 换 token → bridge → fetchSession → 跳转首页 |
| `apps/stage-web/src/pages/auth/sign-in.vue` | Web 登录页:调用 `signInOIDC()` 发起 OIDC 流程 |
### Trusted Clients
| Client | ID 环境变量 | redirect_uri | 类型 |
|--------|------------|--------------|------|
| Web | `OIDC_CLIENT_ID_WEB` | `https://airi.moeru.ai/auth/callback`, `http://localhost:5173/auth/callback` | web |
| Electron | `OIDC_CLIENT_ID_ELECTRON` | `{API_SERVER_URL}/api/auth/oidc/electron-callback`(服务端中继) | native |
| Mobile | `OIDC_CLIENT_ID_POCKET` | `capacitor://localhost/auth/callback` | native |
### 环境变量
```
# 社交 Provider
AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET
AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET
# OIDC Trusted Clients(均 optional,不配则不注册)
# Web and Pocket are public clients (no secret, PKCE only)
OIDC_CLIENT_ID_WEB
OIDC_CLIENT_ID_ELECTRON, OIDC_CLIENT_SECRET_ELECTRON
OIDC_CLIENT_ID_POCKET
```
## Token 层次
| Token | 用途 | 存储位置 | 生命周期 |
|-------|------|---------|---------|
| Authorization Code | 一次性换 token | URL query param (`?code=`) | 极短,一次性 |
| OIDC Access Token | 换 session tokenbridge | 不持久化,仅在回调流程中使用 | 由 oauthProvider 控制 |
| OIDC Refresh Token | 刷新 access token | localStorage `auth/v1/refresh-token` | 长期 |
| Session Token | 实际的 API 鉴权凭证(Bearer | localStorage `auth/v1/token` | 由 better-auth session 控制 |
**为什么不直接用 OIDC access token** 因为 better-auth 的 session 系统使用自己的 `session` 表,OIDC access token 存在 `oauth_access_token` 表中,两者不兼容。所有 API 路由通过 `sessionMiddleware``session` 表鉴权,所以需要 bridge 端点做转换。
**为什么不用 cookie** 客户端和服务端跨域(如 `localhost:5173` vs `localhost:3000`),cookie 无法跨域传递。客户端 `credentials: 'omit'`,纯 Bearer token 鉴权。
## 登录流程
### Web 完整流程
```
Client (localhost:5173) Server (localhost:3000) Social IdP
│ │ │
1. signInOIDC() │ │
构建 PKCE (verifier + challenge) │ │
存 sessionStorage │ │
window.location → │ │
│ │ │
2. GET /api/auth/oauth2/authorize │ │
?response_type=code │ │
&client_id=airi-stage-web │ │
&redirect_uri=localhost:5173/auth/callback │ │
&code_challenge=xxx │ │
&provider=github │ │
│ │ │
│ 3. 用户未登录 │
│ 302 → /sign-in?...所有 OIDC 参数... │
│ │ │
│ 4. /sign-in 看到 provider=github │
│ 重建 callbackURL = /api/auth/oauth2/authorize?... │
│ 302 → /api/auth/sign-in/social │
│ ?provider=github │
│ &callbackURL={OIDC authorize URL} │
│ │ │
│ ──────── 302 to GitHub ───────────────► │
│ │ 5. 用户授权
│ │ ◄──────── callback ────────────────── │
│ │ │
│ 6. better-auth 创建 user + sessionserver cookie
│ 302 → callbackURL= OIDC authorize
│ │ │
│ 7. /api/auth/oauth2/authorize │
│ 用户已有 session → 签发 authorization code │
│ 302 → redirect_uri?code=xxx&state=xxx │
│ │ │
8. /auth/callback │ │
consumeFlowState() 恢复 PKCE │ │
验证 state 防 CSRF │ │
│ │ │
9. POST /api/auth/oauth2/token ──────────────► │ │
(code + code_verifier + client_id) │ │
◄──── { access_token, refresh_token } ────── │ │
│ │ │
10. POST /api/auth/oidc/session ──────────────► │ │
(Bearer: access_token) │ │
◄──── { token: session_token } ───────────── │ │
│ │ │
11. GET /api/auth/get-session ─────────────────► │ │
(Bearer: session_token) │ │
◄──── { user, session } ──────────────────── │ │
│ │ │
12. 写入 authStore → 跳转首页 │ │
```
**关键设计:callbackURL 传递 OIDC 参数**
`/sign-in` 路由收到的 URL 包含所有 OIDC 授权参数(`response_type``client_id``redirect_uri``code_challenge` 等)。它将这些参数重建为完整的 OIDC authorize URL,作为 `callbackURL` 传给社交登录。社交登录完成后,用户被重定向回 OIDC authorize 端点,此时用户已有 server sessionOIDC 流程继续签发 code。
### Electron 特殊处理
Electron 不使用自定义协议(`airi://`),而是在 main process 临时启动一个 HTTP server 监听 `127.0.0.1:{port}/callback`
- 固定端口范围:19721-19725,按顺序尝试
- 收到回调后立即关闭 server
- 5 分钟超时安全机制
**服务端回调中继**
Electron 的 OIDC redirect_uri 不再直接指向 loopback 端口,而是指向服务端的 `/api/auth/oidc/electron-callback`。这个端点返回一个 HTML 页面,页面通过 JS `fetch()` 将 auth code 转发到本地 loopback。
好处:
- 浏览器不显示 `http://127.0.0.1:19721/...` 这样的 URL
- 只需注册一个 redirect_uri(不再需要 5 个端口对应的 URL)
- Loopback server 需要设置 CORS `Access-Control-Allow-Origin: *`
端口编码方式:loopback 端口编码在 `state` 参数中,格式为 `{port}:{originalState}`。中继页面提取端口后,将 code 和原始 state 通过 fetch 发送到 `http://127.0.0.1:{port}/callback`
### OIDC→Session 桥接
better-auth 的 `oidcProvider` 插件发出的 OIDC access token 存在 `oauth_access_token` 表中,与 better-auth session`session` 表)不兼容。所有客户端(Web、Electron、Mobile)登录后都需要调用 `POST /api/auth/oidc/session` 桥接端点:
1. 验证 Bearer token 对应 `oauth_access_token` 表中的有效记录
2. 验证 `clientId` 属于受信任的客户端集合(Web、Electron、Pocket 均可)
3. 验证 `accessTokenExpiresAt > now`
4. 通过 `(await auth.$context).internalAdapter.createSession(userId)` 创建 better-auth session
5. 缓存 `oidc_token → session_token` 映射(TTL 5 分钟,幂等)
6. TTL 过期后删除 `oauth_access_token`
安全措施:
- 客户端 ID 限制(仅受信任客户端)
- Token 过期检查
- 幂等 + TTL 限制重放窗口
- 统一 401 响应(防止信息泄漏)
### 自动 Token 刷新
客户端在 OIDC token 生命周期 80% 时自动调用 `/api/auth/oauth2/token``grant_type=refresh_token`),刷新后重新 bridge 获取新 session token。页面重载后从 localStorage 恢复刷新调度:
- `auth/v1/oidc-client-id` — 客户端 ID
- `auth/v1/oidc-client-secret` — 客户端 Secret
- `auth/v1/oidc-token-expiry` — Token 过期时间戳
### provider 参数直通
客户端在 authorize URL 中附带 `provider` 参数,server 的 `/sign-in` 路由会直接 302 到对应社交 provider,**跳过选择页**。没有 `provider` 参数时 fallback 到 HTML 选择页(兜底场景,如直接浏览器访问)。
## 路由注册顺序
Auth 路由集中在 `src/routes/auth/index.ts`,通过 `.route('/', authRoutes)` 挂载到根路径。路由注册顺序很重要:
1. `GET /sign-in` — 登录选择页(或直接 302 到社交 provider
2. `USE /api/auth/*` — rate limiterIP 限流)
3. `.route('/api/auth/oidc/session')` — session bridge(在 catch-all 之前注册)
4. `.route('/api/auth/oidc/electron-callback')` — electron 回调中继
5. `GET /.well-known/oauth-authorization-server/api/auth` — OAuth 2.1 AS metadata
6. `GET /api/auth/.well-known/openid-configuration` — OIDC discovery
7. `['POST', 'GET'] /api/auth/*`**catch-all**,将所有其他请求转发给 `auth.handler()`
自定义 OIDC 路由(`/api/auth/oidc/*`)注册在 catch-all 之前,所以不会被 better-auth 拦截。`/api/auth/oauth2/authorize``/api/auth/oauth2/token` 等标准端点由 catch-all 转发给 better-auth 内部处理。
## 踩坑记录
### better-auth redirect_uri 精确匹配
better-auth 的 OIDC 插件对 `redirect_uri` 做**精确字符串匹配**`authorize.mjs`):
```javascript
client.redirectUrls.find(url => url === ctx.query.redirect_uri)
```
RFC 8252 S7.3 要求 Authorization Server 对 loopback 地址允许任意端口,但 better-auth 不支持。因此 Electron 使用服务端中继 URL 作为 redirect_uri,绕过了端口匹配问题。
### better-auth cookie 与 Bearer 共存
better-auth client 默认 `credentials: "include"`,会同时发送 cookie。我们 override 为 `credentials: "omit"`,只使用 Bearer token 认证。见 `packages/stage-ui/src/libs/auth.ts` 的 NOTICE 注释。
### skipStateCookieCheck
Capacitor 移动端无法正确处理 state cookie(系统浏览器和 WebView cookie jar 隔离),所以 better-auth 配置了 `skipStateCookieCheck: true`。PKCE 仍然提供 CSRF 防护。
### better-auth internalAdapter
`(await auth.$context).internalAdapter.createSession(userId)` 是创建 session 的正确路径。`auth.api` 是 HTTP endpoint handlers 的集合,没有 `createSession` 方法。参考 better-auth admin 插件和 test-utils 的用法。注意 `createAuth()` 返回 `any`(TS2742),需要无类型安全地访问 `$context`
### OIDC 流程中断:callbackURL 必须指回 authorize
社交登录完成后,`callbackURL` 必须指向 `/api/auth/oauth2/authorize?...OIDC参数...`,否则用户会被重定向到服务端根路径,OIDC 授权码流程中断。`/sign-in` 路由从 URL query params 重建完整的 OIDC authorize URL 作为 `callbackURL`
## 修改指南
- 新增 OIDC client → `src/libs/auth.ts``buildTrustedClientSeeds`,加环境变量到 `src/libs/env.ts`
- 改登录页 → `src/utils/sign-in-page.ts`HTML),或 `src/routes/auth/index.ts``/sign-in` 路由
- 改认证中间件 → `src/app.ts` 的 session middleware
- 改 trusted origins → `src/utils/origin.ts`
- 改桥接端点 → `src/routes/oidc/session.ts`
- 改回调中继 → `src/routes/oidc/electron-callback.ts`
- 改 Auth 路由结构 → `src/routes/auth/index.ts`
- 调试 OIDC 流程 → 检查 `/sign-in` 的 callbackURL 是否正确重建,以及 `oidc_login_prompt` cookie
- Client 端登录逻辑 → `packages/stage-ui/src/libs/auth.ts``packages/stage-ui/src/libs/auth-oidc.ts`
- Electron 认证回调处理 → `apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts`