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 @@ - -