Files
ai-toy-patent-workflow/src/middleware.ts

96 lines
3.1 KiB
TypeScript

import { NextResponse, type NextRequest } from 'next/server';
const COOKIE_NAME = process.env.WEB_AUTH_COOKIE_NAME?.trim() || 'ai_toy_session';
function isPublicPath(pathname: string) {
return (
pathname === '/login' ||
pathname.startsWith('/login/') ||
pathname.startsWith('/_next/') ||
pathname.startsWith('/api/auth/') ||
pathname.startsWith('/api/img/') ||
pathname === '/favicon.ico' ||
pathname === '/robots.txt' ||
pathname === '/sitemap.xml'
);
}
function isBrowserHtml(pathname: string) {
return !pathname.startsWith('/api/') || pathname.startsWith('/api/gallery/') || pathname.startsWith('/api/audit/');
}
function authConfigured() {
return Boolean(process.env.WEB_AUTH_USERNAME && process.env.WEB_AUTH_SESSION_SECRET);
}
function bytesToHex(buffer: ArrayBuffer) {
return [...new Uint8Array(buffer)].map(byte => byte.toString(16).padStart(2, '0')).join('');
}
function safeEqual(a: string, b: string) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
async function sign(body: string) {
const secret = process.env.WEB_AUTH_SESSION_SECRET || '';
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
return bytesToHex(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body)));
}
function decodePayload(body: string) {
const base64 = body.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
return JSON.parse(atob(padded)) as { u?: unknown; exp?: unknown };
}
async function verifyToken(token?: string) {
if (!authConfigured() || !token || !token.includes('.')) return false;
const [body, suppliedSig] = token.split('.', 2);
if (!body || !suppliedSig || !safeEqual(await sign(body), suppliedSig)) return false;
try {
const payload = decodePayload(body);
const username = String(payload.u || '');
const expiresAt = Number(payload.exp || 0);
return username === process.env.WEB_AUTH_USERNAME && expiresAt >= Math.floor(Date.now() / 1000);
} catch {
return false;
}
}
export async function middleware(req: NextRequest) {
const { pathname, search } = req.nextUrl;
const authed = await verifyToken(req.cookies.get(COOKIE_NAME)?.value);
if (pathname === '/login' || pathname.startsWith('/login/')) {
if (authed) {
const next = req.nextUrl.searchParams.get('next') || '/';
return NextResponse.redirect(new URL(next.startsWith('/') ? next : '/', req.url));
}
return NextResponse.next();
}
if (isPublicPath(pathname)) return NextResponse.next();
if (authed) return NextResponse.next();
if (isBrowserHtml(pathname)) {
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('next', `${pathname}${search}`);
return NextResponse.redirect(loginUrl);
}
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
export const config = {
matcher: ['/((?!.*\\.).*)', '/api/:path*'],
};