diff --git a/src/lib/auth/jwt.ts b/src/lib/auth/jwt.ts new file mode 100644 index 00000000..b73cbd90 --- /dev/null +++ b/src/lib/auth/jwt.ts @@ -0,0 +1,79 @@ +import { UserRole } from '@/types/api'; + +interface JWTPayload { + sub: string; + role: UserRole; + email?: string; + iat?: number; + exp?: number; +} + +/** + * Verifies a JWT token and returns its payload. + * Uses the Web Crypto API (Edge-compatible, works in Next.js middleware). + */ +export async function verifyToken(token: string | undefined | null): Promise { + if (!token) return null; + + try { + const secret = process.env.JWT_SECRET; + if (!secret) { + console.error('JWT_SECRET is not set'); + return null; + } + + const parts = token.split('.'); + if (parts.length !== 3) return null; + + const [headerB64, payloadB64, signatureB64] = parts; + + // Import the secret key + const keyData = new TextEncoder().encode(secret); + const key = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'], + ); + + // Verify signature + const signatureBytes = base64UrlDecode(signatureB64).buffer as ArrayBuffer; + const dataToVerify = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + + const isValid = await crypto.subtle.verify( + 'HMAC', + key, + signatureBytes as ArrayBuffer, + dataToVerify, + ); + if (!isValid) return null; + + // Decode payload + const payloadJson = new TextDecoder().decode(base64UrlDecode(payloadB64)); + const payload = JSON.parse(payloadJson) as JWTPayload; + + // Check expiry + if (payload.exp && Date.now() / 1000 > payload.exp) return null; + + // Validate role is a known value + const validRoles: UserRole[] = [ + UserRole.ADMIN, + UserRole.INSTRUCTOR, + UserRole.STUDENT, + UserRole.GUEST, + ]; + if (!validRoles.includes(payload.role)) return null; + + return payload; + } catch { + return null; + } +} + +function base64UrlDecode(str: string): Uint8Array { + const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + const binary = atob(padded); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); +} diff --git a/src/middleware.ts b/src/middleware.ts index 61f4cb46..30774b94 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -15,7 +15,7 @@ import { INTERNAL_API_REQUEST_HEADER, } from './lib/apiVersioning'; -export function middleware(request: NextRequest) { +export async function middleware(request: NextRequest) { const traceId = crypto.randomUUID(); request.headers.set('x-trace-id', traceId); @@ -27,8 +27,13 @@ export function middleware(request: NextRequest) { } // In a real application, you would verify the JWT or session here. - const roleCookie = request.cookies.get('user-role')?.value as UserRole | undefined; - const userRole = roleCookie || null; + // Verify JWT from Authorization header or cookie — never trust client-supplied role cookies + const token = + request.headers.get('Authorization')?.replace('Bearer ', '') ?? + request.cookies.get('Authorization')?.value; + const { verifyToken } = await import('./lib/auth/jwt'); + const payload = await verifyToken(token); + const userRole = payload?.role ?? null; const withHeaders = (response: NextResponse) => { response.headers.set('x-trace-id', traceId); diff --git a/src/middleware/__tests__/rbac.security.test.ts b/src/middleware/__tests__/rbac.security.test.ts new file mode 100644 index 00000000..9189c5d9 --- /dev/null +++ b/src/middleware/__tests__/rbac.security.test.ts @@ -0,0 +1,42 @@ +import { NextRequest } from 'next/server'; +import { checkRoutePermission } from '../rbac'; +import { UserRole } from '@/types/api'; + +function makeRequest(pathname: string) { + return new NextRequest(new URL(`http://localhost${pathname}`)); +} + +describe('RBAC middleware — role elevation prevention', () => { + it('redirects to /login when no role is provided', () => { + const res = checkRoutePermission(makeRequest('/admin'), null); + expect(res?.status).toBe(307); + expect(res?.headers.get('location')).toContain('/login'); + }); + + it('redirects to /unauthorized when STUDENT tries to access /admin', () => { + const res = checkRoutePermission(makeRequest('/admin'), UserRole.STUDENT); + expect(res?.status).toBe(307); + expect(res?.headers.get('location')).toContain('/unauthorized'); + }); + + it('redirects to /unauthorized when INSTRUCTOR tries to access /admin', () => { + const res = checkRoutePermission(makeRequest('/admin'), UserRole.INSTRUCTOR); + expect(res?.status).toBe(307); + expect(res?.headers.get('location')).toContain('/unauthorized'); + }); + + it('allows ADMIN to access /admin', () => { + const res = checkRoutePermission(makeRequest('/admin'), UserRole.ADMIN); + expect(res).toBeNull(); + }); + + it('allows INSTRUCTOR to access /instructor', () => { + const res = checkRoutePermission(makeRequest('/instructor'), UserRole.INSTRUCTOR); + expect(res).toBeNull(); + }); + + it('allows STUDENT to access /dashboard', () => { + const res = checkRoutePermission(makeRequest('/dashboard'), UserRole.STUDENT); + expect(res).toBeNull(); + }); +});