Files
DeskcommCRM/proxy.ts
T
melgarafaelandClaude Opus 5.5 5a1700eb4f fix(auth): a ponte do OAuth social devolve o desfecho da autorizacao
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
2026-09-24 02:00:11 -03:00

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)$).*)",
],
};