fix(auth): stabilize cookie domain handling behind proxy and add auth debug logs
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
This commit is contained in:
@ -38,6 +38,39 @@ function resolveNumber(raw: string | undefined, fallback: number) {
|
|||||||
return value;
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
const { ipAddress, userAgent } = await getRequestAuditContext();
|
const { ipAddress, userAgent } = await getRequestAuditContext();
|
||||||
const baseUrl = getRequestBaseUrl(request);
|
const baseUrl = getRequestBaseUrl(request);
|
||||||
@ -66,6 +99,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const next = getSafePath(typeof rawNext === "string" ? rawNext : null);
|
const next = getSafePath(typeof rawNext === "string" ? rawNext : null);
|
||||||
const email = typeof rawEmail === "string" ? rawEmail.trim() : "";
|
const email = typeof rawEmail === "string" ? rawEmail.trim() : "";
|
||||||
const password = typeof rawPassword === "string" ? rawPassword : "";
|
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) {
|
if (!email || !password) {
|
||||||
const loginUrl = new URL("/login", baseUrl);
|
const loginUrl = new URL("/login", baseUrl);
|
||||||
@ -107,6 +149,14 @@ export async function POST(request: NextRequest) {
|
|||||||
loginUrl.searchParams.set("next", next);
|
loginUrl.searchParams.set("next", next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (AUTH_DEBUG) {
|
||||||
|
console.warn("[AUTH] login_failed", {
|
||||||
|
email: maskEmail(email),
|
||||||
|
ipAddress,
|
||||||
|
userAgent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.redirect(loginUrl);
|
return NextResponse.redirect(loginUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,14 +183,42 @@ export async function POST(request: NextRequest) {
|
|||||||
destination && canAccessPath(session.role as UserRole, destination)
|
destination && canAccessPath(session.role as UserRole, destination)
|
||||||
? destination
|
? destination
|
||||||
: getDefaultPathForRole(session.role as UserRole);
|
: 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));
|
const response = NextResponse.redirect(new URL(safeDestination, baseUrl));
|
||||||
response.cookies.set(SESSION_COOKIE, await serializeSession(session), {
|
response.cookies.set(SESSION_COOKIE, await serializeSession(session), {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: shouldUseSecureCookies(request),
|
||||||
path: "/",
|
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;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,14 +4,55 @@ import { canAccessPath, getDefaultPathForRole, parseSessionCookie, SESSION_COOKI
|
|||||||
import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE } from "@/lib/i18n";
|
import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE } from "@/lib/i18n";
|
||||||
import { getRequestBaseUrl } from "@/lib/request-url";
|
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"];
|
const publicPaths = ["/login", "/forgot-password", "/reset-password", "/unauthorized", "/invite", "/auth"];
|
||||||
|
|
||||||
function isPublicPath(pathname: string) {
|
function isPublicPath(pathname: string) {
|
||||||
return publicPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`));
|
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<string, unknown> = {}) {
|
||||||
|
if (!AUTH_DEBUG) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[AUTH] ${message}`, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDebugHeaders(response: NextResponse, headers: Record<string, string>) {
|
||||||
|
if (!AUTH_DEBUG) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(headers).forEach(([key, value]) => {
|
||||||
|
response.headers.set(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function decodeSessionCookie(value: string) {
|
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) {
|
export async function middleware(request: NextRequest) {
|
||||||
@ -33,19 +74,37 @@ export async function middleware(request: NextRequest) {
|
|||||||
response.cookies.set(LOCALE_COOKIE, detected, {
|
response.cookies.set(LOCALE_COOKIE, detected, {
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 365 * 24 * 60 * 60,
|
maxAge: 365 * 24 * 60 * 60,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: shouldUseSecureCookies(request),
|
||||||
sameSite: "lax"
|
sameSite: "lax"
|
||||||
});
|
});
|
||||||
} else if (!isLocale(localeCookie)) {
|
} else if (!isLocale(localeCookie)) {
|
||||||
response.cookies.set(LOCALE_COOKIE, DEFAULT_LOCALE, {
|
response.cookies.set(LOCALE_COOKIE, DEFAULT_LOCALE, {
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 365 * 24 * 60 * 60,
|
maxAge: 365 * 24 * 60 * 60,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: shouldUseSecureCookies(request),
|
||||||
sameSite: "lax"
|
sameSite: "lax"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session && !isPublicPath(pathname) && pathname !== "/") {
|
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);
|
const loginUrl = new URL("/login", baseUrl);
|
||||||
loginUrl.searchParams.set("next", pathname);
|
loginUrl.searchParams.set("next", pathname);
|
||||||
return NextResponse.redirect(loginUrl);
|
return NextResponse.redirect(loginUrl);
|
||||||
@ -56,13 +115,42 @@ export async function middleware(request: NextRequest) {
|
|||||||
const hasSafeNext = typeof requested === "string" && requested.startsWith("/") && !requested.startsWith("//");
|
const hasSafeNext = typeof requested === "string" && requested.startsWith("/") && !requested.startsWith("//");
|
||||||
const nextPath = hasSafeNext ? requested : null;
|
const nextPath = hasSafeNext ? requested : null;
|
||||||
const destination = nextPath && canAccessPath(session.role, nextPath) ? nextPath : getDefaultPathForRole(session.role);
|
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)) {
|
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;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user