Initial BizOne portal setup
This commit is contained in:
158
frontend/middleware.ts
Normal file
158
frontend/middleware.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
const AUTH_COOKIE = 'wa_session';
|
||||
const REFRESH_COOKIE = 'wa_refresh';
|
||||
const SESSION_REFRESH_LEEWAY_SECONDS = 60;
|
||||
|
||||
type RefreshPayload = {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
access_token_max_age_seconds?: number;
|
||||
refresh_token_max_age_seconds?: number;
|
||||
};
|
||||
|
||||
function isProtectedPath(pathname: string) {
|
||||
if (pathname.startsWith('/dashboard')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!pathname.startsWith('/api')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/auth/password-reset')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/invitations/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function decodeJwtExpiry(token: string) {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
||||
const payload = JSON.parse(atob(padded)) as { exp?: unknown };
|
||||
return typeof payload.exp === 'number' ? payload.exp : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function tokenNeedsRefresh(token: string) {
|
||||
const expiry = decodeJwtExpiry(token);
|
||||
if (!expiry) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return expiry * 1000 <= Date.now() + SESSION_REFRESH_LEEWAY_SECONDS * 1000;
|
||||
}
|
||||
|
||||
function buildRequestCookieHeader(request: NextRequest, payload: RefreshPayload) {
|
||||
const pairs = request.cookies.getAll().map((cookie) => [cookie.name, cookie.value] as const);
|
||||
const nextCookies = new Map<string, string>(pairs);
|
||||
nextCookies.set(AUTH_COOKIE, payload.access_token);
|
||||
nextCookies.set(REFRESH_COOKIE, payload.refresh_token);
|
||||
|
||||
return Array.from(nextCookies.entries())
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function applySessionCookies(response: NextResponse, payload: RefreshPayload) {
|
||||
const secure = process.env.NODE_ENV === 'production';
|
||||
|
||||
response.cookies.set(AUTH_COOKIE, payload.access_token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure,
|
||||
path: '/',
|
||||
maxAge: payload.access_token_max_age_seconds || 60 * 60 * 24,
|
||||
});
|
||||
response.cookies.set(REFRESH_COOKIE, payload.refresh_token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure,
|
||||
path: '/',
|
||||
maxAge: payload.refresh_token_max_age_seconds || 60 * 60 * 24 * 30,
|
||||
});
|
||||
}
|
||||
|
||||
function clearSessionCookies(response: NextResponse) {
|
||||
response.cookies.delete(AUTH_COOKIE);
|
||||
response.cookies.delete(REFRESH_COOKIE);
|
||||
}
|
||||
|
||||
function unauthorizedResponse(request: NextRequest) {
|
||||
if (request.nextUrl.pathname.startsWith('/api')) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
|
||||
async function refreshSession(refreshToken: string) {
|
||||
const response = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as RefreshPayload;
|
||||
}
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
if (!isProtectedPath(request.nextUrl.pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const accessToken = request.cookies.get(AUTH_COOKIE)?.value;
|
||||
const refreshToken = request.cookies.get(REFRESH_COOKIE)?.value;
|
||||
|
||||
if (accessToken && !tokenNeedsRefresh(accessToken)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
const response = unauthorizedResponse(request);
|
||||
clearSessionCookies(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
const payload = await refreshSession(refreshToken);
|
||||
if (!payload?.access_token || !payload.refresh_token) {
|
||||
const response = unauthorizedResponse(request);
|
||||
clearSessionCookies(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set('cookie', buildRequestCookieHeader(request, payload));
|
||||
|
||||
const response = NextResponse.next({
|
||||
request: {
|
||||
headers: requestHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
applySessionCookies(response, payload);
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/dashboard/:path*', '/api/:path*'],
|
||||
};
|
||||
Reference in New Issue
Block a user