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: {
+255
View File
@@ -0,0 +1,255 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const dbMock = vi.hoisted(() => {
const selectQueue: unknown[][] = [];
const updateWhere = vi.fn();
const updateSet = vi.fn((_value: unknown) => ({
where: updateWhere,
}));
const where = vi.fn(() => ({
limit: vi.fn(() => selectQueue.shift() ?? []),
}));
const innerJoin = vi.fn(() => ({ where }));
const from = vi.fn(() => ({ innerJoin, where }));
const select = vi.fn(() => ({ from }));
const update = vi.fn(() => ({ set: updateSet }));
return {
selectQueue,
select,
from,
innerJoin,
where,
update,
updateSet,
updateWhere,
};
});
vi.mock("@reactive-resume/db/client", () => ({
db: {
select: dbMock.select,
update: dbMock.update,
},
}));
const { createGithubProfileMapper, createProfileMapper } = await import("./oauth-profile");
beforeEach(() => {
dbMock.selectQueue.length = 0;
dbMock.select.mockClear();
dbMock.from.mockClear();
dbMock.innerJoin.mockClear();
dbMock.where.mockClear();
dbMock.update.mockClear();
dbMock.updateSet.mockClear();
dbMock.updateWhere.mockClear();
});
describe("createProfileMapper", () => {
it("reuses a migrated GitHub account when the login and email match the legacy account", async () => {
dbMock.selectQueue.push([
{
id: "user-1",
accountId: "legacy-user-id",
email: "Legacy.User@Example.COM",
emailVerified: true,
username: "octo-cat",
displayUsername: "Octo-Cat",
name: "Legacy User",
image: "https://example.com/old.png",
},
]);
const mapper = createGithubProfileMapper();
const result = await mapper({
id: "123456",
email: "legacy.user@example.com",
login: "Octo-Cat",
name: "Provider Name",
avatar_url: "https://example.com/new.png",
});
expect(dbMock.select).toHaveBeenCalledTimes(1);
expect(dbMock.update).toHaveBeenCalledTimes(1);
expect(dbMock.updateSet).toHaveBeenCalledWith({ email: "legacy.user@example.com" });
expect(result).toEqual({
id: "legacy-user-id",
name: "Legacy User",
email: "legacy.user@example.com",
image: "https://example.com/new.png",
username: "octo-cat",
displayUsername: "Octo-Cat",
emailVerified: true,
});
});
it("does not reuse a migrated GitHub account when only the login matches", async () => {
dbMock.selectQueue.push(
[
{
id: "user-1",
accountId: "legacy-user-id",
email: "Legacy.User@Example.COM",
emailVerified: true,
username: "octo-cat",
displayUsername: "Octo-Cat",
name: "Legacy User",
image: "https://example.com/old.png",
},
],
[],
[{ id: "user-1" }],
[],
);
const mapper = createGithubProfileMapper();
const result = await mapper({
id: "123456",
email: "current.github@example.com",
login: "Octo-Cat",
name: "Provider Name",
avatar_url: "https://example.com/new.png",
});
expect(dbMock.select).toHaveBeenCalledTimes(4);
expect(dbMock.update).not.toHaveBeenCalled();
expect(result).toEqual({
name: "Provider Name",
email: "current.github@example.com",
image: "https://example.com/new.png",
username: "octo-cat-1",
displayUsername: "octo-cat-1",
emailVerified: true,
});
});
it("falls back to email matching when no migrated GitHub account matches the provider login", async () => {
dbMock.selectQueue.push(
[],
[
{
id: "user-1",
email: "GitHub.User@Example.COM",
emailVerified: false,
username: "github.user",
displayUsername: "github.user",
name: "GitHub User",
image: null,
},
],
);
const mapper = createGithubProfileMapper();
const result = await mapper({
id: "123456",
email: "github.user@example.com",
login: "other-login",
avatar_url: "https://example.com/new.png",
});
expect(dbMock.select).toHaveBeenCalledTimes(2);
expect(result).toEqual({
name: "GitHub User",
email: "github.user@example.com",
image: "https://example.com/new.png",
username: "github.user",
displayUsername: "github.user",
emailVerified: false,
});
});
it("normalizes a matched legacy user email before handing it to Better Auth", async () => {
dbMock.selectQueue.push([
{
id: "user-1",
email: "Legacy.User@Example.COM",
emailVerified: false,
username: "legacy.user",
displayUsername: "legacy.user",
name: "Legacy User",
image: "https://example.com/old.png",
},
]);
const mapper = createProfileMapper({
providerName: "Google",
getImage: (profile) => profile.picture,
});
const result = await mapper({
email: "legacy.user@example.com",
name: "Provider Name",
picture: "https://example.com/new.png",
});
expect(dbMock.update).toHaveBeenCalledTimes(1);
expect(dbMock.updateSet).toHaveBeenCalledWith({ email: "legacy.user@example.com" });
expect(result).toEqual({
name: "Legacy User",
email: "legacy.user@example.com",
image: "https://example.com/new.png",
username: "legacy.user",
displayUsername: "legacy.user",
emailVerified: false,
});
});
it("does not update the user row when the matched email is already normalized", async () => {
dbMock.selectQueue.push([
{
id: "user-1",
email: "user@example.com",
emailVerified: true,
username: "user",
displayUsername: "user",
name: "User",
image: null,
},
]);
const mapper = createProfileMapper({ providerName: "Google" });
const result = await mapper({ email: "USER@example.com", name: "Provider User" });
expect(dbMock.update).not.toHaveBeenCalled();
expect(result.email).toBe("user@example.com");
expect(result.name).toBe("User");
});
it("allocates a provider username for a new social user", async () => {
dbMock.selectQueue.push([], []);
const mapper = createProfileMapper({
providerName: "GitHub",
getPreferredUsername: (profile) => profile.login,
getName: (profile) => profile.name,
getImage: (profile) => profile.avatar_url,
});
const result = await mapper({
email: "New.User@Example.com",
login: "Octo-Cat",
name: "Octo Cat",
avatar_url: "https://example.com/avatar.png",
});
expect(result).toEqual({
name: "Octo Cat",
email: "new.user@example.com",
image: "https://example.com/avatar.png",
username: "octo-cat",
displayUsername: "octo-cat",
emailVerified: true,
});
});
it("rejects provider profiles without an email address", async () => {
const mapper = createProfileMapper({ providerName: "GitHub" });
await expect(mapper({ login: "octocat" })).rejects.toThrow("GitHub provider did not return an email address");
});
});
+222
View File
@@ -0,0 +1,222 @@
import type { SQL } from "drizzle-orm";
import type { AnyPgColumn } from "drizzle-orm/pg-core";
import { BetterAuthError } from "better-auth";
import { and, eq, or, sql } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { generateId, toUsername } from "@reactive-resume/utils/string";
interface ExistingOAuthUser {
id: string;
email: string;
emailVerified: boolean;
username: string;
displayUsername: string;
name: string;
image: string | null;
accountId?: string;
}
function lower<T extends AnyPgColumn>(column: T): SQL<T> {
return sql`lower(${column})`;
}
async function findExistingUserByEmail(email: string): Promise<ExistingOAuthUser | undefined> {
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;
}
async function findLegacyGithubUser(
profile: OAuthProfile,
context: OAuthMapperContext,
): Promise<ExistingOAuthUser | undefined> {
const login = profile.login;
if (!login) return;
const normalizedLogin = toUsername(login);
const [legacyAccount] = await db
.select({
id: schema.user.id,
accountId: schema.account.accountId,
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.account)
.innerJoin(schema.user, eq(schema.account.userId, schema.user.id))
.where(
and(
eq(schema.account.providerId, "github"),
eq(lower(schema.user.email), context.email),
or(
eq(lower(schema.user.username), normalizedLogin),
eq(schema.user.displayUsername, login),
eq(lower(schema.user.displayUsername), normalizedLogin),
),
),
)
.limit(1);
if (legacyAccount?.email.trim().toLowerCase() !== context.email) return;
return legacyAccount;
}
async function normalizeExistingUserEmail(userId: string, currentEmail: string, normalizedEmail: string) {
if (currentEmail === normalizedEmail) return;
await db.update(schema.user).set({ email: normalizedEmail }).where(eq(schema.user.id, userId));
}
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;
id?: string | number | 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;
findExistingUser?: (profile: TProfile, context: OAuthMapperContext) => Promise<ExistingOAuthUser | undefined>;
getPreferredUsername?: (profile: TProfile, context: OAuthMapperContext) => string | undefined | null;
getName?: (profile: TProfile, context: OAuthMapperContext) => string | undefined | null;
getImage?: (profile: TProfile) => string | undefined | null;
}
export function createProfileMapper<TProfile extends OAuthProfile>({
providerName,
findExistingUser,
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 findExistingUser?.(profile, context)) ?? (await findExistingUserByEmail(email));
const image = getImage?.(profile) ?? undefined;
if (existingUser) {
const existingEmail = existingUser.email.trim().toLowerCase();
await normalizeExistingUserEmail(existingUser.id, existingUser.email, existingEmail);
return {
...(existingUser.accountId ? { id: existingUser.accountId } : {}),
name: existingUser.name,
email: existingEmail,
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,
};
};
}
export function createGithubProfileMapper() {
return createProfileMapper({
providerName: "GitHub",
findExistingUser: findLegacyGithubUser,
getPreferredUsername: (profile, context) => profile.login ?? context.emailLocalPart,
getName: (profile, context) => profile.name ?? profile.login ?? context.emailLocalPart,
getImage: (profile) => profile.avatar_url,
});
}