从 Auth0 迁移到 Better Auth

从 Auth0 迁移到 Better Auth 的分步指南。

在本指南中,我们将介绍如何将项目从 Auth0 迁移到 Better Auth —— 包括支持适当哈希的邮箱/密码、社交/外部账户、双因素认证等。

此迁移将使所有活动会话失效。当启用 Organization 插件时,脚本还会尽力迁移 Auth0 组织、其成员以及每个成员的角色。邀请和已启用连接的配置不会被迁移,应在 Better Auth 中手动重新配置。

迁移前准备

在开始迁移流程之前,请先在项目中设置 Better Auth。请参照安装指南开始。

连接到数据库

你需要连接到数据库以迁移用户和账户。可以使用任意数据库,但本示例使用 PostgreSQL。

npm install pg

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

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

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

启用邮箱和密码(可选)

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

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 })=>{
        // 在这里实现发送邮箱验证的逻辑
      }
    },
})

请参考 邮箱和密码 获取更多配置选项。

设置社交登录提供者(可选)

在你的认证配置中添加已在 Auth0 项目中启用的社交提供者。

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, 
        } 
    } 
})

添加插件(可选)

可以根据需要向认证配置中添加以下插件。

管理 插件可用于管理用户、用户模拟、应用级角色和权限。

双因素认证 插件可用于为应用添加双因素认证。

用户名 插件可用于为应用添加用户名认证。

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) => {
                // 用于验证密码时的边缘情况处理
            }
        }
    },
    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 auth generate

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

npx auth migrate

安装迁移依赖

安装迁移所需的依赖:

npm install auth0

创建迁移脚本

在 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",
    // ... 其他用户数据
  }
];
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 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_DOMAIN
  • AUTH0_CLIENT_ID
  • AUTH0_SECRET

运行迁移

运行迁移脚本:

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);
           }
       }
   }
})

验证迁移

迁移完成后,请验证:

  1. 所有用户都已正确迁移
  2. 社交登录功能正常
  3. 基于密码的登录功能正常
  4. 双因素认证设置已保留(如已启用)
  5. 用户角色和权限已正确映射

更新组件

现在数据已迁移,请更新你的组件以使用 Better Auth。以下是一个登录组件的示例:

components/auth/sign-in.tsx
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 的中间件:

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 依赖

一旦你验证一切在 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 提供更大的灵活性和更多功能——请务必探索文档,以充分发挥其潜力。