b9f3a2ca0b
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.
135 lines
3.9 KiB
TypeScript
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;
|