From 8dc836cbb7067f78065a7e0a7f7c8ac75abfadca Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 16 Feb 2026 19:06:24 -0700 Subject: [PATCH] feat(auth): implement route protection and security headers Add Next.js middleware to handle session-based access control for protected routes while maintaining access to public paths. Configure comprehensive security headers including CSP, HSTS, and frame options in next.config.ts. Also, reduce session cookie cache duration to 5 minutes to ensure frequent revalidation against the database and fix a navigation issue in the EPUB context when jumping to specific CFI locations. --- next.config.ts | 24 +++++++++++ src/contexts/EPUBContext.tsx | 10 +++++ src/lib/server/auth.ts | 2 +- src/middleware.ts | 82 ++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 src/middleware.ts diff --git a/next.config.ts b/next.config.ts index 46689e3..5179aeb 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,30 @@ import type { NextConfig } from "next"; +const securityHeaders = [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + { + key: 'Content-Security-Policy', + value: "frame-ancestors 'self' https://*.huggingface.co https://huggingface.co", + }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload', + }, +]; + const nextConfig: NextConfig = { + async headers() { + return [ + { + // Apply security headers to all routes + source: '/(.*)', + headers: securityHeaders, + }, + ]; + }, turbopack: { resolveAlias: { canvas: '@napi-rs/canvas', diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 3bf2877..5ba5c1c 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -683,6 +683,16 @@ export function EPUBProvider({ children }: { children: ReactNode }) { if (!bookRef.current?.isOpen || !renditionRef.current) return; + // If the location is a CFI string that doesn't match the current rendered position, + // navigate there and let the subsequent locationChanged callback handle text extraction. + if (typeof location === 'string' && location !== 'next' && location !== 'prev' && renditionRef.current?.location) { + const currentStartCfi = renditionRef.current.location?.start?.cfi; + if (currentStartCfi && location !== currentStartCfi) { + renditionRef.current.display(location); + return; + } + } + // Handle special 'next' and 'prev' cases if (location === 'next' && renditionRef.current) { shouldPauseRef.current = false; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index bd82629..8b6b429 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -103,7 +103,7 @@ const createAuth = () => betterAuth({ expiresIn: 60 * 60 * 24 * 7, // 7 days (reasonable for user experience) updateAge: 60 * 60 * 1, // 1 hour (refresh more frequently) cookieCache: { - maxAge: 60 * 60 * 24 * 7, // 7 days for cookie cache + maxAge: 60 * 5, // 5 minutes – revalidate session against DB regularly }, }, plugins: [ diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..ffc5ae2 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,82 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +/** + * Better Auth session cookie name (default prefix + session_token). + * @see https://www.better-auth.com/docs/concepts/session-management + */ +const SESSION_COOKIE = 'better-auth.session_token'; + +/** + * Routes that never require a session cookie. + * Static assets and Next.js internals are excluded via the matcher config below. + */ +const PUBLIC_PATH_PREFIXES = [ + '/api/auth', // Better Auth endpoints (sign-in, sign-up, callbacks, etc.) + '/signin', + '/signup', + '/privacy', +]; + +function isPublicPath(pathname: string): boolean { + // Root landing page + if (pathname === '/') return true; + + return PUBLIC_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix)); +} + +function isAuthEnabled(): boolean { + return !!(process.env.AUTH_SECRET && process.env.BASE_URL); +} + +function isAnonymousAuthEnabled(): boolean { + if (!isAuthEnabled()) return false; + const raw = process.env.USE_ANONYMOUS_AUTH_SESSIONS; + return raw?.trim().toLowerCase() === 'true'; +} + +export function middleware(request: NextRequest) { + // When auth is disabled entirely, let everything through. + if (!isAuthEnabled()) { + return NextResponse.next(); + } + + const { pathname } = request.nextUrl; + + // Public routes are always accessible. + if (isPublicPath(pathname)) { + return NextResponse.next(); + } + + // When anonymous auth is enabled, unauthenticated users need to reach + // the page so AuthLoader.tsx can bootstrap an anonymous session client-side. + if (isAnonymousAuthEnabled()) { + return NextResponse.next(); + } + + // Check for the presence of a session cookie. + const hasSession = request.cookies.has(SESSION_COOKIE); + + if (!hasSession) { + // API routes get a 401 instead of a redirect. + if (pathname.startsWith('/api/')) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Page routes redirect to sign-in. + const signInUrl = request.nextUrl.clone(); + signInUrl.pathname = '/signin'; + return NextResponse.redirect(signInUrl); + } + + return NextResponse.next(); +} + +/** + * Match all routes except static assets and Next.js internals. + */ +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon\\.ico|icon\\.png|icon\\.svg|apple-icon\\.png|manifest\\.json).*)', + ], +};