From 81ca3b55ba03fdf66a2c1a4f9b7e107e9f1ccd0a Mon Sep 17 00:00:00 2001 From: jsnyder10 Date: Sun, 13 Sep 2026 17:57:49 -0500 Subject: [PATCH] fix(auth): case-insensitive lookups no longer treat _ and % as wildcards --- .changeset/auth-insensitive-wildcard.md | 5 + packages/auth/src/adapters/prismaAdapter.ts | 30 ++- packages/auth/src/procedures/base.ts | 19 +- packages/auth/src/procedures/oauth.ts | 6 +- packages/auth/src/procedures/twoFa/shared.ts | 22 +- packages/auth/src/utilities/emailMatch.ts | 25 ++ packages/auth/src/utilities/loginMethods.ts | 4 +- packages/auth/tests/insensitiveLookup.test.ts | 227 ++++++++++++++++++ 8 files changed, 319 insertions(+), 19 deletions(-) create mode 100644 .changeset/auth-insensitive-wildcard.md create mode 100644 packages/auth/src/utilities/emailMatch.ts create mode 100644 packages/auth/tests/insensitiveLookup.test.ts diff --git a/.changeset/auth-insensitive-wildcard.md b/.changeset/auth-insensitive-wildcard.md new file mode 100644 index 0000000..d09372f --- /dev/null +++ b/.changeset/auth-insensitive-wildcard.md @@ -0,0 +1,5 @@ +--- +'@factiii/auth': patch +--- + +Security: case-insensitive email and username lookups are now exact. The Prisma adapter escapes the characters that a case-insensitive database match treats as patterns, and sign-in, OAuth attach-by-email, signup checks, password reset, 2FA reset and login-method lookups re-check that the account found has the same email or username as the one given. Upgrade is recommended for every consumer of the Prisma adapter. diff --git a/packages/auth/src/adapters/prismaAdapter.ts b/packages/auth/src/adapters/prismaAdapter.ts index e688a85..87efb35 100644 --- a/packages/auth/src/adapters/prismaAdapter.ts +++ b/packages/auth/src/adapters/prismaAdapter.ts @@ -10,6 +10,7 @@ import type { SessionWithUser, } from './database'; import type { DeviceAuthAdapter, SessionWithDevice } from './deviceAuth'; +import { escapeLikePattern, sameIdentifier } from '../utilities/emailMatch'; /** Internal accessor for Prisma model delegates (avoids repeating casts). */ type PrismaDelegate = Record Promise>; @@ -41,27 +42,36 @@ export function createPrismaAdapter(prisma: unknown): DatabaseAdapter { const db = prisma as PrismaModelAccess; return { user: { + // `mode: 'insensitive'` equals is ILIKE on Postgres, so the value is escaped + // (utilities/emailMatch.ts) and the row is re-checked before it is returned. async findByEmailInsensitive(email: string): Promise { - return db.user.findFirst({ - where: { email: { equals: email, mode: 'insensitive' } }, - }) as Promise; + const user = (await db.user.findFirst({ + where: { email: { equals: escapeLikePattern(email), mode: 'insensitive' } }, + })) as AuthUser | null; + return user && sameIdentifier(user.email, email) ? user : null; }, async findByUsernameInsensitive(username: string): Promise { - return db.user.findFirst({ - where: { username: { equals: username, mode: 'insensitive' } }, - }) as Promise; + const user = (await db.user.findFirst({ + where: { username: { equals: escapeLikePattern(username), mode: 'insensitive' } }, + })) as AuthUser | null; + return user && sameIdentifier(user.username, username) ? user : null; }, async findByEmailOrUsernameInsensitive(identifier: string): Promise { - return db.user.findFirst({ + const pattern = escapeLikePattern(identifier); + const user = (await db.user.findFirst({ where: { OR: [ - { email: { equals: identifier, mode: 'insensitive' } }, - { username: { equals: identifier, mode: 'insensitive' } }, + { email: { equals: pattern, mode: 'insensitive' } }, + { username: { equals: pattern, mode: 'insensitive' } }, ], }, - }) as Promise; + })) as AuthUser | null; + return user && + (sameIdentifier(user.email, identifier) || sameIdentifier(user.username, identifier)) + ? user + : null; }, async findById(id: number): Promise { diff --git a/packages/auth/src/procedures/base.ts b/packages/auth/src/procedures/base.ts index add3532..d598df7 100644 --- a/packages/auth/src/procedures/base.ts +++ b/packages/auth/src/procedures/base.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { type ClientCookiePayload } from '../types'; import { type AuthProcedure, type BaseProcedure } from '../types/trpc'; import { detectBrowser } from '../utilities/browser'; +import { sameIdentifier } from '../utilities/emailMatch'; import { isTwoFaEnabled, verifyTwoFaChallenge } from './twoFa/verifyChallenge'; import type { ResolvedAuthConfig } from '../utilities/config'; import { clearAuthCookies, setAuthCookies } from '../utilities/cookies'; @@ -98,7 +99,7 @@ export class BaseProcedureFactory< if (username) { const usernameCheck = await this.config.database.user.findByUsernameInsensitive(username); - if (usernameCheck) { + if (usernameCheck && sameIdentifier(usernameCheck.username, username)) { throw new TRPCError({ code: 'CONFLICT', message: 'An account already exists with that username.', @@ -108,7 +109,7 @@ export class BaseProcedureFactory< const emailCheck = await this.config.database.user.findByEmailInsensitive(email); - if (emailCheck) { + if (emailCheck && sameIdentifier(emailCheck.email, email)) { throw new TRPCError({ code: 'CONFLICT', message: 'An account already exists with that email.', @@ -177,7 +178,13 @@ export class BaseProcedureFactory< await this.config.hooks.beforeLogin(typedInput); } - const user = await this.config.database.user.findByEmailOrUsernameInsensitive(username); + const found = await this.config.database.user.findByEmailOrUsernameInsensitive(username); + // Re-checked here as well as in the adapter: a row whose email and username + // both differ from what was typed is not this account. + const user = + found && (sameIdentifier(found.email, username) || sameIdentifier(found.username, username)) + ? found + : null; if (!user) { throw new TRPCError({ @@ -539,7 +546,7 @@ export class BaseProcedureFactory< } const taken = await this.config.database.user.findByUsernameInsensitive(input.username); - if (taken) { + if (taken && sameIdentifier(taken.username, input.username)) { throw new TRPCError({ code: 'CONFLICT', message: 'An account already exists with that username.', @@ -558,7 +565,9 @@ export class BaseProcedureFactory< return this.procedure.input(requestPasswordResetSchema).mutation(async ({ input }) => { const { email } = input; - const user = await this.config.database.user.findByEmailInsensitive(email); + const found = await this.config.database.user.findByEmailInsensitive(email); + // A reset link for a look-alike address must never go to another account. + const user = found && sameIdentifier(found.email, email) ? found : null; if (!user || user.status !== 'ACTIVE') { return { message: 'If an account exists with that email, a reset link has been sent.' }; diff --git a/packages/auth/src/procedures/oauth.ts b/packages/auth/src/procedures/oauth.ts index 955ce4c..48e9a14 100644 --- a/packages/auth/src/procedures/oauth.ts +++ b/packages/auth/src/procedures/oauth.ts @@ -11,6 +11,7 @@ import { issueAuthCookies, revokeDeviceSessionsForUser, } from '../utilities/issueCookies'; +import { sameIdentifier } from '../utilities/emailMatch'; import { assertKeepsLoginMethod } from '../utilities/loginMethods'; import { createOAuthVerifier, type OAuthProvider, type OAuthResult } from '../utilities/oauth'; import { type CreatedSchemas, type OAuthSchemaInput } from '../validators'; @@ -100,7 +101,10 @@ export class OAuthLoginProcedureFactory< }); } - const existing = await this.config.database.user.findByEmailInsensitive(email); + // Re-checked here as well as in the adapter: attaching by email hands over + // the account, so a lookup result for a different address is not a match. + const found = await this.config.database.user.findByEmailInsensitive(email); + const existing = found && sameIdentifier(found.email, email) ? found : null; if (existing?.password) { throw new TRPCError({ code: 'BAD_REQUEST', diff --git a/packages/auth/src/procedures/twoFa/shared.ts b/packages/auth/src/procedures/twoFa/shared.ts index 321aba0..407dc57 100644 --- a/packages/auth/src/procedures/twoFa/shared.ts +++ b/packages/auth/src/procedures/twoFa/shared.ts @@ -8,11 +8,23 @@ import { TRPCError } from '@trpc/server'; import { type BaseProcedure } from '../../types/trpc'; import type { ResolvedAuthConfig } from '../../utilities/config'; +import { sameIdentifier } from '../../utilities/emailMatch'; import { comparePassword } from '../../utilities/password'; import { generateOtp } from '../../utilities/totp'; import { twoFaResetSchema, twoFaResetVerifySchema } from '../../validators/twoFa.shared'; import { isTwoFaEnabled } from './verifyChallenge'; +/** The lookup result only when its email or username is the identifier typed. */ +function matchesIdentifier( + user: T | null, + identifier: string +): T | null { + return user && + (sameIdentifier(user.email, identifier) || sameIdentifier(user.username, identifier)) + ? user + : null; +} + /** * Build the `twoFaReset` procedure: re-authenticates the user with * username + password, then emails them a 6-digit OTP they can use @@ -41,7 +53,10 @@ export function buildTwoFaResetProcedures( checkConfig(); const { username, password } = input; - const user = await config.database.user.findByEmailOrUsernameInsensitive(username); + const user = matchesIdentifier( + await config.database.user.findByEmailOrUsernameInsensitive(username), + username + ); if (!user || !isTwoFaEnabled(config, user)) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid credentials.' }); @@ -84,7 +99,10 @@ export function buildTwoFaResetProcedures( checkConfig(); const { code, username } = input; - const user = await config.database.user.findByEmailOrUsernameInsensitive(username); + const user = matchesIdentifier( + await config.database.user.findByEmailOrUsernameInsensitive(username), + username + ); if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' }); diff --git a/packages/auth/src/utilities/emailMatch.ts b/packages/auth/src/utilities/emailMatch.ts new file mode 100644 index 0000000..ca4cd63 --- /dev/null +++ b/packages/auth/src/utilities/emailMatch.ts @@ -0,0 +1,25 @@ +/** + * Case-insensitive EXACT matching for emails and usernames. + * + * Prisma's `mode: 'insensitive'` `equals` compiles to ILIKE on Postgres, where + * `_` and `%` are pattern characters. Passed through unescaped, a lookup for one + * address can return a different account whose address merely fits the pattern. + * So the adapter escapes the pattern characters, and every caller that acts on a + * lookup result re-checks it with `sameIdentifier`. + */ + +/** The characters LIKE and ILIKE treat specially under the default escape. */ +const LIKE_SPECIAL = /[\\%_]/g; + +/** Escape `\`, `%` and `_` with a backslash, Postgres's default LIKE escape. */ +export function escapeLikePattern(value: string): string { + return value.replace(LIKE_SPECIAL, (char) => `\\${char}`); +} + +/** + * True when a stored email or username is the same identifier as `input`, with + * case the only difference allowed. A null or missing stored value never matches. + */ +export function sameIdentifier(stored: string | null | undefined, input: string): boolean { + return typeof stored === 'string' && stored.toLowerCase() === input.toLowerCase(); +} diff --git a/packages/auth/src/utilities/loginMethods.ts b/packages/auth/src/utilities/loginMethods.ts index f7d2976..c938154 100644 --- a/packages/auth/src/utilities/loginMethods.ts +++ b/packages/auth/src/utilities/loginMethods.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import type { ResolvedAuthConfig } from './config'; +import { sameIdentifier } from './emailMatch'; // TOTP/2FA is a step-up layer on password login, not a standalone method, so it // is never counted here. @@ -49,7 +50,8 @@ export async function resolveLoginMethods( config: ResolvedAuthConfig, username: string ): Promise { - const user = await config.database.user.findByUsernameInsensitive(username); + const found = await config.database.user.findByUsernameInsensitive(username); + const user = found && sameIdentifier(found.username, username) ? found : null; if (!user) { return { found: false, hasPassword: false, hasPasskey: false, providers: [] }; } diff --git a/packages/auth/tests/insensitiveLookup.test.ts b/packages/auth/tests/insensitiveLookup.test.ts new file mode 100644 index 0000000..48ce2c9 --- /dev/null +++ b/packages/auth/tests/insensitiveLookup.test.ts @@ -0,0 +1,227 @@ +import { initTRPC } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createPrismaAdapter } from '../src/adapters/prismaAdapter'; +import { BaseProcedureFactory } from '../src/procedures/base'; +import { OAuthLoginProcedureFactory } from '../src/procedures/oauth'; +import type { AuthProcedure, BaseProcedure, TrpcContext } from '../src/types/trpc'; +import { createAuthConfig } from '../src/utilities/config'; +import { escapeLikePattern, sameIdentifier } from '../src/utilities/emailMatch'; +import { oAuthLoginSchema } from '../src/validators'; + +/** + * Case-insensitive lookups compile to ILIKE on Postgres, where `_` and `%` are + * pattern characters. The package tests have no Postgres, so the adapter tests + * fake a database that answers the way an unescaped ILIKE would — it returns the + * stored row for a look-alike value — and assert both the escaped argument and + * that the adapter drops the look-alike row. The procedure tests fake an adapter + * that still misbehaves, to prove the callers' own re-check holds on its own. + */ + +const { googleVerify } = vi.hoisted(() => ({ googleVerify: vi.fn() })); + +vi.mock('google-auth-library', () => ({ + OAuth2Client: class { + verifyIdToken = googleVerify; + }, +})); + +vi.mock('apple-signin-auth', () => ({ default: { verifyIdToken: vi.fn() } })); + +const STORED = { + id: 7, + email: 'john@outlook.com', + username: 'john', + password: null as string | null, + status: 'ACTIVE', + emailVerificationStatus: 'VERIFIED', + updatedAt: new Date('2026-01-01'), + verifiedHumanAt: null, +}; + +const ctx = { + headers: { 'user-agent': 'Mozilla/5.0' }, + res: { setHeader: vi.fn() }, + userId: null, + sessionId: null, + socketId: null, + ip: '127.0.0.1', +} as unknown as TrpcContext; + +beforeEach(() => { + googleVerify.mockReset(); +}); + +describe('escapeLikePattern', () => { + it('escapes backslash, percent and underscore', () => { + expect(escapeLikePattern('j_hn%x\\y@outlook.com')).toBe('j\\_hn\\%x\\\\y@outlook.com'); + }); + + it('leaves an ordinary address unchanged', () => { + expect(escapeLikePattern('John.Smith+tag@Outlook.com')).toBe('John.Smith+tag@Outlook.com'); + }); +}); + +describe('sameIdentifier', () => { + it('matches when only case differs', () => { + expect(sameIdentifier('john@outlook.com', 'JOHN@Outlook.com')).toBe(true); + }); + + it('does not match a look-alike or a missing value', () => { + expect(sameIdentifier('john@outlook.com', 'j_hn@outlook.com')).toBe(false); + expect(sameIdentifier(null, 'john@outlook.com')).toBe(false); + }); +}); + +describe('Prisma adapter case-insensitive lookups', () => { + // Answers every lookup with the stored row, the way an unescaped ILIKE answers + // a pattern that fits it. + const wildcardPrisma = () => { + const findFirst = vi.fn(async () => ({ ...STORED })); + return { findFirst, adapter: createPrismaAdapter({ user: { findFirst } }) }; + }; + + it('escapes the email and drops a row whose address differs', async () => { + const { findFirst, adapter } = wildcardPrisma(); + + await expect(adapter.user.findByEmailInsensitive('j_hn@outlook.com')).resolves.toBeNull(); + expect(findFirst).toHaveBeenCalledWith({ + where: { email: { equals: 'j\\_hn@outlook.com', mode: 'insensitive' } }, + }); + }); + + it('escapes the username and drops a row whose username differs', async () => { + const { findFirst, adapter } = wildcardPrisma(); + + await expect(adapter.user.findByUsernameInsensitive('jo%')).resolves.toBeNull(); + expect(findFirst).toHaveBeenCalledWith({ + where: { username: { equals: 'jo\\%', mode: 'insensitive' } }, + }); + }); + + it('escapes both branches of the identifier lookup and drops a look-alike', async () => { + const { findFirst, adapter } = wildcardPrisma(); + + await expect(adapter.user.findByEmailOrUsernameInsensitive('j_hn')).resolves.toBeNull(); + expect(findFirst).toHaveBeenCalledWith({ + where: { + OR: [ + { email: { equals: 'j\\_hn', mode: 'insensitive' } }, + { username: { equals: 'j\\_hn', mode: 'insensitive' } }, + ], + }, + }); + }); + + it('still finds a stored address typed in different case', async () => { + const { adapter } = wildcardPrisma(); + + await expect(adapter.user.findByEmailInsensitive('JOHN@Outlook.com')).resolves.toMatchObject({ + id: 7, + }); + await expect(adapter.user.findByEmailOrUsernameInsensitive('John')).resolves.toMatchObject({ + id: 7, + }); + }); +}); + +describe('callers re-check the lookup result', () => { + it('OAuth never attaches a look-alike address to the stored account', async () => { + googleVerify.mockResolvedValue({ + getPayload: () => ({ sub: 'look-alike-sub', email: 'j_hn@outlook.com', email_verified: true }), + }); + const oauthAccounts = { + resolve: vi.fn(async () => null), + link: vi.fn(async () => {}), + list: vi.fn(async () => []), + unlink: vi.fn(async () => {}), + }; + const database = { + user: { + findActiveById: vi.fn(async () => null), + // A misbehaving adapter: returns the stored account for the look-alike. + findByEmailInsensitive: vi.fn(async () => ({ ...STORED })), + create: vi.fn(async (data: { email: string; username: string }) => ({ + ...STORED, + id: 8, + email: data.email, + username: data.username, + })), + }, + session: { + create: vi.fn(async (data: { userId: number }) => ({ id: 99, userId: data.userId })), + findManyByIds: vi.fn(async () => []), + }, + }; + const config = createAuthConfig({ + database, + secrets: { jwt: 'test-secret-key' }, + features: { twoFa: false, oauth: { google: true } }, + oauthKeys: { google: { clientId: 'google-client' } }, + oauthAccounts, + } as unknown as Parameters[0]); + const t = initTRPC.context().create(); + const factory = new OAuthLoginProcedureFactory( + config, + t.procedure as unknown as BaseProcedure, + t.procedure as unknown as AuthProcedure + ); + const router = t.router( + factory.createOAuthLoginProcedures({ oauth: oAuthLoginSchema } as unknown as Parameters< + typeof factory.createOAuthLoginProcedures + >[0]) + ); + + await t.createCallerFactory(router)(ctx).oAuthLogin({ provider: 'GOOGLE', idToken: 'token' }); + + expect(database.user.create).toHaveBeenCalledOnce(); + expect(oauthAccounts.link).toHaveBeenCalledWith(8, expect.anything()); + expect(oauthAccounts.link).not.toHaveBeenCalledWith(7, expect.anything()); + }); + + it('a password reset for a look-alike address sends nothing to the stored account', async () => { + const sendPasswordResetEmail = vi.fn(async () => {}); + const passwordReset = { + deleteAllByUserId: vi.fn(async () => {}), + create: vi.fn(async () => ({ id: 'reset-1' })), + }; + const database = { + user: { + // A misbehaving adapter: returns the stored account for the look-alike. + findByEmailInsensitive: vi.fn(async () => ({ ...STORED, password: 'hash' })), + }, + passwordReset, + }; + const config = createAuthConfig({ + database, + secrets: { jwt: 'test-secret-key' }, + features: { twoFa: false }, + emailService: { + sendPasswordResetEmail, + sendVerificationEmail: vi.fn(async () => {}), + sendOTPEmail: vi.fn(async () => {}), + }, + } as unknown as Parameters[0]); + const t = initTRPC.context().create(); + const factory = new BaseProcedureFactory( + config, + t.procedure as unknown as BaseProcedure, + t.procedure as unknown as AuthProcedure + ); + // Only the reset procedure is under test, so it is built on its own. + const buildReset = ( + factory as unknown as { sendPasswordResetEmail(): ReturnType } + ).sendPasswordResetEmail.bind(factory); + const router = t.router({ sendPasswordResetEmail: buildReset() }); + + const result = await t + .createCallerFactory(router)(ctx) + .sendPasswordResetEmail({ email: 'j_hn@outlook.com' }); + + expect(result).toEqual({ + message: 'If an account exists with that email, a reset link has been sent.', + }); + expect(passwordReset.create).not.toHaveBeenCalled(); + expect(sendPasswordResetEmail).not.toHaveBeenCalled(); + }); +});