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

0% Statements 0/98
0% Branches 0/1
0% Functions 0/1
0% Lines 0/98

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                                                                                                                                                                                                     
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:', err)
      // Fail open for now — log but don't block
      // TODO: Consider fail-closed after confidence period
    }

    // 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({}),
    })
  }
}