All files / web/src/lib/auth withAuth.ts

82% Statements 82/100
57.14% Branches 8/14
100% Functions 1/1
82% Lines 82/100

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 1011x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 3x 3x 3x 3x 3x 3x 3x 3x 3x           3x 3x 3x 3x 3x 3x 3x 3x 2x 1x 3x           3x 2x 2x 2x 2x 2x 2x 1x 1x 3x                 1x 1x 1x 1x 1x 3x 3x 3x 3x  
import { type NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getRouteEnforcer } from './enforcer'
import { resolveUserRole, type UserRole } from './roles'
 
export interface AuthenticatedContext {
  userId: string
  userEmail: string | null
  userRole: UserRole
  /** Next.js dynamic route params (e.g. { id: string }). Always present — resolves to {} for non-dynamic routes. */
  params: Promise<Record<string, string | string[]>>
}
 
interface WithAuthOptions {
  /** Minimum role required. If not specified, route-level RBAC from Casbin decides. */
  role?: 'user' | 'admin'
}
 
/** The context Next.js passes as the second argument to route handlers. */
type NextRouteContext = { params?: Promise<Record<string, string | string[]>> }
 
type RouteHandler = (
  request: NextRequest,
  context: AuthenticatedContext
) => Promise<NextResponse | Response>
 
/**
 * Wrap a Next.js API route handler with authentication and route-level authorization.
 *
 * 1. Gets the current session
 * 2. Resolves the user's role (guest/user/admin)
 * 3. Checks Layer 1 route RBAC (Casbin enforcer with URL pattern matching)
 * 4. Optionally checks a minimum role requirement
 * 5. Passes enriched context to the handler
 *
 * Usage:
 * ```typescript
 * export const GET = withAuth(async (req, { userId, userRole }) => {
 *   return NextResponse.json({ hello: userId })
 * })
 * ```
 */
export function withAuth(handler: RouteHandler, options?: WithAuthOptions) {
  return async (request: NextRequest, routeContext?: NextRouteContext) => {
    const session = await auth()
 
    // Determine role
    let role: UserRole = 'guest'
    let userId = ''
    let userEmail: string | null = null
 
    if (session?.user?.id) {
      userId = session.user.id
      userEmail = session.user.email ?? null

      role = await resolveUserRole({ userId, email: userEmail })
    }
 
    // Check route-level RBAC
    const url = new URL(request.url)
    const pathname = url.pathname
    const method = request.method
 
    try {
      const enforcer = await getRouteEnforcer()
      const allowed = await enforcer.enforce(role, pathname, method)
 
      if (!allowed) {
        if (role === 'guest') {
          return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
        }
        return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
      }
    } catch (err) {
      console.error('[withAuth] Route enforcement error — failing closed:', err)
      // Authorization must fail closed: a broken enforcer means the RBAC layer
      // is gone, and proceeding would silently serve every withAuth route
      // without it.
      return NextResponse.json({ error: 'Authorization unavailable' }, { status: 503 })
    }
 
    // Check minimum role requirement if specified
    if (options?.role) {
      const roleRank = { guest: 0, user: 1, admin: 2 }
      if (roleRank[role] < roleRank[options.role]) {
        if (role === 'guest') {
          return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
        }
        return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
      }
    }
 
    return handler(request, {
      userId,
      userEmail,
      userRole: role,
      params: routeContext?.params ?? Promise.resolve({}),
    })
  }
}