Reactive Resume 隐式社交注册:用 Better Auth 原生行为统一登录页与注册页的社交认证
【免费下载链接】reactive-resumeA one-of-a-kind resume builder that keeps your privacy in mind. Completely secure, customizable, portable, open-source and free forever. Try it out today!项目地址: https://gitcode.com/GitHub_Trending/re/reactive-resume
本文基于 Reactive Resume 仓库中的实施计划 implicit-social-signup,讲解如何让 Google、GitHub、LinkedIn 社交登录在任意入口都自动为陌生身份创建账号(implicit signup),同时把全局开关FLAG_DISABLE_SIGNUPS保留为唯一的服务端硬性拦截。读完后你将掌握:Better Auth 中disableImplicitSignUp与disableSignUp两级开关的分工、如何用回归测试锁定社交提供方注册策略,以及如何删除客户端“注册意图”分支来收敛前端认证逻辑。
问题背景:社交登录行为取决于入口页面
在 Better Auth 中,社交提供方默认具备“隐式注册”(implicit signup)行为:当用某个社交身份认证、而本地不存在绑定该身份的用户时,框架会自动创建用户并登录;已绑定用户则直接登录。Reactive Resume 此前为 Google / GitHub / LinkedIn 三个内置提供方显式写入了disableImplicitSignUp: true,把这一默认行为关掉,并转而依赖客户端区分“从登录页发起”还是“从注册页发起”:注册页通过<SocialAuth requestSignUp />传递注册意图,登录页则不带该 prop。这导致社交认证的行为取决于用户从哪个页面点击进入,逻辑分散在前后端两处。
该计划的配套设计文档 implicit-social-signup-design 将目标行为概括为四点:
- 已绑定到现有用户的社交身份,直接登录该用户;
- 注册功能开启时,陌生社交身份自动创建用户并登录;
FLAG_DISABLE_SIGNUPS开启时,陌生社交身份被拒绝;- 行为与认证从登录页还是注册页发起无关。
计划总览:目标、架构与全局约束
计划文档对实施给出了明确的边界声明,这是后续所有改动的“合同”:
| 项目 | 内容 |
|---|---|
| Goal | 让每个内置社交登录自动为未知用户创建账号,同时保留全局注册限制 |
| Architecture | 通过移除 provider 级disableImplicitSignUp退出开关,改用 Better Auth 原生隐式注册行为;删除客户端requestSignUp区分,让所有页面走同一个社交登录调用;disableSignUp: env.FLAG_DISABLE_SIGNUPS仍是服务端强制执行的一票否决 |
| Tech Stack | TypeScript、Better Auth(计划写作时为 1.6.26,当前仓库 packages/auth/package.json 已固定为better-auth: 1.7.2)、React 19、Vitest、pnpm |
全局约束(Global Constraints):
- 不新增依赖、不引入新抽象;
- 行为只应用于 Google、GitHub、LinkedIn 三个内置提供方;
- 自定义 OAuth(custom provider)与 passkey 行为保持不变;
FLAG_DISABLE_SIGNUPS必须保持“服务端权威”,不能退化成前端提示。
涉及的文件共四个:
| 文件 | 角色 |
|---|---|
| packages/auth/src/config.test.ts | 特征化(characterize)内置社交提供方的注册策略 |
| packages/auth/src/config.ts | 拥有 Better Auth 提供方配置与全局注册限制 |
| apps/web/src/features/auth/components/social-auth.tsx | 发起社交登录,不再携带页面级注册意图 |
| apps/web/src/features/auth/pages/register.tsx | 以无注册意图 prop 的方式复用社交登录组件 |
Task 1 第一步:先写失败测试,锁定注册策略
计划采用 TDD:先写一个注定失败的配置测试,明确“什么样的配置才算正确”。测试直接断言auth.options.socialProviders上三个内置提供方的两个属性——disableImplicitSignUp必须不存在(即没有退出隐式注册),disableSignUp必须精确等于env.FLAG_DISABLE_SIGNUPS:
import { describe, expect, it } from "vitest"; import { env } from "@reactive-resume/env/server"; import { auth } from "./config"; describe("social provider signup policy", () => { it.each(["google", "github", "linkedin"] as const)( "allows implicit signup through %s while honoring the global signup restriction", (provider) => { const config = auth.options.socialProviders?.[provider]; expect(config?.disableImplicitSignUp).toBeUndefined(); expect(config?.disableSignUp).toBe(env.FLAG_DISABLE_SIGNUPS); }, ); });这个测试的作用是让任何“某个提供方重新退出隐式注册”或“disableSignUp与FLAG_DISABLE_SIGNUPS脱钩”的回归都立刻在 CI 中失败——它锁住的是服务端配置本身的策略,而不是依赖手工走一遍 OAuth 流程才能发现的集成层行为。
Task 1 第二步:运行测试并确认预期失败
pnpm --filter @reactive-resume/auth test -- src/config.test.ts预期结果为 FAIL:改动前 Google、GitHub、LinkedIn 三个提供方的disableImplicitSignUp均为true而非undefined,第一条断言会全部不通过。确认“失败是因为旧配置”而非环境错误,是整个 TDD 循环的锚点。
Task 1 第三步:启用原生隐式注册,删除客户端注册意图
这一步包含服务端与前端两侧改动,且两侧必须同步落地。
服务端:只删退出开关,保留全局开关
在 packages/auth/src/config.ts 中,仅删除 google、github、linkedin 三个提供方里的disableImplicitSignUp: true三行,每个提供方既有的这一行必须保留:
disableSignUp: env.FLAG_DISABLE_SIGNUPS,这正是计划 Architecture 一节的落点:disableImplicitSignUp控制“陌生身份能否首次注册”,disableSignUp控制“本实例是否全局禁止注册”。删掉前者让原生隐式注册生效,保留后者让私有化部署仍可通过环境变量一键关闭所有注册。
前端:收敛为单一社交登录调用
在 apps/web/src/features/auth/components/social-auth.tsx 中删除SocialAuthProps、SocialSignInOptions和getSocialSignInOptions,组件签名简化为:
export function SocialAuth() {加载分支改为:
{isLoading ? <SocialAuthSkeleton /> : <SocialAuthButtons providers={providers} />}从按钮 props 与函数参数中移除requestSignUp:
type SocialAuthButtonsProps = { providers: RouterOutput["auth"]["providers"]["list"]; }; function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {Google、GitHub、LinkedIn 的调用统一替换为 Better Auth 客户端的原生入参形态:
authClient.signIn.social({ provider: "google", callbackURL: "/dashboard" }); authClient.signIn.social({ provider: "github", callbackURL: "/dashboard" }); authClient.signIn.social({ provider: "linkedin", callbackURL: "/dashboard" });最后在 apps/web/src/features/auth/pages/register.tsx 中把<SocialAuth requestSignUp />改为<SocialAuth />;登录页LoginPage无需改动,它本就以<SocialAuth />渲染。至此登录页与注册页的社交入口在代码层面完全同构,“注册意图”这一前端概念被彻底消除。
Task 1 第四、五步:验证与静态检查
确认目标测试通过:
pnpm --filter @reactive-resume/auth test -- src/config.test.ts预期三个提供方全部 PASS。随后执行聚焦验证,确保改动没有破坏类型与代码规范:
pnpm --filter @reactive-resume/auth typecheck pnpm --filter web typecheck pnpm exec biome check packages/auth/src/config.test.ts packages/auth/src/config.ts apps/web/src/features/auth/components/social-auth.tsx apps/web/src/features/auth/pages/register.tsx预期所有命令成功退出、无诊断信息、无文件被自动改写。最后按计划提交:
git add packages/auth/src/config.test.ts packages/auth/src/config.ts apps/web/src/features/auth/components/social-auth.tsx apps/web/src/features/auth/pages/register.tsx git commit -m "fix(auth): allow implicit social signup"源码证据:当前仓库中的落地形态
该计划已在当前仓库中实施完成,以下源码状态可以作为文章各结论的逐条印证。
服务端配置:三个提供方只保留disableSignUp
packages/auth/src/config.ts 中socialProviders的现状与计划目标完全一致:
socialProviders: { google: { enabled: !!env.GOOGLE_CLIENT_ID && !!env.GOOGLE_CLIENT_SECRET, disableSignUp: env.FLAG_DISABLE_SIGNUPS, clientId: env.GOOGLE_CLIENT_ID ?? "", clientSecret: env.GOOGLE_CLIENT_SECRET ?? "", mapProfileToUser: createProfileMapper({ providerName: "Google", getName: (profile, context) => profile.name ?? context.emailLocalPart, getImage: (profile) => profile.picture, }), }, // github / linkedin 结构相同,均无 disableImplicitSignUp },三点值得注意:
enabled由凭据存在性决定。任一提供方只有在*_CLIENT_ID与*_CLIENT_SECRET环境变量齐备时才激活(如enabled: !!env.GOOGLE_CLIENT_ID && !!env.GOOGLE_CLIENT_SECRET),自托管实例配置了什么,登录页就显示什么;- 约束遵守情况:custom OAuth 提供方(config.ts#L118-L138)本就只设置
disableSignUp: env.FLAG_DISABLE_SIGNUPS而不退出隐式注册,passkey 走passkey()插件(config.ts#L292)而非社交提供方,两者均按全局约束保持行为不变; - 邮箱注册的同源开关:
emailAndPassword.disableSignUp取值为env.FLAG_DISABLE_SIGNUPS || env.FLAG_DISABLE_EMAIL_AUTH(config.ts#L197),即社交通道与邮箱通道共享同一个全局注册开关,FLAG_DISABLE_EMAIL_AUTH只在关闭邮箱认证这一条额外收紧。
回归测试:锁住策略的守门员
packages/auth/src/config.test.ts 在计划版本基础上略有演化——因为 Better Auth 1.7 允许() => config的惰性配置形态,测试增加了对静态对象形态的防御性断言,并将第一处断言改为expect(config).not.toHaveProperty("disableImplicitSignUp"):
describe("social provider signup policy", () => { it.each(["google", "github", "linkedin"] as const)( "allows implicit signup through %s while honoring the global signup restriction", (provider) => { // Better Auth 1.7 allows a lazy `() => config` form; ours are always static objects. const config = auth.options.socialProviders?.[provider]; if (typeof config === "function") throw new TypeError(`${provider} provider config should be a static object`); expect(config).not.toHaveProperty("disableImplicitSignUp"); expect(config?.disableSignUp).toBe(env.FLAG_DISABLE_SIGNUPS); }, ); });同一文件还附带了对session.freshAge: 0的断言,用于防止会话新鲜度门槛回归——该配置(config.ts#L246)解决了 Better Auth 默认一天新鲜度导致一周内登录的用户无法解绑社交账号的问题。
前端组件:一个无 prop 的SocialAuth
当前的 social-auth.tsx 已经完全收敛:
export function SocialAuth() { const { data: providers = {}, isLoading } = useQuery(orpc.auth.providers.list.queryOptions()); return ( <> {/* "or continue with" 分隔条 */} {isLoading ? <SocialAuthSkeleton /> : <SocialAuthButtons providers={providers} />} </> ); }从源码结构看,SocialAuth通过 oRPC 端点auth.providers.list拉取本实例启用的提供方映射(对应服务端 packages/api/src/features/auth/router.ts 暴露的GET /auth/providers,返回“提供方标识 → 显示名”的映射),按钮按"google" in providers等条件渲染,未启用的提供方保持hidden。每个按钮经runSignIn包装统一处理 loading toast 与错误提示,然后调用authClient.signIn.social({ provider, callbackURL: "/dashboard" })。authClient在 apps/web/src/libs/auth/client.ts 中由createAuthClient创建,插件列表(passkey、twoFactor、apiKey 等)与服务端plugins数组一一对应,保证客户端调用类型与服务端路由一致。
页面侧,register.tsx#L247 与登录页均以无 prop 的<SocialAuth />收尾——计划中“删除requestSignUp区分”的目标在代码层面已完全兑现,登录页与注册页不再存在行为差异。
FLAG_DISABLE_SIGNUPS:服务端权威的注册总闸
隐式注册把“能否注册”的判断完全交还给服务端后,FLAG_DISABLE_SIGNUPS的语义变得格外关键。它在仓库中的全链路如下:
- 定义与默认值:packages/env/src/server.ts#L79 中
FLAG_DISABLE_SIGNUPS: z.stringbool().default(false),默认允许注册,类型经 zod 的stringbool校验; - 自托管配置:.env.example#L84-L90 给出示例与说明——“This flag disables new signups, both on the web app and the server”,并提示
FLAG_DISABLE_EMAIL_AUTH关闭邮箱登录时用户仍可通过社交通道注册(除非FLAG_DISABLE_SIGNUPS同时为 true);docs/self-hosting/docker.mdx 在 Feature Flags 折叠区对其有一致描述,适合私有实例; - 服务端消费:社交提供方(三个内置 + custom)与邮箱通道统一读取该标志(见上一节代码);
- 公开只读 API:packages/api/src/features/flags/router.ts#L35-L41 通过无需鉴权的
/flags端点公开disableSignups等实例级标志,前端据此可以调整入口展示,而真正的拒绝仍发生在 Better Auth 的服务端回调链路中——即使前端隐藏入口,客户端仍可发起signIn.social,陌生身份会在服务端被拒绝; - E2E 环境:tests/e2e/README.md 的迁移、构建与测试命令中显式传入
FLAG_DISABLE_SIGNUPS=false,保证端到端用例始终处于“注册开启”的受控前提。
验证与复现
在仓库根目录下可逐条复现计划中的验证流程(前提:已pnpm install并配置好本地数据库等运行环境):
# 社交提供方注册策略回归测试 pnpm --filter @reactive-resume/auth test -- src/config.test.ts # 类型检查(auth 包与 web 应用) pnpm --filter @reactive-resume/auth typecheck pnpm --filter web typecheck # 代码规范检查(涉及改动的四个文件) pnpm exec biome check packages/auth/src/config.test.ts packages/auth/src/config.ts \ apps/web/src/features/auth/components/social-auth.tsx \ apps/web/src/features/auth/pages/register.tsx小结
这篇计划文档演示了一次小而完整的认证策略调整:服务端只删三行disableImplicitSignUp: true、保留disableSignUp: env.FLAG_DISABLE_SIGNUPS;前端删除requestSignUpprop 与意图构建函数,让登录页与注册页复用同一个无参数<SocialAuth />组件;一个针对auth.options.socialProviders的回归测试把“内置社交提供方允许隐式注册且服从全局限制”的策略固化进 CI。最终效果是社交认证行为不再依赖入口页面,而“能否注册”这一安全决策始终由服务端的FLAG_DISABLE_SIGNUPS单点决定——这对基于 Better Auth 构建的自托管应用,是一个值得参考的注册策略收敛范式。
【免费下载链接】reactive-resumeA one-of-a-kind resume builder that keeps your privacy in mind. Completely secure, customizable, portable, open-source and free forever. Try it out today!项目地址: https://gitcode.com/GitHub_Trending/re/reactive-resume
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考