250 lines
7.0 KiB
TypeScript
250 lines
7.0 KiB
TypeScript
import type { NextRequest } from 'next/server';
|
|
import { NextResponse, URLPattern } from 'next/server';
|
|
|
|
import { CsrfError, createCsrfProtect } from '@edge-csrf/nextjs';
|
|
|
|
import { isSuperAdmin } from '@kit/admin';
|
|
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
|
|
import { createMiddlewareClient } from '@kit/supabase/middleware-client';
|
|
|
|
import appConfig from '~/config/app.config';
|
|
import pathsConfig from '~/config/paths.config';
|
|
|
|
const CSRF_SECRET_COOKIE = 'csrfSecret';
|
|
const NEXT_ACTION_HEADER = 'next-action';
|
|
|
|
export const config = {
|
|
matcher: ['/((?!_next/static|_next/image|images|locales|assets|api/*).*)'],
|
|
};
|
|
|
|
const getUser = (request: NextRequest, response: NextResponse) => {
|
|
const supabase = createMiddlewareClient(request, response);
|
|
|
|
return supabase.auth.getUser();
|
|
};
|
|
|
|
export async function middleware(request: NextRequest) {
|
|
const secureHeaders = await createResponseWithSecureHeaders();
|
|
const response = NextResponse.next(secureHeaders);
|
|
|
|
// set a unique request ID for each request
|
|
// this helps us log and trace requests
|
|
setRequestId(request);
|
|
|
|
// apply CSRF protection for mutating requests
|
|
const csrfResponse = await withCsrfMiddleware(request, response);
|
|
|
|
// handle patterns for specific routes
|
|
const handlePattern = matchUrlPattern(request.url);
|
|
|
|
// if a pattern handler exists, call it
|
|
if (handlePattern) {
|
|
const patternHandlerResponse = await handlePattern(request, csrfResponse);
|
|
|
|
// if a pattern handler returns a response, return it
|
|
if (patternHandlerResponse) {
|
|
return patternHandlerResponse;
|
|
}
|
|
}
|
|
|
|
// append the action path to the request headers
|
|
// which is useful for knowing the action path in server actions
|
|
if (isServerAction(request)) {
|
|
csrfResponse.headers.set('x-action-path', request.nextUrl.pathname);
|
|
}
|
|
|
|
// if no pattern handler returned a response,
|
|
// return the session response
|
|
return csrfResponse;
|
|
}
|
|
|
|
async function withCsrfMiddleware(
|
|
request: NextRequest,
|
|
response: NextResponse,
|
|
) {
|
|
// set up CSRF protection
|
|
const csrfProtect = createCsrfProtect({
|
|
cookie: {
|
|
secure: appConfig.production,
|
|
name: CSRF_SECRET_COOKIE,
|
|
},
|
|
// ignore CSRF errors for server actions since protection is built-in
|
|
ignoreMethods: isServerAction(request)
|
|
? ['POST']
|
|
: // always ignore GET, HEAD, and OPTIONS requests
|
|
['GET', 'HEAD', 'OPTIONS'],
|
|
});
|
|
|
|
try {
|
|
await csrfProtect(request, response);
|
|
|
|
return response;
|
|
} catch (error) {
|
|
// if there is a CSRF error, return a 403 response
|
|
if (error instanceof CsrfError) {
|
|
return NextResponse.json('Invalid CSRF token', {
|
|
status: 401,
|
|
});
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function isServerAction(request: NextRequest) {
|
|
const headers = new Headers(request.headers);
|
|
|
|
return headers.has(NEXT_ACTION_HEADER);
|
|
}
|
|
|
|
async function adminMiddleware(request: NextRequest, response: NextResponse) {
|
|
const isAdminPath = request.nextUrl.pathname.startsWith('/admin');
|
|
|
|
if (!isAdminPath) {
|
|
return;
|
|
}
|
|
|
|
const {
|
|
data: { user },
|
|
error,
|
|
} = await getUser(request, response);
|
|
|
|
// If user is not logged in, redirect to sign in page.
|
|
// This should never happen, but just in case.
|
|
if (!user || error) {
|
|
return NextResponse.redirect(
|
|
new URL(pathsConfig.auth.signIn, request.nextUrl.origin).href,
|
|
);
|
|
}
|
|
|
|
const client = createMiddlewareClient(request, response);
|
|
const userIsSuperAdmin = await isSuperAdmin(client);
|
|
|
|
// If user is not an admin, redirect to 404 page.
|
|
if (!userIsSuperAdmin) {
|
|
return NextResponse.redirect(new URL('/404', request.nextUrl.origin).href);
|
|
}
|
|
|
|
// in all other cases, return the response
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Define URL patterns and their corresponding handlers.
|
|
*/
|
|
function getPatterns() {
|
|
return [
|
|
{
|
|
pattern: new URLPattern({ pathname: '/admin/*?' }),
|
|
handler: adminMiddleware,
|
|
},
|
|
{
|
|
pattern: new URLPattern({ pathname: '/auth/update-account' }),
|
|
handler: async (req: NextRequest, res: NextResponse) => {
|
|
const {
|
|
data: { user },
|
|
} = await getUser(req, res);
|
|
|
|
// the user is logged out, so we don't need to do anything
|
|
if (!user) {
|
|
return NextResponse.redirect(new URL('/', req.nextUrl.origin).href);
|
|
}
|
|
|
|
const client = createMiddlewareClient(req, res);
|
|
const userIsSuperAdmin = await isSuperAdmin(client);
|
|
|
|
if (userIsSuperAdmin) {
|
|
// check if we need to verify MFA (user is authenticated but needs to verify MFA)
|
|
const isVerifyMfa =
|
|
req.nextUrl.pathname === pathsConfig.auth.verifyMfa;
|
|
|
|
// If user is logged in and does not need to verify MFA,
|
|
// redirect to home page.
|
|
if (!isVerifyMfa) {
|
|
const nextPath =
|
|
req.nextUrl.searchParams.get('next') ?? pathsConfig.app.home;
|
|
|
|
return NextResponse.redirect(
|
|
new URL(nextPath, req.nextUrl.origin).href,
|
|
);
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
pattern: new URLPattern({ pathname: '/home/*?' }),
|
|
handler: async (req: NextRequest, res: NextResponse) => {
|
|
const {
|
|
data: { user },
|
|
} = await getUser(req, res);
|
|
|
|
const origin = req.nextUrl.origin;
|
|
const next = req.nextUrl.pathname;
|
|
|
|
// If user is not logged in, redirect to sign in page.
|
|
if (!user) {
|
|
const signIn = pathsConfig.auth.signIn;
|
|
const redirectPath = `${signIn}?next=${next}`;
|
|
|
|
return NextResponse.redirect(new URL(redirectPath, origin).href);
|
|
}
|
|
|
|
const supabase = createMiddlewareClient(req, res);
|
|
|
|
const requiresMultiFactorAuthentication =
|
|
await checkRequiresMultiFactorAuthentication(supabase);
|
|
|
|
// If user requires multi-factor authentication, redirect to MFA page.
|
|
if (requiresMultiFactorAuthentication) {
|
|
return NextResponse.redirect(
|
|
new URL(pathsConfig.auth.verifyMfa, origin).href,
|
|
);
|
|
}
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Match URL patterns to specific handlers.
|
|
* @param url
|
|
*/
|
|
function matchUrlPattern(url: string) {
|
|
const patterns = getPatterns();
|
|
const input = url.split('?')[0];
|
|
|
|
for (const pattern of patterns) {
|
|
const patternResult = pattern.pattern.exec(input);
|
|
|
|
if (patternResult !== null && 'pathname' in patternResult) {
|
|
return pattern.handler;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set a unique request ID for each request.
|
|
* @param request
|
|
*/
|
|
function setRequestId(request: Request) {
|
|
request.headers.set('x-correlation-id', crypto.randomUUID());
|
|
}
|
|
|
|
/**
|
|
* @name createResponseWithSecureHeaders
|
|
* @description Create a middleware with enhanced headers applied (if applied).
|
|
* This is disabled by default. To enable set ENABLE_STRICT_CSP=true
|
|
*/
|
|
async function createResponseWithSecureHeaders() {
|
|
const enableStrictCsp = process.env.ENABLE_STRICT_CSP ?? 'false';
|
|
|
|
// we disable ENABLE_STRICT_CSP by default
|
|
if (enableStrictCsp === 'false') {
|
|
return {};
|
|
}
|
|
|
|
const { createCspResponse } = await import('./lib/create-csp-response');
|
|
|
|
return createCspResponse();
|
|
}
|