← back to Letsbegin

middleware.ts

88 lines

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { AUTH_CONFIG } from '@/lib/auth'
import { verifyDWCentralSession } from '@/lib/dw-central-session'
import { verifySession } from '@/lib/session-token'

// Routes that don't require authentication
const publicPaths = [
  '/login',
  '/live',
  '/api/auth/login',
  '/api/auth/logout',
  '/api/auth/session',
  '/api/processes',
  '/api/ralph',
  '/api/live',
  '/api/url-info',
  '/_next',
  '/favicon.ico',
]

// DW Central SSO cookies are now HMAC-signed (TK-11776 finding #4). The verifier in
// @/lib/dw-central-session REQUIRES DW_SESSION_SECRET and rejects any unsigned/forged
// cookie (incl. the legacy base64('user:'+Date.now()) bypass). The DW Central issuer
// must sign with the SAME secret via that module's signDWCentralSession. Fails closed.

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // Allow public paths
  if (publicPaths.some(path => pathname.startsWith(path))) {
    return NextResponse.next()
  }

  // Check for DW Central SSO session first (from dw.greendomainbrokers.com)
  const dwCentralToken = request.cookies.get('dw_central_session')?.value;
  if (dwCentralToken && (await verifyDWCentralSession(dwCentralToken, process.env.DW_SESSION_SECRET))) {
    return NextResponse.next()
  }

  // Bypass internal auth if already authenticated via nginx basic auth (DW Central portal)
  const authHeader = request.headers.get('authorization')
  if (authHeader?.startsWith('Basic ')) {
    return NextResponse.next()
  }

  // Check for session cookie
  const sessionCookie = request.cookies.get(AUTH_CONFIG.sessionName)

  if (!sessionCookie?.value) {
    // No session - redirect to login
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('redirect', pathname)
    return NextResponse.redirect(loginUrl)
  }

  // Validate session token — HMAC-VERIFIED (TK-11776 follow-up), not JSON-decode-and-trust.
  // Previously any base64(JSON) with authenticated:true passed; verifySession rejects every
  // unsigned/forged cookie (returns null), so only a token minted by /api/auth/login under
  // DW_SESSION_SECRET is honored.
  const sessionData = await verifySession<{ authenticated?: boolean; loginTime?: number }>(
    sessionCookie.value,
    process.env.DW_SESSION_SECRET,
  )

  if (!sessionData || !sessionData.authenticated) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('redirect', pathname)
    return NextResponse.redirect(loginUrl)
  }

  // Check session age (30 days max) — a non-numeric/absent loginTime is treated as expired.
  if (typeof sessionData.loginTime !== 'number' || Date.now() - sessionData.loginTime > AUTH_CONFIG.cookieMaxAge) {
    const loginUrl = new URL('/login', request.url)
    return NextResponse.redirect(loginUrl)
  }

  // Valid, signed, in-window session - proceed
  return NextResponse.next()
}

export const config = {
  matcher: [
    // Match all paths except static files and images
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
}