架构与部署全解析:Better Auth、OIDC 与资源 API 私网协同)
Project AIRI 独立认证服务AIRI Auth Server架构与部署全解析Better Auth、OIDC 与资源 API 私网协同【免费下载链接】airi Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-samas altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi本项目托管后端将认证/身份体系拆分为独立的 Auth 服务server/apps/auth以 Better Auth 为核心承载会话、社交登录、Magic Link、密码与 OIDC 流程并通过私有网络与资源 API 协同完成账号生命周期管理。读完本文你将掌握 AIRI Auth Server 的模块边界与职责划分、本地与 Railway 两种运行方式、完整的环境变量清单以及其账号删除、封禁、OIDC 客户端托管等关键机制的源码级实现原理。服务定位与核心职责AIRI Auth Server 是一个独立的身份与认证应用standalone authentication and identity application它从业务 API 中剥离出来专门负责 Project AIRI 的认证面。根据 server/apps/auth/README.md其核心职责包括Better Auth 会话体系会话session、社交登录social login、Magic Link、邮箱密码登录以及 OIDC 流程的统一承载三类 HTTP 面/api/auth/*、/auth/*以及认证发现端点discovery endpoints如/.well-known/oauth-authorization-server/api/auth与/api/auth/.well-known/openid-configurationAuth 自有的基础设施归 Auth 进程独占的 Redis 配置、事务邮件transactional email与认证遥测auth telemetry私网协同删除在删除业务数据之前通过部署私网调用资源 API完成跨服务的数据清理编排。Auth 只做身份不做业务产品 API、计费、模型路由、聊天、WebSocket 业务状态一律不属于它。这一边界在代码中也得到贯彻——server/apps/auth/src/server.ts 的注释明确写道Only authentication infrastructure is registered here; business services remain owned by the resource API process此处只注册认证基础设施业务服务仍归资源 API 进程所有。扁平代码布局与模块边界该服务刻意保持扁平The runtime is intentionally flat主要边界如下文件职责auth.tsBetter Auth 配置与身份生命周期钩子routes.ts完整公开 Auth HTTP 面与请求认证server.ts依赖组合、健康检查、进程生命周期resource-api.ts唯一的 Auth→资源 API 私网边界rate-limit.ts 与 otel.ts跨路由的运营策略限流与遥测email.ts 与 oidc-jwt-bearer.ts重量级外部集成模块db.ts、env.ts、error.ts、origin.ts贴近边界的小型共享契约src/tests测试集合src/toolingauth-config.tsBetter Auth schema 生成接线独立隔离进程级入口是 main.ts启动时通过 instrumentation.ts 预加载 OpenTelemetry 插桩pg必须保持静态导入以便插桩在应用模块求值之前完成补丁见 db.ts 的注释。Auth 与共享契约包proj-airi/auth-shared认证表结构与主体契约并不放在 Auth 应用内而是放在共享包 server/packages/auth-shared由资源 API 与独立 Auth 服务共同依赖。其 README 明确了使用边界可以放Better Auth 拥有的 PostgreSQL schema、服务端代码中交换的认证主体/会话形态session.ts 中的AuthSession、必须跨进程保持一致性的授权策略如封禁过期判断isUserBannedNow不可以放Better Auth 运行时构造或 HTTP 路由、服务环境变量解析、数据库连接池、Redis、邮件、遥测以及任何对server/apps/api或server/apps/auth的导入。这种共享协议而不互相依赖的设计让两个进程可以在不 import 对方的前提下引用同一套契约。从 schema.ts 可以看到完整的表结构user、session、account、verification、jwks、oauth_client、oauth_refresh_token、oauth_access_token、oauth_consent并配齐了 Drizzle relations 与常用索引如session_userId_idx、account_account_id_provider_id_idx。共享数据库迁移的所有权一个关键约定是API 是共享数据库迁移的所有者migration ownerAuth 不运行迁移。Auth 启动时只做连通性探测SELECT 1从不与迁移竞争就绪检查也故意探测自有表SELECT 1 FROM user LIMIT 1从而保证在迁移所有者装好 auth schema 之前就绪状态保持为 false。这一行为在 server.ts 的/readyz实现与 app.test.ts 的测试断言中都能看到。本地运行单服务与全栈两种姿势仅启动 Auth 服务pnpm -F proj-airi/auth-server dev服务从本目录读取.env.local。package.json中的apply:env脚本使用dotenvx run -f .env.local --overload --ignoreMISSING_ENV_FILE加载环境变量dev 与 start 均通过tsx --import ./instrumentation.ts启动。两个关键环境变量PUBLIC_URL经由 Caddy 呈现的公开 issuer 源public issuer originRESOURCE_SERVER_URL用于内部调用的私有资源 API地址。全栈一键启动pnpm dev:backend该命令从仓库根目录运行使用 server/docker-compose.yaml 拉起 PostgreSQL、Redis、资源 API 与 Auth 的完整组合。注意只对外暴露本地 Caddy 网关http://localhost:6112API 与 Auth 停留在 Caddy 的私有网络内内部/internal/*边界没有任何应用令牌Caddy 在公网边缘直接拒绝该路径。环境变量全景来自 env.tsAuth 进程的环境变量由 valibot schemaAuthEnvSchema严格校验非法配置会导致进程exit(1)并输出错误日志变量默认值必填说明HOST0.0.0.0否监听地址PORT3000否监听端口≥1 的整数PUBLIC_URLhttp://localhost:3000否Better Auth 与 OIDC issuer 源RESOURCE_SERVER_URLhttp://localhost:3001否资源 API 私有地址RATE_LIMIT_TRUSTED_PROXY无否可选值仅railway显式声明代理信任边界AUTH_UI_URLhttps://accounts.airi.build/ui否独立认证 UI 地址ADDITIONAL_TRUSTED_ORIGINS空否逗号分隔的额外可信源会做 URL 解析与去重、归一化为 originDATABASE_URL—是PostgreSQL 连接串REDIS_URL—是Redis 连接串BETTER_AUTH_SECRET—是Better Auth 会话签名密钥AUTH_GOOGLE_CLIENT_ID/AUTH_GOOGLE_CLIENT_SECRET—是Google OAuth 凭据AUTH_GITHUB_CLIENT_ID/AUTH_GITHUB_CLIENT_SECRET—是GitHub OAuth 凭据AUTH_APPLE_CLIENT_ID空否Apple 服务 IDAUTH_APPLE_APP_BUNDLE_IDENTIFIERS空否逗号分隔的原生 App Bundle ID 白名单去重AUTH_APPLE_TEAM_ID空否Apple Team IDAUTH_APPLE_KEY_ID空否Apple 密钥 IDAUTH_APPLE_PRIVATE_KEY_PEM空否Apple 私钥 PEM自动将\n字面量还原为真实换行兼容部署面板的转义存储RESEND_API_KEY空否Resend 事务邮件密钥未配置时相关回调会报 503RESEND_FROM_EMAILnoreplyairi.moeru.ai否发件地址RESEND_FROM_NAMEProject AIRI否发件人名称DB_POOL_MAX20否连接池上限DB_POOL_IDLE_TIMEOUT_MS30000否空闲超时DB_POOL_CONNECTION_TIMEOUT_MS5000否连接超时DB_POOL_KEEPALIVE_INITIAL_DELAY_MS10000否keepalive 初始延迟OTEL_SERVICE_NAMEauth-server否OpenTelemetry 服务名OTEL_EXPORTER_OTLP_ENDPOINT无否设置后启用 OTLP 导出未设置则 OTel 整体禁用initAuthOtel返回 null其中DB_POOL_*通过optionalIntegerFromString校验为整数并设下限ADDITIONAL_TRUSTED_ORIGINS不仅校验 URL 合法性还会统一收敛为origin并去重这为后续 CORS 与回调校验提供了归一化的可信源集合。Railway 部署完整服务契约在 Railway 上将 Auth 部署为独立的 Auth Railway 服务Config File Path/server/apps/auth/railway.tomlRoot Directory必须保持在仓库根目录——因为 Dockerfile 会拷贝工作区清单与server/packages/auth-shared配置文件自身拥有 Dockerfile、启动命令、/readyz健康检查以及每个被拷贝构建输入的 watch 模式。railway.toml 内容如下[build] builder DOCKERFILE dockerfilePath /server/apps/auth/Dockerfile watchPatterns [ server/apps/auth/**, server/packages/auth-shared/**, package.json, pnpm-lock.yaml, pnpm-workspace.yaml, tsconfig.json, patches/** ] [deploy] startCommand pnpm -F proj-airi/auth-server start healthcheckPath /readyz healthcheckTimeout 100对应的 Dockerfile 基于node:24-alpine先corepack enable拷贝锁文件与patches/后执行pnpm install --frozen-lockfile --ignore-scripts --filter proj-airi/auth-server...构建后以非 root 用户airi运行EXPOSE 3000。两个关键变量的一致性约束将PUBLIC_URL设置为该服务的规范公开 issuer URL让资源 API 的AUTH_SERVER_URL与此值完全一致完全相同不能只是同源RESOURCE_SERVER_URL取自资源 API 的 Railway私有域名不要从 Auth 运行共享数据库迁移。server/README.md 的 Railway 部署章节给出了完整的双向服务契约表消费者变量值来源用途资源 APIAUTH_SERVER_URLAuth 规范公开 issuer URLJWT issuer、audience 与公开 JWKS 身份资源 APIAUTH_SERVER_INTERNAL_URLAuth 的 Railway 私有域名私有 JWKS 拉取不改变 issuer 校验AuthPUBLIC_URLAuth 规范公开 issuer URLBetter Auth 与 OIDC issuer URL必须等于 API 的AUTH_SERVER_URLAuthRESOURCE_SERVER_URLAPI 的 Railway 私有域名删除用户业务数据前的私有调用此外还约定数据库、Redis、可观测性变量用 Railway 引用变量共享而非复制RATE_LIMIT_TRUSTED_PROXYrailway仅对直接接收 Railway 代理流量的服务设置/internal/*保持私有公开路由不得暴露 API 的内部 Auth 路由。部署后 Railway 必须收到/readyz的200——仅部署成功不足以证明服务能触达依赖这正是/readyz同时探测user表与 Redis ping 的原因。源码级纵深createAuth 的完整装配createAuth 是认证能力的总装配点值得逐项拆解插件栈plugins: [ bearer(), jwt(), banGuard(), oidcJwtBearer(env), steam(), magicLink({ ... }), oauthProvider({ ... }), ]bearer()jwt()Better Auth 内置的 Bearer token 与会话 JWT 支持banGuard()plugins/ban-guard.ts在 Better Auth 创建会话的session.create.before钩子中读取user.banned/banExpires通过共享的isUserBannedNow判断被封禁用户抛FORBIDDEN BANNED_USER。它只施加持久化封禁状态不清除过期封禁并发管理请求可能在会话钩子读取后续封封禁字段banned、banReason、banExpires由私有管理后端拥有客户端不可写入input: falseoidcJwtBearer(env)弥合架构错配的关键桥接——Better Auth 官方的 OIDC 故事假设 IdP 与资源服务器是不同进程/信任域而本项目把两者放在一个进程。该插件识别 JWT 形态的 Bearer token三段 base64url用本地 JWKS 做 RS256 校验然后铸造一个 5 分钟 TTL 的桥接会话行并注入better-auth.session_tokencookie让sessionMiddleware及所有下游/api/auth/*端点都能接受 OIDC 签发的 JWT access token详见 oidc-jwt-bearer.tssteam()Steam 网页登录是 OpenID 2.0 而非 OAuth2/OIDC无法作为socialProviders条目因此实现为独立插件新增POST /sign-in/steam、POST /link/steam、GET /steam/callback端点。Steam 不提供邮箱新注册用户使用占位邮箱steamid64steam.placeholder.local且emailVerified: true占位邮箱收不到信验证无意义且会永久阻塞登录回调通过 OpenID dumb modeopenid.modecheck_authentication回传 Steam 验证而非自验 RSA 签名省去关联/会话状态管理代价是每次登录多一次 HTTP 往返详见 plugins/steam.tsmagicLink()sendMagicLink回调委托EmailService邮件服务未配置时通过requireEmailService抛出 503让配置错误响亮而非沉默oauthProvider()OIDC 授权服务器核心。配置loginPage: /auth/sign-in、consentPage: /oauth/authorize、作用域openid profile email offline_access、validAudiences: [env.PUBLIC_URL]、accessTokenExpiresIn: 3600。代码注释特别强调不要开启cachedTrustedClients——因为运行时会对受信客户端动态修改redirectUris缓存会导致进程内残留过期白名单并引发invalid_redirect直到重启。安全策略细节禁用内置 token 路由disabledPaths: [/token]公开 OIDC token 端点由 oauthProvider 在/oauth2/token独占真实 IP 提取advanced.ipAddress.ipAddressHeaders: [x-real-ip]——Caddy 会从 Cloudflare 客户端地址重建该头默认的X-Forwarded-For含代理链会把无关客户端折叠进同一限流桶Capacitor 移动端适配account.skipStateCookieCheck: true。Capacitor 的 OAuth 用系统浏览器与 WebView 分离的 cookie jar签名 state cookie 必然缺失否则会报state_security_mismatch同时accountLinking.allowDifferentEmails: true满足登录用户可绑定与 AIRI 账号邮箱不同的 OAuth 身份的产品需求会话落库session.storeSessionInDatabase: true——oauthProvider 的oauth_access_token表对 session 表有外键不落库则签发 token 时外键 INSERT 失败刻意关闭cookieCache若开启/oauth2/end-session删除 DB 会话行但不清 cookie签名sessionDatacookie 会持续呈现有效会话下一次/oauth2/authorize把授权码绑定到已删除的 session.id/oauth2/token便报invalid_request: session no longer exists导致登出后整个 TTL 窗口内无法登录。作为补偿after钩子对/oauth2/end-session镜像执行deleteSessionCookie让 RP-Initiated Logout 彻底失效客户端可见会话。邮件驱动的生命周期emailAndPassword启用并要求邮箱验证requireEmailVerification: trueGoogle/GitHub 社交登录因 OAuth 天然发放已验证账号而绕过。邮箱验证在注册时自动发送sendOnSignUp: true并开启autoSignInAfterVerification——用户点击验证链接即建立会话 cookie原标签页通过轮询检测新会话并恢复 OIDC 交接。user.changeEmail的确认邮件发送到当前邮箱而非新邮箱发到newEmail会让只控制新邮箱的攻击者确认一次接管。密码重置成功后钩子会强制emailVerified: true点击重置链接本身就是拥有邮箱的证明。两步式账号删除删除账号是典型的跨服务编排POST /api/auth/delete-user触发sendDeleteAccountVerification点击邮件链接命中GET /api/auth/delete-user/callbackBetter Auth 在校验 token 后于硬删除 user 行之前调用beforeDeleteasync beforeDelete(user) { await socialAuthorization.revokeForUser(user.id) await requireResourceApi(resourceApi).softDeleteUserData({ userId: user.id, reason: user-requested, }) }即先吊销外部授权OAuth 凭据再经私网调用资源 API 软删除业务数据。两步操作均幂等局部失败可重试若在beforeDelete抛错用户行与验证 token 保持完好同一回调可恢复尝试。遥测与数据库钩子hooks.before/after通过 OpenTelemetry 计数认证尝试与失败带auth.method属性并在 OAuth 回调出错时重定向回 referer 而非返回 API JSON。databaseHooksuser.create.after计数注册并尽力上报user_signed_up事件到资源 APIuser.update.after当用户被置为banned: true时删除其全部oauth_refresh_token/oauth_access_token——oauthProvider 的刷新授权不检查banned被封禁用户理论上可凭有效 refresh token 铸造新 access token虽然资源路径会被isUserBannedNow拒绝这里从源头切断凭据session.create.after更新lastSeenAt与登录计数尽力而为失败仅告警不阻断。Auth 到资源 API 的私网边界resource-api.ts 定义了唯一的跨服务 HTTP 边界两个方法softDeleteUserData({ userId, reason })POST /internal/auth/user-deletion非 2xx 抛 502BAD_GATEWAYtrackAuthEvent(...)POST /internal/auth/events失败仅告警analytics must never make signup or login unavailable。调用目标基于RESOURCE_SERVER_URL私网域名构造公开边缘不得暴露/internal/*。beforeDelete缺少resourceApi时抛 503杜绝静默空操作。HTTP 面与跨路由运营策略routes.ts 挂载在根级因为路由横跨/auth/*、/api/auth/*、/.well-known/*多个前缀/auth/*重定向到独立认证 UIAUTH_UI_URL生产为accounts.airi.build/ui保留路径与查询参数并附带api_server_url参数供 UI 定位 API 源/api/auth/*全量限流max: 20 / windowSec: 60使用 hono-rate-limiter默认内存存储单实例。key 优先级已认证 userId → 受信代理客户端地址仅RATE_LIMIT_TRUSTED_PROXYrailway时读x-real-ip且必须是合法 IP→ 连接信息 →anonymous标准头采用draft-6格式以保证RateLimit-*头兼容性被拦截请求计数airi.rate_limit.blocked指标带route、key_type、limit标签管理路由一律 404/api/auth/admin及其子路径全部返回 404测试 app.test.ts 明确断言管理端点不可达、认证处理不被触发动态受信回调 URI/api/auth/oauth2/authorize前先执行ensureDynamicFirstPartyRedirectUri把基于PUBLIC_URL推导出的合法/auth/callbackweb或同源/api/auth/oidc/electron-callbackElectron动态写入oauth_client.redirect_uris从而支持分支预览部署userinfo 的封禁复核/api/auth/oauth2/userinfo绕过 sessionMiddleware 且只验签名被封禁用户的仍有效 access token≤1h TTL可能读到自身 claim故该端点单独解析 subject忽略封禁再判断封禁则 403无效/过期 token 仍回落到 Better Auth 自身的 401Electron 回调中继/api/auth/oidc/electron-callback返回一个 HTML 页通过 JSfetch()把授权码转发给 Electron 环回服务器避免浏览器直接导航到http://127.0.0.1:{port}发现端点/.well-known/oauth-authorization-server/api/authOAuth 2.1 授权服务器元数据issuer 带路径时须放根级 well-known与/api/auth/.well-known/openid-configuration均带Cache-Control: public, max-age15, stale-while-revalidate15, stale-if-error86400邮箱标识检查POST /api/auth/check-email返回{ exists, hasPassword }支撑统一登录/注册 UI 决策有凭据账号则渲染密码框纯社交账号则引导社交登录。这是账号枚举披露作者注释明确这是对标 Google/Linear/Notion 的标准权衡并依赖限流抑制枚举健康检查/livez恒返回{ status: live }/readyz并行SELECT 1 FROM user LIMIT 1与redis.ping全过返回 200否则 503会话解析resolveAuthRequest优先走 Better Auth 会话回落 Bearer JWT 的 JWKS 校验issuer 为PUBLIC_URL/api/auth、audience 为PUBLIC_URL最后统一经isUserBannedNow过滤/get-session还会为无头像用户生成 Gravatar identicon URL。受信源与 OIDC 客户端托管origin.ts 维护三层受信策略精确源白名单TRUSTED_EXACT_ORIGINS生产 Webhttps://airi.moeru.ai、Capacitor iOScapacitor://localhost、Android 深链ai.moeru.airi-pocket://links、认证 UIhttps://accounts.airi.build、管理 UIhttps://admin.airi.build等正则模式http://localhost(:port)?、http://127.0.0.1(:port)?含 https、Cloudflare Workers 子域*.moeru-ai.workers.dev——注意私有 LAN/CGNAT 主机如 cap-vite 的https://10.x:5273不匹配任何正则必须显式通过ADDITIONAL_TRUSTED_ORIGINS加入Better AuthtrustedOrigins通配http://localhost:*与http://127.0.0.1:*永远可信环回地址公网不可达能解析到 localhost 的源必然与用户同机供回调 URL 校验使用原生深链 scheme 不能照抄进回调校验Better Auth 按前缀匹配非 http(s) 源。受信 OIDC 客户端在 auth.ts 中以种子seed形式内建共三个第一方客户端全部为public client PKCEWebView/二进制无法安全保存 secretElectron 客户端 secret 只是混淆而非机密边界clientId名称类型redirect URIsairi-stage-webAIRI Stage Webweb默认三连生产 localhost:5173/4173动态并入PUBLIC_URL派生源airi-stage-electronAIRI Stage Desktopnative{PUBLIC_URL}/api/auth/oidc/electron-callbackairi-stage-pocketAIRI Stage Mobilenativecapacitor://localhost/auth/callback、ai.moeru.airi-pocket://links/auth/callback种子客户端在启动时由 seedTrustedClients 写入oauth_client表已存在则按当前配置更新如 public ↔ confidential 变更否则插入。机密客户端 secret 存储前会用SHA-256 → base64url(去填充)哈希以匹配 oauthProvider 内部默认的storeClientSecret: hashed模式——若用明文 raw INSERTtoken 交换校验时会失败。服务启动日志会逐个打印就绪客户端的clientId、名称与 redirectUris。测试保障src/tests下的 Vitest 测试覆盖了上述关键行为可作为行为契约阅读app.test.ts管理端点 404、根路径 issuer 标识、业务路由不泄漏、/readyz只探测认证所需基础设施auth.test.ts、ban-guard.test.ts封禁会话拦截resource-api.test.ts私网边界调用rate-limit.test.ts限流 key 与拦截routes-ui.test.ts、routes-userinfo.test.tsUI 重定向与 userinfo 封禁复核social-authorization.test.ts、steam.test.ts社交授权吊销与 Steam OpenIDenv.test.ts、origin.test.ts环境校验与受信源策略。使用边界小结最后回到 server/apps/auth/README.md 划定的不要用它做什么红线这也是整个架构最重要的约定不要承载产品 API、计费、模型路由、聊天或 WebSocket 业务状态不要从 Auth 导入server/apps/api的模块不要在正常进程启动时运行共享数据库迁移历史——Auth 表与主体契约在proj-airi/auth-sharedDrizzle 在 API 启动时读取共享迁移文件API 始终是迁移所有者。理解这三条边界就把握住了 AIRI 后端认证独立、业务独立、迁移唯一所有者的总体设计意图。若需要完整的服务间契约可继续阅读 server/README.md 的 Railway deployment 章节若想深入迁移生成链路可查看src/tooling/auth-config.ts对应脚本pnpm run auth:generate输出直写server/packages/auth-shared/src/schema.ts。【免费下载链接】airi Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-samas altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考