mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-27 18:34:51 +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: {
|
||||
|
||||
Reference in New Issue
Block a user