From 0f0c2f0e96b5b784a89cf11577aa63b5838c507d Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Sat, 9 May 2026 10:15:31 -0700 Subject: [PATCH] feat(auth): sliding session renewal middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/middleware/auth.ts | 6 +- src/middleware/session-renewal.ts | 27 ++++ tests/integration/session-persistence.test.ts | 100 ++++++++++++++ tests/unit/session-renewal.test.ts | 122 ++++++++++++++++++ 4 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 src/middleware/session-renewal.ts create mode 100644 tests/integration/session-persistence.test.ts create mode 100644 tests/unit/session-renewal.test.ts diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 812a637..a72cb1c 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -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 { try { - await request.jwtVerify(); + const payload = await request.jwtVerify(); + slideSessionIfNeeded(request, reply, payload, request.server, config.cookieSecure); } catch { const isApi = request.url.startsWith('/api/'); if (isApi) { diff --git a/src/middleware/session-renewal.ts b/src/middleware/session-renewal.ts new file mode 100644 index 0000000..b6878b9 --- /dev/null +++ b/src/middleware/session-renewal.ts @@ -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, + ); +} diff --git a/tests/integration/session-persistence.test.ts b/tests/integration/session-persistence.test.ts new file mode 100644 index 0000000..ae54bf7 --- /dev/null +++ b/tests/integration/session-persistence.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createTestApp, type TestContext, loginAs } from '../helpers/setup.ts'; +import { createUser } from '../../src/db/users.ts'; +import { hashPassword } from '../../src/services/auth.ts'; + +describe('session persistence (sliding renewal)', () => { + let ctx: TestContext; + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + + ctx = createTestApp(); + const hash = await hashPassword('secret'); + createUser(ctx.db, { username: 'alice', passwordHash: hash }); + }); + + afterEach(async () => { + vi.useRealTimers(); + await ctx.app.close(); + ctx.cleanup(); + }); + + it('login response includes Set-Cookie with Max-Age=2592000', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'secret' }), + }); + expect(res.statusCode).toBe(200); + expect(res.headers['set-cookie']).toMatch(/nanodrop_session=/); + expect(res.headers['set-cookie']).toMatch(/Max-Age=2592000/); + }); + + it('request 25 days after login triggers a fresh Set-Cookie', async () => { + const token = await loginAs(ctx.app, 'alice', 'secret'); + vi.setSystemTime(new Date('2026-01-26T00:00:00Z')); // +25 days + + const res = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/files', + cookies: { nanodrop_session: token }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['set-cookie']).toBeTruthy(); + expect(res.headers['set-cookie']).toMatch(/nanodrop_session=/); + expect(res.headers['set-cookie']).toMatch(/Max-Age=2592000/); + }); + + it('request within renewal threshold does NOT include a fresh Set-Cookie', async () => { + const token = await loginAs(ctx.app, 'alice', 'secret'); + vi.setSystemTime(new Date('2026-01-01T00:30:00Z')); // +30 minutes (under 1 hour) + + const res = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/files', + cookies: { nanodrop_session: token }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['set-cookie']).toBeFalsy(); + }); + + it('logout still clears cookie even after renewal threshold has passed', async () => { + const token = await loginAs(ctx.app, 'alice', 'secret'); + vi.setSystemTime(new Date('2026-01-01T02:00:00Z')); // +2 hours (past threshold) + + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/logout', + cookies: { nanodrop_session: token }, + }); + expect(res.statusCode).toBe(200); + const setCookie = res.headers['set-cookie'] as string | string[]; + const setCookieStr = Array.isArray(setCookie) ? setCookie.join('\n') : setCookie; + // Cookie cleared (Max-Age=0 or empty value) + expect(setCookieStr).toMatch(/nanodrop_session=;/); + // No fresh session cookie issued by the renewer + const freshIssue = /nanodrop_session=eyJ/.test(setCookieStr); + expect(freshIssue).toBe(false); + }); + + it('request 31 days after login is bounced (idle lapse)', async () => { + const token = await loginAs(ctx.app, 'alice', 'secret'); + vi.setSystemTime(new Date('2026-02-01T01:00:00Z')); // +31 days, past 30-day TTL + + const res = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/files', + cookies: { nanodrop_session: token }, + }); + expect(res.statusCode).toBe(401); + }); + + it('anonymous request to /api/v1/files returns 401 with no Set-Cookie', async () => { + const res = await ctx.app.inject({ method: 'GET', url: '/api/v1/files' }); + expect(res.statusCode).toBe(401); + expect(res.headers['set-cookie']).toBeFalsy(); + }); +}); diff --git a/tests/unit/session-renewal.test.ts b/tests/unit/session-renewal.test.ts new file mode 100644 index 0000000..abd789a --- /dev/null +++ b/tests/unit/session-renewal.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import fastifyJwt from '@fastify/jwt'; +import { slideSessionIfNeeded } from '../../src/middleware/session-renewal.ts'; +import { + SESSION_COOKIE_NAME, + SESSION_RENEW_THRESHOLD_SECONDS, + SESSION_TTL_SECONDS, +} from '../../src/constants.ts'; + +interface TestPayload { + sub: number; + username: string; + iat?: number; +} + +async function buildTestServer(): Promise { + const app = Fastify(); + await app.register(fastifyCookie); + await app.register(fastifyJwt, { + secret: 'test-secret', + cookie: { cookieName: SESSION_COOKIE_NAME, signed: false }, + }); + await app.ready(); + return app; +} + +const NOW_ISO = '2026-01-01T12:00:00Z'; +const NOW_SEC = Math.floor(new Date(NOW_ISO).getTime() / 1000); + +describe('slideSessionIfNeeded', () => { + let app: FastifyInstance; + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(NOW_ISO)); + app = await buildTestServer(); + }); + + afterEach(async () => { + vi.useRealTimers(); + await app.close(); + }); + + function fakeReply(): { setCookieCalls: Array<{ name: string; value: string; opts: Record }> } & Record { + const setCookieCalls: Array<{ name: string; value: string; opts: Record }> = []; + return { + setCookieCalls, + setCookie(name: string, value: string, opts: Record) { + setCookieCalls.push({ name, value, opts }); + return this; + }, + }; + } + + function fakeRequest(url: string): { url: string } { + return { url }; + } + + it('re-issues cookie when now - iat >= SESSION_RENEW_THRESHOLD_SECONDS', () => { + const reply = fakeReply(); + const payload: TestPayload = { + sub: 1, + username: 'alice', + iat: NOW_SEC - SESSION_RENEW_THRESHOLD_SECONDS, + }; + slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, false); + expect(reply.setCookieCalls.length).toBe(1); + expect(reply.setCookieCalls[0].name).toBe(SESSION_COOKIE_NAME); + expect(reply.setCookieCalls[0].opts.maxAge).toBe(SESSION_TTL_SECONDS); + }); + + it('skips re-issue when now - iat < SESSION_RENEW_THRESHOLD_SECONDS', () => { + const reply = fakeReply(); + const payload: TestPayload = { + sub: 1, + username: 'alice', + iat: NOW_SEC - (SESSION_RENEW_THRESHOLD_SECONDS - 1), + }; + slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, false); + expect(reply.setCookieCalls.length).toBe(0); + }); + + it('skips re-issue on /logout path', () => { + const reply = fakeReply(); + const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 }; // ancient + slideSessionIfNeeded(fakeRequest('/logout') as never, reply as never, payload, app, false); + expect(reply.setCookieCalls.length).toBe(0); + }); + + it('skips re-issue on /api/v1/auth/logout path', () => { + const reply = fakeReply(); + const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 }; + slideSessionIfNeeded(fakeRequest('/api/v1/auth/logout') as never, reply as never, payload, app, false); + expect(reply.setCookieCalls.length).toBe(0); + }); + + it('skips re-issue when /logout has a query string', () => { + const reply = fakeReply(); + const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 }; + slideSessionIfNeeded(fakeRequest('/logout?redirect=foo') as never, reply as never, payload, app, false); + expect(reply.setCookieCalls.length).toBe(0); + }); + + it('treats missing iat as 0 (forces refresh)', () => { + const reply = fakeReply(); + const payload: TestPayload = { sub: 1, username: 'alice' }; + slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, false); + expect(reply.setCookieCalls.length).toBe(1); + }); + + it('produces a cookie carrying Max-Age=SESSION_TTL_SECONDS', () => { + const reply = fakeReply(); + const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 }; + slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, true); + expect(reply.setCookieCalls[0].opts.maxAge).toBe(SESSION_TTL_SECONDS); + expect(reply.setCookieCalls[0].opts.secure).toBe(true); + expect(reply.setCookieCalls[0].opts.httpOnly).toBe(true); + expect(reply.setCookieCalls[0].opts.sameSite).toBe('strict'); + }); +});