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
@@ -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);
});
});
});