Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/auth-insensitive-wildcard.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 20 additions & 10 deletions packages/auth/src/adapters/prismaAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (...args: unknown[]) => Promise<unknown>>;
Expand Down Expand Up @@ -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<AuthUser | null> {
return db.user.findFirst({
where: { email: { equals: email, mode: 'insensitive' } },
}) as Promise<AuthUser | null>;
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<AuthUser | null> {
return db.user.findFirst({
where: { username: { equals: username, mode: 'insensitive' } },
}) as Promise<AuthUser | null>;
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<AuthUser | null> {
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<AuthUser | null>;
})) as AuthUser | null;
return user &&
(sameIdentifier(user.email, identifier) || sameIdentifier(user.username, identifier))
? user
: null;
},

async findById(id: number): Promise<AuthUser | null> {
Expand Down
19 changes: 14 additions & 5 deletions packages/auth/src/procedures/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.',
Expand All @@ -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.',
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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.',
Expand All @@ -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.' };
Expand Down
6 changes: 5 additions & 1 deletion packages/auth/src/procedures/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
22 changes: 20 additions & 2 deletions packages/auth/src/procedures/twoFa/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends { email?: string | null; username?: string | null }>(
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
Expand Down Expand Up @@ -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.' });
Expand Down Expand Up @@ -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' });
Expand Down
25 changes: 25 additions & 0 deletions packages/auth/src/utilities/emailMatch.ts
Original file line number Diff line number Diff line change
@@ -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();
}
4 changes: 3 additions & 1 deletion packages/auth/src/utilities/loginMethods.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -49,7 +50,8 @@ export async function resolveLoginMethods(
config: ResolvedAuthConfig,
username: string
): Promise<ResolvedLoginMethods> {
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: [] };
}
Expand Down
Loading
Loading