mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
fix(auth): reconcile migrated social login accounts (#3095)
This commit is contained in:
+3
-151
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user