Auth0에서 Better Auth로 마이그레이션
이 가이드에서는 Auth0에서 Better Auth로 프로젝트를 마이그레이션하는 단계를 안내합니다. 적절한 해싱을 사용하는 이메일/비밀번호, 소셜/외부 계정, 2단계 인증 등이 포함됩니다.
이 마이그레이션은 모든 활성 세션을 무효화합니다. 이 가이드는 현재 조직(Organizations)을 마이그레이션하는 방법을 보여주지 않지만, 조직 플러그인을 사용하여 추가 단계로 가능합니다.
시작하기 전에
마이그레이션 프로세스를 시작하기 전에 프로젝트에서 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 플러그인을 사용하면 사용자 관리, 사용자 가장(impersonation), 앱 수준 역할 및 권한을 관리할 수 있습니다.
Two Factor 플러그인을 사용하면 애플리케이션에 2단계 인증을 추가할 수 있습니다.
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라는 새 파일을 만들고 다음 코드를 추가하세요:
Management API를 사용하는 대신 Auth0의 대량 사용자 내보내기 기능을 사용하고 내보낸 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();
}
// 비합리적인 날짜 확인 (2000년 이전 또는 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;
}
// 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(`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,
// Enterprise 사용자인 경우 refresh token 또는 모든 토큰셋을 가져올 수 있습니다 - 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({
// 첫 번째 시도가 실패한 경우 선택적 필드 없이 생성 시도
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 {
// 비밀번호 기반 사용자인지 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('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);
}
// 나머지 마이그레이션은 여기에 작성됩니다.
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는 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);
}
}
}
})마이그레이션 확인
마이그레이션 실행 후 다음을 확인하세요:
- 모든 사용자가 올바르게 마이그레이션되었는지 확인
- 소셜 연결이 작동하는지 확인
- 비밀번호 기반 인증이 작동하는지 확인
- 2단계 인증 설정이 보존되었는지 확인 (활성화된 경우)
- 사용자 역할 및 권한이 올바르게 매핑되었는지 확인
컴포넌트 업데이트
이제 데이터가 마이그레이션되었으므로 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">로그인</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의 속도 제한에 따라 perPage 값을 조정하세요.
마무리
이제 Auth0에서 Better Auth로 성공적으로 마이그레이션했습니다!
Better Auth는 더 큰 유연성과 더 많은 기능을 제공합니다. 문서를 탐색하여 전체 잠재력을 활용하세요.