从 Auth0 迁移到 Better Auth
从 Auth0 迁移到 Better Auth 的分步指南。
在本指南中,我们将介绍如何将项目从 Auth0 迁移到 Better Auth —— 包括支持适当哈希的邮箱/密码、社交/外部账户、双因素认证等。
此迁移将使所有活动会话失效。当启用 Organization 插件时,脚本还会尽力迁移 Auth0 组织、其成员以及每个成员的角色。邀请和已启用连接的配置不会被迁移,应在 Better Auth 中手动重新配置。
迁移前准备
在开始迁移流程之前,请先在项目中设置 Better Auth。请参照安装指南开始。
连接到数据库
你需要连接到数据库以迁移用户和账户。可以使用任意数据库,但本示例使用 PostgreSQL。
npm install pg然后可以使用以下代码连接到数据库。
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
})启用邮箱和密码(可选)
在你的认证配置中启用邮箱和密码,并自行实现发送验证邮件、重置密码邮件等逻辑。
import { betterAuth } from "better-auth";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
emailAndPassword: {
enabled: true,
},
emailVerification: {
sendVerificationEmail: async({ user, url })=>{
// 在这里实现发送邮箱验证的逻辑
}
},
})请参考 邮箱和密码 获取更多配置选项。
设置社交登录提供者(可选)
在你的认证配置中添加已在 Auth0 项目中启用的社交提供者。
import { betterAuth } from "better-auth";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
emailAndPassword: {
enabled: true,
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}
}
})添加插件(可选)
可以根据需要向认证配置中添加以下插件。
管理 插件可用于管理用户、用户模拟、应用级角色和权限。
双因素认证 插件可用于为应用添加双因素认证。
用户名 插件可用于为应用添加用户名认证。
import { Pool } from "pg";
import { betterAuth } from "better-auth";
import { admin, twoFactor, username } from "better-auth/plugins";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
emailAndPassword: {
enabled: true,
password: {
verify: (data) => {
// 用于验证密码时的边缘情况处理
}
}
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}
},
plugins: [admin(), twoFactor(), username()],
})创建迁移脚本
在 scripts 文件夹中创建一个名为 migrate-auth0.ts 的新文件,并添加以下代码:
你可以使用 Auth0 的批量用户导出功能,而不是使用管理 API,直接将导出的 JSON 数据传递给 auth0Users 数组。这在需要迁移密码哈希和完整用户数据时尤其有用,而这些数据无法通过管理 API 获取。
重要说明:
- 密码哈希导出仅适用于 Auth0 企业用户
- 免费计划用户无法导出密码哈希,需要申请支持工单
- 关于批量用户导出的详细信息,请参见 Auth0 批量用户导出文档
- 关于密码哈希导出的详细信息,请参见 导出密码哈希
示例:
// 将此处替换为导出的用户 JSON 数据
const auth0Users = [
{
"email": "[email protected]",
"email_verified": false,
"name": "Hello world",
// 注意:password_hash 仅适用于企业用户
"password_hash": "$2b$10$w4kfaZVjrcQ6ZOMiG.M8JeNvnVQkPKZV03pbDUHbxy9Ug0h/McDXi",
// ... 其他用户数据
}
];import { ManagementClient } from 'auth0';
import { generateRandomString, symmetricEncrypt } from "better-auth/crypto";
import { auth } from '@/lib/auth';
const auth0Client = new ManagementClient({
domain: process.env.AUTH0_DOMAIN!,
clientId: process.env.AUTH0_CLIENT_ID!,
clientSecret: process.env.AUTH0_SECRET!,
});
function getAccountProviderId(identity: { provider: string }) {
return identity.provider === "auth0"
? "credential"
: (identity.provider.split("-")[0] ?? identity.provider);
}
function safeDateConversion(timestamp?: string | number): Date {
if (!timestamp) return new Date();
const numericTimestamp = typeof timestamp === 'string' ? Date.parse(timestamp) : timestamp;
const milliseconds = numericTimestamp < 1000000000000 ? numericTimestamp * 1000 : numericTimestamp;
const date = new Date(milliseconds);
if (isNaN(date.getTime())) {
console.warn(`无效的时间戳:${timestamp},回退到当前日期`);
return new Date();
}
// 检查是否为不合理的日期(2000 年之前或 2100 年之后)
const year = date.getFullYear();
if (year < 2000 || year > 2100) {
console.warn(`可疑的年份:${year},回退到当前日期`);
return new Date();
}
return date;
}
// 生成 2FA 备用验证码的辅助函数
async function generateBackupCodes(secret: string) {
const key = secret;
const backupCodes = Array.from({ length: 10 })
.fill(null)
.map(() => generateRandomString(10, "a-z", "0-9", "A-Z"))
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`);
const encCodes = await symmetricEncrypt({
data: JSON.stringify(backupCodes),
key: key,
});
return encCodes;
}
function mapAuth0RoleToBetterAuthRole(auth0Roles: string[]) {
if (typeof auth0Roles === 'string') return auth0Roles;
if (Array.isArray(auth0Roles)) return auth0Roles.join(',');
}
// 将 Auth0 中的密码迁移到 Better Auth 的辅助函数,适用于自定义哈希和算法
async function migratePassword(auth0User: any) {
if (auth0User.password_hash) {
if (auth0User.password_hash.startsWith('$2a$') || auth0User.password_hash.startsWith('$2b$')) {
return auth0User.password_hash;
}
}
if (auth0User.custom_password_hash) {
const customHash = auth0User.custom_password_hash;
if (customHash.algorithm === 'bcrypt') {
const hash = customHash.hash.value;
if (hash.startsWith('$2a$') || hash.startsWith('$2b$')) {
return hash;
}
}
return JSON.stringify({
algorithm: customHash.algorithm,
hash: {
value: customHash.hash.value,
encoding: customHash.hash.encoding || 'utf8',
...(customHash.hash.digest && { digest: customHash.hash.digest }),
...(customHash.hash.key && {
key: {
value: customHash.hash.key.value,
encoding: customHash.hash.key.encoding || 'utf8'
}
})
},
...(customHash.salt && {
salt: {
value: customHash.salt.value,
encoding: customHash.salt.encoding || 'utf8',
position: customHash.salt.position || 'prefix'
}
}),
...(customHash.password && {
password: {
encoding: customHash.password.encoding || 'utf8'
}
}),
...(customHash.algorithm === 'scrypt' && {
keylen: customHash.keylen,
cost: customHash.cost || 16384,
blockSize: customHash.blockSize || 8,
parallelization: customHash.parallelization || 1
})
});
}
return null;
}
async function migrateMFAFactors(auth0User: any, userId: string | undefined, ctx: any) {
if (!userId || !auth0User.mfa_factors || !Array.isArray(auth0User.mfa_factors)) {
return;
}
for (const factor of auth0User.mfa_factors) {
try {
if (factor.totp && factor.totp.secret) {
await ctx.adapter.create({
model: "twoFactor",
data: {
userId: userId,
secret: factor.totp.secret,
backupCodes: await generateBackupCodes(factor.totp.secret)
}
});
}
} catch (error) {
console.error(`为用户 ${userId} 迁移 MFA 因子失败:`, error);
}
}
}
async function migrateOAuthAccounts(auth0User: any, userId: string | undefined, ctx: any) {
if (!userId || !auth0User.identities || !Array.isArray(auth0User.identities)) {
return;
}
for (const identity of auth0User.identities) {
try {
const providerId = getAccountProviderId(identity);
const accountId = providerId === "credential"
? userId
: identity.user_id;
await ctx.adapter.create({
model: "account",
data: {
id: `${auth0User.user_id}|${identity.provider}|${identity.user_id}`,
userId: userId,
password: await migratePassword(auth0User),
providerId: providerId || identity.provider,
accountId,
accessToken: identity.access_token,
tokenType: identity.token_type,
refreshToken: identity.refresh_token,
accessTokenExpiresAt: identity.expires_in ? new Date(Date.now() + identity.expires_in * 1000) : undefined,
// 如果你是企业用户,可以获取刷新令牌或全部 token set - auth0Client.users.federatedConnectionsTokensets.list(auth0User.user_id)
refreshTokenExpiresAt: identity.refresh_token_expires_in ? new Date(Date.now() + identity.refresh_token_expires_in * 1000) : undefined,
scope: identity.scope,
idToken: identity.id_token,
createdAt: safeDateConversion(auth0User.created_at),
updatedAt: safeDateConversion(auth0User.updated_at)
},
forceAllowId: true
}).catch((error: Error) => {
console.error(`为用户 ${userId} 使用提供者 ${providerId} 创建 OAuth 账户失败:`, error);
return ctx.adapter.create({
// 如果第一次尝试失败,则尝试在不使用可选字段的情况下创建
model: "account",
data: {
id: `${auth0User.user_id}|${identity.provider}|${identity.user_id}`,
userId: userId,
password: await migratePassword(auth0User),
providerId: providerId,
accountId,
accessToken: identity.access_token,
tokenType: identity.token_type,
refreshToken: identity.refresh_token,
accessTokenExpiresAt: identity.expires_in ? new Date(Date.now() + identity.expires_in * 1000) : undefined,
refreshTokenExpiresAt: identity.refresh_token_expires_in ? new Date(Date.now() + identity.refresh_token_expires_in * 1000) : undefined,
scope: identity.scope,
idToken: identity.id_token,
createdAt: safeDateConversion(auth0User.created_at),
updatedAt: safeDateConversion(auth0User.updated_at)
},
forceAllowId: true
});
});
console.log(`成功为用户 ${userId} 迁移了提供者 ${providerId} 的 OAuth 账户`);
} catch (error) {
console.error(`为用户 ${userId} 迁移 OAuth 账户失败:`, error);
}
}
}
async function migrateOrganizations(ctx: any) {
try {
const organizations: any[] = [];
let orgPage = await auth0Client.organizations.list({ take: 50 });
organizations.push(...orgPage.data);
while (orgPage.hasNextPage()) {
orgPage = await orgPage.getNextPage();
organizations.push(...orgPage.data);
}
for (const org of organizations) {
try {
await ctx.adapter.create({
model: "organization",
data: {
id: org.id,
name: org.display_name || org.id,
slug: (org.display_name || org.id).toLowerCase().replace(/[^a-z0-9]/g, '-'),
logo: org.branding?.logo_url,
metadata: JSON.stringify(org.metadata || {}),
createdAt: safeDateConversion(org.created_at),
},
forceAllowId: true
});
const members: any[] = [];
let memberPage = await auth0Client.organizations.members.list(org.id, { take: 50 });
members.push(...memberPage.data);
while (memberPage.hasNextPage()) {
memberPage = await memberPage.getNextPage();
members.push(...memberPage.data);
}
for (const member of members) {
try {
const userRoles: any[] = [];
let rolePage = await auth0Client.organizations.members.roles.list(
org.id,
member.user_id,
{ per_page: 50, page: 0 }
);
userRoles.push(...(rolePage.data || []));
while (rolePage.hasNextPage()) {
rolePage = await rolePage.getNextPage();
userRoles.push(...(rolePage.data || []));
}
const role = mapAuth0RoleToBetterAuthRole(userRoles.map(r => r.name));
await ctx.adapter.create({
model: "member",
data: {
id: `${org.id}|${member.user_id}`,
organizationId: org.id,
userId: member.user_id,
role: role,
createdAt: new Date()
},
forceAllowId: true
});
console.log(`成功迁移组织 ${org.display_name || org.id} 的成员 ${member.user_id}`);
} catch (error) {
console.error(`迁移组织 ${org.display_name || org.id} 的成员 ${member.user_id} 失败:`, error);
}
}
console.log(`成功迁移组织:${org.display_name || org.id}`);
} catch (error) {
console.error(`迁移组织 ${org.display_name || org.id} 失败:`, error);
}
}
console.log('组织迁移完成');
} catch (error) {
console.error('迁移组织失败:', error);
}
}
async function migrateFromAuth0() {
try {
const ctx = await auth.$context;
const isAdminEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "admin");
const isUsernameEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "username");
const isOrganizationEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "organization");
const auth0Users: any[] = [];
try {
let userPage = await auth0Client.users.list({
per_page: 100,
page: 0,
include_totals: true,
});
auth0Users.push(...userPage.data);
while (userPage.hasNextPage()) {
userPage = await userPage.getNextPage();
auth0Users.push(...userPage.data);
}
} catch (error) {
console.error('获取用户时出错:', error);
}
console.log(`Found ${auth0Users.length} users to migrate`);
for (const auth0User of auth0Users) {
try {
// 确定这是基于密码的用户还是 OAuth 用户
const isOAuthUser = auth0User.identities?.some((identity: any) => identity.provider !== 'auth0');
// 两种类型通用的基础用户数据
const baseUserData = {
id: auth0User.user_id,
email: auth0User.email,
emailVerified: auth0User.email_verified || false,
name: auth0User.name || auth0User.nickname,
image: auth0User.picture,
createdAt: safeDateConversion(auth0User.created_at),
updatedAt: safeDateConversion(auth0User.updated_at),
...(isAdminEnabled ? {
banned: auth0User.blocked || false,
role: mapAuth0RoleToBetterAuthRole(auth0User.roles || []),
} : {}),
...(isUsernameEnabled ? {
username: auth0User.username || auth0User.nickname,
} : {}),
};
const createdUser = await ctx.adapter.create({
model: "user",
data: {
...baseUserData,
},
forceAllowId: true
});
if (!createdUser?.id) {
throw new Error('创建用户失败');
}
await migrateOAuthAccounts(auth0User, createdUser.id, ctx)
console.log(`成功迁移用户:${auth0User.email}`);
} catch (error) {
console.error(`迁移用户 ${auth0User.email} 失败:`, error);
}
}
if (isOrganizationEnabled) {
await migrateOrganizations(ctx);
}
console.log('迁移成功完成');
} catch (error) {
console.error('迁移失败:', error);
throw error;
}
}
migrateFromAuth0()
.then(() => {
console.log('迁移完成');
process.exit(0);
})
.catch((error) => {
console.error('迁移失败:', error);
process.exit(1);
}); 请确保将 Auth0 环境变量替换为你自己的值:
AUTH0_DOMAINAUTH0_CLIENT_IDAUTH0_SECRET
运行迁移
运行迁移脚本:
bun run scripts/migrate-auth0.ts # or use your preferred runtime重要注意事项:
- 请先在开发环境中测试迁移
- 监控迁移过程中的错误
- 在迁移完成之前验证 Better Auth 中的数据
- 在迁移完成之前保持 Auth0 安装和配置
- 默认情况下该脚本会处理 bcrypt 密码哈希。对于自定义密码哈希算法,你需要修改
migratePassword函数
更改密码哈希算法
默认情况下,Better Auth 使用 scrypt 算法来哈希密码。由于 Auth0 使用 bcrypt,你需要配置 Better Auth 使用 bcrypt 进行密码验证。
首先安装 bcrypt:
npm install bcrypt
npm install -D @types/bcrypt然后更新你的认证配置:
import { betterAuth } from "better-auth";
import bcrypt from "bcrypt";
export const auth = betterAuth({
emailAndPassword: {
password: {
hash: async (password) => {
return await bcrypt.hash(password, 10);
},
verify: async ({ hash, password }) => {
return await bcrypt.compare(password, hash);
}
}
}
})更新组件
现在数据已迁移,请更新你的组件以使用 Better Auth。以下是一个登录组件的示例:
import { authClient } from "@/lib/auth-client";
export const SignIn = () => {
const handleSignIn = async () => {
const { data, error } = await authClient.signIn.email({
email: "[email protected]",
password: "helloworld",
});
if (error) {
console.error(error);
return;
}
// 处理成功登录
};
return (
<form onSubmit={handleSignIn}>
<button type="submit">登录</button>
</form>
);
};更新中间件
将你的 Auth0 中间件替换为 Better Auth 的中间件:
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
export async function middleware(request: NextRequest) {
const sessionCookie = getSessionCookie(request);
const { pathname } = request.nextUrl;
if (sessionCookie && ["/login", "/signup"].includes(pathname)) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
if (!sessionCookie && pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard", "/login", "/signup"],
};移除 Auth0 依赖
一旦你验证一切在 Better Auth 中正常工作,请移除 Auth0 依赖:
npm remove @auth0/auth0-react @auth0/auth0-spa-js @auth0/nextjs-auth0额外注意事项
密码迁移
迁移脚本默认处理 bcrypt 密码哈希。如果你在 Auth0 中使用了自定义密码哈希算法,需要修改迁移脚本中的 migratePassword 函数以处理你的具体情况。
角色映射
脚本包含一个基本的角色映射函数(mapAuth0RoleToBetterAuthRole)。请根据你的 Auth0 角色和 Better Auth 角色需求自定义此函数。
速率限制
迁移脚本包含分页处理以应对大量用户。请根据你的需求和 Auth0 的速率限制,调整传递给 Auth0 list() 调用的 per_page(偏移分页)和 take(检查点分页)值。
总结
现在!你已成功从 Auth0 迁移到了 Better Auth。
Better Auth 提供更大的灵活性和更多功能——请务必探索文档,以充分发挥其潜力。