diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 7e77426..01b3474 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; import argon2 from "argon2"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; @@ -129,7 +129,7 @@ export async function authRoutes(app: FastifyInstance) { const { username, password } = parsed.data; // Check if username already exists - const [existingUser] = await db.select().from(users).where(eq(users.username, username)); + const [existingUser] = await db.select().from(users).where(sql`lower(${users.username}) = lower(${username})`); if (existingUser) { return reply.status(409).send({ error: "Username already taken", code: "USERNAME_EXISTS" }); } @@ -190,7 +190,7 @@ export async function authRoutes(app: FastifyInstance) { const { username, password, rememberMe } = parsed.data; // Find user by username - const [user] = await db.select().from(users).where(eq(users.username, username)); + const [user] = await db.select().from(users).where(sql`lower(${users.username}) = lower(${username})`); // Generic error to prevent user enumeration const invalidCredentialsError = () => diff --git a/backend/src/routes/oidc.ts b/backend/src/routes/oidc.ts index a9cc089..555f4dc 100644 --- a/backend/src/routes/oidc.ts +++ b/backend/src/routes/oidc.ts @@ -1,5 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply } from "fastify"; import * as client from "openid-client"; import { db } from "../db/client.js"; @@ -234,7 +234,7 @@ async function findOrCreateOIDCUser( } // Check if username already exists (potential collision) - const [existingByUsername] = await db.select().from(users).where(eq(users.username, username)); + const [existingByUsername] = await db.select().from(users).where(sql`lower(${users.username}) = lower(${username})`); if (existingByUsername) { // Username collision! Check if it's a local user without OIDC linked diff --git a/backend/src/test/auth.test.ts b/backend/src/test/auth.test.ts index eb1abeb..d51b6c9 100644 --- a/backend/src/test/auth.test.ts +++ b/backend/src/test/auth.test.ts @@ -194,6 +194,29 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => { expect(response.json().code).toBe("USERNAME_EXISTS"); }); + it("should reject duplicate username regardless of case", async () => { + await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "CaseUser", + password: "TestPassword123", + }, + }); + + const response = await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "caseuser", + password: "AnotherPassword123", + }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().code).toBe("USERNAME_EXISTS"); + }); + it("should reject short password", async () => { const response = await app.inject({ method: "POST", @@ -275,6 +298,21 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => { expect(cookies.find((c: any) => c.name === "refresh_token")).toBeDefined(); }); + it("should login case-insensitively with different username casing", async () => { + const response = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: "LOGINUSER", + password: "TestPassword123", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().ok).toBe(true); + expect(response.json().user.username).toBe("loginuser"); + }); + it("should reject invalid password", async () => { const response = await app.inject({ method: "POST",