Skip to content
Merged
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
79 changes: 79 additions & 0 deletions src/lib/auth/jwt.ts
Original file line number Diff line number Diff line change
@@ -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<JWTPayload | null> {
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));
}
11 changes: 8 additions & 3 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);
Expand Down
42 changes: 42 additions & 0 deletions src/middleware/__tests__/rbac.security.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading