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:
parent
f58a2b690d
commit
8dc836cbb7
4 changed files with 117 additions and 1 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
82
src/middleware.ts
Normal 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).*)',
|
||||
],
|
||||
};
|
||||
Loading…
Reference in a new issue