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/.claude/rules/coding-style.md b/.claude/rules/coding-style.md
index f6c0dda86..ca6c1ceee 100644
--- a/.claude/rules/coding-style.md
+++ b/.claude/rules/coding-style.md
@@ -51,6 +51,15 @@ Delete function only if: (1) zero callers, (2) replacement exists, (3) dependent
Before removing an import, grep the entire file for all usages — removing one call site doesn't mean no others exist.
+## Residual-Reference Sweeps
+
+When removing a dependency or renaming a symbol, sweep the **whole repo**, not just `src`:
+
+- `grep --include='*.{ts,svelte,md}'` does NOT brace-expand — it silently matches nothing.
+ Use `rg -ni 'name' -g '*.ts' -g '*.svelte' -g '*.md'` instead.
+- Include root files (CONTRIBUTING.md, README), `e2e/`, and config (`vite.config.ts`) —
+ not only `src prisma .claude`.
+
## Documentation
- **Plans/dev-notes**: Japanese
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 31068f5a0..df11b70c6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -37,8 +37,8 @@
- [Vitest](https://vitest.dev/): 単体テスト (ユーティリティ、コンポーネント)
- [Playwright](https://playwright.dev/): e2eテスト
- [Nock](https://github.com/nock/nock): API 統合テスト用の HTTP モック
-- 認証ライブラリ
- - [Lucia](https://lucia-auth.com/)
+- 認証
+ - 自前実装(セッション管理・パスワードハッシュ)。旧 [Lucia](https://lucia-auth.com/) v2 のセッション / パスワード形式に準拠(互換性維持のため、cookie 名・ハッシュ形式は安易に変更しないこと)
- ORM
- [Prisma](https://www.prisma.io/)
- バリデーション
diff --git a/e2e/problems_cache.spec.ts b/e2e/problems_cache.spec.ts
index de61c5c9f..1c28c9b4d 100644
--- a/e2e/problems_cache.spec.ts
+++ b/e2e/problems_cache.spec.ts
@@ -19,9 +19,9 @@ test.describe('anonymous /problems response', () => {
expect(headers['cache-control']).toContain('stale-while-revalidate=600');
// set-cookie makes a response ineligible for CDN caching.
- // Lucia must not attach a session cookie to anonymous requests.
- // If this assertion fails after a Lucia upgrade, verify it does not
- // set cookies on unauthenticated requests.
+ // The auth layer must not attach a session cookie to anonymous requests.
+ // If this assertion fails, verify the session handling does not set cookies
+ // on unauthenticated requests.
expect(headers['set-cookie']).toBeUndefined();
});
});
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..bf1f7bea3 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,12 +129,12 @@ async function addUser(
username: user.name,
role: user.role,
});
- const hashed_password = await generateLuciaPasswordHash(password);
+ const hashedPassword = await hashPassword(password);
await keyFactory.create({
user: { connect: currentUser },
- id: 'username:' + user.name.toLocaleLowerCase(),
- hashed_password: hashed_password,
+ id: 'username:' + user.name.toLowerCase(),
+ hashed_password: hashedPassword,
});
}
diff --git a/src/app.d.ts b/src/app.d.ts
index 22e732c7a..16eef8e48 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;
@@ -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/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..63394b690
--- /dev/null
+++ b/src/lib/server/auth.test.ts
@@ -0,0 +1,122 @@
+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(),
+}));
+
+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..34053f0f2 100644
--- a/src/lib/server/auth.ts
+++ b/src/lib/server/auth.ts
@@ -1,25 +1,67 @@
-// 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 client from '$lib/server/database';
-
-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;
+import type { RequestEvent } from '@sveltejs/kit';
+
+import {
+ SESSION_COOKIE_NAME,
+ validateSession,
+ type SessionCookieData,
+ type ValidatedSession,
+} from '$lib/server/session';
+
+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);
+ },
+ };
+};
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/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;
+};
diff --git a/src/lib/server/session.test.ts b/src/lib/server/session.test.ts
new file mode 100644
index 000000000..09ad8679c
--- /dev/null
+++ b/src/lib/server/session.test.ts
@@ -0,0 +1,220 @@
+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();
+ });
+
+ test('returns null when the row is deleted concurrently during extension (P2025)', async () => {
+ // race: another request (logout / account deletion) deletes the row between findUnique and update
+ mockFindUnique(buildSessionRow(NOW - 1000, NOW + IDLE_PERIOD_MS));
+ mockDb.session.update.mockRejectedValue(
+ buildPrismaError('P2025', 'Record to update not found.'),
+ );
+
+ expect(await validateSession(SESSION_ID)).toBeNull();
+ });
+
+ test('re-throws update errors other than P2025', async () => {
+ mockFindUnique(buildSessionRow(NOW - 1000, NOW + IDLE_PERIOD_MS));
+ const error = buildPrismaError('P2003', 'Foreign key constraint failed.');
+ mockDb.session.update.mockRejectedValue(error);
+
+ await expect(validateSession(SESSION_ID)).rejects.toBe(error);
+ });
+ });
+
+ 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..d84b315c1
--- /dev/null
+++ b/src/lib/server/session.ts
@@ -0,0 +1,105 @@
+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);
+
+ try {
+ await client.session.update({
+ where: { id: sessionId },
+ data: {
+ active_expires: activePeriodExpiresAt.getTime(),
+ idle_expires: idlePeriodExpiresAt.getTime(),
+ },
+ });
+ } catch (error) {
+ // P2025: the row was deleted concurrently (e.g. logout) between findUnique and update.
+ // Treat the session as gone (unauthenticated) instead of surfacing a 500.
+ if (error instanceof PrismaClientKnownRequestError && error.code === 'P2025') {
+ return null;
+ }
+
+ throw error;
+ }
+ }
+
+ 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 };
+};
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 @@
-
-