diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index ad9460404..d25fd7f5c 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -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(column: T): SQL { - 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 { - 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 { - 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 { - 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 { - 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({ - providerName, - getPreferredUsername, - getName, - getImage, -}: OAuthMapperOptions) { - 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: { diff --git a/packages/auth/src/oauth-profile.test.ts b/packages/auth/src/oauth-profile.test.ts new file mode 100644 index 000000000..bddcf1dda --- /dev/null +++ b/packages/auth/src/oauth-profile.test.ts @@ -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"); + }); +}); diff --git a/packages/auth/src/oauth-profile.ts b/packages/auth/src/oauth-profile.ts new file mode 100644 index 000000000..8930530ca --- /dev/null +++ b/packages/auth/src/oauth-profile.ts @@ -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(column: T): SQL { + return sql`lower(${column})`; +} + +async function findExistingUserByEmail(email: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + providerName: string; + findExistingUser?: (profile: TProfile, context: OAuthMapperContext) => Promise; + 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({ + providerName, + findExistingUser, + getPreferredUsername, + getName, + getImage, +}: OAuthMapperOptions) { + 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, + }); +}