158 lines
4.2 KiB
TypeScript
158 lines
4.2 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { SERVER_API_URL as API_URL } from './src/lib/server-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*'],
|
|
};
|