diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..bea0d34 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,10 @@ +// Family-wide session policy. Used by every bchen.dev app's session cookie. +// DO NOT diverge per-app — coherence across apps is a feature. + +export const SESSION_TTL_DAYS = 30; +export const SESSION_TTL_SECONDS = SESSION_TTL_DAYS * 24 * 60 * 60; +export const SESSION_RENEW_THRESHOLD_SECONDS = 60 * 60; +// Phase 1: keep cookie name as 'token' so existing tests stay green. +// Phase 2 flips this to 'nanodrop_session'. +export const SESSION_COOKIE_NAME = 'token'; +export const LOGOUT_PATHS = new Set(['/logout', '/api/v1/auth/logout']); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 77050e5..812a637 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,24 +1,45 @@ -import type { FastifyRequest, FastifyReply } from 'fastify'; +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'; -export async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise { - try { - await request.jwtVerify(); - } catch { - // API routes get 401, page routes get redirect - const isApi = request.url.startsWith('/api/'); - if (isApi) { - reply.status(401).send({ error: 'Unauthorized' }); - } else { - reply.redirect('/'); - } - } -} - -export function tokenCookieOptions(secure: boolean): { +export function sessionCookieOptions(secure: boolean): { httpOnly: boolean; sameSite: 'strict'; secure: boolean; path: string; + maxAge: number; } { - return { httpOnly: true, sameSite: 'strict', secure, path: '/' }; + return { + httpOnly: true, + sameSite: 'strict', + secure, + path: '/', + maxAge: SESSION_TTL_SECONDS, + }; +} + +export function issueSessionCookie( + reply: FastifyReply, + server: FastifyInstance, + claims: JwtPayload, + cookieSecure: boolean, +): void { + const token = server.jwt.sign(claims, { expiresIn: SESSION_TTL_SECONDS }); + reply.setCookie(SESSION_COOKIE_NAME, token, sessionCookieOptions(cookieSecure)); +} + +export function makeRequireAuth(_config: Config) { + return async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise { + try { + await request.jwtVerify(); + } catch { + const isApi = request.url.startsWith('/api/'); + if (isApi) { + reply.status(401).send({ error: 'Unauthorized' }); + } else { + reply.redirect('/'); + } + } + }; } diff --git a/src/routes/api/v1/auth.ts b/src/routes/api/v1/auth.ts index 7b11200..fe3632b 100644 --- a/src/routes/api/v1/auth.ts +++ b/src/routes/api/v1/auth.ts @@ -4,7 +4,8 @@ import type { Config } from '../../../config.ts'; import type { Logger } from '../../../middleware/logging.ts'; import type { LockoutService } from '../../../services/lockout.ts'; import { attemptLogin } from '../../../services/login-handler.ts'; -import { requireAuth, tokenCookieOptions } from '../../../middleware/auth.ts'; +import { makeRequireAuth, issueSessionCookie } from '../../../middleware/auth.ts'; +import { SESSION_COOKIE_NAME } from '../../../constants.ts'; interface Deps { db: Database.Database; @@ -20,6 +21,7 @@ interface LoginBody { export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { const { config } = deps; + const requireAuth = makeRequireAuth(config); app.post<{ Body: LoginBody }>( '/login', @@ -56,15 +58,17 @@ export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { d return reply.status(401).send({ error: 'Invalid credentials' }); } - const token = app.jwt.sign( + issueSessionCookie( + reply, + app, { sub: result.user.id, username: result.user.username }, - { expiresIn: config.jwtExpiry }, + config.cookieSecure, ); - reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).send({ ok: true }); + reply.send({ ok: true }); }, ); app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => { - reply.clearCookie('token', { path: '/' }).send({ ok: true }); + reply.clearCookie(SESSION_COOKIE_NAME, { path: '/' }).send({ ok: true }); }); }; diff --git a/src/routes/api/v1/files.ts b/src/routes/api/v1/files.ts index 4da58ce..fd582df 100644 --- a/src/routes/api/v1/files.ts +++ b/src/routes/api/v1/files.ts @@ -7,7 +7,7 @@ import type { Logger } from '../../../middleware/logging.ts'; import type { JwtPayload } from '../../../types.ts'; import { createFile, getFilesByUserId, getFileById, deleteFile } from '../../../db/files.ts'; import { saveFile, deleteStoredFile } from '../../../services/storage.ts'; -import { requireAuth } from '../../../middleware/auth.ts'; +import { makeRequireAuth } from '../../../middleware/auth.ts'; interface Deps { db: Database.Database; @@ -17,6 +17,7 @@ interface Deps { export const filesApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { const { db, config } = deps; + const requireAuth = makeRequireAuth(config); app.get('/', { preHandler: requireAuth }, async (request, reply) => { const { sub: userId } = request.user as JwtPayload; diff --git a/src/routes/pages.ts b/src/routes/pages.ts index 0800896..6b16a52 100644 --- a/src/routes/pages.ts +++ b/src/routes/pages.ts @@ -10,7 +10,8 @@ import type { LockoutService } from '../services/lockout.ts'; import { createFile, getFileById, getFilesByUserId, deleteFile } from '../db/files.ts'; import { saveFile, deleteStoredFile, getFilePath } from '../services/storage.ts'; import { attemptLogin } from '../services/login-handler.ts'; -import { requireAuth, tokenCookieOptions } from '../middleware/auth.ts'; +import { makeRequireAuth, issueSessionCookie } from '../middleware/auth.ts'; +import { SESSION_COOKIE_NAME } from '../constants.ts'; import { loginPage } from '../views/login.ts'; import { uploadPage, uploadResultPage } from '../views/upload.ts'; import { fileListPage } from '../views/file-list.ts'; @@ -49,6 +50,7 @@ function parseRangeHeader(header: string, fileSize: number): { start: number; en export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { const { db, config, logger } = deps; + const requireAuth = makeRequireAuth(config); const loginRateLimit = { rateLimit: { max: config.loginRateLimitMax, @@ -57,6 +59,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }; // GET / — login page or redirect if authed + // opportunistic auth — does not slide the session; see middleware/session-renewal.ts app.get('/', async (request, reply) => { try { await request.jwtVerify(); @@ -91,17 +94,19 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps return reply.type('text/html').send(loginPage({ error: 'Invalid username or password' })); } - const token = app.jwt.sign( + issueSessionCookie( + reply, + app, { sub: result.user.id, username: result.user.username }, - { expiresIn: config.jwtExpiry }, + config.cookieSecure, ); - reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).redirect('/upload'); + reply.redirect('/upload'); }, ); // POST /logout app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => { - reply.clearCookie('token', { path: '/' }).redirect('/'); + reply.clearCookie(SESSION_COOKIE_NAME, { path: '/' }).redirect('/'); }); // GET /upload @@ -163,6 +168,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }); // GET /f/:id — public file view (owner-aware) + // opportunistic auth — does not slide the session; see middleware/session-renewal.ts app.get<{ Params: { id: string } }>('/f/:id', async (request, reply) => { const { id } = request.params; diff --git a/src/types.ts b/src/types.ts index 7408ccf..5c126e2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ export interface JwtPayload { sub: number; username: string; + iat?: number; }