diff --git a/apps/api/src/app/controllers/auth.controller.ts b/apps/api/src/app/controllers/auth.controller.ts index 0f56b422..683fb44c 100644 --- a/apps/api/src/app/controllers/auth.controller.ts +++ b/apps/api/src/app/controllers/auth.controller.ts @@ -94,14 +94,14 @@ export const routeDefinition = { action: z.literal('login'), csrfToken: z.string(), captchaToken: z.string().nullish(), - email: z.string().email().min(5).max(255), + email: z.string().email().min(5).max(255).toLowerCase(), password: z.string().min(8).max(255), }), z.object({ action: z.literal('register'), csrfToken: z.string(), captchaToken: z.string().nullish(), - email: z.string().email().min(5).max(255), + email: z.string().email().min(5).max(255).toLowerCase(), name: z.string().min(1).max(255).trim(), password: z.string().min(8).max(255), }), @@ -147,7 +147,7 @@ export const routeDefinition = { requestPasswordReset: { controllerFn: () => requestPasswordReset, validators: { - body: z.object({ captchaToken: z.string().nullish(), email: z.string(), csrfToken: z.string() }), + body: z.object({ captchaToken: z.string().nullish(), email: z.string().toLowerCase(), csrfToken: z.string() }), hasSourceOrg: false, }, }, @@ -155,7 +155,7 @@ export const routeDefinition = { controllerFn: () => validatePasswordReset, validators: { body: z.object({ - email: z.string().email(), + email: z.string().email().toLowerCase(), token: z.string(), password: z.string(), csrfToken: z.string(), diff --git a/apps/jetstream-e2e/src/tests/authentication/login1.spec.ts b/apps/jetstream-e2e/src/tests/authentication/login1.spec.ts index 2375d5b0..dcdcb3e9 100644 --- a/apps/jetstream-e2e/src/tests/authentication/login1.spec.ts +++ b/apps/jetstream-e2e/src/tests/authentication/login1.spec.ts @@ -63,6 +63,14 @@ test.describe('Login 1', () => { await authenticationPage.fillOutLoginForm(email, password); await page.waitForURL(`**/app`); await expect(page.url()).toContain('/app'); + await playwrightPage.logout(); + await expect(page.getByTestId('home-hero-container')).toBeVisible(); + }); + + await test.step('Email address should be case-insensitive', async () => { + await authenticationPage.fillOutLoginForm(email.toUpperCase(), password); + await page.waitForURL(`**/app`); + await expect(page.url()).toContain('/app'); }); }); }); diff --git a/apps/jetstream-e2e/src/tests/authentication/login2.spec.ts b/apps/jetstream-e2e/src/tests/authentication/login2.spec.ts index 45642adb..3ed775dd 100644 --- a/apps/jetstream-e2e/src/tests/authentication/login2.spec.ts +++ b/apps/jetstream-e2e/src/tests/authentication/login2.spec.ts @@ -21,7 +21,8 @@ test.describe('Login 2', () => { await playwrightPage.logout(); await authenticationPage.goToPasswordReset(); - await authenticationPage.fillOutResetPasswordForm(email); + // Use uppercase email to ensure case insensitivity + await authenticationPage.fillOutResetPasswordForm(email.toUpperCase()); await expect( page.getByText('You will receive an email with instructions if an account exists and is eligible for password reset.') ).toBeVisible(); @@ -34,7 +35,10 @@ test.describe('Login 2', () => { await authenticationPage.fillOutResetPasswordVerifyForm(password, password); - await authenticationPage.loginAndVerifyEmail(email, password); + await expect(page.getByText('Login with your new password')).toBeVisible(); + + // Use uppercase email to ensure case insensitivity + await authenticationPage.loginAndVerifyEmail(email.toUpperCase(), password); await authenticationPage.page.waitForURL(`**/app`); }); diff --git a/apps/jetstream-e2e/src/tests/authentication/login3.spec.ts b/apps/jetstream-e2e/src/tests/authentication/login3.spec.ts index 8beb1bbb..c91c17f0 100644 --- a/apps/jetstream-e2e/src/tests/authentication/login3.spec.ts +++ b/apps/jetstream-e2e/src/tests/authentication/login3.spec.ts @@ -59,5 +59,10 @@ test.describe('Login 3', () => { await authenticationPage.fillOutSignUpForm(email, 'test person', password, password); await expect(page.getByText('This email is already registered.')).toBeVisible(); }); + + await test.step('Attempt to register with same email address, using email with uppercase', async () => { + await authenticationPage.fillOutSignUpForm(email.toUpperCase(), 'test person', password, password); + await expect(page.getByText('This email is already registered.')).toBeVisible(); + }); }); }); diff --git a/apps/jetstream-e2e/src/utils/database-validation.utils.ts b/apps/jetstream-e2e/src/utils/database-validation.utils.ts index 1734859b..13e7dd00 100644 --- a/apps/jetstream-e2e/src/utils/database-validation.utils.ts +++ b/apps/jetstream-e2e/src/utils/database-validation.utils.ts @@ -2,18 +2,22 @@ import { prisma } from '@jetstream/api-config'; import { SessionData } from '@jetstream/auth/types'; export async function verifyEmailLogEntryExists(email: string, subject: string) { + email = email.toLowerCase(); await prisma.emailActivity.findFirstOrThrow({ where: { email, subject: { contains: subject } } }); } export async function getPasswordResetToken(email: string) { + email = email.toLowerCase(); return await prisma.passwordResetToken.findFirst({ where: { email, expiresAt: { gt: new Date() } } }); } export async function hasPasswordResetToken(email: string, token: string) { + email = email.toLowerCase(); return (await prisma.passwordResetToken.count({ where: { email, token } })) > 0; } export async function getUserSessionByEmail(email: string) { + email = email.toLowerCase(); const session = await prisma.sessions.findFirstOrThrow({ where: { sess: { diff --git a/libs/auth/server/src/lib/auth.db.service.ts b/libs/auth/server/src/lib/auth.db.service.ts index 5dccddda..766687ef 100644 --- a/libs/auth/server/src/lib/auth.db.service.ts +++ b/libs/auth/server/src/lib/auth.db.service.ts @@ -78,6 +78,7 @@ async function findUserByProviderId(provider: OauthProviderType, providerAccount } async function findUsersByEmail(email: string) { + email = email.toLowerCase(); return prisma.user.findMany({ select: userSelect, where: { email }, @@ -384,6 +385,7 @@ export async function setPasswordForUser(id: string, password: string) { } export const generatePasswordResetToken = async (email: string) => { + email = email.toLowerCase(); // NOTE: There could be duplicate users with the same email, but only one with a password set // These users were migrated from Auth0, but we do not support this as a standard path const user = await prisma.user.findMany({ @@ -422,6 +424,7 @@ export const generatePasswordResetToken = async (email: string) => { }; export const resetUserPassword = async (email: string, token: string, password: string) => { + email = email.toLowerCase(); // if there is an existing token, delete it const restToken = await prisma.passwordResetToken.findUnique({ where: { email_token: { email, token } }, @@ -478,6 +481,7 @@ export const removePasswordFromUser = async (id: string) => { }; async function getUserAndVerifyPassword(email: string, password: string) { + email = email.toLowerCase(); const UNSAFE_userWithPassword = await prisma.user.findFirst({ select: { id: true, password: true }, where: { email, password: { not: null } }, @@ -515,6 +519,7 @@ async function getUserAndVerifyPassword(email: string, password: string) { } async function migratePasswordFromAuth0(email: string, password: string) { + email = email.toLowerCase(); // If the user has a linked social identity, we have no way to confirm 100% that this is the correct account // since we allowed same email on multiple accounts with Auth0 const userWithoutSocialIdentities = await prisma.user.findFirst({ @@ -590,10 +595,11 @@ export async function removeIdentityFromUser(userId: string, provider: OauthProv } async function createUserFromProvider(providerUser: ProviderUser, provider: OauthProviderType) { + const email = providerUser.email?.toLowerCase(); return prisma.user.create({ select: userSelect, data: { - email: providerUser.email, + email, // TODO: do we really get any benefit from storing this userId like this? // TODO: only reason I can think of is user migration since the id is a UUID so we need to different identifier // TODO: this is nice as we can identify which identity is primary without joining the identity table - but could solve in other ways @@ -608,7 +614,7 @@ async function createUserFromProvider(providerUser: ProviderUser, provider: Oaut type: 'oauth', provider: provider, providerAccountId: providerUser.id, - email: providerUser.email, + email, name: providerUser.name, emailVerified: providerUser.emailVerified, username: providerUser.username, @@ -630,6 +636,7 @@ async function createUserFromProvider(providerUser: ProviderUser, provider: Oaut async function updateIdentityAttributesFromProvider(userId: string, providerUser: ProviderUser, provider: OauthProviderType) { try { + const email = providerUser.email?.toLowerCase(); const existingProfile = await prisma.authIdentity.findUniqueOrThrow({ select: { isPrimary: true, @@ -650,7 +657,7 @@ async function updateIdentityAttributesFromProvider(userId: string, providerUser }); const skipUpdate = - existingProfile.email === providerUser.email && + existingProfile.email === email && existingProfile.name === providerUser.name && existingProfile.emailVerified === providerUser.emailVerified && existingProfile.username === providerUser.username && @@ -672,7 +679,7 @@ async function updateIdentityAttributesFromProvider(userId: string, providerUser data: { provider: provider, providerAccountId: providerUser.id, - email: providerUser.email, + email, name: providerUser.name, emailVerified: providerUser.emailVerified, username: providerUser.username, @@ -695,7 +702,7 @@ async function updateIdentityAttributesFromProvider(userId: string, providerUser data: { provider: provider, providerAccountId: providerUser.id, - email: providerUser.email, + email, name: providerUser.name, emailVerified: providerUser.emailVerified, username: providerUser.username, @@ -715,6 +722,7 @@ async function updateIdentityAttributesFromProvider(userId: string, providerUser } async function createUserFromUserInfo(email: string, name: string, password: string) { + email = email.toLowerCase(); const passwordHash = await hashPassword(password); return prisma.$transaction(async (tx) => { // Create initial user @@ -831,7 +839,9 @@ export async function handleSignInOrRegistration( isNewUser = true; } } else if (providerType === 'credentials') { - const { action, email, password } = payload; + const { action, password } = payload; + const email = payload.email.toLowerCase(); + if (!password) { throw new InvalidCredentials(); }