Fix admin session cookie by setting it on the admin domain after login.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 01:00:16 +02:00
parent 29595dd98f
commit 39ecf1af56
3 changed files with 106 additions and 12 deletions

View file

@ -3,6 +3,9 @@ import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const runtime = "nodejs"; export const runtime = "nodejs";
const SESSION_COOKIE = "mc_admin_session";
const SESSION_MAX_AGE = 7 * 24 * 60 * 60;
function candidateBases(): string[] { function candidateBases(): string[] {
const configured = [ const configured = [
process.env.MASTER_API_URL, process.env.MASTER_API_URL,
@ -14,10 +17,50 @@ function candidateBases(): string[] {
return [...new Set(configured.map((v) => v.replace(/\/$/, "")))]; return [...new Set(configured.map((v) => v.replace(/\/$/, "")))];
} }
function isHttps(request: NextRequest): boolean {
const proto = request.headers.get("x-forwarded-proto");
if (proto) return proto.split(",")[0].trim() === "https";
return request.nextUrl.protocol === "https:";
}
function applySessionCookie(
response: NextResponse,
token: string,
request: NextRequest,
expiresAt?: string
) {
const secure = isHttps(request) || process.env.NODE_ENV === "production";
response.cookies.set({
name: SESSION_COOKIE,
value: token,
httpOnly: true,
secure,
sameSite: "lax",
path: "/",
maxAge: SESSION_MAX_AGE,
...(expiresAt ? { expires: new Date(expiresAt) } : {}),
});
}
function clearSessionCookie(response: NextResponse, request: NextRequest) {
const secure = isHttps(request) || process.env.NODE_ENV === "production";
response.cookies.set({
name: SESSION_COOKIE,
value: "",
httpOnly: true,
secure,
sameSite: "lax",
path: "/",
maxAge: 0,
});
}
async function proxy(request: NextRequest, pathSegments: string[]) { async function proxy(request: NextRequest, pathSegments: string[]) {
const targetPath = pathSegments.join("/"); const targetPath = pathSegments.join("/");
const url = new URL(request.url); const url = new URL(request.url);
const search = url.search; const search = url.search;
const isLogin = request.method === "POST" && targetPath === "v1/auth/login";
const isLogout = request.method === "POST" && targetPath === "v1/auth/logout";
const headers = new Headers(); const headers = new Headers();
const contentType = request.headers.get("content-type"); const contentType = request.headers.get("content-type");
@ -43,27 +86,71 @@ async function proxy(request: NextRequest, pathSegments: string[]) {
redirect: "manual", redirect: "manual",
}); });
const responseBody = await upstream.arrayBuffer(); // Special-case auth so the session cookie is owned by admin.vonas.nl
const response = new NextResponse(responseBody, { status: upstream.status }); if (isLogin) {
const text = await upstream.text();
let data: {
user?: unknown;
sessionToken?: string;
expiresAt?: string;
error?: unknown;
} = {};
try {
data = JSON.parse(text);
} catch {
return new NextResponse(text, {
status: upstream.status,
headers: { "content-type": "application/json" },
});
}
const upstreamType = upstream.headers.get("content-type"); const response = NextResponse.json(
response.headers.set( { user: data.user, error: data.error },
"content-type", { status: upstream.status }
upstreamType ?? "application/json"
); );
if (upstream.ok && data.sessionToken) {
applySessionCookie(response, data.sessionToken, request, data.expiresAt);
}
return response;
}
if (isLogout) {
const responseBody = await upstream.arrayBuffer();
const response = new NextResponse(responseBody, {
status: upstream.status,
headers: {
"content-type":
upstream.headers.get("content-type") ?? "application/json",
},
});
clearSessionCookie(response, request);
return response;
}
const responseBody = await upstream.arrayBuffer();
const response = new NextResponse(responseBody, { status: upstream.status });
response.headers.set(
"content-type",
upstream.headers.get("content-type") ?? "application/json"
);
// Forward any upstream cookies as fallback (rewritten without Domain)
const anyHeaders = upstream.headers as Headers & { const anyHeaders = upstream.headers as Headers & {
getSetCookie?: () => string[]; getSetCookie?: () => string[];
}; };
const setCookies = const setCookies =
typeof anyHeaders.getSetCookie === "function" typeof anyHeaders.getSetCookie === "function"
? anyHeaders.getSetCookie() ? anyHeaders.getSetCookie()
: upstream.headers.get("set-cookie")
? [upstream.headers.get("set-cookie") as string]
: []; : [];
for (const c of setCookies) { for (const raw of setCookies) {
response.headers.append("set-cookie", c); const cleaned = raw
.split(";")
.map((p) => p.trim())
.filter((p) => !/^domain=/i.test(p))
.join("; ");
response.headers.append("set-cookie", cleaned);
} }
return response; return response;

View file

@ -12,7 +12,9 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
fetch("/api/v1/auth/me", { credentials: "include" }) fetch("/api/v1/auth/me", { credentials: "include" })
.then((r) => r.ok && router.push("/dashboard")) .then((r) => {
if (r.ok) router.push("/dashboard");
})
.catch(() => {}); .catch(() => {});
}, [router]); }, [router]);
@ -38,10 +40,10 @@ export default function LoginPage() {
} }
throw new Error(message); throw new Error(message);
} }
router.push("/dashboard"); // Full navigation so the session cookie is definitely included
window.location.assign("/dashboard");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Inloggen mislukt"); setError(err instanceof Error ? err.message : "Inloggen mislukt");
} finally {
setLoading(false); setLoading(false);
} }
} }

View file

@ -46,7 +46,12 @@ export async function registerAuthRoutes(
expires: expiresAt, expires: expiresAt,
}); });
return { user: { id: user.id, email: user.email, name: user.name } }; // sessionToken also in body so Admin UI proxy can set cookie on its own domain
return {
user: { id: user.id, email: user.email, name: user.name },
sessionToken: token,
expiresAt: expiresAt.toISOString(),
};
}); });
app.post("/api/v1/auth/logout", async (request, reply) => { app.post("/api/v1/auth/logout", async (request, reply) => {