Civitai Monorepo:将 OAuth2/OIDC Provider 从主应用迁移至 apps/auth 的完整技术拆解
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
本文基于仓库中的迁移规划文档 oauth-provider-to-auth-app.md,详解 Civitai monorepo 如何把 OAuth2/OIDC Provider 从主 Next.js 应用整体搬入apps/auth(auth.civitai.com):包括迁移之所以"安全"的底层前提(共享 Postgres + 统一的ApiKey哈希校验)、SvelteKit 与 Next.js 的框架差异表、端点级迁移清单、Phase 0–7 的完整路线,以及 GET 重定向与 POST 反向代理两种兼容策略的源码实现。读完你可以掌握"同一 Postgres 作为信任集成点"的跨应用令牌互认方案,以及协议类服务迁移中"重定向 vs 代理"的取舍方法。
1. 目标:让 apps/auth 成为唯一身份权威
规划文档的状态为plan / proposal(2026-06-10),关联文档见 centralized-auth-app.md(§1c、§3——"oauth/* 是 auth app 的天然租户")、auth-verification-strategy.md 与 oauth-scoped-tokens.md。
目标一句话概括:让apps/auth(auth.civitai.com)成为单一身份权威,并包含它对第三方应用作为 OAuth2/OIDCProvider的角色。迁移前,该 Provider 完全驻留在主 Next.js 应用里(src/pages/login/oauth/*、src/pages/api/auth/oauth/*、src/server/oauth/*)。迁移策略是:
- 把协议核心 + 同意(consent)UI搬进 hub;
- 主应用的旧路由降级为重定向(用户页面)与薄代理(机器端点),让已注册的第三方客户端零改动继续工作。
2. 让这次迁移"安全"的关键事实
文档把迁移的安全性归结为两个事实,仓库源码可以逐一印证:
事实一:两个应用连同一个 Postgres,OAuth 令牌以哈希后的ApiKey行存储。主应用的 bearer-token 中间件对任何入站令牌先哈希、再到ApiKey表查找。只要 hub 用同一算法 + 同一密钥计算哈希(generateSecretHash),hub 签发的令牌就能在所有 spoke 上零改动通过校验。数据库就是集成点,没有发明新的信任路径。
事实二:hub 已经拥有 RS256 签名密钥(@civitai/auth的maybeCreateSessionSigner+/.well-known/jwks.jsonJWKS 端点)。OIDCid_token的签名在 hub 里比在主应用里更"顺理成章"——discovery 文档的jwks_uri本来就指向 hub。
对应到源码,令牌哈希的实现位于共享包 secret-hash.ts:
// SHA-512 hash of a public key salted with NEXTAUTH_SECRET. Stored in the DB. export function generateSecretHash(key: string): string { const secret = loadAuthEnv().NEXTAUTH_SECRET; if (!secret) { throw new Error('[@civitai/auth] NEXTAUTH_SECRET is required for generateSecretHash (token hashing)'); } return createHash('sha512').update(`${key}${secret}`).digest('hex'); }从该实现可以看到两个工程细节,正好回应了文档中最高风险的假设:
- 密钥来源是包级 env 读取(
loadAuthEnv)而非某个应用的私有 schema——源码注释明确写道:"hub 和主应用必须从同一个 key 推导出相同的 hash,所以 salt 来自包 env 读取的NEXTAUTH_SECRET,而不是 app 专属 schema"。主应用从~/server/utils/key-generator再导出这两个函数,保持旧调用点不变。这把"密钥一致性"从部署约定升级为代码层面的共享依赖。 NEXTAUTH_SECRET缺失时快速失败(fail fast):注释解释,若 salt 为undefined,会计算出可预测的SHA512(key + "undefined"),且与主应用的哈希不匹配,从而无报错地破坏令牌校验——所以任何调用方在缺密钥时直接抛错。
文档中的原始警示(Phase 0 必须先验证的前提):"若
generateSecretHash以NEXTAUTH_SECRET为 key,hub 必须共享它。若哈希不一致,hub 签发的令牌会在主应用中静默校验失败。这是全项目风险最高的假设——先证明它。" 从当前仓库看,该 spike 的结论已落入共享包 civitai-auth 的实现中:单一generateSecretHash实现 + 缺失即抛错。
3. 框架差异:为什么这不是复制粘贴
主应用是React/Next + Prisma + tRPC + NextAuth;apps/auth是SvelteKit 2 / Svelte 5 + Kysely + 自研 RS256 会话(无 NextAuth、无 Prisma、无 tRPC)。规划文档给出的逐项对照表如下:
| 关注点 | 主应用现状 | 在apps/auth中 |
|---|---|---|
| OAuth 协议核心 | @node-oauth/oauth2-server(框架无关) | 原样复用——在+server.ts中构造其Request/Response |
oauth/model.ts的 DB 访问 | Prisma(prisma.oauthClient、prisma.apiKey等) | 重写为 Kysely(真正的大头) |
| Redis(codes、device、nonce) | @civitai/redis打包 key | 原样复用——同包、同 key |
id_token签名 | @civitai/authsigner(可选,主应用默认关闭) | 复用 hub signer——此处已配置 |
| 登录用户(consent 门槛) | getServerAuthSession(NextAuth) | event.locals.user(已在hooks.server.ts填充) |
| Consent / device UI | React + Mantine | 用 Svelte 重写(客户端 logo 用@civitai/brand) |
| 客户端/consent 管理 | tRPC routers | Phase 1 留在主应用(见 §6 决策) |
4. 迁移清单:源 → 目标
协议端点→apps/auth/src/routes/...下以+server.ts落地:
| 主应用 | Hub 路由 |
|---|---|
api/auth/oauth/authorize.ts(GET 表单数据 + POST) | /api/auth/oauth/authorize/+server.ts |
api/auth/oauth/token.ts | /api/auth/oauth/token/+server.ts |
api/auth/oauth/userinfo.ts | /api/auth/oauth/userinfo/+server.ts |
api/auth/oauth/revoke.ts | /api/auth/oauth/revoke/+server.ts |
api/auth/oauth/device.ts | /api/auth/oauth/device/+server.ts |
api/auth/oauth/device-info.ts | /api/auth/oauth/device-info/+server.ts |
api/auth/oauth/device-approve.ts | /api/auth/oauth/device-approve/+server.ts |
api/auth/oauth/device-token.ts | /api/auth/oauth/device-token/+server.ts |
api/.well-known/openid-configuration.ts | /.well-known/openid-configuration/+server.ts |
用户页面→ Svelte:
| 主应用 | Hub 路由 |
|---|---|
src/pages/login/oauth/authorize.tsx | /login/oauth/authorize/+page.svelte(++page.server.ts) |
src/pages/login/oauth/device.tsx | /login/oauth/device/+page.svelte(++page.server.ts) |
服务端库→apps/auth/src/lib/server/oauth/:server.ts(复用)、model.ts(→Kysely)、token-helpers.ts(→Kysely)、constants.ts、oidc-nonce.ts、audit-log.ts、rate-limit.ts、errors.ts。
从当前仓库结构看,apps/auth侧已经落位:协议端点目录 apps/auth/src/routes/api/auth/oauth 下不仅有清单中的authorize/、token/、userinfo/、revoke/、device/、device-info/、device-token/,还有清单之外的device-deny/、introspect/(含 introspect/+server.ts 及其测试)、session/、legacy-exchange/,可见迁移后的端点面比规划时更完整。库目录 apps/auth/src/lib/server/oauth 同样在规划的 8 个文件基础上扩充了access.ts、block-guard.ts、device-codes.ts、first-party.ts、http.ts、redirect-uri.ts、redis-atomic.ts、scope.ts等模块,并配有 model.test.ts、token-helpers.test.ts、audit-log.test.ts 等并行测试文件——这正是文档风险一节要求的"针对 Prisma 版本做 parity 测试"的落点。
5. 分阶段路线图(Phase 0–7)
Phase 0 — 去风险(spike,约半天)
- 证明主应用与 hub 之间
generateSecretHash的密钥一致性(见 §2 警示)。操作:通过主应用现有流程铸造一个令牌,再用 hub 的 Kysely client 对着ApiKey做一次查找校验。整个项目以此为 gate。 - 确认
@node-oauth/oauth2-server能在 SvelteKit 的 Node adapter 下运行(它是纯 Node,预期没问题)。
Phase 1 — Schema 类型(消费共享包)
文档注明此阶段与另一会话并行:Kysely DB 类型正被移入@civitai/db-schema包(从prisma/schema.prisma生成)。本阶段消费该成果而非手写类型:
- 生成类型落地后,
apps/auth从共享包导入OauthClient、OauthConsent、ApiKey表类型(及ApiKeyType枚举),删掉手写的apps/auth/src/lib/server/db/schema.ts(它此前只声明了User/Account/VerificationToken)。hub 的 Kysely client 重新按生成的DB接口定型。 - 依赖协调:Phase 2 的
model.ts/token-helpers.tsKysely 重写依赖这三张表类型,需确保 OAuth 表被纳入生成输出(若另一个会话只切了现有三张表,初始切片里可能没有)。若生成晚于需求,退路是为 OAuth 表写临时本地类型 stub、切换时删除——但优先等共享类型,避免漂移。
Phase 2 — 移植 OAuth 核心库
- 直接拷贝:
constants.ts、errors.ts、audit-log.ts(console 日志,无改动)、rate-limit.ts(Redis——复用@civitai/redis)、oidc-nonce.ts(Redis——复用)。 - 重写
model.ts+token-helpers.ts的 Prisma 调用为 Kysely。这是大头,且必须保持行为一致:Redis 中 SHA256 哈希的 code、crypto.timingSafeEqual的密钥比较、scope 位掩码 ↔ 字符串数组的转换、强制UserRead基线、refresh→access 的级联吊销、1 小时 access / 30 天 refresh 的 TTL。 server.ts(@node-oauth/oauth2-server工厂)原样移植。
Phase 3 — 协议端点
- 逐个实现
+server.ts:从 SvelteKit 的request(method、headers、已解析 body、query)构造库的Request/Response。保留全部安全属性:PKCE S256 强制、state 强制、public client 按来源 CORS / confidential 通配、限流 key(authorize 按用户、token/revoke 按 IP)、审计事件。 /authorize读event.locals.user做会话门槛;未登录 → 重定向到/login?callbackUrl=<self>(hub 对其他路由已有此行为)。id_token:authorization_codegrant 且授予UserRead时,用 hub 现有 signer(maybeCreateSessionSigner)铸造;nonce 从 OIDC 上下文的 Redis key 取。hub总是配置了密钥(主应用里是可选的),所以 OIDC 在此默认开启——需确认这符合预期。openid-configuration:由 hub 提供权威副本,使用 hub URL +jwks_uri指向 hub JWKS。
Phase 4 — Consent + device 页面(Svelte)
authorize.tsx→ Svelte 重写:客户端名称/logo/描述、scope 列表(tokenScopeLabels)、"记住我的选择"、Authorize/Deny 按钮。用 SvelteKit form action 替换document.createElement('form')POST。- Buzz 消费限额 UI(当请求
AIServicesWrite时显示)会牵入buzzLimitSchema、simpleBuzzLimitToBudgets以及 orchestrator 调用(bustBuzzLimitCache、deleteAuthSubject)。这些是 HTTP/orchestrator 调用、可移植,但扩大了面。选项:Phase 4 先不带 buzz-limit 控件上线(authorize 仍可用,限额走默认值),后续快速跟进。见 §6 决策 1。 device.tsx→ Svelte 重写(输入 code → 复核 → 批准),调用 hub 的 device 端点。
Phase 5 — 旧路由的重定向/代理(兼容层)
GET 与 POST 行为不同——不能一刀切重定向。
- 用户页面(
/login/oauth/authorize、/login/oauth/device):308/307重定向到auth.civitai.com/...,保留 query string。浏览器可正常跟随。 - 机器端点(
token、userinfo、revoke、device*):不要 302。第三方客户端硬编码了civitai.com/api/auth/oauth/*,且不可靠地会跨源带 body 跟随重定向 POST。改为每个旧路由做薄的服务端代理(fetch透传到 hub,保留 method/headers/body 并中继响应 + CORS)。这些代理保留到遥测显示没有客户端再访问为止。 - Discovery(主应用上的
/.well-known/openid-configuration):重定向(GET)到 hub,或者继续服务但改为 hub 端点 URL,让新客户端自行路由到auth.civitai.com。Discovery 是间接层——客户端一旦从它读到 hub URL,就不再触碰主应用。
该策略在当前仓库中的落地:主应用侧的 OAuth API 已收敛为单一 catch-all 路由 src/pages/api/auth/oauth/[...path].ts,文件头注释完整解释了"为何代理而非重定向",并实现了 Phase 5 的全部要点:
// Only browser-navigation endpoints redirect; every other oauth path is an API call and is proxied. const REDIRECT_ENDPOINTS = new Set(['authorize']); // ... // Browser flow → redirect (308 preserves method + body; the browser follows transparently). if (REDIRECT_ENDPOINTS.has(endpoint)) { res.redirect(308, target); return; } // Server-to-server → transparent reverse proxy.实现要点:
- Hub 地址来自
AUTH_JWT_ISSUER环境变量(去尾部斜杠),未配置时返回 500server_error; bodyParser: false+readRawBody以 Buffer 逐字节转发请求体,避免 Next 先解析/消费 body;- hop-by-hop 头剥离(RFC 7230 §6.1):请求侧剥离
host/connection/content-length/transfer-encoding等,content-length/encoding由 fetch(请求侧)与 Next(响应侧)重算;响应侧额外剥离content-encoding,因为 fetch 的arrayBuffer已解码 body,原编码头会过期; Authorization/Cookie完整保留——注释说明这是"代理优于重定向"的全部理由:跨源 308 时按 Fetch 规范敏感头会被剥离,会破坏client_secret_basic(token/revoke)与Bearer(userinfo)鉴权,导致invalid_client/401;Set-Cookie按数组中继(多值头不能逗号拼接);cf-connecting-ip/x-forwarded-for随头透传,保证 hub 限流器仍能看到真实客户端 IP;- 15 秒超时(
AbortController),超时返回 504、其他失败返回 502,均带error: 'server_error'的 OAuth 风格错误体; redirect: 'manual'防止代理自身跟随 hub 的重定向。
这比规划文档更进一步:文件注释明确记录了两类静默故障场景(部分 OAuth HTTP client 在 POST 上跟随后丢弃 body;跨源重定向剥离Authorization头),说明代理方案是在观察到"客户端 access token 过期被迫走 refresh 路径"的现场问题后确认的。
Phase 6 — 管理面(oauth-client/oauth-consentrouters)
这两个 tRPC router 驱动"注册/管理我的 OAuth 应用"(开发者)与"已连接应用"(用户)UI。它们是管理面而非协议,读写同一个共享 DB。
- Phase 1 立场:留在主应用的 tRPC 中。协议迁移并不要求它们。协议唯一需要的写操作——"记住"时 upsert
OauthConsent——直接在 hub 的/authorize处理器里用 Kysely 实现,与 router 解耦。 - 若/当管理 UI 本身搬迁时再移植到 hub,作为独立工作项跟踪。
Phase 7 — 切换
- 更新各 provider/app 控制台 + OAuth 客户端注册表,让新的 authorize/token URL 指向
auth.civitai.com。 - 弃用窗口期间保留主应用代理,观察
origin.rejected/ 代理命中的审计日志。 - 代理流量趋近于零后,从主应用删除
src/pages/login/oauth/*、src/pages/api/auth/oauth/*、src/server/oauth/*。
从当前仓库状态看,切换已经走到较后阶段:src/pages/login/oauth目录已不存在,src/pages/api/auth/oauth只剩代理文件,协议端点已全部在 hub 侧运行(见 §4 的目录证据)。
6. 需要团队拍板的决策
原文档标记了五项待决策(@ai:*标注处需@dev输入):
- Buzz 消费限额(consent 时)——Phase 4 移植(前期增加 orchestrator 耦合),还是先不带上线再快速跟进?推荐:快速跟进,让首次切换尽量小。
- 管理 routers——确认暂留主应用(推荐),还是同批移植到 hub。
- OIDC 默认开启——hub 总是有签名密钥,所以
id_token签发默认开启(主应用用可选密钥 gate)。确认这是否意图。 - Schema 来源——已解决:另一会话正在把生成的 Kysely 类型移入
@civitai/db-schema,hub 消费之(见 Phase 1)。唯一开放子点:确认 OAuth 表(OauthClient、OauthConsent、ApiKey)在生成切片中,而不只是已有三张表。 - 代理寿命——机器端点代理保留多久、何时强制客户端切到
auth.civitai.com?取决于有多少第三方客户端硬编码了旧 URL。
7. 风险清单
- 令牌哈希一致性(Phase 0)——成败假设,已 gate。落地手段是共享 secret-hash.ts 的单一实现(SHA-512 +
NEXTAUTH_SECRETsalt、缺失即抛错),并有对应测试 secret-hash.test.ts。 - 切换时的跨源 POST——用"机器端点走代理而非重定向"缓解,实现见 [...path].ts。
model.ts的 Kysely 重写——机械但安全敏感(timing-safe 比较、scope 位掩码、级联吊销)。需要仔细 review + 针对 Prisma 版本的 parity 测试;hub 侧 model.test.ts 与 token-helpers.test.ts 即为该要求的落点。- Scope/
TokenScope常量 +tokenScopeLabels必须在 hub 与主应用之间共享而非分叉——抽到共享包或@civitai/auth,不要复制。hub 侧的 scope.ts 与 scope.test.ts 对应该关注点。
8. 小结:这套迁移的可复用模式
从这份规划及其在仓库中的最终落地,可以提炼出协议类服务迁移的三个可复用模式:
- 把数据库当作信任集成点:不新建令牌验证信任链,两个应用共享 Postgres 与同一个哈希函数(
generateSecretHash放进共享包@civitai/auth),任何一方签发的令牌在另一方零改动可用。风险点(密钥一致性)用 fail fast + 共享实现而非部署约定来消除。 - GET 重定向、POST 代理:浏览器导航型端点(
authorize)用 308 保留 method/body;机器端点(token/revoke/device*)绝不能靠重定向——跨源重定向会被客户端丢弃 body、被 Fetch 规范剥离Authorization头。薄代理 + 剥离 hop-by-hop 头 + 15s 超时 + 502/504 错误语义,是"零客户端改动"兼容层的完整配方。 - Discovery 文档是间接层:把
jwks_uri与端点 URL 指向 hub,让新注册客户端自动路由到新位置,旧客户端由代理托底,遥测(审计日志中的代理命中)决定何时拆除旧路由。
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考