diff --git a/apps/admin-ui/src/app/api/[...path]/route.ts b/apps/admin-ui/src/app/api/[...path]/route.ts index 7f65d75..06c829b 100644 --- a/apps/admin-ui/src/app/api/[...path]/route.ts +++ b/apps/admin-ui/src/app/api/[...path]/route.ts @@ -3,6 +3,9 @@ import { NextRequest, NextResponse } from "next/server"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; +const SESSION_COOKIE = "mc_admin_session"; +const SESSION_MAX_AGE = 7 * 24 * 60 * 60; + function candidateBases(): string[] { const configured = [ process.env.MASTER_API_URL, @@ -14,10 +17,50 @@ function candidateBases(): string[] { 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[]) { const targetPath = pathSegments.join("/"); const url = new URL(request.url); 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 contentType = request.headers.get("content-type"); @@ -43,27 +86,71 @@ async function proxy(request: NextRequest, pathSegments: string[]) { redirect: "manual", }); + // Special-case auth so the session cookie is owned by admin.vonas.nl + 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 response = NextResponse.json( + { user: data.user, error: data.error }, + { status: upstream.status } + ); + + 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 }); - - const upstreamType = upstream.headers.get("content-type"); response.headers.set( "content-type", - upstreamType ?? "application/json" + upstream.headers.get("content-type") ?? "application/json" ); + // Forward any upstream cookies as fallback (rewritten without Domain) const anyHeaders = upstream.headers as Headers & { getSetCookie?: () => string[]; }; const setCookies = typeof anyHeaders.getSetCookie === "function" ? anyHeaders.getSetCookie() - : upstream.headers.get("set-cookie") - ? [upstream.headers.get("set-cookie") as string] - : []; + : []; - for (const c of setCookies) { - response.headers.append("set-cookie", c); + for (const raw of setCookies) { + const cleaned = raw + .split(";") + .map((p) => p.trim()) + .filter((p) => !/^domain=/i.test(p)) + .join("; "); + response.headers.append("set-cookie", cleaned); } return response; diff --git a/apps/admin-ui/src/app/page.tsx b/apps/admin-ui/src/app/page.tsx index b0d6020..de6cbea 100644 --- a/apps/admin-ui/src/app/page.tsx +++ b/apps/admin-ui/src/app/page.tsx @@ -12,7 +12,9 @@ export default function LoginPage() { useEffect(() => { fetch("/api/v1/auth/me", { credentials: "include" }) - .then((r) => r.ok && router.push("/dashboard")) + .then((r) => { + if (r.ok) router.push("/dashboard"); + }) .catch(() => {}); }, [router]); @@ -38,10 +40,10 @@ export default function LoginPage() { } throw new Error(message); } - router.push("/dashboard"); + // Full navigation so the session cookie is definitely included + window.location.assign("/dashboard"); } catch (err) { setError(err instanceof Error ? err.message : "Inloggen mislukt"); - } finally { setLoading(false); } } diff --git a/apps/master-api/src/auth/routes.ts b/apps/master-api/src/auth/routes.ts index db724bd..bf3ac71 100644 --- a/apps/master-api/src/auth/routes.ts +++ b/apps/master-api/src/auth/routes.ts @@ -46,7 +46,12 @@ export async function registerAuthRoutes( 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) => {