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.
This commit is contained in:
2026-04-16 03:41:52 +03:00
parent 3fa30f72a3
commit b9f3a2ca0b
17 changed files with 489 additions and 187 deletions
@@ -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;
+19 -2
View File
@@ -15,8 +15,6 @@ model User {
avatarUrl String? avatarUrl String?
authProvider String @default("local") // local | oauth authProvider String @default("local") // local | oauth
role String @default("user") // admin | user role String @default("user") // admin | user
refreshToken String?
refreshTokenExpiresAt DateTime?
onboardingComplete Boolean @default(false) onboardingComplete Boolean @default(false)
trackRecentApps Boolean @default(true) trackRecentApps Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -29,6 +27,7 @@ model User {
locale String? locale String?
groups UserGroup[] groups UserGroup[]
sessions Session[]
createdApps App[] createdApps App[]
boards Board[] boards Board[]
favorites UserFavorite[] favorites UserFavorite[]
@@ -42,6 +41,24 @@ model User {
@@index([email]) @@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 { model Group {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String @unique
+46 -47
View File
@@ -9,7 +9,8 @@ import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
import { initBackupScheduler } from '$lib/server/jobs/backupScheduler.js'; import { initBackupScheduler } from '$lib/server/jobs/backupScheduler.js';
import { import {
clearSessionCookies, clearSessionCookies,
setRotatedCookies rotateSessionCookies,
COOKIE_NAMES
} from '$lib/server/utils/sessionCookies.js'; } from '$lib/server/utils/sessionCookies.js';
// Initialize backup scheduler on server startup // 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)); 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 }) => { export const handle: Handle = async ({ event, resolve }) => {
// Initialize locals
event.locals.user = null; event.locals.user = null;
event.locals.session = null; event.locals.session = null;
event.locals.apiTokenScope = null; event.locals.apiTokenScope = null;
const accessToken = event.cookies.get(ACCESS_TOKEN_COOKIE); const accessToken = event.cookies.get(COOKIE_NAMES.ACCESS_TOKEN);
const refreshToken = event.cookies.get(REFRESH_TOKEN_COOKIE); const refreshToken = event.cookies.get(COOKIE_NAMES.REFRESH_TOKEN);
const sessionId = event.cookies.get(COOKIE_NAMES.SESSION_ID);
if (accessToken) { if (accessToken) {
try { try {
@@ -44,7 +42,7 @@ export const handle: Handle = async ({ event, resolve }) => {
role: user.role as 'admin' | 'user' role: user.role as 'admin' | 'user'
}; };
event.locals.session = { event.locals.session = {
id: payload.userId, id: sessionId ?? payload.userId,
expiresAt: new Date(Date.now() + 15 * 60 * 1000) expiresAt: new Date(Date.now() + 15 * 60 * 1000)
}; };
} catch { } catch {
@@ -52,40 +50,36 @@ export const handle: Handle = async ({ event, resolve }) => {
} }
} }
// If no valid session but refresh token exists, attempt rotation // If no valid session but refresh + session id exist, try to rotate.
if (!event.locals.user && refreshToken) { if (!event.locals.user && refreshToken && sessionId) {
try { try {
// We need to find the user by refresh token. const session = await authService.validateSession(sessionId, refreshToken);
// The refresh token is stored hashed per-user, so we need if (session) {
// a userId from somewhere. We store it in a separate cookie. const user = await userService.findById(session.userId);
const userIdFromCookie = event.cookies.get('refresh_user_id'); await rotateSessionCookies(
if (userIdFromCookie) { event.cookies,
const isValid = await authService.validateRefreshToken(userIdFromCookie, refreshToken); session.id,
if (isValid) { { id: user.id, email: user.email, role: user.role },
const user = await userService.findById(userIdFromCookie); session.rememberMe
const tokens = await authService.rotateTokens(user.id, user.email, user.role); );
setRotatedCookies(event.cookies, tokens); event.locals.user = {
id: user.id,
event.locals.user = { email: user.email,
id: user.id, displayName: user.displayName,
email: user.email, role: user.role as 'admin' | 'user'
displayName: user.displayName, };
role: user.role as 'admin' | 'user' event.locals.session = {
}; id: session.id,
event.locals.session = { expiresAt: new Date(Date.now() + 15 * 60 * 1000)
id: user.id, };
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
};
}
} }
} catch { } catch {
// Refresh failed — clear stale cookies
clearSessionCookies(event.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) { if (!event.locals.user) {
const bearerToken = extractBearerToken(event); const bearerToken = extractBearerToken(event);
if (bearerToken) { if (bearerToken) {
@@ -117,27 +111,33 @@ export const handle: Handle = async ({ event, resolve }) => {
if (event.locals.apiTokenScope) { if (event.locals.apiTokenScope) {
const method = event.request.method; const method = event.request.method;
const scope = event.locals.apiTokenScope; 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'); const isAdminPath = pathname.startsWith('/api/admin') || pathname.startsWith('/admin');
if (scope === 'read' && isWriteMethod) { if (scope === 'read' && isWriteMethod) {
return new Response(JSON.stringify({ success: false, data: null, error: 'API token scope "read" does not allow write operations' }), { return new Response(
status: 403, JSON.stringify({
headers: { 'Content-Type': 'application/json' } 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) { if (scope !== 'admin' && isAdminPath) {
return new Response(JSON.stringify({ success: false, data: null, error: 'API token scope does not allow admin operations' }), { return new Response(
status: 403, JSON.stringify({
headers: { 'Content-Type': 'application/json' } 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)) { if (!event.locals.user && !isPublicPath(pathname)) {
// Check if this is a guest-accessible board route
const boardMatch = pathname.match(/^\/boards\/([^/]+)/); const boardMatch = pathname.match(/^\/boards\/([^/]+)/);
if (boardMatch) { if (boardMatch) {
const boardId = boardMatch[1]; 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 === '/') { if (pathname === '/') {
return resolve(event); return resolve(event);
} }
@@ -3,9 +3,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock prisma before importing authService // Mock prisma before importing authService
vi.mock('../../prisma.js', () => ({ vi.mock('../../prisma.js', () => ({
prisma: { prisma: {
user: { session: {
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(), update: vi.fn(),
findUnique: vi.fn() deleteMany: vi.fn(),
findMany: vi.fn()
} }
} }
})); }));
@@ -19,8 +22,9 @@ import {
signAccessToken, signAccessToken,
verifyAccessToken, verifyAccessToken,
generateRefreshToken, generateRefreshToken,
getRefreshTokenExpiry, createSession,
rotateTokens rotateSession,
validateSession
} from '../authService.js'; } from '../authService.js';
import { prisma } from '../../prisma.js'; import { prisma } from '../../prisma.js';
@@ -84,31 +88,88 @@ describe('authService', () => {
}); });
}); });
describe('getRefreshTokenExpiry', () => { describe('createSession', () => {
it('returns a future date', () => { it('creates a session row and returns the raw refresh token', async () => {
const expiry = getRefreshTokenExpiry(); vi.mocked(prisma.session.create).mockResolvedValue({
expect(expiry.getTime()).toBeGreaterThan(Date.now()); 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', () => { it('extends expiry for remember-me sessions', async () => {
const expiry = getRefreshTokenExpiry(); vi.mocked(prisma.session.create).mockImplementation(
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000; (({ data }: { data: Record<string, unknown> }) =>
const diff = expiry.getTime() - Date.now(); Promise.resolve({
// Allow 10 seconds tolerance id: 'ses-2',
expect(diff).toBeGreaterThan(sevenDaysMs - 10000); ...data,
expect(diff).toBeLessThan(sevenDaysMs + 10000); 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', () => { describe('validateSession', () => {
it('generates new token pair and saves refresh token', async () => { it('returns null for missing session', async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never); 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(); describe('rotateSession', () => {
expect(result.refreshToken).toBeTruthy(); it('updates token hash and keeps the same session id', async () => {
expect(prisma.user.update).toHaveBeenCalledTimes(1); 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);
}); });
}); });
}); });
+113 -57
View File
@@ -2,7 +2,7 @@ import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { prisma } from '../prisma.js'; import { prisma } from '../prisma.js';
import { DEFAULTS } from '$lib/utils/constants.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; const SALT_ROUNDS = 12;
@@ -18,15 +18,6 @@ function getJwtExpiry(): string {
return process.env.JWT_EXPIRY || DEFAULTS.JWT_EXPIRY; 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<string> { export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS); 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(''); return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
} }
export function getRefreshTokenExpiry(): Date { export interface SessionMetadata {
const days = getRefreshTokenExpiryDays(); 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(); const expiry = new Date();
expiry.setDate(expiry.getDate() + days); expiry.setDate(expiry.getDate() + days);
return expiry; return expiry;
} }
export async function saveRefreshToken(userId: string, refreshToken: string): Promise<void> { /**
const hashedToken = await bcrypt.hash(refreshToken, SALT_ROUNDS); * Create a new session row and return the session id + raw refresh token.
await prisma.user.update({ * The raw token is returned once — only the hash is stored.
where: { id: userId }, */
data: { export async function createSession(
refreshToken: hashedToken,
refreshTokenExpiresAt: getRefreshTokenExpiry()
}
});
}
export async function validateRefreshToken(userId: string, refreshToken: string): Promise<boolean> {
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<void> {
await prisma.user.update({
where: { id: userId },
data: {
refreshToken: null,
refreshTokenExpiresAt: null
}
});
}
export async function rotateTokens(
userId: string, userId: string,
email: string, meta: SessionMetadata = {}
role: string ): Promise<IssuedSession> {
): Promise<TokenPair> {
const accessToken = signAccessToken({ userId, email, role });
const refreshToken = generateRefreshToken(); 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<IssuedSession> {
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<void> {
await prisma.session.deleteMany({ where: { id: sessionId } });
}
export async function revokeAllUserSessions(userId: string, exceptSessionId?: string): Promise<void> {
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<number> {
const result = await prisma.session.deleteMany({
where: { expiresAt: { lt: new Date() } }
});
return result.count;
} }
+1 -3
View File
@@ -1,10 +1,8 @@
/** /**
* JWT utilities — thin re-exports from authService. * JWT utilities — thin re-exports from authService.
* authService already handles sign, verify, and refresh token generation.
*/ */
export { export {
signAccessToken, signAccessToken,
verifyAccessToken, verifyAccessToken,
generateRefreshToken, generateRefreshToken
getRefreshTokenExpiry
} from '../services/authService.js'; } from '../services/authService.js';
+87 -23
View File
@@ -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 * as authService from '$lib/server/services/authService.js';
import { parseUserAgentLabel } from './userAgent.js';
export const ACCESS_TOKEN_TTL_SEC = 900; // 15 minutes 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 { function isHttpsOrigin(): boolean {
const origin = process.env.ORIGIN; const origin = process.env.ORIGIN;
if (origin) return origin.startsWith('https://'); if (origin) return origin.startsWith('https://');
// Fall back to NODE_ENV only when ORIGIN is unset.
return process.env.NODE_ENV === 'production'; return process.env.NODE_ENV === 'production';
} }
/** /**
* Shared cookie attributes (httpOnly, secure, sameSite=lax, path=/). * Shared cookie attributes. `secure` is derived from ORIGIN (https://...)
* `secure` is derived from ORIGIN (https://...) rather than NODE_ENV so * rather than NODE_ENV so plain-HTTP production deployments don't silently
* plain-HTTP production deployments don't silently drop cookies. * drop cookies.
*/ */
export function cookieBase() { export function cookieBase() {
return { return {
@@ -31,40 +36,99 @@ interface SessionUser {
readonly role: string; readonly role: string;
} }
interface IssueOptions {
readonly rememberMe?: boolean;
/** When set, metadata (user agent, IP, label) is pulled from this event. */
readonly event?: Pick<RequestEvent, 'request' | 'getClientAddress'>;
}
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. * Create a new session, persist it, and set access + refresh + session_id cookies.
* Persists a new refresh token in the DB.
*/ */
export async function issueSessionCookies(cookies: Cookies, user: SessionUser): Promise<void> { export async function issueSessionCookies(
cookies: Cookies,
user: SessionUser,
options: IssueOptions = {}
): Promise<void> {
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({ const accessToken = authService.signAccessToken({
userId: user.id, userId: user.id,
email: user.email, email: user.email,
role: user.role role: user.role
}); });
const refreshToken = authService.generateRefreshToken();
await authService.saveRefreshToken(user.id, refreshToken);
const base = cookieBase(); const base = cookieBase();
cookies.set('access_token', accessToken, { ...base, maxAge: ACCESS_TOKEN_TTL_SEC }); const ttl = refreshTtl(rememberMe);
cookies.set('refresh_token', refreshToken, { ...base, maxAge: REFRESH_TOKEN_TTL_SEC });
cookies.set('refresh_user_id', user.id, { ...base, maxAge: REFRESH_TOKEN_TTL_SEC }); 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). * Rotate an existing session's tokens and set new cookies. Used on refresh.
* Does not touch the DB.
*/ */
export function setRotatedCookies( export async function rotateSessionCookies(
cookies: Cookies, cookies: Cookies,
tokens: { readonly accessToken: string; readonly refreshToken: string } sessionId: string,
): void { user: SessionUser,
rememberMe: boolean
): Promise<void> {
const rotated = await authService.rotateSession(sessionId);
const accessToken = authService.signAccessToken({
userId: user.id,
email: user.email,
role: user.role
});
const base = cookieBase(); const base = cookieBase();
cookies.set('access_token', tokens.accessToken, { ...base, maxAge: ACCESS_TOKEN_TTL_SEC }); const ttl = refreshTtl(rememberMe);
cookies.set('refresh_token', tokens.refreshToken, { ...base, maxAge: REFRESH_TOKEN_TTL_SEC });
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 { export function clearSessionCookies(cookies: Cookies): void {
cookies.delete('access_token', { path: '/' }); cookies.delete(ACCESS_TOKEN_COOKIE, { path: '/' });
cookies.delete('refresh_token', { 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: '/' }); 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;
+28
View File
@@ -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}`;
}
+1 -5
View File
@@ -4,14 +4,10 @@ export interface JwtPayload {
readonly role: string; readonly role: string;
} }
export interface TokenPair {
readonly accessToken: string;
readonly refreshToken: string;
}
export interface LoginRequest { export interface LoginRequest {
readonly email: string; readonly email: string;
readonly password: string; readonly password: string;
readonly rememberMe?: boolean;
} }
export interface RegisterRequest { export interface RegisterRequest {
+2 -1
View File
@@ -18,7 +18,8 @@ import {
export const loginSchema = z.object({ export const loginSchema = z.object({
email: z.string().email('Invalid email address'), 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({ export const registerSchema = z.object({
+3 -2
View File
@@ -32,7 +32,8 @@ export const GET: RequestHandler = async () => {
* POST /api/onboarding — Complete an onboarding step. * POST /api/onboarding — Complete an onboarding step.
* No auth required (onboarding runs before any user exists). * 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; let body: unknown;
try { try {
body = await request.json(); 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 }); 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 })); return json(success({ complete: true }));
} }
+10 -8
View File
@@ -1,21 +1,23 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js'; import type { RequestHandler } from './$types.js';
import * as authService from '$lib/server/services/authService.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 }) => { export const POST: RequestHandler = async ({ cookies }) => {
// Revoke refresh token in database // Revoke the current session if we have its id
if (locals.user) { const sessionId = cookies.get(COOKIE_NAMES.SESSION_ID);
if (sessionId) {
try { try {
await authService.revokeRefreshToken(locals.user.id); await authService.revokeSession(sessionId);
} catch { } catch {
// Best-effort revocation — continue with cookie cleanup // Best-effort revocation — continue with cookie cleanup
} }
} }
// Clear all auth cookies clearSessionCookies(cookies);
cookies.delete('access_token', { path: '/' });
cookies.delete('refresh_token', { path: '/' });
cookies.delete('refresh_user_id', { path: '/' });
throw redirect(302, '/login'); throw redirect(302, '/login');
}; };
+3 -2
View File
@@ -4,7 +4,8 @@ import * as oauthService from '$lib/server/services/oauthService.js';
import * as userService from '$lib/server/services/userService.js'; import * as userService from '$lib/server/services/userService.js';
import { issueSessionCookies } from '$lib/server/utils/sessionCookies.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 { try {
// Check for error response from the provider // Check for error response from the provider
const oauthError = url.searchParams.get('error'); const oauthError = url.searchParams.get('error');
@@ -51,7 +52,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
groups: userInfo.groups ? [...userInfo.groups] : undefined groups: userInfo.groups ? [...userInfo.groups] : undefined
}); });
await issueSessionCookies(cookies, user); await issueSessionCookies(cookies, user, { event });
throw redirect(302, '/'); throw redirect(302, '/');
} catch (err) { } catch (err) {
+14 -10
View File
@@ -5,29 +5,33 @@ import * as userService from '$lib/server/services/userService.js';
import { error as apiError } from '$lib/server/utils/response.js'; import { error as apiError } from '$lib/server/utils/response.js';
import { import {
ACCESS_TOKEN_TTL_SEC, ACCESS_TOKEN_TTL_SEC,
COOKIE_NAMES,
clearSessionCookies, clearSessionCookies,
setRotatedCookies rotateSessionCookies
} from '$lib/server/utils/sessionCookies.js'; } from '$lib/server/utils/sessionCookies.js';
export const POST: RequestHandler = async ({ cookies }) => { export const POST: RequestHandler = async ({ cookies }) => {
const refreshToken = cookies.get('refresh_token'); const refreshToken = cookies.get(COOKIE_NAMES.REFRESH_TOKEN);
const userId = cookies.get('refresh_user_id'); const sessionId = cookies.get(COOKIE_NAMES.SESSION_ID);
if (!refreshToken || !userId) { if (!refreshToken || !sessionId) {
return json(apiError('No refresh token provided'), { status: 401 }); return json(apiError('No refresh token provided'), { status: 401 });
} }
try { try {
const isValid = await authService.validateRefreshToken(userId, refreshToken); const session = await authService.validateSession(sessionId, refreshToken);
if (!isValid) { if (!session) {
clearSessionCookies(cookies); clearSessionCookies(cookies);
return json(apiError('Invalid or expired refresh token'), { status: 401 }); return json(apiError('Invalid or expired refresh token'), { status: 401 });
} }
const user = await userService.findById(userId); const user = await userService.findById(session.userId);
const tokens = await authService.rotateTokens(user.id, user.email, user.role); await rotateSessionCookies(
cookies,
setRotatedCookies(cookies, tokens); session.id,
{ id: user.id, email: user.email, role: user.role },
session.rememberMe
);
return json({ return json({
success: true, success: true,
+4 -3
View File
@@ -28,14 +28,15 @@ export const load: PageServerLoad = async ({ locals }) => {
}; };
export const actions: Actions = { export const actions: Actions = {
default: async ({ request, cookies }) => { default: async (event) => {
const { request, cookies } = event;
const form = await superValidate(request, zod(loginSchema)); const form = await superValidate(request, zod(loginSchema));
if (!form.valid) { if (!form.valid) {
return fail(400, { form }); return fail(400, { form });
} }
const { email, password } = form.data; const { email, password, rememberMe } = form.data;
const user = await userService.findByEmail(email); const user = await userService.findByEmail(email);
if (!user) { if (!user) {
@@ -51,7 +52,7 @@ export const actions: Actions = {
return setError(form, 'email', 'Invalid email or password'); return setError(form, 'email', 'Invalid email or password');
} }
await issueSessionCookies(cookies, user); await issueSessionCookies(cookies, user, { rememberMe, event });
throw redirect(302, '/'); throw redirect(302, '/');
} }
+10
View File
@@ -105,6 +105,16 @@
{/if} {/if}
</div> </div>
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
name="rememberMe"
bind:checked={$form.rememberMe}
class="h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-ring/30"
/>
<span>Keep me signed in for 30 days</span>
</label>
<button <button
type="submit" type="submit"
disabled={$submitting} disabled={$submitting}
+3 -2
View File
@@ -33,7 +33,8 @@ export const load: PageServerLoad = async ({ locals }) => {
}; };
export const actions: Actions = { export const actions: Actions = {
default: async ({ request, cookies }) => { default: async (event) => {
const { request, cookies } = event;
const registrationEnabled = await isRegistrationEnabled(); const registrationEnabled = await isRegistrationEnabled();
if (!registrationEnabled) { if (!registrationEnabled) {
throw error(403, { message: 'Registration is currently disabled' }); throw error(403, { message: 'Registration is currently disabled' });
@@ -65,7 +66,7 @@ export const actions: Actions = {
// Add user to default groups // Add user to default groups
await groupService.addUserToDefaultGroups(user.id); await groupService.addUserToDefaultGroups(user.id);
await issueSessionCookies(cookies, user); await issueSessionCookies(cookies, user, { event });
throw redirect(302, '/'); throw redirect(302, '/');
} }