从 Auth0 迁移到 Better Auth
在本指南中,我们将逐步介绍如何将项目从 Auth0 迁移到 Better Auth——包括带有正确哈希的电子邮件/密码、社会/外部账户、双因素认证,以及更多功能。
此次迁移将使所有活动会话失效。本指南目前未显示如何迁移 Organizations,但通过额外步骤和 Organization 插件应该是可能的。
在开始之前
在开始迁移过程之前,请在您的项目中设置 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,
}
}
})添加插件(可选)
根据您的需求,您可以在认证配置中添加以下插件。
Admin 插件将允许您管理用户、用户模拟和应用级角色及权限。
Two Factor 插件将允许您为应用添加双因素认证。
Username 插件将允许您为应用添加用户名认证。
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()],
})生成架构
如果您使用自定义数据库适配器,请生成架构:
npx @better-auth/cli generate或者,如果您使用默认适配器,您可以使用以下命令:
npx @better-auth/cli migrate创建迁移脚本
在 scripts 文件夹中创建一个名为 migrate-auth0.ts 的新文件,并添加以下代码:
您可以使用 Auth0 的批量用户导出功能代替 Management API,并将导出的 JSON 数据直接传递给 auth0Users 数组。这在需要迁移密码哈希和完整用户数据时特别有用,这些数据无法通过 Management API 获取。
重要注意事项:
- 密码哈希导出仅适用于 Auth0 Enterprise 用户
- 免费计划用户无法导出密码哈希,需要提交支持票据
- 有关批量用户导出的详细信息,请参阅 Auth0 批量用户导出文档
- 有关密码哈希导出详情,请参阅 导出密码哈希
示例:
// 用您导出的用户 JSON 数据替换此内容
const auth0Users = [
{
"email": "helloworld@gmail.com",
"email_verified": false,
"name": "Hello world",
// 注意:password_hash 仅适用于 Enterprise 用户
"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 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(`Invalid timestamp: ${timestamp}, falling back to current date`);
return new Date();
}
// Check for unreasonable dates (before 2000 or after 2100)
const year = date.getFullYear();
if (year < 2000 || year > 2100) {
console.warn(`Suspicious date year: ${year}, falling back to current date`);
return new Date();
}
return date;
}
// Helper function to generate backup codes for 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(',');
}
// helper function to migrate password from auth0 to better auth for custom hashes and algs
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(`Failed to migrate MFA factor for user ${userId}:`, 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 = identity.provider === 'auth0' ? "credential" : identity.provider.split("-")[0];
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: identity.user_id,
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,
// if you are enterprise user, you can get the refresh tokens or all the tokensets - auth0Client.users.getAllTokensets
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(`Failed to create OAuth account for user ${userId} with provider ${providerId}:`, error);
return ctx.adapter.create({
// Try creating without optional fields if the first attempt failed
model: "account",
data: {
id: `${auth0User.user_id}|${identity.provider}|${identity.user_id}`,
userId: userId,
password: migratePassword(auth0User),
providerId: providerId,
accountId: identity.user_id,
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(`Successfully migrated OAuth account for user ${userId} with provider ${providerId}`);
} catch (error) {
console.error(`Failed to migrate OAuth account for user ${userId}:`, error);
}
}
}
async function migrateOrganizations(ctx: any) {
try {
const organizations = await auth0Client.organizations.getAll();
for (const org of organizations.data || []) {
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 = await auth0Client.organizations.getMembers({ id: org.id });
for (const member of members.data || []) {
try {
const userRoles = await auth0Client.organizations.getMemberRoles({
id: org.id,
user_id: member.user_id
});
const role = mapAuth0RoleToBetterAuthRole(userRoles.data?.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(`Successfully migrated member ${member.user_id} for organization ${org.display_name || org.id}`);
} catch (error) {
console.error(`Failed to migrate member ${member.user_id} for organization ${org.display_name || org.id}:`, error);
}
}
console.log(`Successfully migrated organization: ${org.display_name || org.id}`);
} catch (error) {
console.error(`Failed to migrate organization ${org.display_name || org.id}:`, error);
}
}
console.log('Organization migration completed');
} catch (error) {
console.error('Failed to migrate organizations:', 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 perPage = 100;
const auth0Users: any[] = [];
let pageNumber = 0;
while (true) {
try {
const params = {
per_page: perPage,
page: pageNumber,
include_totals: true,
};
const response = (await auth0Client.users.getAll(params)).data as any;
const users = response.users || [];
if (users.length === 0) break;
auth0Users.push(...users);
pageNumber++;
if (users.length < perPage) break;
} catch (error) {
console.error('Error fetching users:', error);
break;
}
}
console.log(`Found ${auth0Users.length} users to migrate`);
for (const auth0User of auth0Users) {
try {
// Determine if this is a password-based or OAuth user
const isOAuthUser = auth0User.identities?.some((identity: any) => identity.provider !== 'auth0');
// Base user data that's common for both types
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('Failed to create user');
}
await migrateOAuthAccounts(auth0User, createdUser.id, ctx)
console.log(`Successfully migrated user: ${auth0User.email}`);
} catch (error) {
console.error(`Failed to migrate user ${auth0User.email}:`, error);
}
}
if (isOrganizationEnabled) {
await migrateOrganizations(ctx);
}
// the reset of migration will be here.
console.log('Migration completed successfully');
} catch (error) {
console.error('Migration failed:', error);
throw error;
}
}
migrateFromAuth0()
.then(() => {
console.log('Migration completed');
process.exit(0);
})
.catch((error) => {
console.error('Migration failed:', error);
process.exit(1);
}); 确保用您自己的值替换 Auth0 环境变量:
AUTH0_DOMAINAUTH0_CLIENT_IDAUTH0_SECRET
运行迁移
运行迁移脚本:
bun run scripts/migrate-auth0.ts # 或使用您首选的运行时重要注意事项:
- 先在开发环境中测试迁移
- 监控迁移过程中的任何错误
- 在继续之前验证 Better Auth 中的迁移数据
- 在迁移完成之前保持 Auth0 已安装和配置
- 该脚本默认处理 bcrypt 密码哈希。对于自定义密码哈希算法,您需要修改迁移脚本中的
migratePassword函数
更新您的组件
现在数据已迁移,请更新您的组件以使用 Better Auth。以下是登录组件的示例:
import { authClient } from "better-auth/client";
export const SignIn = () => {
const handleSignIn = async () => {
const { data, error } = await authClient.signIn.email({
email: "helloworld@gmail.com",
password: "helloworld",
});
if (error) {
console.error(error);
return;
}
// 处理成功登录
};
return (
<form onSubmit={handleSignIn}>
<button type="submit">Sign in</button>
</form>
);
};更新中间件
用 Better Auth 的中间件替换您的 Auth0 中间件:
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 的速率限制调整 perPage 值。
总结
现在!您已成功从 Auth0 迁移到 Better Auth。
Better Auth 提供更大的灵活性和更多功能——请务必探索 文档 以充分发挥其潜力。