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
+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, '/');
}