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'); + }); +});