feat: persistent session cookies (30d sliding) — nanodrop tier #4
@@ -2,6 +2,7 @@ import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
|||||||
import type { Config } from '../config.ts';
|
import type { Config } from '../config.ts';
|
||||||
import type { JwtPayload } from '../types.ts';
|
import type { JwtPayload } from '../types.ts';
|
||||||
import { SESSION_COOKIE_NAME, SESSION_TTL_SECONDS } from '../constants.ts';
|
import { SESSION_COOKIE_NAME, SESSION_TTL_SECONDS } from '../constants.ts';
|
||||||
|
import { slideSessionIfNeeded } from './session-renewal.ts';
|
||||||
|
|
||||||
export function sessionCookieOptions(secure: boolean): {
|
export function sessionCookieOptions(secure: boolean): {
|
||||||
httpOnly: boolean;
|
httpOnly: boolean;
|
||||||
@@ -29,10 +30,11 @@ export function issueSessionCookie(
|
|||||||
reply.setCookie(SESSION_COOKIE_NAME, token, sessionCookieOptions(cookieSecure));
|
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> {
|
return async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await request.jwtVerify<JwtPayload & { iat?: number }>();
|
const payload = await request.jwtVerify<JwtPayload & { iat?: number }>();
|
||||||
|
slideSessionIfNeeded(request, reply, payload, request.server, config.cookieSecure);
|
||||||
} catch {
|
} catch {
|
||||||
const isApi = request.url.startsWith('/api/');
|
const isApi = request.url.startsWith('/api/');
|
||||||
if (isApi) {
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
100
tests/integration/session-persistence.test.ts
Normal file
100
tests/integration/session-persistence.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
122
tests/unit/session-renewal.test.ts
Normal file
122
tests/unit/session-renewal.test.ts
Normal file
@@ -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<FastifyInstance> {
|
||||||
|
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<string, unknown> }> } & Record<string, unknown> {
|
||||||
|
const setCookieCalls: Array<{ name: string; value: string; opts: Record<string, unknown> }> = [];
|
||||||
|
return {
|
||||||
|
setCookieCalls,
|
||||||
|
setCookie(name: string, value: string, opts: Record<string, unknown>) {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user