From b9f3a2ca0b964dfc7ce07ee9df196ea1164ff667 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 16 Apr 2026 03:41:52 +0300 Subject: [PATCH] feat(auth): Session model + remember-me Replace the single `user.refreshToken` column with a proper Session table so users can have multiple concurrent sessions (phone, laptop, etc.), each with their own refresh token, expiry, label, and remember-me flag. - Add Session model (id, userId, tokenHash, label, userAgent, ipAddress, rememberMe, lastUsedAt, expiresAt). - Drop `User.refreshToken` and `User.refreshTokenExpiresAt`. - authService: new createSession/validateSession/rotateSession/ revokeSession/listUserSessions helpers; remove refresh-token-on-user functions. - sessionCookies helper now issues a session_id cookie alongside access_token and refresh_token; rotateSessionCookies keeps the same session id on refresh. - Login form adds a "Keep me signed in for 30 days" checkbox; TTL is 7d by default, 30d with remember-me. - User-Agent parsed into a friendly label ("Chrome on Windows") for the upcoming sessions page. - hooks.server.ts, refresh endpoint, logout, register, oauth callback, and onboarding all switched to the new session API. --- .../migration.sql | 62 +++++++ prisma/schema.prisma | 21 ++- src/hooks.server.ts | 93 +++++----- .../services/__tests__/authService.test.ts | 105 ++++++++--- src/lib/server/services/authService.ts | 170 ++++++++++++------ src/lib/server/utils/jwt.ts | 4 +- src/lib/server/utils/sessionCookies.ts | 110 +++++++++--- src/lib/server/utils/userAgent.ts | 28 +++ src/lib/types/auth.ts | 6 +- src/lib/utils/validators.ts | 3 +- src/routes/api/onboarding/+server.ts | 5 +- src/routes/auth/logout/+server.ts | 18 +- src/routes/auth/oauth/callback/+server.ts | 5 +- src/routes/auth/refresh/+server.ts | 24 +-- src/routes/login/+page.server.ts | 7 +- src/routes/login/+page.svelte | 10 ++ src/routes/register/+page.server.ts | 5 +- 17 files changed, 489 insertions(+), 187 deletions(-) create mode 100644 prisma/migrations/20260416000000_add_session_model/migration.sql create mode 100644 src/lib/server/utils/userAgent.ts diff --git a/prisma/migrations/20260416000000_add_session_model/migration.sql b/prisma/migrations/20260416000000_add_session_model/migration.sql new file mode 100644 index 0000000..bce9403 --- /dev/null +++ b/prisma/migrations/20260416000000_add_session_model/migration.sql @@ -0,0 +1,62 @@ +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "label" TEXT, + "userAgent" TEXT, + "ipAddress" TEXT, + "rememberMe" BOOLEAN NOT NULL DEFAULT false, + "lastUsedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); + +-- CreateIndex +CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt"); + +-- RedefineTables: drop refreshToken + refreshTokenExpiresAt from User. +-- All existing user sessions will be invalidated; users must re-login once after upgrade. +PRAGMA foreign_keys=OFF; + +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "password" TEXT, + "displayName" TEXT NOT NULL, + "avatarUrl" TEXT, + "authProvider" TEXT NOT NULL DEFAULT 'local', + "role" TEXT NOT NULL DEFAULT 'user', + "onboardingComplete" BOOLEAN NOT NULL DEFAULT false, + "trackRecentApps" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "themeMode" TEXT, + "primaryHue" INTEGER, + "primarySaturation" INTEGER, + "backgroundType" TEXT, + "locale" TEXT +); + +INSERT INTO "new_User" ( + "id", "email", "password", "displayName", "avatarUrl", "authProvider", "role", + "onboardingComplete", "trackRecentApps", "createdAt", "updatedAt", + "themeMode", "primaryHue", "primarySaturation", "backgroundType", "locale" +) +SELECT + "id", "email", "password", "displayName", "avatarUrl", "authProvider", "role", + "onboardingComplete", "trackRecentApps", "createdAt", "updatedAt", + "themeMode", "primaryHue", "primarySaturation", "backgroundType", "locale" +FROM "User"; + +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; + +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +CREATE INDEX "User_email_idx" ON "User"("email"); + +PRAGMA foreign_keys=ON; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 21a5218..f8567d7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,8 +15,6 @@ model User { avatarUrl String? authProvider String @default("local") // local | oauth role String @default("user") // admin | user - refreshToken String? - refreshTokenExpiresAt DateTime? onboardingComplete Boolean @default(false) trackRecentApps Boolean @default(true) createdAt DateTime @default(now()) @@ -29,6 +27,7 @@ model User { locale String? groups UserGroup[] + sessions Session[] createdApps App[] boards Board[] favorites UserFavorite[] @@ -42,6 +41,24 @@ model User { @@index([email]) } +model Session { + id String @id @default(cuid()) + userId String + tokenHash String // bcrypt hash of the refresh token + label String? // user-friendly, e.g. "Chrome on Windows" + userAgent String? + ipAddress String? + rememberMe Boolean @default(false) + lastUsedAt DateTime @default(now()) + expiresAt DateTime + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([expiresAt]) +} + model Group { id String @id @default(cuid()) name String @unique diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 01605c5..43bfc37 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -9,7 +9,8 @@ import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js'; import { initBackupScheduler } from '$lib/server/jobs/backupScheduler.js'; import { clearSessionCookies, - setRotatedCookies + rotateSessionCookies, + COOKIE_NAMES } from '$lib/server/utils/sessionCookies.js'; // Initialize backup scheduler on server startup @@ -21,17 +22,14 @@ function isPublicPath(pathname: string): boolean { return PUBLIC_PATHS.some((path) => pathname === path || pathname.startsWith(path)); } -const ACCESS_TOKEN_COOKIE = 'access_token'; -const REFRESH_TOKEN_COOKIE = 'refresh_token'; - export const handle: Handle = async ({ event, resolve }) => { - // Initialize locals event.locals.user = null; event.locals.session = null; event.locals.apiTokenScope = null; - const accessToken = event.cookies.get(ACCESS_TOKEN_COOKIE); - const refreshToken = event.cookies.get(REFRESH_TOKEN_COOKIE); + const accessToken = event.cookies.get(COOKIE_NAMES.ACCESS_TOKEN); + const refreshToken = event.cookies.get(COOKIE_NAMES.REFRESH_TOKEN); + const sessionId = event.cookies.get(COOKIE_NAMES.SESSION_ID); if (accessToken) { try { @@ -44,7 +42,7 @@ export const handle: Handle = async ({ event, resolve }) => { role: user.role as 'admin' | 'user' }; event.locals.session = { - id: payload.userId, + id: sessionId ?? payload.userId, expiresAt: new Date(Date.now() + 15 * 60 * 1000) }; } catch { @@ -52,40 +50,36 @@ export const handle: Handle = async ({ event, resolve }) => { } } - // If no valid session but refresh token exists, attempt rotation - if (!event.locals.user && refreshToken) { + // If no valid session but refresh + session id exist, try to rotate. + if (!event.locals.user && refreshToken && sessionId) { try { - // We need to find the user by refresh token. - // The refresh token is stored hashed per-user, so we need - // a userId from somewhere. We store it in a separate cookie. - const userIdFromCookie = event.cookies.get('refresh_user_id'); - if (userIdFromCookie) { - const isValid = await authService.validateRefreshToken(userIdFromCookie, refreshToken); - if (isValid) { - const user = await userService.findById(userIdFromCookie); - const tokens = await authService.rotateTokens(user.id, user.email, user.role); + const session = await authService.validateSession(sessionId, refreshToken); + if (session) { + const user = await userService.findById(session.userId); + await rotateSessionCookies( + event.cookies, + session.id, + { id: user.id, email: user.email, role: user.role }, + session.rememberMe + ); - setRotatedCookies(event.cookies, tokens); - - event.locals.user = { - id: user.id, - email: user.email, - displayName: user.displayName, - role: user.role as 'admin' | 'user' - }; - event.locals.session = { - id: user.id, - expiresAt: new Date(Date.now() + 15 * 60 * 1000) - }; - } + event.locals.user = { + id: user.id, + email: user.email, + displayName: user.displayName, + role: user.role as 'admin' | 'user' + }; + event.locals.session = { + id: session.id, + expiresAt: new Date(Date.now() + 15 * 60 * 1000) + }; } } catch { - // Refresh failed — clear stale cookies clearSessionCookies(event.cookies); } } - // If still no valid session, try API token from Authorization header + // Bearer API tokens (no session cookie). if (!event.locals.user) { const bearerToken = extractBearerToken(event); if (bearerToken) { @@ -117,27 +111,33 @@ export const handle: Handle = async ({ event, resolve }) => { if (event.locals.apiTokenScope) { const method = event.request.method; const scope = event.locals.apiTokenScope; - const isWriteMethod = method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE'; + const isWriteMethod = + method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE'; const isAdminPath = pathname.startsWith('/api/admin') || pathname.startsWith('/admin'); if (scope === 'read' && isWriteMethod) { - return new Response(JSON.stringify({ success: false, data: null, error: 'API token scope "read" does not allow write operations' }), { - status: 403, - headers: { 'Content-Type': 'application/json' } - }); + return new Response( + JSON.stringify({ + success: false, + data: null, + error: 'API token scope "read" does not allow write operations' + }), + { status: 403, headers: { 'Content-Type': 'application/json' } } + ); } if (scope !== 'admin' && isAdminPath) { - return new Response(JSON.stringify({ success: false, data: null, error: 'API token scope does not allow admin operations' }), { - status: 403, - headers: { 'Content-Type': 'application/json' } - }); + return new Response( + JSON.stringify({ + success: false, + data: null, + error: 'API token scope does not allow admin operations' + }), + { status: 403, headers: { 'Content-Type': 'application/json' } } + ); } } - // Route protection - if (!event.locals.user && !isPublicPath(pathname)) { - // Check if this is a guest-accessible board route const boardMatch = pathname.match(/^\/boards\/([^/]+)/); if (boardMatch) { const boardId = boardMatch[1]; @@ -147,7 +147,6 @@ export const handle: Handle = async ({ event, resolve }) => { } } - // Root path — allow through so +page.server.ts can handle redirect logic if (pathname === '/') { return resolve(event); } diff --git a/src/lib/server/services/__tests__/authService.test.ts b/src/lib/server/services/__tests__/authService.test.ts index df0bac0..bf84de2 100644 --- a/src/lib/server/services/__tests__/authService.test.ts +++ b/src/lib/server/services/__tests__/authService.test.ts @@ -3,9 +3,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock prisma before importing authService vi.mock('../../prisma.js', () => ({ prisma: { - user: { + session: { + create: vi.fn(), + findUnique: vi.fn(), update: vi.fn(), - findUnique: vi.fn() + deleteMany: vi.fn(), + findMany: vi.fn() } } })); @@ -19,8 +22,9 @@ import { signAccessToken, verifyAccessToken, generateRefreshToken, - getRefreshTokenExpiry, - rotateTokens + createSession, + rotateSession, + validateSession } from '../authService.js'; import { prisma } from '../../prisma.js'; @@ -84,31 +88,88 @@ describe('authService', () => { }); }); - describe('getRefreshTokenExpiry', () => { - it('returns a future date', () => { - const expiry = getRefreshTokenExpiry(); - expect(expiry.getTime()).toBeGreaterThan(Date.now()); + describe('createSession', () => { + it('creates a session row and returns the raw refresh token', async () => { + vi.mocked(prisma.session.create).mockResolvedValue({ + id: 'ses-1', + userId: 'usr-1', + tokenHash: 'hash', + label: 'Chrome on Windows', + userAgent: 'ua', + ipAddress: '127.0.0.1', + rememberMe: false, + lastUsedAt: new Date(), + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + createdAt: new Date() + } as never); + + const result = await createSession('usr-1', { userAgent: 'ua', ipAddress: '127.0.0.1' }); + + expect(result.sessionId).toBe('ses-1'); + expect(result.refreshToken.length).toBe(96); + expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now()); + expect(prisma.session.create).toHaveBeenCalledTimes(1); }); - it('defaults to 7 days from now', () => { - const expiry = getRefreshTokenExpiry(); - const sevenDaysMs = 7 * 24 * 60 * 60 * 1000; - const diff = expiry.getTime() - Date.now(); - // Allow 10 seconds tolerance - expect(diff).toBeGreaterThan(sevenDaysMs - 10000); - expect(diff).toBeLessThan(sevenDaysMs + 10000); + it('extends expiry for remember-me sessions', async () => { + vi.mocked(prisma.session.create).mockImplementation( + (({ data }: { data: Record }) => + Promise.resolve({ + id: 'ses-2', + ...data, + lastUsedAt: new Date(), + createdAt: new Date() + })) as never + ); + + const result = await createSession('usr-1', { rememberMe: true }); + + const diffDays = (result.expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000); + expect(diffDays).toBeGreaterThan(29); + expect(diffDays).toBeLessThan(31); }); }); - describe('rotateTokens', () => { - it('generates new token pair and saves refresh token', async () => { - vi.mocked(prisma.user.update).mockResolvedValue({} as never); + describe('validateSession', () => { + it('returns null for missing session', async () => { + vi.mocked(prisma.session.findUnique).mockResolvedValue(null); + const result = await validateSession('ses-x', 'token'); + expect(result).toBeNull(); + }); - const result = await rotateTokens('usr-1', 'test@test.com', 'user'); + it('returns null for expired session', async () => { + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + id: 'ses-1', + userId: 'usr-1', + tokenHash: 'hash', + rememberMe: false, + expiresAt: new Date(Date.now() - 1000), + lastUsedAt: new Date(), + createdAt: new Date(), + label: null, + userAgent: null, + ipAddress: null + } as never); + const result = await validateSession('ses-1', 'token'); + expect(result).toBeNull(); + }); + }); - expect(result.accessToken).toBeTruthy(); - expect(result.refreshToken).toBeTruthy(); - expect(prisma.user.update).toHaveBeenCalledTimes(1); + describe('rotateSession', () => { + it('updates token hash and keeps the same session id', async () => { + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + id: 'ses-1', + userId: 'usr-1', + rememberMe: false, + expiresAt: new Date(Date.now() + 1000) + } as never); + vi.mocked(prisma.session.update).mockResolvedValue({} as never); + + const result = await rotateSession('ses-1'); + + expect(result.sessionId).toBe('ses-1'); + expect(result.refreshToken.length).toBe(96); + expect(prisma.session.update).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/lib/server/services/authService.ts b/src/lib/server/services/authService.ts index be484c5..d540b0e 100644 --- a/src/lib/server/services/authService.ts +++ b/src/lib/server/services/authService.ts @@ -2,7 +2,7 @@ import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { prisma } from '../prisma.js'; import { DEFAULTS } from '$lib/utils/constants.js'; -import type { JwtPayload, TokenPair } from '$lib/types/auth.js'; +import type { JwtPayload } from '$lib/types/auth.js'; const SALT_ROUNDS = 12; @@ -18,15 +18,6 @@ function getJwtExpiry(): string { return process.env.JWT_EXPIRY || DEFAULTS.JWT_EXPIRY; } -function getRefreshTokenExpiryDays(): number { - const envValue = process.env.REFRESH_TOKEN_EXPIRY; - if (envValue) { - const days = parseInt(envValue.replace('d', ''), 10); - if (!isNaN(days)) return days; - } - return DEFAULTS.REFRESH_TOKEN_EXPIRY_DAYS; -} - export async function hashPassword(password: string): Promise { return bcrypt.hash(password, SALT_ROUNDS); } @@ -60,59 +51,124 @@ export function generateRefreshToken(): string { return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } -export function getRefreshTokenExpiry(): Date { - const days = getRefreshTokenExpiryDays(); +export interface SessionMetadata { + readonly userAgent?: string; + readonly ipAddress?: string; + readonly label?: string; + readonly rememberMe?: boolean; +} + +export interface IssuedSession { + readonly sessionId: string; + readonly refreshToken: string; + readonly expiresAt: Date; +} + +const DEFAULT_SESSION_TTL_DAYS = 7; +const REMEMBER_ME_SESSION_TTL_DAYS = 30; + +function sessionExpiry(rememberMe: boolean): Date { + const days = rememberMe ? REMEMBER_ME_SESSION_TTL_DAYS : DEFAULT_SESSION_TTL_DAYS; const expiry = new Date(); expiry.setDate(expiry.getDate() + days); return expiry; } -export async function saveRefreshToken(userId: string, refreshToken: string): Promise { - const hashedToken = await bcrypt.hash(refreshToken, SALT_ROUNDS); - await prisma.user.update({ - where: { id: userId }, - data: { - refreshToken: hashedToken, - refreshTokenExpiresAt: getRefreshTokenExpiry() - } - }); -} - -export async function validateRefreshToken(userId: string, refreshToken: string): Promise { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { refreshToken: true, refreshTokenExpiresAt: true } - }); - - if (!user?.refreshToken || !user.refreshTokenExpiresAt) { - return false; - } - - if (new Date() > user.refreshTokenExpiresAt) { - return false; - } - - return bcrypt.compare(refreshToken, user.refreshToken); -} - -export async function revokeRefreshToken(userId: string): Promise { - await prisma.user.update({ - where: { id: userId }, - data: { - refreshToken: null, - refreshTokenExpiresAt: null - } - }); -} - -export async function rotateTokens( +/** + * Create a new session row and return the session id + raw refresh token. + * The raw token is returned once — only the hash is stored. + */ +export async function createSession( userId: string, - email: string, - role: string -): Promise { - const accessToken = signAccessToken({ userId, email, role }); + meta: SessionMetadata = {} +): Promise { const refreshToken = generateRefreshToken(); - await saveRefreshToken(userId, refreshToken); + const tokenHash = await bcrypt.hash(refreshToken, SALT_ROUNDS); + const expiresAt = sessionExpiry(meta.rememberMe ?? false); - return { accessToken, refreshToken }; + const session = await prisma.session.create({ + data: { + userId, + tokenHash, + label: meta.label ?? null, + userAgent: meta.userAgent ?? null, + ipAddress: meta.ipAddress ?? null, + rememberMe: meta.rememberMe ?? false, + expiresAt + } + }); + + return { sessionId: session.id, refreshToken, expiresAt }; +} + +/** + * Validate a session id + refresh token. Returns the session row if valid, else null. + * Does NOT mutate the session. + */ +export async function validateSession(sessionId: string, refreshToken: string) { + const session = await prisma.session.findUnique({ where: { id: sessionId } }); + if (!session) return null; + if (new Date() > session.expiresAt) return null; + + const matches = await bcrypt.compare(refreshToken, session.tokenHash); + if (!matches) return null; + + return session; +} + +/** + * Rotate a session's refresh token. Keeps the same session id (so the sessions + * page shows a stable row). Refreshes expiry based on the session's rememberMe. + */ +export async function rotateSession(sessionId: string): Promise { + const existing = await prisma.session.findUnique({ where: { id: sessionId } }); + if (!existing) throw new Error('Session not found'); + + const refreshToken = generateRefreshToken(); + const tokenHash = await bcrypt.hash(refreshToken, SALT_ROUNDS); + const expiresAt = sessionExpiry(existing.rememberMe); + + await prisma.session.update({ + where: { id: sessionId }, + data: { tokenHash, expiresAt, lastUsedAt: new Date() } + }); + + return { sessionId, refreshToken, expiresAt }; +} + +export async function revokeSession(sessionId: string): Promise { + await prisma.session.deleteMany({ where: { id: sessionId } }); +} + +export async function revokeAllUserSessions(userId: string, exceptSessionId?: string): Promise { + await prisma.session.deleteMany({ + where: { + userId, + ...(exceptSessionId ? { NOT: { id: exceptSessionId } } : {}) + } + }); +} + +export async function listUserSessions(userId: string) { + return prisma.session.findMany({ + where: { userId }, + orderBy: { lastUsedAt: 'desc' }, + select: { + id: true, + label: true, + userAgent: true, + ipAddress: true, + rememberMe: true, + lastUsedAt: true, + expiresAt: true, + createdAt: true + } + }); +} + +export async function deleteExpiredSessions(): Promise { + const result = await prisma.session.deleteMany({ + where: { expiresAt: { lt: new Date() } } + }); + return result.count; } diff --git a/src/lib/server/utils/jwt.ts b/src/lib/server/utils/jwt.ts index bba335d..1711cd7 100644 --- a/src/lib/server/utils/jwt.ts +++ b/src/lib/server/utils/jwt.ts @@ -1,10 +1,8 @@ /** * JWT utilities — thin re-exports from authService. - * authService already handles sign, verify, and refresh token generation. */ export { signAccessToken, verifyAccessToken, - generateRefreshToken, - getRefreshTokenExpiry + generateRefreshToken } from '../services/authService.js'; diff --git a/src/lib/server/utils/sessionCookies.ts b/src/lib/server/utils/sessionCookies.ts index 0c72f5e..1904b94 100644 --- a/src/lib/server/utils/sessionCookies.ts +++ b/src/lib/server/utils/sessionCookies.ts @@ -1,20 +1,25 @@ -import type { Cookies } from '@sveltejs/kit'; +import type { Cookies, RequestEvent } from '@sveltejs/kit'; import * as authService from '$lib/server/services/authService.js'; +import { parseUserAgentLabel } from './userAgent.js'; export const ACCESS_TOKEN_TTL_SEC = 900; // 15 minutes -export const REFRESH_TOKEN_TTL_SEC = 604800; // 7 days +export const DEFAULT_REFRESH_TTL_SEC = 7 * 24 * 60 * 60; // 7 days +export const REMEMBER_ME_REFRESH_TTL_SEC = 30 * 24 * 60 * 60; // 30 days + +const ACCESS_TOKEN_COOKIE = 'access_token'; +const REFRESH_TOKEN_COOKIE = 'refresh_token'; +const SESSION_ID_COOKIE = 'session_id'; function isHttpsOrigin(): boolean { const origin = process.env.ORIGIN; if (origin) return origin.startsWith('https://'); - // Fall back to NODE_ENV only when ORIGIN is unset. return process.env.NODE_ENV === 'production'; } /** - * Shared cookie attributes (httpOnly, secure, sameSite=lax, path=/). - * `secure` is derived from ORIGIN (https://...) rather than NODE_ENV so - * plain-HTTP production deployments don't silently drop cookies. + * Shared cookie attributes. `secure` is derived from ORIGIN (https://...) + * rather than NODE_ENV so plain-HTTP production deployments don't silently + * drop cookies. */ export function cookieBase() { return { @@ -31,40 +36,99 @@ interface SessionUser { readonly role: string; } +interface IssueOptions { + readonly rememberMe?: boolean; + /** When set, metadata (user agent, IP, label) is pulled from this event. */ + readonly event?: Pick; +} + +function refreshTtl(rememberMe: boolean): number { + return rememberMe ? REMEMBER_ME_REFRESH_TTL_SEC : DEFAULT_REFRESH_TTL_SEC; +} + +function readMetadata(event: IssueOptions['event']) { + if (!event) return { userAgent: undefined, ipAddress: undefined, label: undefined }; + const userAgent = event.request.headers.get('user-agent') ?? undefined; + let ipAddress: string | undefined; + try { + ipAddress = event.getClientAddress(); + } catch { + ipAddress = undefined; + } + return { + userAgent, + ipAddress, + label: parseUserAgentLabel(userAgent) + }; +} + /** - * Issue access + refresh cookies for a freshly-authenticated user. - * Persists a new refresh token in the DB. + * Create a new session, persist it, and set access + refresh + session_id cookies. */ -export async function issueSessionCookies(cookies: Cookies, user: SessionUser): Promise { +export async function issueSessionCookies( + cookies: Cookies, + user: SessionUser, + options: IssueOptions = {} +): Promise { + const rememberMe = options.rememberMe ?? false; + const meta = readMetadata(options.event); + + const session = await authService.createSession(user.id, { + rememberMe, + userAgent: meta.userAgent, + ipAddress: meta.ipAddress, + label: meta.label + }); + const accessToken = authService.signAccessToken({ userId: user.id, email: user.email, role: user.role }); - const refreshToken = authService.generateRefreshToken(); - await authService.saveRefreshToken(user.id, refreshToken); const base = cookieBase(); - cookies.set('access_token', accessToken, { ...base, maxAge: ACCESS_TOKEN_TTL_SEC }); - cookies.set('refresh_token', refreshToken, { ...base, maxAge: REFRESH_TOKEN_TTL_SEC }); - cookies.set('refresh_user_id', user.id, { ...base, maxAge: REFRESH_TOKEN_TTL_SEC }); + const ttl = refreshTtl(rememberMe); + + cookies.set(ACCESS_TOKEN_COOKIE, accessToken, { ...base, maxAge: ACCESS_TOKEN_TTL_SEC }); + cookies.set(REFRESH_TOKEN_COOKIE, session.refreshToken, { ...base, maxAge: ttl }); + cookies.set(SESSION_ID_COOKIE, session.sessionId, { ...base, maxAge: ttl }); } /** - * Set access + refresh cookies from a pre-generated token pair (used by refresh rotation). - * Does not touch the DB. + * Rotate an existing session's tokens and set new cookies. Used on refresh. */ -export function setRotatedCookies( +export async function rotateSessionCookies( cookies: Cookies, - tokens: { readonly accessToken: string; readonly refreshToken: string } -): void { + sessionId: string, + user: SessionUser, + rememberMe: boolean +): Promise { + const rotated = await authService.rotateSession(sessionId); + const accessToken = authService.signAccessToken({ + userId: user.id, + email: user.email, + role: user.role + }); + const base = cookieBase(); - cookies.set('access_token', tokens.accessToken, { ...base, maxAge: ACCESS_TOKEN_TTL_SEC }); - cookies.set('refresh_token', tokens.refreshToken, { ...base, maxAge: REFRESH_TOKEN_TTL_SEC }); + const ttl = refreshTtl(rememberMe); + + cookies.set(ACCESS_TOKEN_COOKIE, accessToken, { ...base, maxAge: ACCESS_TOKEN_TTL_SEC }); + cookies.set(REFRESH_TOKEN_COOKIE, rotated.refreshToken, { ...base, maxAge: ttl }); + cookies.set(SESSION_ID_COOKIE, rotated.sessionId, { ...base, maxAge: ttl }); } export function clearSessionCookies(cookies: Cookies): void { - cookies.delete('access_token', { path: '/' }); - cookies.delete('refresh_token', { path: '/' }); + cookies.delete(ACCESS_TOKEN_COOKIE, { path: '/' }); + cookies.delete(REFRESH_TOKEN_COOKIE, { path: '/' }); + cookies.delete(SESSION_ID_COOKIE, { path: '/' }); + // Clean up legacy cookie name (from pre-Session refactor) so upgrades + // don't leave a stale cookie in the browser. cookies.delete('refresh_user_id', { path: '/' }); } + +export const COOKIE_NAMES = { + ACCESS_TOKEN: ACCESS_TOKEN_COOKIE, + REFRESH_TOKEN: REFRESH_TOKEN_COOKIE, + SESSION_ID: SESSION_ID_COOKIE +} as const; diff --git a/src/lib/server/utils/userAgent.ts b/src/lib/server/utils/userAgent.ts new file mode 100644 index 0000000..8afcf00 --- /dev/null +++ b/src/lib/server/utils/userAgent.ts @@ -0,0 +1,28 @@ +/** + * Parse a User-Agent header into a short, human-readable label like + * "Chrome on Windows" or "Safari on iOS". Intentionally heuristic — not + * a replacement for a full UA parser. + */ +export function parseUserAgentLabel(userAgent: string | null | undefined): string { + if (!userAgent) return 'Unknown device'; + + const ua = userAgent; + + let browser = 'Browser'; + if (/Edg\//.test(ua)) browser = 'Edge'; + else if (/OPR\//.test(ua) || /Opera/.test(ua)) browser = 'Opera'; + else if (/Firefox\//.test(ua)) browser = 'Firefox'; + else if (/Chrome\//.test(ua)) browser = 'Chrome'; + else if (/Safari\//.test(ua)) browser = 'Safari'; + else if (/curl\//i.test(ua)) browser = 'curl'; + else if (/PostmanRuntime/.test(ua)) browser = 'Postman'; + + let os = 'Unknown OS'; + if (/Windows NT/.test(ua)) os = 'Windows'; + else if (/Mac OS X|Macintosh/.test(ua)) os = 'macOS'; + else if (/Android/.test(ua)) os = 'Android'; + else if (/iPhone|iPad|iPod/.test(ua)) os = 'iOS'; + else if (/Linux/.test(ua)) os = 'Linux'; + + return `${browser} on ${os}`; +} diff --git a/src/lib/types/auth.ts b/src/lib/types/auth.ts index 10a5840..9459855 100644 --- a/src/lib/types/auth.ts +++ b/src/lib/types/auth.ts @@ -4,14 +4,10 @@ export interface JwtPayload { readonly role: string; } -export interface TokenPair { - readonly accessToken: string; - readonly refreshToken: string; -} - export interface LoginRequest { readonly email: string; readonly password: string; + readonly rememberMe?: boolean; } export interface RegisterRequest { diff --git a/src/lib/utils/validators.ts b/src/lib/utils/validators.ts index b65f11e..d6a357d 100644 --- a/src/lib/utils/validators.ts +++ b/src/lib/utils/validators.ts @@ -18,7 +18,8 @@ import { export const loginSchema = z.object({ email: z.string().email('Invalid email address'), - password: z.string().min(1, 'Password is required') + password: z.string().min(1, 'Password is required'), + rememberMe: z.boolean().optional().default(false) }); export const registerSchema = z.object({ diff --git a/src/routes/api/onboarding/+server.ts b/src/routes/api/onboarding/+server.ts index 8f4110b..29b3b60 100644 --- a/src/routes/api/onboarding/+server.ts +++ b/src/routes/api/onboarding/+server.ts @@ -32,7 +32,8 @@ export const GET: RequestHandler = async () => { * POST /api/onboarding — Complete an onboarding step. * No auth required (onboarding runs before any user exists). */ -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { request, cookies } = event; let body: unknown; try { body = await request.json(); @@ -197,7 +198,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { return json(error('Onboarding complete but no admin user found'), { status: 500 }); } - await issueSessionCookies(cookies, adminUser); + await issueSessionCookies(cookies, adminUser, { event }); return json(success({ complete: true })); } diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts index 4da8938..e7c9344 100644 --- a/src/routes/auth/logout/+server.ts +++ b/src/routes/auth/logout/+server.ts @@ -1,21 +1,23 @@ import { redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import * as authService from '$lib/server/services/authService.js'; +import { + COOKIE_NAMES, + clearSessionCookies +} from '$lib/server/utils/sessionCookies.js'; -export const POST: RequestHandler = async ({ cookies, locals }) => { - // Revoke refresh token in database - if (locals.user) { +export const POST: RequestHandler = async ({ cookies }) => { + // Revoke the current session if we have its id + const sessionId = cookies.get(COOKIE_NAMES.SESSION_ID); + if (sessionId) { try { - await authService.revokeRefreshToken(locals.user.id); + await authService.revokeSession(sessionId); } catch { // Best-effort revocation — continue with cookie cleanup } } - // Clear all auth cookies - cookies.delete('access_token', { path: '/' }); - cookies.delete('refresh_token', { path: '/' }); - cookies.delete('refresh_user_id', { path: '/' }); + clearSessionCookies(cookies); throw redirect(302, '/login'); }; diff --git a/src/routes/auth/oauth/callback/+server.ts b/src/routes/auth/oauth/callback/+server.ts index e8025f9..b48cfd3 100644 --- a/src/routes/auth/oauth/callback/+server.ts +++ b/src/routes/auth/oauth/callback/+server.ts @@ -4,7 +4,8 @@ import * as oauthService from '$lib/server/services/oauthService.js'; import * as userService from '$lib/server/services/userService.js'; import { issueSessionCookies } from '$lib/server/utils/sessionCookies.js'; -export const GET: RequestHandler = async ({ url, cookies }) => { +export const GET: RequestHandler = async (event) => { + const { url, cookies } = event; try { // Check for error response from the provider const oauthError = url.searchParams.get('error'); @@ -51,7 +52,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { groups: userInfo.groups ? [...userInfo.groups] : undefined }); - await issueSessionCookies(cookies, user); + await issueSessionCookies(cookies, user, { event }); throw redirect(302, '/'); } catch (err) { diff --git a/src/routes/auth/refresh/+server.ts b/src/routes/auth/refresh/+server.ts index 242c857..ec1894d 100644 --- a/src/routes/auth/refresh/+server.ts +++ b/src/routes/auth/refresh/+server.ts @@ -5,29 +5,33 @@ import * as userService from '$lib/server/services/userService.js'; import { error as apiError } from '$lib/server/utils/response.js'; import { ACCESS_TOKEN_TTL_SEC, + COOKIE_NAMES, clearSessionCookies, - setRotatedCookies + rotateSessionCookies } from '$lib/server/utils/sessionCookies.js'; export const POST: RequestHandler = async ({ cookies }) => { - const refreshToken = cookies.get('refresh_token'); - const userId = cookies.get('refresh_user_id'); + const refreshToken = cookies.get(COOKIE_NAMES.REFRESH_TOKEN); + const sessionId = cookies.get(COOKIE_NAMES.SESSION_ID); - if (!refreshToken || !userId) { + if (!refreshToken || !sessionId) { return json(apiError('No refresh token provided'), { status: 401 }); } try { - const isValid = await authService.validateRefreshToken(userId, refreshToken); - if (!isValid) { + const session = await authService.validateSession(sessionId, refreshToken); + if (!session) { clearSessionCookies(cookies); return json(apiError('Invalid or expired refresh token'), { status: 401 }); } - const user = await userService.findById(userId); - const tokens = await authService.rotateTokens(user.id, user.email, user.role); - - setRotatedCookies(cookies, tokens); + const user = await userService.findById(session.userId); + await rotateSessionCookies( + cookies, + session.id, + { id: user.id, email: user.email, role: user.role }, + session.rememberMe + ); return json({ success: true, diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index a0af568..7e65d25 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -28,14 +28,15 @@ export const load: PageServerLoad = async ({ locals }) => { }; export const actions: Actions = { - default: async ({ request, cookies }) => { + default: async (event) => { + const { request, cookies } = event; const form = await superValidate(request, zod(loginSchema)); if (!form.valid) { return fail(400, { form }); } - const { email, password } = form.data; + const { email, password, rememberMe } = form.data; const user = await userService.findByEmail(email); if (!user) { @@ -51,7 +52,7 @@ export const actions: Actions = { return setError(form, 'email', 'Invalid email or password'); } - await issueSessionCookies(cookies, user); + await issueSessionCookies(cookies, user, { rememberMe, event }); throw redirect(302, '/'); } diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 5e3652a..94ce818 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -105,6 +105,16 @@ {/if} + +