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?
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
+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 {
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);
}
@@ -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<string, unknown> }) =>
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);
});
});
});
+113 -57
View File
@@ -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<string> {
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<void> {
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<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(
/**
* 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<TokenPair> {
const accessToken = signAccessToken({ userId, email, role });
meta: SessionMetadata = {}
): Promise<IssuedSession> {
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.
* authService already handles sign, verify, and refresh token generation.
*/
export {
signAccessToken,
verifyAccessToken,
generateRefreshToken,
getRefreshTokenExpiry
generateRefreshToken
} 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 { 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<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.
* 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<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({
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<void> {
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;
+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;
}
export interface TokenPair {
readonly accessToken: string;
readonly refreshToken: string;
}
export interface LoginRequest {
readonly email: string;
readonly password: string;
readonly rememberMe?: boolean;
}
export interface RegisterRequest {
+2 -1
View File
@@ -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({
+3 -2
View File
@@ -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 }));
}
+10 -8
View File
@@ -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');
};
+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 { 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) {
+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 {
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,
+4 -3
View File
@@ -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, '/');
}
+10
View File
@@ -105,6 +105,16 @@
{/if}
</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
type="submit"
disabled={$submitting}
+3 -2
View File
@@ -33,7 +33,8 @@ export const load: PageServerLoad = async ({ locals }) => {
};
export const actions: Actions = {
default: async ({ request, cookies }) => {
default: async (event) => {
const { request, cookies } = event;
const registrationEnabled = await isRegistrationEnabled();
if (!registrationEnabled) {
throw error(403, { message: 'Registration is currently disabled' });
@@ -65,7 +66,7 @@ export const actions: Actions = {
// Add user to default groups
await groupService.addUserToDefaultGroups(user.id);
await issueSessionCookies(cookies, user);
await issueSessionCookies(cookies, user, { event });
throw redirect(302, '/');
}