diff --git a/app/auth/login/route.ts b/app/auth/login/route.ts index bf64d42..9b952c6 100644 --- a/app/auth/login/route.ts +++ b/app/auth/login/route.ts @@ -38,6 +38,39 @@ function resolveNumber(raw: string | undefined, fallback: number) { return value; } +const AUTH_DEBUG = process.env.AUTH_DEBUG === "true" || process.env.AUTH_DEBUG === "1"; + +function maskEmail(email: string) { + if (!email) { + return ""; + } + + const [name, domain] = email.split("@"); + if (!domain) { + return "*****"; + } + + if (name.length <= 2) { + return `${name[0]}***@${domain}`; + } + + return `${name.slice(0, 2)}***@${domain}`; +} + +function shouldUseSecureCookies(request: NextRequest) { + const explicit = process.env.COOKIE_SECURE?.toLowerCase() ?? ""; + if (explicit === "true" || explicit === "1") { + return true; + } + + if (explicit === "false" || explicit === "0") { + return false; + } + + const forwardedProto = request.headers.get("x-forwarded-proto"); + return request.nextUrl.protocol === "https:" || (forwardedProto?.split(",")[0]?.toLowerCase() === "https"); +} + export async function POST(request: NextRequest) { const { ipAddress, userAgent } = await getRequestAuditContext(); const baseUrl = getRequestBaseUrl(request); @@ -66,6 +99,15 @@ export async function POST(request: NextRequest) { const next = getSafePath(typeof rawNext === "string" ? rawNext : null); const email = typeof rawEmail === "string" ? rawEmail.trim() : ""; const password = typeof rawPassword === "string" ? rawPassword : ""; + if (AUTH_DEBUG) { + console.warn("[AUTH] login_attempt", { + email: maskEmail(email), + hasPassword: password.length > 0, + next, + ipAddress, + userAgent + }); + } if (!email || !password) { const loginUrl = new URL("/login", baseUrl); @@ -107,6 +149,14 @@ export async function POST(request: NextRequest) { loginUrl.searchParams.set("next", next); } + if (AUTH_DEBUG) { + console.warn("[AUTH] login_failed", { + email: maskEmail(email), + ipAddress, + userAgent + }); + } + return NextResponse.redirect(loginUrl); } @@ -133,14 +183,42 @@ export async function POST(request: NextRequest) { destination && canAccessPath(session.role as UserRole, destination) ? destination : getDefaultPathForRole(session.role as UserRole); + const sessionMaxAgeSeconds = Math.max( + 60, + Math.floor(session.expiresAt - Math.floor(Date.now() / 1000)) + ); const response = NextResponse.redirect(new URL(safeDestination, baseUrl)); response.cookies.set(SESSION_COOKIE, await serializeSession(session), { httpOnly: true, sameSite: "lax", - secure: process.env.NODE_ENV === "production", + secure: shouldUseSecureCookies(request), path: "/", - maxAge: Math.max(1, Math.floor(session.expiresAt - Date.now() / 1000)) + maxAge: sessionMaxAgeSeconds }); + if (AUTH_DEBUG) { + console.warn("[AUTH] session_cookie_issued", { + userId: session.userId, + role: session.role, + sessionExpiresAt: session.expiresAt, + maxAge: sessionMaxAgeSeconds, + host: request.headers.get("host") || "unknown", + protocol: request.nextUrl.protocol, + forwardedProto: request.headers.get("x-forwarded-proto") || "unknown", + secureCookies: shouldUseSecureCookies(request) + }); + console.warn("[AUTH] login_success_redirect", { + userId: session.userId, + destination: safeDestination, + setCookie: response.headers.get("set-cookie") + }); + } + + response.headers.set("X-Auth-Session", "issued"); + response.headers.set("X-Auth-Session-User", session.userId); + response.headers.set("X-Auth-Session-Role", session.role); + response.headers.set("X-Auth-Session-Base-Url", baseUrl.toString()); + response.headers.set("X-Auth-Session-Max-Age", String(sessionMaxAgeSeconds)); + response.headers.set("X-Auth-Session-Secure", String(shouldUseSecureCookies(request))); return response; } diff --git a/middleware.ts b/middleware.ts index 66bdd7c..cdbc71e 100644 --- a/middleware.ts +++ b/middleware.ts @@ -4,14 +4,55 @@ import { canAccessPath, getDefaultPathForRole, parseSessionCookie, SESSION_COOKI import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE } from "@/lib/i18n"; import { getRequestBaseUrl } from "@/lib/request-url"; +const AUTH_DEBUG = process.env.AUTH_DEBUG === "true" || process.env.AUTH_DEBUG === "1"; + const publicPaths = ["/login", "/forgot-password", "/reset-password", "/unauthorized", "/invite", "/auth"]; function isPublicPath(pathname: string) { return publicPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`)); } +function shouldUseSecureCookies(request: NextRequest) { + const explicit = process.env.COOKIE_SECURE?.toLowerCase() ?? ""; + if (explicit === "true" || explicit === "1") { + return true; + } + + if (explicit === "false" || explicit === "0") { + return false; + } + + const forwardedProto = request.headers.get("x-forwarded-proto"); + return request.nextUrl.protocol === "https:" || (forwardedProto?.split(",")[0]?.toLowerCase() === "https"); +} + +function debugAuth(message: string, details: Record = {}) { + if (!AUTH_DEBUG) { + return; + } + + console.warn(`[AUTH] ${message}`, details); +} + +function setDebugHeaders(response: NextResponse, headers: Record) { + if (!AUTH_DEBUG) { + return; + } + + Object.entries(headers).forEach(([key, value]) => { + response.headers.set(key, value); + }); +} + async function decodeSessionCookie(value: string) { - return (await parseSessionCookie(value)) as null | { role: UserRole }; + const parsed = (await parseSessionCookie(value)) as null | { role: UserRole }; + if (!parsed) { + debugAuth("invalid_session_cookie", { + cookieLength: value.length, + cookiePreview: `${value.slice(0, 28)}...${value.slice(-14)}` + }); + } + return parsed; } export async function middleware(request: NextRequest) { @@ -33,19 +74,37 @@ export async function middleware(request: NextRequest) { response.cookies.set(LOCALE_COOKIE, detected, { path: "/", maxAge: 365 * 24 * 60 * 60, - secure: process.env.NODE_ENV === "production", + secure: shouldUseSecureCookies(request), sameSite: "lax" }); } else if (!isLocale(localeCookie)) { response.cookies.set(LOCALE_COOKIE, DEFAULT_LOCALE, { path: "/", maxAge: 365 * 24 * 60 * 60, - secure: process.env.NODE_ENV === "production", + secure: shouldUseSecureCookies(request), sameSite: "lax" }); } if (!session && !isPublicPath(pathname) && pathname !== "/") { + const clientIp = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "unknown"; + + debugAuth("missing_or_invalid_session", { + pathname, + method: request.method, + ip: clientIp, + userAgent: request.headers.get("user-agent") || "unknown", + hasSessionCookie: Boolean(sessionCookie), + sessionCookieLength: sessionCookie?.length || 0, + host: request.headers.get("host") || "unknown", + protocol: request.nextUrl.protocol, + forwardedProto: request.headers.get("x-forwarded-proto") || "unknown", + secureCookies: shouldUseSecureCookies(request) + }); + const loginUrl = new URL("/login", baseUrl); loginUrl.searchParams.set("next", pathname); return NextResponse.redirect(loginUrl); @@ -56,13 +115,42 @@ export async function middleware(request: NextRequest) { const hasSafeNext = typeof requested === "string" && requested.startsWith("/") && !requested.startsWith("//"); const nextPath = hasSafeNext ? requested : null; const destination = nextPath && canAccessPath(session.role, nextPath) ? nextPath : getDefaultPathForRole(session.role); - return NextResponse.redirect(new URL(destination, baseUrl)); + const redirectResponse = NextResponse.redirect(new URL(destination, baseUrl)); + setDebugHeaders(redirectResponse, { + "X-Auth-Session": "valid", + "X-Auth-Session-Has-Cookie": String(Boolean(sessionCookie)), + "X-Auth-Session-Role": session.role, + "X-Auth-Path": pathname, + "X-Auth-Base-Url": baseUrl.toString() + }); + return redirectResponse; } if (session && !isPublicPath(pathname) && !canAccessPath(session.role, pathname)) { - return NextResponse.redirect(new URL("/unauthorized", baseUrl)); + debugAuth("role_forbidden", { + pathname, + role: session.role + }); + const forbiddenResponse = NextResponse.redirect(new URL("/unauthorized", baseUrl)); + setDebugHeaders(forbiddenResponse, { + "X-Auth-Session": "forbidden", + "X-Auth-Session-Has-Cookie": String(Boolean(sessionCookie)), + "X-Auth-Session-Role": session.role, + "X-Auth-Path": pathname, + "X-Auth-Base-Url": baseUrl.toString() + }); + return forbiddenResponse; } + setDebugHeaders(response, { + "X-Auth-Session": session ? "valid" : "missing", + "X-Auth-Session-Has-Cookie": String(Boolean(sessionCookie)), + "X-Auth-Session-Valid-Role": session?.role || "n/a", + "X-Auth-Path": pathname, + "X-Auth-Base-Url": baseUrl.toString(), + "X-Auth-Host": request.headers.get("host") || "unknown" + }); + return response; }