fix(session): session glissante prolongée par l'activité #14

Merged
kyuno merged 1 commit from fix/sliding-session into main 2026-10-14 07:43:10 +02:00
3 changed files with 74 additions and 1 deletions
Showing only changes of commit 8a3fe1407f - Show all commits

View file

@ -9,6 +9,7 @@ import { ensureUploadDir, removeFile } from './lib/storage.js';
import { createUploadSessionStore } from './lib/upload-sessions.js'; import { createUploadSessionStore } from './lib/upload-sessions.js';
import { createOidcClient, type OidcClient } from './oidc.js'; import { createOidcClient, type OidcClient } from './oidc.js';
import { authRoutes } from './routes/auth.js'; import { authRoutes } from './routes/auth.js';
import { createSessionRefresher } from './session.js';
import { chunkedUploadRoutes } from './routes/upload-chunked.js'; import { chunkedUploadRoutes } from './routes/upload-chunked.js';
import { healthRoutes } from './routes/health.js'; import { healthRoutes } from './routes/health.js';
import { pageRoutes } from './routes/pages.js'; import { pageRoutes } from './routes/pages.js';
@ -61,6 +62,12 @@ export async function buildApp(
await notifier.close(); 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. // Corps brut (flux) pour les morceaux d'upload fragmenté ; écrit en streaming.
app.addContentTypeParser('application/octet-stream', (_request, payload, taskDone) => { app.addContentTypeParser('application/octet-stream', (_request, payload, taskDone) => {
taskDone(null, payload); taskDone(null, payload);

View file

@ -26,6 +26,25 @@ export function getSessionUser(request: FastifyRequest): SessionUser | null {
return user; 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. */ /** Hook `preHandler` : rejette (401) toute requête sans session valide. */
export function requireSession( export function requireSession(
request: FastifyRequest, request: FastifyRequest,

View file

@ -2,7 +2,12 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { UnauthorizedError } from '../src/errors.js'; 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 { function requestWithSessionUser(user: SessionUser | undefined): FastifyRequest {
return { 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<typeof vi.fn>;
} {
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', () => { describe('requireSession', () => {
it('appelle done(UnauthorizedError) sans session', () => { it('appelle done(UnauthorizedError) sans session', () => {
const done = vi.fn(); const done = vi.fn();