news 2026/9/17 10:30:18

Civitai Monorepo:将 OAuth2/OIDC Provider 从主应用迁移至 apps/auth 的完整技术拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Civitai Monorepo:将 OAuth2/OIDC Provider 从主应用迁移至 apps/auth 的完整技术拆解

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/authauth.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/authmaybeCreateSessionSigner+/.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'); }

从该实现可以看到两个工程细节,正好回应了文档中最高风险的假设:

  1. 密钥来源是包级 env 读取(loadAuthEnv)而非某个应用的私有 schema——源码注释明确写道:"hub 和主应用必须从同一个 key 推导出相同的 hash,所以 salt 来自包 env 读取的NEXTAUTH_SECRET,而不是 app 专属 schema"。主应用从~/server/utils/key-generator再导出这两个函数,保持旧调用点不变。这把"密钥一致性"从部署约定升级为代码层面的共享依赖
  2. NEXTAUTH_SECRET缺失时快速失败(fail fast):注释解释,若 salt 为undefined,会计算出可预测的SHA512(key + "undefined"),且与主应用的哈希不匹配,从而无报错地破坏令牌校验——所以任何调用方在缺密钥时直接抛错。

文档中的原始警示(Phase 0 必须先验证的前提):"若generateSecretHashNEXTAUTH_SECRET为 key,hub 必须共享它。若哈希不一致,hub 签发的令牌会在主应用中静默校验失败。这是全项目风险最高的假设——先证明它。" 从当前仓库看,该 spike 的结论已落入共享包 civitai-auth 的实现中:单一generateSecretHash实现 + 缺失即抛错。

3. 框架差异:为什么这不是复制粘贴

主应用是React/Next + Prisma + tRPC + NextAuthapps/authSvelteKit 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.oauthClientprisma.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 UIReact + Mantine用 Svelte 重写(客户端 logo 用@civitai/brand
客户端/consent 管理tRPC routersPhase 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.tsoidc-nonce.tsaudit-log.tsrate-limit.tserrors.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.tsblock-guard.tsdevice-codes.tsfirst-party.tshttp.tsredirect-uri.tsredis-atomic.tsscope.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从共享包导入OauthClientOauthConsentApiKey表类型(及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.tserrors.tsaudit-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)、审计事件。
  • /authorizeevent.locals.user做会话门槛;未登录 → 重定向到/login?callbackUrl=<self>(hub 对其他路由已有此行为)。
  • id_tokenauthorization_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时显示)会牵入buzzLimitSchemasimpleBuzzLimitToBudgets以及 orchestrator 调用(bustBuzzLimitCachedeleteAuthSubject)。这些是 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。浏览器可正常跟随。
  • 机器端点tokenuserinforevokedevice*):不要 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 中。协议迁移并不要求它们。协议唯一需要的写操作——"记住"时 upsertOauthConsent——直接在 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输入):

  1. Buzz 消费限额(consent 时)——Phase 4 移植(前期增加 orchestrator 耦合),还是先不带上线再快速跟进?推荐:快速跟进,让首次切换尽量小。
  2. 管理 routers——确认暂留主应用(推荐),还是同批移植到 hub。
  3. OIDC 默认开启——hub 总是有签名密钥,所以id_token签发默认开启(主应用用可选密钥 gate)。确认这是否意图。
  4. Schema 来源——已解决:另一会话正在把生成的 Kysely 类型移入@civitai/db-schema,hub 消费之(见 Phase 1)。唯一开放子点:确认 OAuth 表(OauthClientOauthConsentApiKey)在生成切片中,而不只是已有三张表。
  5. 代理寿命——机器端点代理保留多久、何时强制客户端切到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. 小结:这套迁移的可复用模式

从这份规划及其在仓库中的最终落地,可以提炼出协议类服务迁移的三个可复用模式:

  1. 把数据库当作信任集成点:不新建令牌验证信任链,两个应用共享 Postgres 与同一个哈希函数(generateSecretHash放进共享包@civitai/auth),任何一方签发的令牌在另一方零改动可用。风险点(密钥一致性)用 fail fast + 共享实现而非部署约定来消除。
  2. GET 重定向、POST 代理:浏览器导航型端点(authorize)用 308 保留 method/body;机器端点(token/revoke/device*)绝不能靠重定向——跨源重定向会被客户端丢弃 body、被 Fetch 规范剥离Authorization头。薄代理 + 剥离 hop-by-hop 头 + 15s 超时 + 502/504 错误语义,是"零客户端改动"兼容层的完整配方。
  3. Discovery 文档是间接层:把jwks_uri与端点 URL 指向 hub,让新注册客户端自动路由到新位置,旧客户端由代理托底,遥测(审计日志中的代理命中)决定何时拆除旧路由。

【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 10:26:23

6G服务化RAN:从基站拆分到端到端重构的演进之路

简介&#xff1a;《2022年6G服务化RAN白皮书》由中国移动通信研究院发布&#xff0c;是一份面向通信研究者、网络架构师及高校通信专业师生的技术文献&#xff0c;系统回应了5G核心网已服务化但RAN仍以集成单体为主的发展痛点。白皮书提出基于云原生技术的端到端服务化RAN总体构…

作者头像 李华
网站建设 2026/9/17 10:26:21

phpstudy搭建MySQL开发环境:从建库建表到增删改查实战教程

1. 为什么要用phpstudy玩数据库先说说我自己的情况。这几年帮人搭课程设计、带新人入门&#xff0c;见过太多人卡在第一步&#xff1a;数据库装好了连不上&#xff0c;装到一半报错&#xff0c;配置改了以后服务起不来。很多刚接触Web开发的朋友&#xff0c;一上来就被MySQL原版…

作者头像 李华
网站建设 2026/9/17 10:23:25

守望先锋9.10热补丁后卡顿、渲染丢失与闪退排查指南

守望先锋9.10热补丁推送之后&#xff0c;我和固定车队里几个人的机器几乎在同一时间撞上了三类毛病&#xff1a;团战集火时帧数像被人从后面拽了一把&#xff0c;画面里英雄模型和场景贴图一块块消失、变成灰白色的空壳&#xff0c;最狠的是点进游戏到加载地图之间随机闪退回桌…

作者头像 李华