fix(auth): reconcile migrated social login accounts (#3095)

This commit is contained in:
Yu Sun
2026-05-25 21:46:57 +08:00
committed by GitHub
parent 266bc291eb
commit 86fff7237f
3 changed files with 480 additions and 151 deletions
+3 -151
View File
@@ -1,6 +1,4 @@
import type { GenericOAuthConfig } from "better-auth/plugins";
import type { SQL } from "drizzle-orm";
import type { AnyPgColumn } from "drizzle-orm/pg-core";
import type { JWTPayload } from "jose";
import { apiKey } from "@better-auth/api-key";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
@@ -8,14 +6,13 @@ import { dash } from "@better-auth/infra";
import { oauthProvider } from "@better-auth/oauth-provider";
import { passkey } from "@better-auth/passkey";
import { compare, hash } from "bcrypt";
import { APIError, BetterAuthError, betterAuth } from "better-auth";
import { APIError, betterAuth } from "better-auth";
import { createAuthMiddleware } from "better-auth/api";
import { verifyAccessToken } from "better-auth/oauth2";
import { admin, jwt } from "better-auth/plugins";
import { genericOAuth } from "better-auth/plugins/generic-oauth";
import { twoFactor } from "better-auth/plugins/two-factor";
import { username } from "better-auth/plugins/username";
import { eq, or, sql } from "drizzle-orm";
import { createElement } from "react";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
@@ -25,6 +22,7 @@ import { env } from "@reactive-resume/env/server";
import { rateLimitConfig, TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
import { generateId, toUsername } from "@reactive-resume/utils/string";
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
import { createGithubProfileMapper, createProfileMapper } from "./oauth-profile";
import { getTrustedOrigins } from "./trusted-origins";
const authBaseUrl = env.APP_URL;
@@ -68,147 +66,6 @@ const oauthProviderRateLimit = isRateLimitEnabled
userinfo: false,
} as const);
function lower<T extends AnyPgColumn>(column: T): SQL<T> {
return sql`lower(${column})`;
}
async function findExistingUserByEmail(email: string) {
const normalizedEmail = email.trim().toLowerCase();
const [existingUser] = await db
.select({
id: schema.user.id,
email: schema.user.email,
emailVerified: schema.user.emailVerified,
username: schema.user.username,
displayUsername: schema.user.displayUsername,
name: schema.user.name,
image: schema.user.image,
})
.from(schema.user)
.where(eq(lower(schema.user.email), normalizedEmail))
.limit(1);
return existingUser;
}
function getEmailLocalPart(email: string): string {
return email.split("@", 1)[0] ?? "";
}
function appendUsernameSuffix(base: string, suffix: string): string {
const maxBaseLength = 64 - suffix.length;
return `${base.slice(0, maxBaseLength)}${suffix}`;
}
async function isUsernameTaken(candidate: string): Promise<boolean> {
const normalizedCandidate = candidate.trim().toLowerCase();
const [existingUser] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(
or(
eq(lower(schema.user.username), normalizedCandidate),
eq(lower(schema.user.displayUsername), normalizedCandidate),
),
)
.limit(1);
return Boolean(existingUser);
}
async function allocateUniqueUsername(email: string, preferredUsername?: string | null): Promise<string> {
const emailLocalPart = getEmailLocalPart(email);
const preferred = preferredUsername ? toUsername(preferredUsername) : "";
const normalizedEmailLocalPart = toUsername(emailLocalPart);
const baseUsername = preferred || normalizedEmailLocalPart || "user";
if (!(await isUsernameTaken(baseUsername))) return baseUsername;
const suffixedUsername = await findAvailableUsernameSuffix(baseUsername);
if (suffixedUsername) return suffixedUsername;
return appendUsernameSuffix(baseUsername, `-${generateId().slice(0, 8).toLowerCase()}`);
}
async function findAvailableUsernameSuffix(baseUsername: string, index = 1): Promise<string | null> {
if (index > 999) return null;
const candidate = appendUsernameSuffix(baseUsername, `-${index}`);
if (!(await isUsernameTaken(candidate))) return candidate;
return findAvailableUsernameSuffix(baseUsername, index + 1);
}
interface OAuthProfile {
email?: string | null;
name?: string | null;
picture?: string | null;
image?: string | null;
avatar_url?: string | null;
login?: string | null;
preferred_username?: string | null;
}
interface OAuthMapperContext {
email: string;
emailLocalPart: string;
}
interface OAuthMapperOptions<TProfile extends OAuthProfile> {
providerName: string;
getPreferredUsername?: (profile: TProfile, context: OAuthMapperContext) => string | undefined | null;
getName?: (profile: TProfile, context: OAuthMapperContext) => string | undefined | null;
getImage?: (profile: TProfile) => string | undefined | null;
}
function createProfileMapper<TProfile extends OAuthProfile>({
providerName,
getPreferredUsername,
getName,
getImage,
}: OAuthMapperOptions<TProfile>) {
return async (profile: TProfile) => {
if (!profile.email) {
throw new BetterAuthError(
`${providerName} provider did not return an email address. This is required for user creation.`,
{ cause: "EMAIL_REQUIRED" },
);
}
const email = profile.email.trim().toLowerCase();
const emailLocalPart = getEmailLocalPart(email);
const context = { email, emailLocalPart };
const existingUser = await findExistingUserByEmail(email);
const image = getImage?.(profile) ?? undefined;
if (existingUser) {
return {
name: existingUser.name,
email: existingUser.email,
image: image ?? existingUser.image,
username: existingUser.username,
displayUsername: existingUser.displayUsername,
emailVerified: existingUser.emailVerified,
};
}
const preferredUsername = getPreferredUsername?.(profile, context);
const username = await allocateUniqueUsername(email, preferredUsername);
const mappedName = getName?.(profile, context)?.trim();
return {
name: mappedName || username || emailLocalPart,
email,
image,
username,
displayUsername: username,
emailVerified: true,
};
};
}
const getAuthConfig = () => {
const authConfigs: GenericOAuthConfig[] = [];
@@ -355,12 +212,7 @@ const getAuthConfig = () => {
disableImplicitSignUp: true,
clientId: env.GITHUB_CLIENT_ID ?? "",
clientSecret: env.GITHUB_CLIENT_SECRET ?? "",
mapProfileToUser: createProfileMapper({
providerName: "GitHub",
getPreferredUsername: (profile, context) => profile.login ?? context.emailLocalPart,
getName: (profile, context) => profile.name ?? profile.login ?? context.emailLocalPart,
getImage: (profile) => profile.avatar_url,
}),
mapProfileToUser: createGithubProfileMapper(),
},
linkedin: {