Files
web-app-launcher/src/lib/server/utils/sessionCookies.ts
T
alexei.dolgolyov b9f3a2ca0b 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.
2026-04-16 03:41:52 +03:00

135 lines
3.9 KiB
TypeScript

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 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://');
return process.env.NODE_ENV === 'production';
}
/**
* 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 {
httpOnly: true,
secure: isHttpsOrigin(),
sameSite: 'lax' as const,
path: '/'
};
}
interface SessionUser {
readonly id: string;
readonly email: 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)
};
}
/**
* Create a new session, persist it, and set access + refresh + session_id cookies.
*/
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 base = cookieBase();
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 });
}
/**
* Rotate an existing session's tokens and set new cookies. Used on refresh.
*/
export async function rotateSessionCookies(
cookies: Cookies,
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();
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_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;