Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/__tests__/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { middleware } from '../middleware';

// Mock NextResponse
jest.mock('next/server', () => ({
NextResponse: {
next: jest.fn(() => ({ headers: new Map() })),
redirect: jest.fn((url) => ({ url, headers: new Map() }))
}
}));

describe('Middleware Authentication', () => {
let mockRequest: Partial<NextRequest>;

beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();

// Setup basic mock request
mockRequest = {
nextUrl: new URL('http://localhost:3000/dashboard'),
cookies: {
get: jest.fn()
}
};
});

// Test public paths
test('should allow access to public paths without authentication', async () => {
mockRequest.nextUrl = new URL('http://localhost:3000/');
const response = await middleware(mockRequest as NextRequest);
expect(NextResponse.next).toHaveBeenCalled();
});

// Test OAuth flow
test('should allow OAuth flow without authentication', async () => {
mockRequest.nextUrl = new URL('http://localhost:3000/callback?privy_oauth_code=123');
const response = await middleware(mockRequest as NextRequest);
expect(NextResponse.next).toHaveBeenCalled();
});

// Test authenticated requests
test('should allow access when properly authenticated', async () => {
mockRequest.cookies.get = jest.fn((name) => ({
value: name === 'privy-token' ? 'valid-token' : null
}));
const response = await middleware(mockRequest as NextRequest);
expect(NextResponse.next).toHaveBeenCalled();
});

// Test token refresh flow
test('should redirect to refresh when session exists but no auth token', async () => {
mockRequest.cookies.get = jest.fn((name) => ({
value: name === 'privy-session' ? 'valid-session' : null
}));
const response = await middleware(mockRequest as NextRequest);
expect(NextResponse.redirect).toHaveBeenCalledWith(
expect.stringContaining('/refresh'),
expect.any(Object)
);
});

// Test unauthenticated requests
test('should redirect to login when no authentication present', async () => {
mockRequest.cookies.get = jest.fn(() => null);
const response = await middleware(mockRequest as NextRequest);
expect(NextResponse.redirect).toHaveBeenCalledWith(
expect.stringContaining('/'),
expect.any(Object)
);
});

// Test error handling
test('should redirect to error page on unexpected errors', async () => {
mockRequest.cookies.get = jest.fn(() => {
throw new Error('Unexpected error');
});
const response = await middleware(mockRequest as NextRequest);
expect(NextResponse.redirect).toHaveBeenCalledWith(
expect.stringContaining('/error'),
expect.any(Object)
);
});

// Test security headers
test('should add security headers to successful responses', async () => {
mockRequest.cookies.get = jest.fn((name) => ({
value: name === 'privy-token' ? 'valid-token' : null
}));
const response = await middleware(mockRequest as NextRequest);
expect(response.headers.get('X-Frame-Options')).toBe('DENY');
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
expect(response.headers.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin');
expect(response.headers.get('Permissions-Policy')).toBeTruthy();
});
});
188 changes: 136 additions & 52 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,38 @@
import { type NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';

// Public pages that don't require authentication
const PUBLIC_PAGES = [
'/', // Home page (Login)
'/refresh', // Token refresh page
];

// Public static asset extensions that don't require authentication
const PUBLIC_ASSETS = [
'.svg', // SVG images
'.png', // PNG images
'.jpg', // JPG images
'.jpeg', // JPEG images
'.ico', // Icon files
'.webp', // WebP images
'.gif', // GIF images
];
// Constants for configuration
const CONFIG = {
PUBLIC_PAGES: [
'/', // Home page (Login)
'/refresh', // Token refresh page
'/error', // Error page
'/maintenance' // Maintenance page
],
PUBLIC_ASSETS: [
'.svg', // SVG images
'.png', // PNG images
'.jpg', // JPG images
'.jpeg', // JPEG images
'.ico', // Icon files
'.webp', // WebP images
'.gif', // GIF images
'.css', // CSS files
'.js', // JavaScript files
'.json' // JSON files
],
AUTH_COOKIE: 'privy-token',
SESSION_COOKIE: 'privy-session',
MAX_RETRY_ATTEMPTS: 3,
RETRY_DELAY_MS: 1000
} as const;

// Interface for authentication response
interface AuthResponse {
success: boolean;
error?: string;
redirectUrl?: string;
}

export const config = {
matcher: [
Expand All @@ -25,54 +42,121 @@ export const config = {
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (website icon)
* - assets (public assets)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
'/((?!api|_next/static|_next/image|favicon.ico|assets).*)',
],
};

export async function middleware(req: NextRequest) {
const cookieAuthToken = req.cookies.get('privy-token');
const cookieSession = req.cookies.get('privy-session');
const { pathname } = req.nextUrl;

// Skip middleware for public pages
if (PUBLIC_PAGES.includes(pathname)) {
return NextResponse.next();
}
/**
* Checks if a path is public
* @param pathname The path to check
* @returns boolean indicating if the path is public
*/
function isPublicPath(pathname: string): boolean {
return CONFIG.PUBLIC_PAGES.includes(pathname) ||
CONFIG.PUBLIC_ASSETS.some(ext => pathname.toLowerCase().endsWith(ext));
}

// Skip middleware for static assets
if (PUBLIC_ASSETS.some((ext) => pathname.toLowerCase().endsWith(ext))) {
return NextResponse.next();
}
/**
* Checks if the request is part of the Privy OAuth flow
* @param searchParams URLSearchParams from the request
* @returns boolean indicating if the request is part of OAuth flow
*/
function isPrivyOAuth(searchParams: URLSearchParams): boolean {
return Boolean(
searchParams.get('privy_oauth_code') ||
searchParams.get('privy_oauth_state') ||
searchParams.get('privy_oauth_provider')
);
}

// Skip middleware for Privy OAuth authentication flow
if (
req.nextUrl.searchParams.has('privy_oauth_code') ||
req.nextUrl.searchParams.has('privy_oauth_state') ||
req.nextUrl.searchParams.has('privy_oauth_provider')
) {
return NextResponse.next();
}
/**
* Creates a redirect response with proper headers
* @param url The URL to redirect to
* @param currentPath The current path for redirect_uri
* @returns NextResponse with redirect
*/
function createRedirectResponse(url: string, currentPath: string): NextResponse {
const redirectUrl = new URL(url, process.env.NEXT_PUBLIC_APP_URL);
redirectUrl.searchParams.set('redirect_uri', currentPath);

return NextResponse.redirect(redirectUrl, {
headers: {
'Cache-Control': 'no-store, must-revalidate',
'Pragma': 'no-cache'
}
});
}

// User authentication status check
const definitelyAuthenticated = Boolean(cookieAuthToken); // User is definitely authenticated (has access token)
const maybeAuthenticated = Boolean(cookieSession); // User might be authenticated (has session)
/**
* Handles authentication check and response
* @param authToken The authentication token
* @param sessionToken The session token
* @param pathname Current path
* @returns AuthResponse object
*/
function handleAuthentication(
authToken: string | undefined,
sessionToken: string | undefined,
pathname: string
): AuthResponse {
const definitelyAuthenticated = Boolean(authToken);
const maybeAuthenticated = Boolean(sessionToken);

// Handle token refresh cases
if (!definitelyAuthenticated && maybeAuthenticated) {
const redirectUrl = new URL('/refresh', req.url);
// Ensure redirect_uri is the current page path
redirectUrl.searchParams.set('redirect_uri', pathname);
return NextResponse.redirect(redirectUrl);
return {
success: false,
redirectUrl: '/refresh',
error: 'Token refresh required'
};
}

// Handle unauthenticated cases
if (!definitelyAuthenticated && !maybeAuthenticated) {
const loginUrl = new URL('/', req.url);
// Ensure redirect_uri is the current page path
loginUrl.searchParams.set('redirect_uri', pathname);
return NextResponse.redirect(loginUrl);
return {
success: false,
redirectUrl: '/',
error: 'Authentication required'
};
}

return NextResponse.next();
return { success: true };
}

/**
* Main middleware function
*/
export async function middleware(req: NextRequest) {
try {
const { pathname } = req.nextUrl;

// Skip middleware for public paths and OAuth flow
if (isPublicPath(pathname) || isPrivyOAuth(req.nextUrl.searchParams)) {
return NextResponse.next();
}

// Get authentication tokens
const cookieAuthToken = req.cookies.get(CONFIG.AUTH_COOKIE)?.value;
const cookieSession = req.cookies.get(CONFIG.SESSION_COOKIE)?.value;

// Handle authentication
const authResult = handleAuthentication(cookieAuthToken, cookieSession, pathname);

if (!authResult.success) {
console.warn(`Authentication failed: ${authResult.error}`);
return createRedirectResponse(authResult.redirectUrl!, pathname);
}

// Add security headers
const response = NextResponse.next();
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');

return response;
} catch (error) {
console.error('Middleware error:', error);
return NextResponse.redirect(new URL('/error', req.url));
}
}