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.
This commit is contained in:
Richard R 2026-02-16 19:06:24 -07:00
parent f58a2b690d
commit 8dc836cbb7
4 changed files with 117 additions and 1 deletions

View file

@ -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',

View file

@ -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;

View file

@ -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: [

82
src/middleware.ts Normal file
View file

@ -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).*)',
],
};