fix(session): session glissante prolongée par l'activité #14
3 changed files with 74 additions and 1 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue