从 Auth0 迁移到 Better Auth

在本指南中,我们将演示如何将项目从 Auth0 迁移到 Better Auth - 包括带有适当哈希处理的电子邮件/密码、社交/外部账户、双因素认证等步骤。

【In this guide, we'll walk through the steps to migrate a project from Auth0 to Better Auth — including email/password with proper hashing, social/external accounts, two-factor authentication, and more.】

此迁移将使所有活动会话失效。此指南目前未显示如何迁移组织,但通过额外步骤和 组织 插件,应该是可行的。

在你开始之前

【Before You Begin】

在开始迁移过程之前,请在你的项目中设置 Better Auth。请按照安装指南开始操作。

【Before starting the migration process, set up Better Auth in your project. Follow the installation guide to get started.】

连接到你的数据库

【Connect to your database】

你需要连接到你的数据库以迁移用户和账户。你可以使用任何数据库,但在此示例中,我们将使用 PostgreSQL。

【You'll need to connect to your database to migrate the users and accounts. You can use any database you want, but for this example, we'll use PostgreSQL.】

npm install pg

然后你可以使用以下代码连接到你的数据库。

【And then you can use the following code to connect to your database.】

auth.ts
import { Pool } from "pg";

export const auth = betterAuth({
    database: new Pool({ 
        connectionString: process.env.DATABASE_URL 
    }),
})

启用电子邮件和密码(可选)

【Enable Email and Password (Optional)】

在你的认证配置中启用电子邮件和密码,并实现你自己的逻辑来发送验证邮件、重置密码邮件等。

【Enable the email and password in your auth config and implement your own logic for sending verification emails, reset password emails, etc.】

auth.ts
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 })=>{
        // implement your logic here to send email verification
      }
    },
})

有关更多配置选项,请参见 电子邮件和密码

设置社交提供商(可选)

【Setup Social Providers (Optional)】

在你的身份验证配置中添加你在 Auth0 项目中启用的社交提供商。

【Add social providers you have enabled in your Auth0 project in your auth config.】

auth.ts
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, 
        } 
    } 
})

添加插件(可选)

【Add Plugins (Optional)】

你可以根据需要将以下插件添加到你的身份验证配置中。

【You can add the following plugins to your auth config based on your needs.】

Admin 插件将允许你管理用户、用户模拟以及应用级别的角色和权限。

双因素 插件将允许你为你的应用添加双因素认证。

Username 插件将允许你向你的应用添加用户名认证。

auth.ts
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) => {
                // this for an edgecase that you might run in to on verifying the password
            }
        }
    },
    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()], 
})

生成模式

【Generate Schema】

如果你正在使用自定义数据库适配器,请生成模式:

【If you're using a custom database adapter, generate the schema:】

npx @better-auth/cli generate

或者如果你使用的是默认适配器,你可以使用以下命令:

【or if you're using the default adapter, you can use the following command:】

npx @better-auth/cli migrate

安装依赖

【Install Dependencies】

安装迁移所需的依赖:

【Install the required dependencies for the migration:】

npm install auth0

创建迁移脚本

【Create the migration script】

scripts 文件夹中创建一个名为 migrate-auth0.ts 的新文件,并添加以下代码:

【Create a new file called migrate-auth0.ts in the scripts folder and add the following code:】

你可以不用使用管理 API,而是使用 Auth0 的批量用户导出功能,并将导出的 JSON 数据直接传递给 auth0Users 数组。如果你需要迁移密码哈希和完整的用户数据(这些数据管理 API 无法提供),这尤其有用。

重要说明:

  • 密码哈希导出仅对 Auth0 企业用户可用
  • 免费计划用户无法导出密码哈希,需要提交支持工单
  • 有关批量用户导出的详细信息,请参阅 Auth0 批量用户导出文档
  • 有关密码哈希导出的详细信息,请参阅 导出密码哈希

示例:

【Example:】

// Replace this with your exported users JSON data
const auth0Users = [
  {
    "email": "helloworld@gmail.com",
    "email_verified": false,
    "name": "Hello world",
    // Note: password_hash is only available for Enterprise users
    "password_hash": "$2b$10$w4kfaZVjrcQ6ZOMiG.M8JeNvnVQkPKZV03pbDUHbxy9Ug0h/McDXi",
    // ... other user data
  }
];
scripts/migrate-auth0.ts
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_DOMAIN
  • AUTH0_CLIENT_ID
  • AUTH0_SECRET

运行迁移

【Run the migration】

运行迁移脚本:

【Run the migration script:】

bun run scripts/migrate-auth0.ts # or use your preferred runtime

重要注意事项:

  1. 首先在开发环境中测试迁移
  2. 监控迁移过程中的任何错误
  3. 在继续操作之前,验证在 Better Auth 中迁移的数据
  4. 在迁移完成之前,保持 Auth0 已安装并正确配置
  5. 脚本默认处理 bcrypt 密码哈希。对于自定义密码哈希算法,你需要修改 migratePassword 函数

更改密码哈希算法

默认情况下,Better Auth 使用 scrypt 算法来哈希密码。由于 Auth0 使用 bcrypt,你需要配置 Better Auth 使用 bcrypt 来验证密码。

首先,安装 bcrypt:

npm install bcrypt
npm install -D @types/bcrypt

然后更新你的认证配置:

auth.ts
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);
           }
       }
   }
})

验证迁移

【Verify the migration】

运行迁移后,请确认:

  1. 所有用户已正确迁移
  2. 社交连接正常工作
  3. 基于密码的身份验证正常工作
  4. 双重认证设置已保留(如果已启用)
  5. 用户角色和权限已正确映射

更新你的组件

【Update your components】

现在数据已迁移,请更新你的组件以使用更好的身份验证。以下是登录组件的示例:

【Now that the data is migrated, update your components to use Better Auth. Here's an example for the sign-in component:】

components/auth/sign-in.tsx
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;
    }
    // Handle successful sign in
  };

  return (
    <form onSubmit={handleSignIn}>
      <button type="submit">Sign in</button>
    </form>
  );
};

更新中间件

【Update the middleware】

用 Better Auth 的中间件替换你的 Auth0 中间件:

【Replace your Auth0 middleware with Better Auth's middleware:】

middleware.ts
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 依赖

【Remove Auth0 Dependencies】

一旦确认 Better Auth 一切运行正常,卸载 Auth0:

【Once you've verified everything is working correctly with Better Auth, remove Auth0:】

npm remove @auth0/auth0-react @auth0/auth0-spa-js @auth0/nextjs-auth0

其他注意事项

【Additional Considerations】

密码迁移

【Password Migration】

迁移脚本默认处理 bcrypt 密码哈希。如果你在 Auth0 中使用自定义密码哈希算法,你需要修改迁移脚本中的 migratePassword 函数来处理你的特定情况。

【The migration script handles bcrypt password hashes by default. If you're using custom password hashing algorithms in Auth0, you'll need to modify the migratePassword function in the migration script to handle your specific case.】

角色映射

【Role Mapping】

脚本包含一个基本的角色映射函数(mapAuth0RoleToBetterAuthRole)。请根据你的 Auth0 角色和 Better Auth 角色需求自定义此函数。

【The script includes a basic role mapping function (mapAuth0RoleToBetterAuthRole). Customize this function based on your Auth0 roles and Better Auth role requirements.】

速率限制

【Rate Limiting】

迁移脚本包含分页功能,以处理大量用户。请根据你的需求和 Auth0 的速率限制调整 perPage 值。

【The migration script includes pagination to handle large numbers of users. Adjust the perPage value based on your needs and Auth0's rate limits.】

总结

【Wrapping Up】

现在!你已成功从 Auth0 迁移到 Better Auth。

【Now! You've successfully migrated from Auth0 to Better Auth.】

Better Auth 提供了更高的灵活性和更多功能 - 务必查看文档以充分发挥其潜力。

【Better Auth offers greater flexibility and more features—be sure to explore the documentation to unlock its full potential. 】