From 8a3fe1407f388db2f7cd32da1d758b839919f588 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 14 Oct 2026 07:36:16 +0200 Subject: [PATCH] =?UTF-8?q?fix(session):=20session=20glissante=20prolong?= =?UTF-8?q?=C3=A9e=20par=20l'activit=C3=A9=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chaque requête authentifiée repousse l'expiration (au plus une fois par minute), côté jeton et côté cookie. Co-Authored-By: Claude Sonnet 5.5 --- src/app.ts | 7 +++++++ src/session.ts | 19 +++++++++++++++++ tests/session.test.ts | 49 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index 47a7e87..8b0a54f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,6 +9,7 @@ import { ensureUploadDir, removeFile } from './lib/storage.js'; import { createUploadSessionStore } from './lib/upload-sessions.js'; import { createOidcClient, type OidcClient } from './oidc.js'; import { authRoutes } from './routes/auth.js'; +import { createSessionRefresher } from './session.js'; import { chunkedUploadRoutes } from './routes/upload-chunked.js'; import { healthRoutes } from './routes/health.js'; import { pageRoutes } from './routes/pages.js'; @@ -61,6 +62,12 @@ export async function buildApp( await notifier.close(); }); + const refreshSession = createSessionRefresher(config.session.maxAgeSeconds); + app.addHook('preHandler', (request, _reply, hookDone) => { + refreshSession(request); + hookDone(); + }); + // Corps brut (flux) pour les morceaux d'upload fragmenté ; écrit en streaming. app.addContentTypeParser('application/octet-stream', (_request, payload, taskDone) => { taskDone(null, payload); diff --git a/src/session.ts b/src/session.ts index d036a55..ed65e14 100644 --- a/src/session.ts +++ b/src/session.ts @@ -26,6 +26,25 @@ export function getSessionUser(request: FastifyRequest): SessionUser | null { return user; } +/** Intervalle minimal entre deux prolongations (évite un `Set-Cookie` à chaque requête). */ +const REFRESH_MIN_INTERVAL_MS = 60_000; + +/** + * Session glissante : chaque requête authentifiée repousse l'expiration de + * `maxAgeSeconds` (au plus une fois par minute), côté jeton et côté cookie. + * Une session n'expire donc qu'après `maxAgeSeconds` d'inactivité. + */ +export function createSessionRefresher(maxAgeSeconds: number): (request: FastifyRequest) => void { + const maxAgeMs = maxAgeSeconds * 1000; + return (request: FastifyRequest): void => { + const user = getSessionUser(request); + if (user === null || user.expiresAt - Date.now() >= maxAgeMs - REFRESH_MIN_INTERVAL_MS) { + return; + } + request.session.set('user', { ...user, expiresAt: Date.now() + maxAgeMs }); + }; +} + /** Hook `preHandler` : rejette (401) toute requête sans session valide. */ export function requireSession( request: FastifyRequest, diff --git a/tests/session.test.ts b/tests/session.test.ts index bcb3fbb..c99686d 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -2,7 +2,12 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { describe, expect, it, vi } from 'vitest'; import { UnauthorizedError } from '../src/errors.js'; -import { getSessionUser, requireSession, type SessionUser } from '../src/session.js'; +import { + createSessionRefresher, + getSessionUser, + requireSession, + type SessionUser, +} from '../src/session.js'; function requestWithSessionUser(user: SessionUser | undefined): FastifyRequest { return { @@ -28,6 +33,48 @@ describe('getSessionUser', () => { }); }); +describe('createSessionRefresher (session glissante)', () => { + const MAX_AGE_S = 3600; + + function requestRecordingSet(user: SessionUser | undefined): { + request: FastifyRequest; + set: ReturnType; + } { + const set = vi.fn(); + const request = { + session: { get: (key: string) => (key === 'user' ? user : undefined), set }, + } as unknown as FastifyRequest; + return { request, set }; + } + + it('prolonge une session utilisée depuis plus d’une minute', () => { + const user: SessionUser = { sub: 'u1', name: 'Alice', expiresAt: Date.now() + 1_800_000 }; + const { request, set } = requestRecordingSet(user); + createSessionRefresher(MAX_AGE_S)(request); + expect(set).toHaveBeenCalledTimes(1); + const [key, value] = set.mock.calls[0] as [string, SessionUser]; + expect(key).toBe('user'); + expect(value.sub).toBe('u1'); + expect(value.expiresAt).toBeGreaterThan(Date.now() + 3_500_000); + }); + + it('ne réécrit pas la session à chaque requête (moins d’une minute)', () => { + const user: SessionUser = { sub: 'u1', name: 'Alice', expiresAt: Date.now() + 3_590_000 }; + const { request, set } = requestRecordingSet(user); + createSessionRefresher(MAX_AGE_S)(request); + expect(set).not.toHaveBeenCalled(); + }); + + it('ne ressuscite pas une session expirée ni une requête anonyme', () => { + const expired: SessionUser = { sub: 'u1', name: 'Alice', expiresAt: Date.now() - 1000 }; + for (const user of [expired, undefined]) { + const { request, set } = requestRecordingSet(user); + createSessionRefresher(MAX_AGE_S)(request); + expect(set).not.toHaveBeenCalled(); + } + }); +}); + describe('requireSession', () => { it('appelle done(UnauthorizedError) sans session', () => { const done = vi.fn(); -- 2.45.2