从 WorkOS 迁移到 Better Auth
从 WorkOS 迁移到 Better Auth 的分步指南。
本指南将带您了解如何将项目从 WorkOS 迁移到 Better Auth,涵盖如何迁移一个与 Next.js 应用集成的基础 WorkOS 设置及关键注意事项。
开始前
开始之前,让我们回顾一下哪些 WorkOS 认证功能在 Better Auth 中得到了完全或部分支持。如果您在 WorkOS 中使用的某个功能是通过插件提供的,您需要在下一步进行配置。
创建 Better Auth 实例
首先,在您的项目中设置 Better Auth。请按照安装指南开始使用。
数据库
Better Auth 支持多种数据库。请设置您偏好的数据库。本指南中,我们将使用 PostgreSQL 和默认数据库适配器。
import { betterAuth } from "better-auth";
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL,
}),
});邮箱 & 密码
按如下方式启用邮箱和密码认证。由于 WorkOS 默认会验证每个用户的邮箱,因此此设置与默认行为类似。如有需要,您可以进行调整。更多信息请参阅邮箱/密码认证文档。
import { betterAuth } from "better-auth";
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL,
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
minPasswordLength: 10,
sendResetPassword: async ({ user, url, token }, request) => {
// 实现您的邮件发送逻辑
},
},
emailVerification: {
sendVerificationEmail: async ({ user, url, token }, request) => {
// 实现您的邮件发送逻辑
},
},
});社交提供商(可选)
按如下方式设置您在 WorkOS 中使用的社交提供商。Better Auth 支持更广泛的提供商,因此如果需要,您可以添加更多。由于 WorkOS 会确保邮箱唯一,请在 Better Auth 中配置 account.accountLinking 以确保相同的行为。
import { betterAuth } from "better-auth";
import { Pool } from "pg";
export const auth = betterAuth({
// ... other options
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
// ... 其他提供商
},
account: {
accountLinking: {
enabled: true,
trustedProviders: ["email-password", "github"],
},
},
});其他字段
您很可能在 WorkOS 中使用了 metadata。为了保留这些 metadata 以及来自 WorkOS 的用户 id(例如 user_01KBT4BMFF7ASGRDD0WZ6W63FF),请如下扩展 user schema。Better Auth 提供了更灵活的方式来存储用户数据。更多信息请参阅扩展核心 schema 文档。
import { betterAuth } from "better-auth";
import { Pool } from "pg";
export const auth = betterAuth({
// ... other options
user: {
additionalFields: {
metadata: {
type: "json",
required: false,
defaultValue: null,
},
},
},
});插件
请参考上方将 WorkOS 功能映射到 Better Auth 的章节。如果您在 WorkOS 中使用的某个功能在 Better Auth 中作为插件提供,请将其添加到插件选项中。Better Auth 通过插件提供了更广泛的开箱即用功能。更多信息请参阅插件文档。
import { betterAuth } from "better-auth";
import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned";
import { Pool } from "pg";
export const auth = betterAuth({
// ... other options
plugins: [
haveIBeenPwned()
// ... 其他插件
],
});如果您依赖超出基础邮箱+密码和社交登录之外的高级 WorkOS 功能,请参考上方的功能映射来配置相应的插件。
生成 Schema
Better Auth 允许您控制自己的数据库,并且可以通过 CLI 轻松为您的 auth 实例生成合适的 schema。更多信息请参阅CLI 文档。
默认数据库适配器
运行 migrate 命令,在数据库中为您的 Better Auth 实例创建 schema。
npx auth migrate其他数据库适配器
如果您使用的是 Prisma 或 Drizzle 之类的数据库适配器,请使用 generate 命令为您的 ORM 创建 schema。之后,使用 Drizzle Kit 等外部工具运行迁移。
npx auth generate迁移脚本
创建迁移脚本
创建一个迁移脚本,将您的用户数据从 WorkOS 导入到数据库中。
import { auth } from "@/lib/auth"; // 您的 auth 实例路径
import { WorkOS } from "@workos-inc/node";
//==============================================================================
/*
限流配置
WorkOS 读取 API:每 10 秒 1,000 次请求
默认设置:使用限制的 80%,以避免边缘情况
参考:https://workos.com/docs/reference/rate-limits
*/
const TIME_WINDOW_MS = 10 * 1000; // 时间窗口(毫秒,10 秒)
const MAX_REQUESTS_PER_WINDOW = 800; // 每个时间窗口允许的最大 API 调用次数
const USERS_PER_REQUEST = 100; // 每次 API 调用获取多少用户
//==============================================================================
if (!process.env.WORKOS_API_KEY || !process.env.WORKOS_CLIENT_ID) {
throw new Error(
"缺少必需的环境变量 WORKOS_API_KEY 和/或 WORKOS_CLIENT_ID",
);
}
const workos = new WorkOS(process.env.WORKOS_API_KEY);
/**
* 创建一个速率限制器来跟踪和控制请求速率
*/
const createRateLimiter = (maxRequests: number, windowMs: number) => {
let requestTimestamps: number[] = [];
const waitIfNeeded = async (): Promise<void> => {
const now = Date.now();
// 移除当前窗口之外的时间戳
requestTimestamps = requestTimestamps.filter(
(timestamp) => now - timestamp < windowMs,
);
// 如果达到限制,则计算等待时间
if (requestTimestamps.length >= maxRequests) {
const oldestTimestamp = requestTimestamps[0]!;
const waitTime = windowMs - (now - oldestTimestamp) + 1000; // 1 秒缓冲
console.log(
`⏳ 节流中(已使用 ${requestTimestamps.length}/${maxRequests} 次调用)。等待 ${Math.ceil(waitTime / 1000)} 秒...`,
);
await new Promise((resolve) => setTimeout(resolve, waitTime));
// 等待后清理旧时间戳
const newNow = Date.now();
requestTimestamps = requestTimestamps.filter(
(timestamp) => newNow - timestamp < windowMs,
);
}
// 记录本次请求
requestTimestamps.push(Date.now());
};
const getStats = (): {
current: number;
max: number;
windowMinutes: number;
} => {
const now = Date.now();
requestTimestamps = requestTimestamps.filter(
(timestamp) => now - timestamp < windowMs,
);
return {
current: requestTimestamps.length,
max: maxRequests,
windowMinutes: windowMs / (60 * 1000),
};
};
return { waitIfNeeded, getStats };
};
/**
* 安全地将各种日期格式转换为 Date 对象。
* 如果转换失败则返回当前日期(适用于 createdAt/updatedAt)。
*/
const safeDateConversion = (date?: string | number | Date | null): Date => {
if (date == null) return new Date();
if (date instanceof Date) return new Date(date.getTime());
if (typeof date === "number") {
if (!Number.isFinite(date)) return new Date();
return new Date(date);
}
if (typeof date === "string") {
const trimmed = date.trim();
if (trimmed === "") return new Date();
const parsed = new Date(trimmed);
if (isNaN(parsed.getTime())) return new Date();
return parsed;
}
return new Date();
};
/**
* 安全地将 firstName 和 lastName 转换为完整姓名字符串。
* 如果两个名字都为空,则返回 "Username"。
*/
const safeNameConversion = (
firstName?: string | null,
lastName?: string | null,
): string => {
const trimmedFirstName = firstName?.trim();
const trimmedLastName = lastName?.trim();
if (trimmedFirstName && trimmedLastName) {
return `${trimmedFirstName} ${trimmedLastName}`;
}
if (trimmedFirstName) return trimmedFirstName;
if (trimmedLastName) return trimmedLastName;
return "Username";
};
async function migrateFromWorkOS() {
const ctx = await auth.$context;
const rateLimiter = createRateLimiter(
MAX_REQUESTS_PER_WINDOW,
TIME_WINDOW_MS,
);
let totalUsers = 0;
let migratedUsers = 0;
let skippedUsers = 0;
let failedUsers = 0;
let hasMoreUsers = true;
let after: string | undefined;
let batchCount = 0;
console.log("");
console.log("=".repeat(40));
console.log("🚀 开始迁移");
console.log("");
console.log(`Settings:`);
console.log(
` - Max API calls: ${MAX_REQUESTS_PER_WINDOW} per ${TIME_WINDOW_MS / 1000}s`,
);
console.log(` - Users per call: ${USERS_PER_REQUEST}`);
console.log("=".repeat(40));
console.log("");
while (hasMoreUsers) {
try {
await rateLimiter.waitIfNeeded();
const workosUserList = await workos.userManagement.listUsers({
limit: USERS_PER_REQUEST,
after,
});
batchCount++;
console.log(
`📦 批次 ${batchCount}:从 WorkOS 获取了 ${workosUserList.data.length} 位用户`,
);
after = workosUserList.listMetadata.after || undefined;
hasMoreUsers = !!after;
totalUsers += workosUserList.data.length;
for (const workosUser of workosUserList.data) {
try {
console.log(`\n正在处理用户:${workosUser.email}`);
// 按邮箱检查用户是否已存在
// WorkOS 通过邮箱验证流程确保所有用户邮箱唯一
const existingUser = await ctx.adapter.findOne<
typeof auth.$Infer.Session.user
>({
model: "user",
where: [
{
field: "email",
value: workosUser.email,
},
],
});
if (existingUser) {
console.log(
`🟡 用户已存在,跳过:${workosUser.email}`,
);
skippedUsers++;
continue;
}
// 创建用户
await ctx.adapter.create<typeof auth.$Infer.Session.user>({
model: "user",
data: {
email: workosUser.email,
emailVerified: workosUser.emailVerified,
image: workosUser.profilePictureUrl,
name: safeNameConversion(
workosUser.firstName,
workosUser.lastName,
),
createdAt: safeDateConversion(workosUser.createdAt),
updatedAt: safeDateConversion(workosUser.updatedAt),
metadata: {
workosId: workosUser.id,
...(workosUser.metadata || {}),
},
},
});
console.log(`🟢 已迁移用户 ${workosUser.email}`);
migratedUsers++;
} catch (error) {
console.error(
`🔴 迁移用户 ${workosUser.email} 失败\n`,
error,
);
failedUsers++;
}
}
console.log("");
} catch (error) {
console.error("🚨 获取批次时出错:", error);
throw error;
}
}
console.log("");
console.log("=".repeat(40));
console.log("📝 迁移摘要");
console.log(`处理的用户总数:${totalUsers}`);
console.log("");
console.log(`🔴 失败:${failedUsers}`);
console.log(`🟡 跳过:${skippedUsers}`);
console.log(`🟢 成功迁移:${migratedUsers}`);
console.log("=".repeat(40));
}
async function main() {
try {
await migrateFromWorkOS();
process.exit(0);
} catch (error) {
console.error("\n迁移失败:", error);
process.exit(1);
}
}
main();注意
- 从 WorkOS 获取用户数据时,您需要使用其 API,而该 API 受限流限制。示例脚本包含基础配置,请根据您的环境进行调整。
- 此迁移脚本涵盖了使用邮箱+密码和社交登录管理用户的常见情况。对于 SSO 或 CLI Auth 等在 Better Auth 中以插件提供的功能,请务必根据示例更新脚本。
运行迁移脚本
bun scripts/migration.ts # 或使用 node、ts-node 等🎉 现在您已经将用户数据迁移到数据库中了,接下来让我们看看如何更新应用逻辑。
创建客户端实例
这个客户端实例包含一组用于与 Better Auth 服务端实例交互的函数。更多信息请参阅客户端文档。
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
plugins: [
// 如有需要,添加需要客户端的插件
]
});创建 API 路由
在 WorkOS 中,auth API 是作为托管服务提供的。而在 Better Auth 中,auth API 现在直接位于您的应用中。
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth)登录/注册页面
在 WorkOS 中,您可能会像这样获取并使用 URL。
const signInUrl = await getSignInUrl();
const signUpUrl = await getSignUpUrl();在 Better Auth 中,不再通过 API 获取这些值,而是可以在您想要的路径上创建页面并直接使用它们。
保护资源
Proxy(中间件)并不适合进行缓慢的数据获取。虽然 Proxy 可用于基于权限重定向等乐观检查,但不应将其用作完整的会话管理或授权解决方案。- Next.js 文档
中间件认证
WorkOS 提供 Proxy(中间件)认证。Better Auth 不建议直接在中间件中保护资源,因此我们没有提供专门的辅助工具。
import { authkitMiddleware } from '@workos-inc/authkit-nextjs';
export default authkitMiddleware({
middlewareAuth: {
enabled: true,
unauthenticatedPaths: ['/'],
},
});
export const config = { matcher: ['/', '/account/:page*'] };在 Better Auth 中,为了方便而非资源保护,可以按如下方式使用 proxy(中间件)。这在 Next.js 15+ 的 Node.js 运行时中受支持。
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
export async function proxy(request: NextRequest) {
const session = await auth.api.getSession({
headers: await headers()
})
// 这是推荐的乐观重定向用户的方法
// 我们建议在每个页面/路由中处理认证检查
if(!session) {
return NextResponse.redirect(new URL("/sign-in", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard"], // 指定中间件适用的路由
};基于页面的认证
在 WorkOS 中,如果资源是在每个页面上进行保护的,您可以在 Better Auth 中按如下方式更新逻辑。
服务端
import { withAuth } from "@workos-inc/authkit-nextjs";
export default async function DashboardPage() {
const { user } = await withAuth({ ensureSignedIn: true });
return (
<div>
<p>Welcome {user.firstName && `, ${user.lastName}`}</p>
</div>
);
}客户端
"use client";
import { useAuth } from "@workos-inc/authkit-nextjs/components";
export default function HomePage() {
const { user, loading } = useAuth({ ensureSignedIn: true });
if (loading) {
return <div>Loading...</div>;
}
return (
<div>
<p>Welcome {user.firstName && `, ${user.lastName}`}</p>
</div>
);
}
如果像 ensureSignedIn 这样的选项在 WorkOS 中很方便,您可以在 Better Auth 中创建一个可复用的辅助函数,例如 ensureSession()。
注意事项
密码哈希如果您管理的用户使用邮箱 + 密码系统,则 WorkOS 目前不支持导出密码哈希。迁移后,用户需要在您的认证系统中重置密码。请务必在迁移前后充足时间内通知用户此变更。
数据同步WorkOS 作为托管服务,通过 API 或 Webhook 与您的服务器保持数据同步。Better Auth 则由您自行完全掌控认证系统,通过 API 自由管理数据。但如果之前依赖 Webhook 实现同步,则需要额外调整。
停机时间WorkOS 通过其 API 暴露数据,但有限制,如不支持导出密码哈希。基于这些限制,实现零停机迁移较为困难。请规划好迁移流程,预留足够缓冲时间,并告知用户预期影响。
活跃会话现有活跃会话不会被迁移。迁移后用户需重新登录,务必提前通知用户。
总结
恭喜!您已成功完成从 WorkOS 迁移至 Better Auth。Better Auth 提供了更高的灵活性和更多功能,务必深入探索文档以发挥其全部潜力。