mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
A tela de Redes sociais le `error` e `connected` para avisar se a autorizacao deu certo ou falhou. A ponte /auth/social-return mandava sempre para /app/connections?aba=sociais fixo, e a intercepcao do proxy tambem descartava os dois: a pessoa voltava logada, mas sem saber o desfecho. Agora so a PRESENCA de error/connected escolhe entre tres destinos fixos (error tem precedencia); nenhum valor da query, nem o connect_token, e refletido. O hash da CSP e calculado para o script do destino escolhido. Refs #1579 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N9fyW7pXJCsyDPtu5PoQpN
144 lines
5.3 KiB
TypeScript
144 lines
5.3 KiB
TypeScript
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
|
import { cookieSecure } from "@/lib/supabase/cookie-secure";
|
|
import { NextResponse, type NextRequest } from "next/server";
|
|
import { env } from "@/lib/env";
|
|
import { isPublicPath } from "@/lib/auth/public-paths";
|
|
import {
|
|
verifyImpersonateCookieEdge,
|
|
IMPERSONATE_COOKIE_NAME_EDGE,
|
|
} from "@/lib/impersonate/cookie-edge";
|
|
|
|
const COOKIE_NAME = "sb-deskcomm-auth";
|
|
|
|
export async function proxy(request: NextRequest) {
|
|
const response = NextResponse.next({ request: { headers: request.headers } });
|
|
|
|
// Inject X-Request-Id for downstream correlation (audit log, error wrappers).
|
|
const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID();
|
|
response.headers.set("x-request-id", requestId);
|
|
|
|
const { pathname, search } = request.nextUrl;
|
|
// Recupera retornos de OAuth social já emitidos antes da landing pública existir.
|
|
// Apenas a navegação é tratada: o vínculo de conta segue protegido pelos guards canônicos.
|
|
// Passa adiante só o SINAL `connected=1` — nunca o `connect_token` nem o valor recebido.
|
|
if (
|
|
request.method === "GET" &&
|
|
pathname === "/app/connections" &&
|
|
request.nextUrl.searchParams.has("connected") &&
|
|
request.nextUrl.searchParams.has("connect_token")
|
|
) {
|
|
const landing = NextResponse.redirect(new URL("/auth/social-return?connected=1", request.url));
|
|
landing.headers.set("Cache-Control", "no-store");
|
|
landing.headers.set("Referrer-Policy", "no-referrer");
|
|
return landing;
|
|
}
|
|
// Expose pathname to Server Components via header (used by onboarding layout).
|
|
response.headers.set("x-pathname", pathname);
|
|
request.headers.set("x-pathname", pathname);
|
|
|
|
// EPIC-11: the admin surface is reached by PATH (`/admin/*`) — the self-host kit
|
|
// points `NEXT_PUBLIC_ADMIN_URL` at the same host as the app and maps no `admin.`
|
|
// sub-domain. The host-based branch below stays a NOOP today and only exists as
|
|
// documentation of the intended deploy topology.
|
|
const host = request.headers.get("host") ?? "";
|
|
const isAdminSurface = host.startsWith("admin.") || pathname.startsWith("/admin");
|
|
|
|
if (isPublicPath(pathname)) {
|
|
return response;
|
|
}
|
|
|
|
const supabase = createServerClient(
|
|
env.NEXT_PUBLIC_SUPABASE_URL,
|
|
env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
|
{
|
|
cookies: {
|
|
getAll() {
|
|
return request.cookies.getAll();
|
|
},
|
|
setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
|
|
cookiesToSet.forEach(({ name, value, options }) => {
|
|
request.cookies.set(name, value);
|
|
response.cookies.set(name, value, options);
|
|
});
|
|
},
|
|
},
|
|
cookieOptions: {
|
|
name: COOKIE_NAME,
|
|
sameSite: "strict",
|
|
httpOnly: true,
|
|
secure: cookieSecure(),
|
|
path: "/",
|
|
},
|
|
},
|
|
);
|
|
|
|
// Validate JWT server-side (NEVER use getSession on backend per CLAUDE.md).
|
|
const {
|
|
data: { user },
|
|
} = await supabase.auth.getUser();
|
|
|
|
if (!user) {
|
|
// API routes must respond with JSON envelope (contract: {error:{code,message}})
|
|
// — never redirect HTML to JSON consumers. UI routes redirect to /login as before.
|
|
if (pathname.startsWith("/api/")) {
|
|
return new NextResponse(
|
|
JSON.stringify({
|
|
error: {
|
|
code: "unauthenticated",
|
|
message: "Authentication required",
|
|
},
|
|
}),
|
|
{
|
|
status: 401,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-request-id": requestId,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
const loginUrl = new URL("/login", request.url);
|
|
loginUrl.searchParams.set("next", pathname + search);
|
|
return NextResponse.redirect(loginUrl);
|
|
}
|
|
|
|
// EPIC-11 S-11.07: validate impersonate cookie on /app/* paths. Middleware
|
|
// runs in Edge — no DB access, only HMAC + expiry. On any failure we delete
|
|
// the presentation cookie. The database support session remains authoritative:
|
|
// expired/revoked support still blocks the app until explicit exit.
|
|
if (pathname.startsWith("/app")) {
|
|
const impCookie = request.cookies.get(IMPERSONATE_COOKIE_NAME_EDGE)?.value;
|
|
if (impCookie) {
|
|
const result = await verifyImpersonateCookieEdge(
|
|
impCookie,
|
|
env.IMPERSONATE_COOKIE_SECRET ?? "",
|
|
);
|
|
if (!result.valid) {
|
|
console.warn(
|
|
`[middleware] impersonate cookie invalid (${result.reason ?? "unknown"}) — clearing`,
|
|
);
|
|
response.cookies.delete(IMPERSONATE_COOKIE_NAME_EDGE);
|
|
}
|
|
}
|
|
}
|
|
|
|
// /admin/* additionally requires platform_admin (early gate — authoritative
|
|
// check is server-side in `requirePlatformAdmin`). Skip the RPC for
|
|
// `/admin/forbidden` (rendered to non-admins, would otherwise loop).
|
|
if (isAdminSurface && pathname.startsWith("/admin") && pathname !== "/admin/forbidden") {
|
|
const { data: isAdmin, error } = await supabase.rpc("fn_is_platform_admin");
|
|
if (error || !isAdmin) {
|
|
return NextResponse.redirect(new URL("/admin/forbidden", request.url));
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
// Run on all paths except static assets / Next internals.
|
|
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*)",
|
|
],
|
|
};
|