feat(auth): sliding session renewal middleware
Adds src/middleware/session-renewal.ts with slideSessionIfNeeded — a pure function that re-mints the session cookie iff the JWT is older than SESSION_RENEW_THRESHOLD_SECONDS (1 hour). Wired into makeRequireAuth so every authenticated request bumps the cookie's expiration window forward, giving the 'stay signed in unless you clear cookies' UX. Logout paths are explicitly guarded via LOGOUT_PATHS so the renewer never resurrects a session the user is actively terminating. Query-string strip prevents /logout?next=foo bypass. Opportunistic-auth blocks (GET /, GET /f/:id) verify JWT directly without going through makeRequireAuth, so they don't slide — by design, a public file view shouldn't extend the owner's session. Tests cover threshold semantics, both logout paths, query-string handling, missing iat (legacy token forces refresh), and a full integration suite simulating 25-day and 31-day jumps via vi.setSystemTime.
This commit is contained in:
@@ -2,6 +2,7 @@ import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
||||
import type { Config } from '../config.ts';
|
||||
import type { JwtPayload } from '../types.ts';
|
||||
import { SESSION_COOKIE_NAME, SESSION_TTL_SECONDS } from '../constants.ts';
|
||||
import { slideSessionIfNeeded } from './session-renewal.ts';
|
||||
|
||||
export function sessionCookieOptions(secure: boolean): {
|
||||
httpOnly: boolean;
|
||||
@@ -29,10 +30,11 @@ export function issueSessionCookie(
|
||||
reply.setCookie(SESSION_COOKIE_NAME, token, sessionCookieOptions(cookieSecure));
|
||||
}
|
||||
|
||||
export function makeRequireAuth(_config: Config) {
|
||||
export function makeRequireAuth(config: Config) {
|
||||
return async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
await request.jwtVerify<JwtPayload & { iat?: number }>();
|
||||
const payload = await request.jwtVerify<JwtPayload & { iat?: number }>();
|
||||
slideSessionIfNeeded(request, reply, payload, request.server, config.cookieSecure);
|
||||
} catch {
|
||||
const isApi = request.url.startsWith('/api/');
|
||||
if (isApi) {
|
||||
|
||||
27
src/middleware/session-renewal.ts
Normal file
27
src/middleware/session-renewal.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
||||
import type { JwtPayload } from '../types.ts';
|
||||
import { LOGOUT_PATHS, SESSION_RENEW_THRESHOLD_SECONDS } from '../constants.ts';
|
||||
import { issueSessionCookie } from './auth.ts';
|
||||
|
||||
// Family pattern includes an mfa-pending early-return; nanodrop has no MFA, so this is N/A.
|
||||
export function slideSessionIfNeeded(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
payload: JwtPayload & { iat?: number },
|
||||
server: FastifyInstance,
|
||||
cookieSecure: boolean,
|
||||
): void {
|
||||
const path = request.url.split('?', 1)[0];
|
||||
if (LOGOUT_PATHS.has(path)) return;
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const iat = payload.iat ?? 0;
|
||||
if (nowSec - iat < SESSION_RENEW_THRESHOLD_SECONDS) return;
|
||||
|
||||
issueSessionCookie(
|
||||
reply,
|
||||
server,
|
||||
{ sub: payload.sub, username: payload.username },
|
||||
cookieSecure,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user