From edff9a2f8e44419c8b68902af805d8242cc278ef Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 15 Jul 2026 07:10:11 +0000 Subject: [PATCH 01/12] feat(server): add lucia v2 compatible password hashing utilities Ports lucia v2's scrypt-based hash/verify and random string generation so existing password hashes stay valid without a data migration, ahead of removing the lucia dependency itself. Co-Authored-By: Claude Sonnet 5 --- src/lib/server/password.test.ts | 109 ++++++++++++++++++++++++++++++++ src/lib/server/password.ts | 55 ++++++++++++++++ src/lib/server/random.test.ts | 28 ++++++++ src/lib/server/random.ts | 20 ++++++ 4 files changed, 212 insertions(+) create mode 100644 src/lib/server/password.test.ts create mode 100644 src/lib/server/password.ts create mode 100644 src/lib/server/random.test.ts create mode 100644 src/lib/server/random.ts diff --git a/src/lib/server/password.test.ts b/src/lib/server/password.test.ts new file mode 100644 index 000000000..8ae9c9df6 --- /dev/null +++ b/src/lib/server/password.test.ts @@ -0,0 +1,109 @@ +import { describe, test, expect } from 'vitest'; + +import { hashPassword, verifyPassword } from './password'; + +// Fixtures generated with lucia v2 (`generateLuciaPasswordHash` from 'lucia/utils') before removing +// lucia, so these hashes are the compatibility anchor. Regenerate with: +// import { generateLuciaPasswordHash } from 'lucia/utils'; +// for (const p of ['Ch0kuda1', 'AtC0derN0viSteps', 'Ch0kuda1']) console.log(await generateLuciaPasswordHash(p)); +// +// - 'Ch0kuda1': the seed password (authSchema-compliant, prod-compatible anchor) +// - 'AtC0derN0viSteps': a second compliant password (rules out an accidental single match) +// - 'Ch0kuda1': out of the app input domain (authSchema is ASCII-only); NFKC-normalizes to +// 'Ch0kuda1', so verifying it with the half-width input proves the normalize step exists. +const LUCIA_FIXTURES = { + ascii1: { + password: 'Ch0kuda1', + hash: 's2:idsv8a6pec9boqr1:05e7bdf0828585f6a8ffbcfe04837524993eaa22df5c769df0388dd4c4d9f71bad9b51452167f22d5d17cea4595cae0d01528b01ca2a4b3c48cbad5668b9671d', + }, + ascii2: { + password: 'AtC0derN0viSteps', + hash: 's2:jky3g008bt42uobf:f1ad5f5fa1920891f5812d578b5db26da43c0258433df37edd144e53383b71a60ed3cd43f0c87e55035d116bdc575ce38fec96127ebf030afbb02179acbba0af', + }, + fullWidth: { + password: 'Ch0kuda1', + hash: 's2:pxgjkm74zjfu1am5:e87a0132275c95eaee49b8f4ef6806d177877a557e1b2a1c074fb34531951a8b8db6a3ac7c212e02b6caa4737c9d7b4b93ce48e4d57c4bb7dbc34fb09d51c20f', + }, +} as const; + +describe('hashPassword', () => { + test('produces the s2:{16-char salt}:{128-char hex} format', async () => { + const hash = await hashPassword('Ch0kuda1'); + expect(hash).toMatch(/^s2:[a-z0-9]{16}:[0-9a-f]{128}$/); + }); + + test('round-trips: a freshly hashed password verifies against its own hash', async () => { + const hash = await hashPassword('AtC0derN0viSteps'); + expect(await verifyPassword('AtC0derN0viSteps', hash)).toBe(true); + }); + + test('uses a random salt so the same password hashes to different values', async () => { + const first = await hashPassword('Ch0kuda1'); + const second = await hashPassword('Ch0kuda1'); + expect(first).not.toBe(second); + }); +}); + +describe('verifyPassword', () => { + describe('lucia v2 compatibility (byte-for-byte)', () => { + test('accepts the seed password against its lucia-generated hash', async () => { + expect(await verifyPassword(LUCIA_FIXTURES.ascii1.password, LUCIA_FIXTURES.ascii1.hash)).toBe( + true, + ); + }); + + test('accepts a second compliant password against its lucia-generated hash', async () => { + expect(await verifyPassword(LUCIA_FIXTURES.ascii2.password, LUCIA_FIXTURES.ascii2.hash)).toBe( + true, + ); + }); + }); + + describe('NFKC normalization guard (out of app input domain)', () => { + test('accepts the full-width input against its own hash', async () => { + expect( + await verifyPassword(LUCIA_FIXTURES.fullWidth.password, LUCIA_FIXTURES.fullWidth.hash), + ).toBe(true); + }); + + test('accepts the half-width input against the full-width hash (normalize exists)', async () => { + expect(await verifyPassword('Ch0kuda1', LUCIA_FIXTURES.fullWidth.hash)).toBe(true); + }); + + test('accepts the full-width input against the half-width hash (normalize is symmetric)', async () => { + expect(await verifyPassword('Ch0kuda1', LUCIA_FIXTURES.ascii1.hash)).toBe(true); + }); + }); + + describe('returns false without throwing', () => { + test('wrong password against a valid hash', async () => { + expect(await verifyPassword('wrongPassword1', LUCIA_FIXTURES.ascii1.hash)).toBe(false); + }); + + test('legacy two-part {salt}:{hash} format (lucia v1)', async () => { + const [, salt, hash] = LUCIA_FIXTURES.ascii1.hash.split(':'); + expect(await verifyPassword('Ch0kuda1', `${salt}:${hash}`)).toBe(false); + }); + + test('bcrypt $2a$ format', async () => { + expect( + await verifyPassword( + 'Ch0kuda1', + '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', + ), + ).toBe(false); + }); + + test('empty stored hash', async () => { + expect(await verifyPassword('Ch0kuda1', '')).toBe(false); + }); + + test('malformed hash with too few parts (s2:saltonly)', async () => { + expect(await verifyPassword('Ch0kuda1', 's2:saltonly')).toBe(false); + }); + + test('empty password against a valid hash', async () => { + expect(await verifyPassword('', LUCIA_FIXTURES.ascii1.hash)).toBe(false); + }); + }); +}); diff --git a/src/lib/server/password.ts b/src/lib/server/password.ts new file mode 100644 index 000000000..487cf3209 --- /dev/null +++ b/src/lib/server/password.ts @@ -0,0 +1,55 @@ +import { scrypt, timingSafeEqual, type BinaryLike, type ScryptOptions } from 'node:crypto'; +import { promisify } from 'node:util'; + +import { generateRandomString } from './random'; + +// Explicit type args select scrypt's options overload; a bare promisify(scrypt) resolves to the +// 3-arg signature and rejects the maxmem options object. +const scryptAsync = promisify(scrypt); + +// lucia v2 compatible parameters. 128 * N * r = 32MiB hits the default maxmem, so raise it to 64MiB. +const SCRYPT_OPTIONS = { N: 16384, r: 16, p: 1, maxmem: 64 * 1024 * 1024 }; +const KEY_LENGTH = 64; +const SALT_LENGTH = 16; +const HASH_VERSION = 's2'; + +/** Generates a lucia v2 compatible password hash: `s2:{salt}:{hash}`. */ +export const hashPassword = async (password: string): Promise => { + const salt = generateRandomString(SALT_LENGTH); + const hash = await computeHash(password, salt); + + return `${HASH_VERSION}:${salt}:${hash}`; +}; + +/** Verifies a password against a stored hash. Unknown formats (v1 two-part, bcrypt $2a) return false. */ +export const verifyPassword = async (password: string, storedHash: string): Promise => { + const parts = storedHash.split(':'); + + if (parts.length !== 3 || parts[0] !== HASH_VERSION) { + return false; + } + + const [, salt, expectedHash] = parts; + const actualHash = await computeHash(password, salt); + const expectedBuffer = Buffer.from(expectedHash, 'hex'); + const actualBuffer = Buffer.from(actualHash, 'hex'); + + // timingSafeEqual throws on length mismatch, so check length first + if (expectedBuffer.length !== actualBuffer.length) { + return false; + } + + return timingSafeEqual(expectedBuffer, actualBuffer); +}; + +const computeHash = async (password: string, salt: string): Promise => { + // NFKC-normalize so visually identical unicode inputs hash identically (lucia v2 behavior) + const derivedKey = await scryptAsync( + password.normalize('NFKC'), + salt, + KEY_LENGTH, + SCRYPT_OPTIONS, + ); + + return derivedKey.toString('hex'); +}; diff --git a/src/lib/server/random.test.ts b/src/lib/server/random.test.ts new file mode 100644 index 000000000..0372b482a --- /dev/null +++ b/src/lib/server/random.test.ts @@ -0,0 +1,28 @@ +import { describe, test, expect } from 'vitest'; + +import { generateRandomString } from './random'; + +const ALPHABET_PATTERN = /^[a-z0-9]+$/; + +describe('generateRandomString', () => { + test.each([16, 15, 40])('returns a string of the requested length (%i)', (length) => { + expect(generateRandomString(length)).toHaveLength(length); + }); + + test('returns only characters from the 36-char [a-z0-9] alphabet', () => { + // 1000 samples across the lengths lucia v2 uses (salt / user id / session id) + for (let i = 0; i < 1000; i++) { + expect(generateRandomString(40)).toMatch(ALPHABET_PATTERN); + } + }); + + test('returns a different value on each call (no collisions)', () => { + const values = new Set(); + + for (let i = 0; i < 1000; i++) { + values.add(generateRandomString(40)); + } + + expect(values.size).toBe(1000); + }); +}); diff --git a/src/lib/server/random.ts b/src/lib/server/random.ts new file mode 100644 index 000000000..9197fc61e --- /dev/null +++ b/src/lib/server/random.ts @@ -0,0 +1,20 @@ +// Ported from lucia v2's generateRandomString (lucia/dist/utils/crypto.js). lucia's signature takes +// an optional `alphabet` param, but every call site — lucia's own (salt / user id / session id) and +// ours — uses this default 36-char alphabet, so it is inlined instead of kept as an unused param. +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz1234567890'; + +/** lucia v2 compatible random string (used for salts, user ids, and session ids). */ +export const generateRandomString = (length: number): string => { + const randomUint32Values = new Uint32Array(length); + crypto.getRandomValues(randomUint32Values); + const u32Max = 0xffffffff; + let result = ''; + + // bound the loop by the array length (lucia's exact condition), not the raw `length` argument + for (let i = 0; i < randomUint32Values.length; i++) { + const rand = randomUint32Values[i] / (u32Max + 1); + result += ALPHABET[Math.floor(ALPHABET.length * rand)]; + } + + return result; +}; From a290c2a63ce8545ed6cd3b3c4e727296ec1827ad Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 15 Jul 2026 07:33:26 +0000 Subject: [PATCH 02/12] feat(server): add lucia v2 compatible session management Reimplements lucia v2's session create/validate/invalidate semantics (24h active + 14d idle window, idle-triggered renewal without id rotation, idempotent delete) directly against Prisma, continuing the lucia dependency removal alongside the password hashing utilities. Co-Authored-By: Claude Sonnet 5 --- src/lib/server/session.test.ts | 202 +++++++++++++++++++++++++++++++++ src/lib/server/session.ts | 95 ++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 src/lib/server/session.test.ts create mode 100644 src/lib/server/session.ts diff --git a/src/lib/server/session.test.ts b/src/lib/server/session.test.ts new file mode 100644 index 000000000..7f2ff9eee --- /dev/null +++ b/src/lib/server/session.test.ts @@ -0,0 +1,202 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { Prisma, Roles } from '@prisma/client'; + +vi.mock('$lib/server/database', () => ({ + default: { + session: { + create: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }, +})); + +import db from '$lib/server/database'; +import { createSession, validateSession, invalidateSession, SESSION_COOKIE_NAME } from './session'; + +const mockDb = db as unknown as { + session: { + create: ReturnType; + findUnique: ReturnType; + update: ReturnType; + delete: ReturnType; + }; +}; + +// lucia v2 defaults, duplicated here so assertions pin literal periods rather than the impl's constants +const ACTIVE_PERIOD_MS = 1000 * 60 * 60 * 24; // 24h +const IDLE_PERIOD_MS = 1000 * 60 * 60 * 24 * 14; // 14d + +// fixed clock so expiration math is deterministic under fake timers +const NOW = new Date('2026-07-15T00:00:00.000Z').getTime(); + +const SESSION_ID = 'a'.repeat(40); +const USER = { id: 'abcdefghij12345', username: 'guest', role: Roles.USER }; + +// active_expires / idle_expires MUST be BigInt here: Prisma returns them as bigint, so a Number mock +// would hide a missing Number() conversion in the implementation. +const buildSessionRow = (activeExpires: number, idleExpires: number) => ({ + id: SESSION_ID, + user_id: USER.id, + active_expires: BigInt(activeExpires), + idle_expires: BigInt(idleExpires), + user: { id: USER.id, username: USER.username, role: USER.role }, +}); + +// a fully-active session (both deadlines well in the future); shared by the two active-state cases +const activeSessionRow = () => + buildSessionRow(NOW + ACTIVE_PERIOD_MS, NOW + ACTIVE_PERIOD_MS + IDLE_PERIOD_MS); + +const mockFindUnique = (row: ReturnType | null) => + mockDb.session.findUnique.mockResolvedValue(row); + +const buildPrismaError = (code: string, message: string) => + new Prisma.PrismaClientKnownRequestError(message, { code, clientVersion: '5.0.0' }); + +beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('createSession', () => { + test('creates a row with a 40-char [a-z0-9] id and lucia v2 expirations', async () => { + const result = await createSession(USER.id); + + expect(result.sessionId).toMatch(/^[a-z0-9]{40}$/); + // expirations are written as Number (not BigInt), matching the previous lucia adapter + expect(mockDb.session.create).toHaveBeenCalledWith({ + data: { + id: result.sessionId, + user_id: USER.id, + active_expires: NOW + ACTIVE_PERIOD_MS, + idle_expires: NOW + ACTIVE_PERIOD_MS + IDLE_PERIOD_MS, + }, + }); + }); + + test('returns the idle-period expiry as the cookie expiry Date', async () => { + const result = await createSession(USER.id); + + expect(result.idlePeriodExpiresAt).toEqual(new Date(NOW + ACTIVE_PERIOD_MS + IDLE_PERIOD_MS)); + }); +}); + +describe('validateSession', () => { + describe('active session (now <= active_expires)', () => { + test('returns the session and user without updating or deleting', async () => { + mockFindUnique(activeSessionRow()); + + const result = await validateSession(SESSION_ID); + + expect(result).toEqual({ + sessionId: SESSION_ID, + user: { userId: USER.id, username: USER.username, role: USER.role }, + }); + expect(mockDb.session.update).not.toHaveBeenCalled(); + expect(mockDb.session.delete).not.toHaveBeenCalled(); + }); + + test('reads the session and user in a single findUnique with include: { user: true }', async () => { + mockFindUnique(activeSessionRow()); + + await validateSession(SESSION_ID); + + expect(mockDb.session.findUnique).toHaveBeenCalledWith({ + where: { id: SESSION_ID }, + include: { user: true }, + }); + }); + + test('treats now === active_expires as active (no write) — lucia > comparison', async () => { + mockFindUnique(buildSessionRow(NOW, NOW + IDLE_PERIOD_MS)); + + const result = await validateSession(SESSION_ID); + + expect(result).not.toBeNull(); + expect(mockDb.session.update).not.toHaveBeenCalled(); + }); + }); + + describe('idle session (active_expires < now <= idle_expires)', () => { + test('extends both expirations on the same id without deleting', async () => { + mockFindUnique(buildSessionRow(NOW - 1000, NOW + IDLE_PERIOD_MS)); + + const result = await validateSession(SESSION_ID); + + expect(result).not.toBeNull(); + expect(mockDb.session.update).toHaveBeenCalledWith({ + where: { id: SESSION_ID }, + data: { + active_expires: NOW + ACTIVE_PERIOD_MS, + idle_expires: NOW + ACTIVE_PERIOD_MS + IDLE_PERIOD_MS, + }, + }); + expect(mockDb.session.delete).not.toHaveBeenCalled(); + }); + + test('treats now === idle_expires as idle (extends) — lucia > comparison', async () => { + mockFindUnique(buildSessionRow(NOW - IDLE_PERIOD_MS, NOW)); + + await validateSession(SESSION_ID); + + expect(mockDb.session.update).toHaveBeenCalledOnce(); + }); + }); + + describe('dead session (now > idle_expires)', () => { + test('returns null and keeps the row (no update, no delete)', async () => { + mockFindUnique(buildSessionRow(NOW - IDLE_PERIOD_MS - ACTIVE_PERIOD_MS, NOW - 1)); + + const result = await validateSession(SESSION_ID); + + expect(result).toBeNull(); + expect(mockDb.session.update).not.toHaveBeenCalled(); + expect(mockDb.session.delete).not.toHaveBeenCalled(); + }); + }); + + describe('missing session', () => { + test('returns null when the row does not exist', async () => { + mockFindUnique(null); + + expect(await validateSession(SESSION_ID)).toBeNull(); + }); + }); +}); + +describe('invalidateSession', () => { + test('deletes the session row by id', async () => { + mockDb.session.delete.mockResolvedValue(buildSessionRow(NOW, NOW)); + + await invalidateSession(SESSION_ID); + + expect(mockDb.session.delete).toHaveBeenCalledWith({ where: { id: SESSION_ID } }); + }); + + test('swallows P2025 (row already gone) for logout idempotency', async () => { + const error = buildPrismaError('P2025', 'Record to delete does not exist.'); + mockDb.session.delete.mockRejectedValue(error); + + await expect(invalidateSession(SESSION_ID)).resolves.toBeUndefined(); + }); + + test('re-throws Prisma errors other than P2025', async () => { + const error = buildPrismaError('P2003', 'Foreign key constraint failed.'); + mockDb.session.delete.mockRejectedValue(error); + + await expect(invalidateSession(SESSION_ID)).rejects.toBe(error); + }); +}); + +describe('SESSION_COOKIE_NAME', () => { + test('matches the lucia v2 cookie name so existing sessions stay valid', () => { + expect(SESSION_COOKIE_NAME).toBe('auth_session'); + }); +}); diff --git a/src/lib/server/session.ts b/src/lib/server/session.ts new file mode 100644 index 000000000..a85910661 --- /dev/null +++ b/src/lib/server/session.ts @@ -0,0 +1,95 @@ +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; +import type { Roles } from '@prisma/client'; + +import client from '$lib/server/database'; +import { generateRandomString } from '$lib/server/random'; + +// lucia v2 defaults: active 24h, idle +14d +const ACTIVE_PERIOD_MS = 1000 * 60 * 60 * 24; +const IDLE_PERIOD_MS = 1000 * 60 * 60 * 24 * 14; +const SESSION_ID_LENGTH = 40; + +export const SESSION_COOKIE_NAME = 'auth_session'; + +export type SessionCookieData = { sessionId: string; idlePeriodExpiresAt: Date }; + +export type ValidatedSession = { + sessionId: string; + user: { userId: string; username: string; role: Roles }; +}; + +export const createSession = async (userId: string): Promise => { + const sessionId = generateRandomString(SESSION_ID_LENGTH); + const { activePeriodExpiresAt, idlePeriodExpiresAt } = computeExpirations(Date.now()); + + await client.session.create({ + data: { + id: sessionId, + user_id: userId, + active_expires: activePeriodExpiresAt.getTime(), + idle_expires: idlePeriodExpiresAt.getTime(), + }, + }); + + return { sessionId, idlePeriodExpiresAt }; +}; + +export const validateSession = async (sessionId: string): Promise => { + const session = await client.session.findUnique({ + where: { id: sessionId }, + include: { user: true }, + }); + + if (!session) { + return null; + } + + const now = Date.now(); + + // lucia v2: expired only when now is strictly past the deadline; dead rows are kept (no cleanup) + if (now > Number(session.idle_expires)) { + return null; + } + + if (now > Number(session.active_expires)) { + // idle state: extend expirations keeping the same id (no rotation, cookie untouched) + const { activePeriodExpiresAt, idlePeriodExpiresAt } = computeExpirations(now); + + await client.session.update({ + where: { id: sessionId }, + data: { + active_expires: activePeriodExpiresAt.getTime(), + idle_expires: idlePeriodExpiresAt.getTime(), + }, + }); + } + + return { + sessionId: session.id, + user: { + userId: session.user.id, + username: session.user.username, + role: session.user.role, + }, + }; +}; + +export const invalidateSession = async (sessionId: string): Promise => { + try { + await client.session.delete({ where: { id: sessionId } }); + } catch (error) { + // P2025 (row already gone): swallow for logout idempotency, same as the lucia adapter + if (error instanceof PrismaClientKnownRequestError && error.code === 'P2025') { + return; + } + + throw error; + } +}; + +const computeExpirations = (now: number) => { + const activePeriodExpiresAt = new Date(now + ACTIVE_PERIOD_MS); + const idlePeriodExpiresAt = new Date(activePeriodExpiresAt.getTime() + IDLE_PERIOD_MS); + + return { activePeriodExpiresAt, idlePeriodExpiresAt }; +}; From 280edf78a89034c19fec066226a13387ef2fd595 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 15 Jul 2026 11:22:31 +0000 Subject: [PATCH 03/12] feat(server): wire self-managed session/auth into hooks and app.d.ts Adds createAuthRequest as a lucia-free replacement for auth.handleRequest(event), backed by the new session service (validateSession) and session cookie constants. Wires it into hooks.server.ts and retypes app.d.ts's Locals.auth accordingly. The lucia import in auth.ts is still present pending full removal. Co-Authored-By: Claude Sonnet 5 --- src/app.d.ts | 2 +- src/hooks.server.ts | 6 +- src/lib/server/auth.test.ts | 127 ++++++++++++++++++++++++++++++++++++ src/lib/server/auth.ts | 66 +++++++++++++++++++ 4 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 src/lib/server/auth.test.ts diff --git a/src/app.d.ts b/src/app.d.ts index 22e732c7a..845182fb8 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -7,7 +7,7 @@ declare global { namespace App { // interface Error {} interface Locals { - auth: import('lucia').AuthRequest; + auth: import('$lib/server/auth').AuthRequest; user: { id: string; name: string; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 5fb9430a8..1f33289f2 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,14 +1,14 @@ // See: -// https://lucia-auth.com/getting-started/sveltekit/ // https://github.com/joysofcode/sveltekit-deploy // https://tech-blog.rakus.co.jp/entry/20230209/sveltekit -import { auth } from '$lib/server/auth'; import type { Handle } from '@sveltejs/kit'; +import { createAuthRequest } from '$lib/server/auth'; + import * as userService from '$lib/services/users'; export const handle: Handle = async ({ event, resolve }) => { - event.locals.auth = auth.handleRequest(event); + event.locals.auth = createAuthRequest(event); const session = await event.locals.auth.validate(); if (!session) { diff --git a/src/lib/server/auth.test.ts b/src/lib/server/auth.test.ts new file mode 100644 index 000000000..3bcc18b57 --- /dev/null +++ b/src/lib/server/auth.test.ts @@ -0,0 +1,127 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; + +import type { RequestEvent } from '@sveltejs/kit'; +import { Roles } from '@prisma/client'; + +// dev = false so `secure: !dev` resolves to true (the production-relevant cookie flag) +vi.mock('$app/environment', () => ({ dev: false })); + +vi.mock('$lib/server/session', () => ({ + SESSION_COOKIE_NAME: 'auth_session', + validateSession: vi.fn(), +})); + +// auth.ts still initializes lucia in Phase 3, which pulls in the database client; stub it out +vi.mock('$lib/server/database', () => ({ + default: { session: {}, key: {}, user: {} }, +})); + +import { createAuthRequest } from './auth'; +import { validateSession, SESSION_COOKIE_NAME } from '$lib/server/session'; + +const mockValidateSession = validateSession as unknown as ReturnType; + +const VALID_SESSION = { + sessionId: 'a'.repeat(40), + user: { userId: 'abcdefghij12345', username: 'guest', role: Roles.USER }, +}; + +const createMockEvent = (cookieValue?: string) => { + const cookies = { + get: vi.fn().mockReturnValue(cookieValue), + set: vi.fn(), + delete: vi.fn(), + }; + + return { cookies } as unknown as RequestEvent & { + cookies: { + get: ReturnType; + set: ReturnType; + delete: ReturnType; + }; + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('createAuthRequest', () => { + describe('validate', () => { + test('returns null and skips validateSession when no cookie is present', async () => { + const event = createMockEvent(undefined); + const auth = createAuthRequest(event); + + expect(await auth.validate()).toBeNull(); + expect(mockValidateSession).not.toHaveBeenCalled(); + }); + + test('returns the validated session for a present, valid cookie', async () => { + mockValidateSession.mockResolvedValue(VALID_SESSION); + const event = createMockEvent(VALID_SESSION.sessionId); + const auth = createAuthRequest(event); + + expect(await auth.validate()).toEqual(VALID_SESSION); + expect(mockValidateSession).toHaveBeenCalledWith(VALID_SESSION.sessionId); + }); + + test('caches within the request: a second validate() does not re-query', async () => { + mockValidateSession.mockResolvedValue(VALID_SESSION); + const event = createMockEvent(VALID_SESSION.sessionId); + const auth = createAuthRequest(event); + + await auth.validate(); + await auth.validate(); + + expect(mockValidateSession).toHaveBeenCalledOnce(); + }); + + test('clears the stale cookie and returns null when the session is invalid', async () => { + mockValidateSession.mockResolvedValue(null); + const event = createMockEvent('stale-session-id'); + const auth = createAuthRequest(event); + + expect(await auth.validate()).toBeNull(); + expect(event.cookies.delete).toHaveBeenCalledWith(SESSION_COOKIE_NAME, { path: '/' }); + }); + }); + + describe('setSession', () => { + test('sets the cookie with lucia v2 attributes for a session', () => { + const event = createMockEvent(); + const auth = createAuthRequest(event); + const idlePeriodExpiresAt = new Date('2026-07-29T00:00:00.000Z'); + + auth.setSession({ sessionId: VALID_SESSION.sessionId, idlePeriodExpiresAt }); + + expect(event.cookies.set).toHaveBeenCalledWith(SESSION_COOKIE_NAME, VALID_SESSION.sessionId, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: true, // !dev, with dev mocked to false + expires: idlePeriodExpiresAt, + }); + }); + + test('deletes the cookie when passed null', () => { + const event = createMockEvent(); + const auth = createAuthRequest(event); + + auth.setSession(null); + + expect(event.cookies.delete).toHaveBeenCalledWith(SESSION_COOKIE_NAME, { path: '/' }); + }); + + test('invalidates the request cache so the next validate() re-queries', async () => { + mockValidateSession.mockResolvedValue(VALID_SESSION); + const event = createMockEvent(VALID_SESSION.sessionId); + const auth = createAuthRequest(event); + + await auth.validate(); + auth.setSession(null); + await auth.validate(); + + expect(mockValidateSession).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 1743d4b8e..488100fa6 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -5,7 +5,15 @@ import { lucia } from 'lucia'; import { sveltekit } from 'lucia/middleware'; import { dev } from '$app/environment'; import { prisma } from '@lucia-auth/adapter-prisma'; +import type { RequestEvent } from '@sveltejs/kit'; + import client from '$lib/server/database'; +import { + SESSION_COOKIE_NAME, + validateSession, + type SessionCookieData, + type ValidatedSession, +} from '$lib/server/session'; export const auth = lucia({ env: dev ? 'DEV' : 'PROD', @@ -23,3 +31,61 @@ export const auth = lucia({ }); export type Auth = typeof auth; + +export type AuthRequest = { + validate: () => Promise; + setSession: (session: SessionCookieData | null) => void; +}; + +/** + * Request-scoped auth handle that replaces lucia's `auth.handleRequest(event)`. + * Reads/writes the session cookie and delegates session lookup to the self-managed session service. + */ +export const createAuthRequest = (event: RequestEvent): AuthRequest => { + let validatePromise: Promise | null = null; + + const setSessionCookie = (session: SessionCookieData | null): void => { + if (session) { + event.cookies.set(SESSION_COOKIE_NAME, session.sessionId, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: !dev, + expires: session.idlePeriodExpiresAt, + }); + } else { + event.cookies.delete(SESSION_COOKIE_NAME, { path: '/' }); + } + }; + + return { + validate: () => { + // request-scoped cache: hooks and route guards both call validate() on every request + if (validatePromise) { + return validatePromise; + } + + validatePromise = (async () => { + const sessionId = event.cookies.get(SESSION_COOKIE_NAME); + + if (!sessionId) { + return null; + } + + const session = await validateSession(sessionId); + + if (!session) { + setSessionCookie(null); // clear the stale cookie (lucia v2 behavior) + } + + return session; + })(); + + return validatePromise; + }, + setSession: (session) => { + validatePromise = null; + setSessionCookie(session); + }, + }; +}; From de8e2a71b32a01ce38e754ace1109452fa4b2958 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 15 Jul 2026 12:47:48 +0000 Subject: [PATCH 04/12] refactor(auth): remove lucia dependency, run auth on self-managed session/credentials Replaces lucia's createUser/createSession/useKey/invalidateSession calls in the login, signup, and logout routes with the new credentials service (registerUser/authenticateUser) and session service (createSession/invalidateSession) added in prior commits. Drops @lucia-auth/adapter-prisma and lucia from package.json/lockfile, removes the lucia auth() export, and cleans up now-stale lucia references in schema.prisma, database.ts, app.d.ts, and auth.md. Co-Authored-By: Claude Sonnet 5 --- .claude/rules/auth.md | 11 +- package.json | 2 - pnpm-lock.yaml | 24 --- prisma/schema.prisma | 10 +- prisma/seed.ts | 7 +- src/app.d.ts | 14 -- .../services/atcoder_verification.test.ts | 3 +- .../auth/services/credentials.test.ts | 157 ++++++++++++++++++ src/features/auth/services/credentials.ts | 52 ++++++ src/lib/server/auth.test.ts | 5 - src/lib/server/auth.ts | 24 --- src/lib/server/database.ts | 1 - src/routes/(auth)/login/+page.server.ts | 33 ++-- src/routes/(auth)/logout/+page.server.ts | 7 +- src/routes/(auth)/signup/+page.server.ts | 42 +---- src/routes/+layout.server.ts | 2 - src/routes/+page.server.ts | 2 - src/routes/+page.svelte | 2 - 18 files changed, 245 insertions(+), 153 deletions(-) create mode 100644 src/features/auth/services/credentials.test.ts create mode 100644 src/features/auth/services/credentials.ts diff --git a/.claude/rules/auth.md b/.claude/rules/auth.md index 6fc8c9b5b..a287e753e 100644 --- a/.claude/rules/auth.md +++ b/.claude/rules/auth.md @@ -10,10 +10,11 @@ paths: # Authentication -## Lucia v2 +## Self-Managed Session Auth -- Session validation in `src/hooks.server.ts` -- Session data attached to `event.locals.user` +Auth is a self-managed implementation (no external auth library), kept compatible with the retired lucia v2 so existing sessions and password hashes stay valid. See `## Key Files` for the module map. + +- Session validation in `src/hooks.server.ts`; session data attached to `event.locals.user` - User properties: `id`, `name`, `role`, `atcoder_name`, `is_validated` ## Protected Routes @@ -30,7 +31,9 @@ paths: ## Key Files -- `src/lib/server/auth.ts`: Lucia configuration +- `src/lib/server/auth.ts`: `createAuthRequest` (request-scoped session handle) +- `src/lib/server/session.ts` / `src/lib/server/password.ts`: self-managed session + password crypto +- `src/features/auth/services/credentials.ts`: `registerUser` / `authenticateUser` - `src/hooks.server.ts`: Global request handler - `src/features/auth/services/session.ts`: - `getLoggedInUser(locals, url?)` — returns logged-in user or redirects to `/login` diff --git a/package.json b/package.json index a32e1b8f4..0d2c597e4 100644 --- a/package.json +++ b/package.json @@ -73,12 +73,10 @@ "dependencies": { "@dnd-kit/helpers": "0.5.0", "@dnd-kit/svelte": "0.5.0", - "@lucia-auth/adapter-prisma": "3.0.2", "@lucide/svelte": "1.24.0", "@prisma/client": "5.22.0", "@types/node": "25.9.5", "debug": "4.4.3", - "lucia": "2.7.7", "p-queue": "9.3.1", "playwright": "1.61.1", "svelte-eslint-parser": "1.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb2924319..15d5808c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,9 +33,6 @@ importers: '@dnd-kit/svelte': specifier: 0.5.0 version: 0.5.0(svelte@5.56.4(@typescript-eslint/types@8.64.0)) - '@lucia-auth/adapter-prisma': - specifier: 3.0.2 - version: 3.0.2(@prisma/client@5.22.0(prisma@5.22.0))(lucia@2.7.7) '@lucide/svelte': specifier: 1.24.0 version: 1.24.0(svelte@5.56.4(@typescript-eslint/types@8.64.0)) @@ -48,9 +45,6 @@ importers: debug: specifier: 4.4.3 version: 4.4.3 - lucia: - specifier: 2.7.7 - version: 2.7.7 p-queue: specifier: 9.3.1 version: 9.3.1 @@ -763,13 +757,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@lucia-auth/adapter-prisma@3.0.2': - resolution: {integrity: sha512-EyJWZene1/zasPwPctv8wwNErZt5mwwm5JATbhg+kXr3R8pbC7lJfVzDTAeeFClVH5k/FywRcsBl3JkPaNIcow==} - deprecated: This package has been deprecated. Please see https://lucia-auth.com/lucia-v3/migrate. - peerDependencies: - '@prisma/client': ^4.2.0 || ^5.0.0 - lucia: ^2.0.0 - '@lucide/svelte@1.24.0': resolution: {integrity: sha512-yXwewA7ANQ5hfaSDrvsecosWjZn5RglzeXUZRSnxeANBskpNwblOkEJTqD0ujDdNKIKL8E9eVc2U/P3ziJr7OA==} peerDependencies: @@ -2909,10 +2896,6 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} - lucia@2.7.7: - resolution: {integrity: sha512-Hy1nMcLquLl3yDvV9zYA9fSBGUDy/Fw2zEUw2Ia4iGdk/O+hI/TvQ1tAfED/U1WE/c5DT1hEger3m7qIx1qbUg==} - deprecated: This package has been deprecated. Please see https://lucia-auth.com/lucia-v3/migrate. - luxon@3.7.2: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} @@ -4481,11 +4464,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@lucia-auth/adapter-prisma@3.0.2(@prisma/client@5.22.0(prisma@5.22.0))(lucia@2.7.7)': - dependencies: - '@prisma/client': 5.22.0(prisma@5.22.0) - lucia: 2.7.7 - '@lucide/svelte@1.24.0(svelte@5.56.4(@typescript-eslint/types@8.64.0))': dependencies: svelte: 5.56.4(@typescript-eslint/types@8.64.0) @@ -6689,8 +6667,6 @@ snapshots: lru-cache@7.18.3: {} - lucia@2.7.7: {} - luxon@3.7.2: {} magic-string@0.30.21: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9c518fbfe..1dde3079e 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,13 +31,9 @@ datasource db { directUrl = env("DIRECT_URL") } -// Note: Lucia v3 + v3 Prisma adaperでは、全てのフィールドがキャメルケースであることが要求されている。 -// 段階的に移行を進めるため、modelを追加・修正する場合は、キャメルケースで記述する。 -// See: -// https://lucia-auth.com/upgrade-v3/prisma/postgresql - -// See: -// https://lucia-auth.com/database-adapters/prisma/ +// Note: write new models and fields in camelCase. +// The snake_case fields on User / Session / Key are legacy (from the old lucia adapter); +// kept as-is to avoid a DB migration. model User { id String @id @unique // here you can add custom fields for your user diff --git a/prisma/seed.ts b/prisma/seed.ts index 03070452b..f94de65c7 100755 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -19,7 +19,7 @@ import { defineWorkBookFactory, } from './.fabbrica'; import PQueue from 'p-queue'; -import { generateLuciaPasswordHash } from 'lucia/utils'; +import { hashPassword } from '../src/lib/server/password'; import { getTaskGrade } from '../src/lib/types/task'; import type { PlacementCreate } from '../src/features/workbooks/types/workbook_placement'; @@ -62,7 +62,6 @@ const QUEUE_CONCURRENCY = { // See: // https://github.com/TeemuKoivisto/sveltekit-monorepo-template/blob/main/packages/db/prisma/seed.ts -// https://lucia-auth.com/basics/keys/#password-hashing // https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#findunique // https://github.com/sindresorhus/p-queue async function main() { @@ -119,8 +118,6 @@ async function addUsers() { console.log('Finished adding users.'); } -// See: -// https://lucia-auth.com/reference/lucia/modules/utils/#generateluciapasswordhash async function addUser( user: (typeof users)[number], password: string, @@ -132,7 +129,7 @@ async function addUser( username: user.name, role: user.role, }); - const hashed_password = await generateLuciaPasswordHash(password); + const hashed_password = await hashPassword(password); await keyFactory.create({ user: { connect: currentUser }, diff --git a/src/app.d.ts b/src/app.d.ts index 845182fb8..16eef8e48 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -21,19 +21,5 @@ declare global { } } -// See: -// https://lucia-auth.com/getting-started/sveltekit/ -/// -declare global { - namespace Lucia { - type Auth = import('$lib/server/auth').Auth; - type UserAttributes = { - username: string; - role: Roles; - }; - // type SessionAttributes = {}; - } -} - // THIS IS IMPORTANT!!! export {}; diff --git a/src/features/account/services/atcoder_verification.test.ts b/src/features/account/services/atcoder_verification.test.ts index 929d1f0a7..1f63c872d 100644 --- a/src/features/account/services/atcoder_verification.test.ts +++ b/src/features/account/services/atcoder_verification.test.ts @@ -42,7 +42,8 @@ type UserWithAtCoderAccount = Awaited ({ + default: { + $transaction: vi.fn(), + user: { + create: vi.fn(), + }, + key: { + create: vi.fn(), + findUnique: vi.fn(), + }, + }, +})); + +vi.mock('$lib/server/password', () => ({ + hashPassword: vi.fn(), + verifyPassword: vi.fn(), +})); + +import db from '$lib/server/database'; +import { hashPassword, verifyPassword } from '$lib/server/password'; +import { registerUser, authenticateUser } from './credentials'; + +const mockDb = db as unknown as { + $transaction: ReturnType; + user: { create: ReturnType }; + key: { create: ReturnType; findUnique: ReturnType }; +}; + +const mockHashPassword = hashPassword as unknown as ReturnType; +const mockVerifyPassword = verifyPassword as unknown as ReturnType; + +// realistic values: mixed-case username so the lowercased key id is exercised +const USERNAME = 'Chokudai'; +const KEY_ID = 'username:chokudai'; +const PASSWORD = 'Ch0kuda1'; +const HASHED_PASSWORD = 's2:0123456789abcdef:' + 'a'.repeat(128); + +const buildPrismaError = (code: string, message: string) => + new Prisma.PrismaClientKnownRequestError(message, { code, clientVersion: '5.0.0' }); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('registerUser', () => { + describe('successful case', () => { + test('creates the user and key in a single transaction and returns the new user id', async () => { + mockHashPassword.mockResolvedValue(HASHED_PASSWORD); + mockDb.$transaction.mockResolvedValue([]); + + const result = await registerUser(USERNAME, PASSWORD); + + // user id is a lucia v2 compatible 15-char [a-z0-9] random string + expect(result).not.toBeNull(); + const userId = result!.userId; + expect(userId).toMatch(/^[a-z0-9]{15}$/); + + // the same generated id is used for the user row and the key's user_id + expect(mockDb.user.create).toHaveBeenCalledWith({ + data: { id: userId, username: USERNAME }, + }); + expect(mockDb.key.create).toHaveBeenCalledWith({ + data: { id: KEY_ID, user_id: userId, hashed_password: HASHED_PASSWORD }, + }); + + // both writes run atomically via $transaction + expect(mockDb.$transaction).toHaveBeenCalledOnce(); + }); + + test('lowercases the username for the key id but keeps the original case for the user row', async () => { + mockHashPassword.mockResolvedValue(HASHED_PASSWORD); + mockDb.$transaction.mockResolvedValue([]); + + await registerUser(USERNAME, PASSWORD); + + expect(mockDb.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ username: USERNAME }), + }); + expect(mockDb.key.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ id: KEY_ID }), + }); + }); + }); + + describe('error cases', () => { + test('returns null when the username is already taken (P2002)', async () => { + mockHashPassword.mockResolvedValue(HASHED_PASSWORD); + mockDb.$transaction.mockRejectedValue( + buildPrismaError('P2002', 'Unique constraint failed on the fields: (`username`)'), + ); + + expect(await registerUser(USERNAME, PASSWORD)).toBeNull(); + }); + + test('re-throws Prisma errors other than P2002', async () => { + mockHashPassword.mockResolvedValue(HASHED_PASSWORD); + const error = buildPrismaError('P2003', 'Foreign key constraint failed.'); + mockDb.$transaction.mockRejectedValue(error); + + await expect(registerUser(USERNAME, PASSWORD)).rejects.toBe(error); + }); + }); +}); + +describe('authenticateUser', () => { + describe('successful case', () => { + test('returns the user id when the key exists and the password verifies', async () => { + mockDb.key.findUnique.mockResolvedValue({ + id: KEY_ID, + user_id: 'abcdefghij12345', + hashed_password: HASHED_PASSWORD, + }); + mockVerifyPassword.mockResolvedValue(true); + + const result = await authenticateUser(USERNAME, PASSWORD); + + expect(result).toEqual({ userId: 'abcdefghij12345' }); + expect(mockDb.key.findUnique).toHaveBeenCalledWith({ where: { id: KEY_ID } }); + expect(mockVerifyPassword).toHaveBeenCalledWith(PASSWORD, HASHED_PASSWORD); + }); + }); + + describe('error cases', () => { + test('returns null when no key exists for the username (does not check the password)', async () => { + mockDb.key.findUnique.mockResolvedValue(null); + + expect(await authenticateUser(USERNAME, PASSWORD)).toBeNull(); + expect(mockVerifyPassword).not.toHaveBeenCalled(); + }); + + test('returns null when the key has no hashed_password (does not check the password)', async () => { + mockDb.key.findUnique.mockResolvedValue({ + id: KEY_ID, + user_id: 'abcdefghij12345', + hashed_password: null, + }); + + expect(await authenticateUser(USERNAME, PASSWORD)).toBeNull(); + expect(mockVerifyPassword).not.toHaveBeenCalled(); + }); + + test('returns null when the password does not match', async () => { + mockDb.key.findUnique.mockResolvedValue({ + id: KEY_ID, + user_id: 'abcdefghij12345', + hashed_password: HASHED_PASSWORD, + }); + mockVerifyPassword.mockResolvedValue(false); + + expect(await authenticateUser(USERNAME, PASSWORD)).toBeNull(); + }); + }); +}); diff --git a/src/features/auth/services/credentials.ts b/src/features/auth/services/credentials.ts new file mode 100644 index 000000000..c2d0a78bd --- /dev/null +++ b/src/features/auth/services/credentials.ts @@ -0,0 +1,52 @@ +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; + +import client from '$lib/server/database'; +import { hashPassword, verifyPassword } from '$lib/server/password'; +import { generateRandomString } from '$lib/server/random'; + +const USER_ID_LENGTH = 15; // lucia v2 createUser default + +const buildKeyId = (username: string): string => `username:${username.toLowerCase()}`; + +/** Creates a user with a password key. Returns null when the username is already taken. */ +export const registerUser = async ( + username: string, + password: string, +): Promise<{ userId: string } | null> => { + const userId = generateRandomString(USER_ID_LENGTH); + const hashedPassword = await hashPassword(password); + + try { + await client.$transaction([ + client.user.create({ data: { id: userId, username } }), + client.key.create({ + data: { id: buildKeyId(username), user_id: userId, hashed_password: hashedPassword }, + }), + ]); + } catch (error) { + // P2002 on user.username or key.id both mean the username is taken + if (error instanceof PrismaClientKnownRequestError && error.code === 'P2002') { + return null; + } + + throw error; + } + + return { userId }; +}; + +/** Returns the user id for valid credentials, or null (missing user and wrong password are indistinguishable). */ +export const authenticateUser = async ( + username: string, + password: string, +): Promise<{ userId: string } | null> => { + const key = await client.key.findUnique({ where: { id: buildKeyId(username) } }); + + if (!key || key.hashed_password === null) { + return null; + } + + const isValid = await verifyPassword(password, key.hashed_password); + + return isValid ? { userId: key.user_id } : null; +}; diff --git a/src/lib/server/auth.test.ts b/src/lib/server/auth.test.ts index 3bcc18b57..63394b690 100644 --- a/src/lib/server/auth.test.ts +++ b/src/lib/server/auth.test.ts @@ -11,11 +11,6 @@ vi.mock('$lib/server/session', () => ({ validateSession: vi.fn(), })); -// auth.ts still initializes lucia in Phase 3, which pulls in the database client; stub it out -vi.mock('$lib/server/database', () => ({ - default: { session: {}, key: {}, user: {} }, -})); - import { createAuthRequest } from './auth'; import { validateSession, SESSION_COOKIE_NAME } from '$lib/server/session'; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 488100fa6..34053f0f2 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,13 +1,6 @@ -// See: -// https://lucia-auth.com/getting-started/sveltekit/ -// https://lucia-auth.com/database-adapters/prisma/ -import { lucia } from 'lucia'; -import { sveltekit } from 'lucia/middleware'; import { dev } from '$app/environment'; -import { prisma } from '@lucia-auth/adapter-prisma'; import type { RequestEvent } from '@sveltejs/kit'; -import client from '$lib/server/database'; import { SESSION_COOKIE_NAME, validateSession, @@ -15,23 +8,6 @@ import { type ValidatedSession, } from '$lib/server/session'; -export const auth = lucia({ - env: dev ? 'DEV' : 'PROD', - middleware: sveltekit(), - adapter: prisma(client), - - // https://lucia-auth.com/reference/lucia/interfaces/#user - getUserAttributes: (userData) => { - return { - userId: userData.id, - username: userData.username, - role: userData.role, - }; - }, -}); - -export type Auth = typeof auth; - export type AuthRequest = { validate: () => Promise; setSession: (session: SessionCookieData | null) => void; diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index bf0316f5b..f63d939d2 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -1,5 +1,4 @@ // See: -// https://lucia-auth.com/getting-started/sveltekit/ // https://www.reddit.com/r/sveltejs/comments/ozt7mk/sveltekit_with_prisma_fix/?rdt=38249 import Prisma, * as PrismaScope from '@prisma/client'; diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts index 88ae77c45..0e6f15285 100644 --- a/src/routes/(auth)/login/+page.server.ts +++ b/src/routes/(auth)/login/+page.server.ts @@ -1,12 +1,9 @@ -// See: -// https://lucia-auth.com/guidebook/sign-in-with-username-and-password/sveltekit/ - // This route uses centralized helpers with fallback validation strategies. // See src/lib/utils/auth_forms.ts for the current form handling approach. import { fail, redirect } from '@sveltejs/kit'; -import { LuciaError } from 'lucia'; -import { auth } from '$lib/server/auth'; +import { createSession } from '$lib/server/session'; +import { authenticateUser } from '$features/auth/services/credentials'; import { initializeAuthForm, validateAuthFormWithFallback } from '$lib/utils/auth_forms'; import { isSameOriginRedirect } from '$lib/utils/url'; @@ -39,25 +36,11 @@ export const actions: Actions = { } try { - // find user by key - // and validate password - const key = await auth.useKey( - 'username', - form.data.username.toLowerCase(), - form.data.password, - ); - const session = await auth.createSession({ - userId: key.userId, - attributes: {}, - }); + // find the user by username and validate the password + const authenticated = await authenticateUser(form.data.username, form.data.password); - locals.auth.setSession(session); // set session cookie - } catch (e) { - if ( - e instanceof LuciaError && - (e.message === 'AUTH_INVALID_KEY_ID' || e.message === 'AUTH_INVALID_PASSWORD') - ) { - // user does not exist or invalid password + // null means the user does not exist or the password is wrong (indistinguishable by design) + if (!authenticated) { return fail(BAD_REQUEST, { form: { ...form, @@ -67,6 +50,10 @@ export const actions: Actions = { }); } + const session = await createSession(authenticated.userId); + locals.auth.setSession(session); // set session cookie + } catch { + // unexpected failure (e.g. DB error); invalid credentials already map to null above return fail(INTERNAL_SERVER_ERROR, { form: { ...form, diff --git a/src/routes/(auth)/logout/+page.server.ts b/src/routes/(auth)/logout/+page.server.ts index 584940ce0..d16f20e37 100644 --- a/src/routes/(auth)/logout/+page.server.ts +++ b/src/routes/(auth)/logout/+page.server.ts @@ -1,8 +1,7 @@ -// See: -// https://lucia-auth.com/guidebook/sign-in-with-username-and-password/sveltekit/ -import { auth } from '$lib/server/auth'; import { fail, redirect } from '@sveltejs/kit'; +import { invalidateSession } from '$lib/server/session'; + import { SEE_OTHER, UNAUTHORIZED } from '$lib/constants/http-response-status-codes'; import { HOME_PAGE } from '$lib/constants/navbar-links'; @@ -20,7 +19,7 @@ export const actions: Actions = { return fail(UNAUTHORIZED); } - await auth.invalidateSession(session.sessionId); // invalidate session + await invalidateSession(session.sessionId); // invalidate session locals.auth.setSession(null); // remove cookie redirect(SEE_OTHER, HOME_PAGE); diff --git a/src/routes/(auth)/signup/+page.server.ts b/src/routes/(auth)/signup/+page.server.ts index ac0c0415b..22e757149 100644 --- a/src/routes/(auth)/signup/+page.server.ts +++ b/src/routes/(auth)/signup/+page.server.ts @@ -1,13 +1,9 @@ -// See: -// https://lucia-auth.com/guidebook/sign-in-with-username-and-password/sveltekit/ - // This route uses centralized helpers with fallback validation strategies. // See src/lib/utils/auth_forms.ts for the current form handling approach. import { fail, redirect } from '@sveltejs/kit'; -import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; -import { LuciaError } from 'lucia'; -import { auth } from '$lib/server/auth'; +import { createSession } from '$lib/server/session'; +import { registerUser } from '$features/auth/services/credentials'; import { initializeAuthForm, validateAuthFormWithFallback } from '$lib/utils/auth_forms'; import { isSameOriginRedirect } from '$lib/utils/url'; @@ -41,34 +37,10 @@ export const actions: Actions = { } try { - const user = await auth.createUser({ - key: { - providerId: 'username', // auth method - providerUserId: form.data.username.toLowerCase(), // unique id when using "username" auth method - password: form.data.password, // hashed by Lucia - }, - attributes: { - username: form.data.username, - }, - }); + const registered = await registerUser(form.data.username, form.data.password); - const session = await auth.createSession({ - userId: user.userId, - attributes: {}, - }); - - locals.auth.setSession(session); // set session cookie - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - // See: - // https://www.prisma.io/docs/reference/api-reference/error-reference#prismaclientknownrequesterror - // https://www.prisma.io/docs/concepts/components/prisma-client/handling-exceptions-and-errors - // https://lucia-auth.com/basics/error-handling/ - if ( - (e instanceof PrismaClientKnownRequestError && e.code === 'P2002') || - (e instanceof LuciaError && e.message === 'AUTH_DUPLICATE_KEY_ID') - ) { + // null means the username is already taken (duplicate user or key) + if (!registered) { return fail(BAD_REQUEST, { form: { ...form, @@ -78,6 +50,10 @@ export const actions: Actions = { }); } + const session = await createSession(registered.userId); + locals.auth.setSession(session); // set session cookie + } catch { + // unexpected failure (e.g. DB error); registerUser already maps duplicates to null above return fail(INTERNAL_SERVER_ERROR, { form: { ...form, diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 8b1f291ee..712255d78 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -4,8 +4,6 @@ import { TWITTER_HANDLE_NAME, } from '$lib/constants/product-info'; -// See: -// https://lucia-auth.com/guidebook/sign-in-with-username-and-password/sveltekit/ import { Roles } from '$lib/types/user'; const getBaseMetaTags = (url: URL) => { diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index d066a3b5e..670e22ab4 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,5 +1,3 @@ -// See: -// https://lucia-auth.com/guidebook/sign-in-with-username-and-password/sveltekit/ import type { PageServerLoad } from './$types'; import { Roles } from '$lib/types/user'; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 829adea84..db2b9a39f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,3 @@ - -