Autentica homologação com Supabase real e resolve workspace no servidor

Duas lacunas impediam qualquer uso de homologação. Primeira: sem SSO do Elo
Pro e sem o login local, não havia como entrar. Segunda, mais grave: nada no
repositório gravava o cookie `elo_pro_workspace_id`, que era lido como origem
do workspace ativo — mesmo com sessão válida a resposta era sempre "sem
workspace".

- `identity-provider.ts` define o contrato de identidade. Trocar o Supabase de
  homologação pelo SSO do Elo Pro passa a ser acrescentar uma implementação;
  nenhum outro arquivo conhece o mecanismo.
- `/api/auth/staging/session` autentica com `signInWithPassword` no projeto
  Supabase de homologação. Há senha, emitida e validada pelo Supabase, e o
  Adaptador continua sem tabela de usuário, senha ou sessão própria.
- O login local sem senha existe apenas em `ADAPTER_ENV=development`, e não
  por configuração: fora de development o provedor nem entra na lista.
- `workspace.ts` resolve o workspace por associação real lida sob RLS. O
  cookie passa a ser preferência, não prova: aceitá-lo como autorização
  transformava um valor editável pelo cliente em credencial.
- `/api/auth/workspace` grava a preferência só depois de confirmar a
  associação, e `/selecionar-workspace` existe para quem participa de mais de
  um workspace.
- Rate limiting por usuário e bucket nas rotas do módulo, com Retry-After.
  Upload e processamento custam muito mais que leitura.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
camilafagundesandClaude Opus 5 committed 2026-10-15 20:38:45 -03:00
1 parent b972589949
commit f5560cc4ac
15 files changed
+586 -55

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ export const dynamic = "force-dynamic";
export default async function SettingsPage() {
const auth = await resolveAuth();
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
if (auth.kind === "workspace_choice") redirect("/selecionar-workspace?next=%2Fadaptador%2Fconfiguracoes");
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
if (!canManageSpreadsheetModels(auth.ctx)) {
return (
+1
View File
@@ -8,6 +8,7 @@ export const dynamic = "force-dynamic";
export default async function OperationPage() {
const auth = await resolveAuth();
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
if (auth.kind === "workspace_choice") redirect("/selecionar-workspace?next=%2Fadaptador%2Foperacao");
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
return (
<AppShell ctx={auth.ctx}>
+8 -1
View File
@@ -10,10 +10,17 @@ export const dynamic = "force-dynamic";
export default async function AdapterHome() {
const auth = await resolveAuth();
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
if (auth.kind === "workspace_choice") redirect("/selecionar-workspace?next=%2Fadaptador");
if (auth.kind !== "ok") {
return (
<main className="p-8">
<ErrorState message={auth.kind === "no_workspace" ? "O Elo Pro ainda não informou o workspace ativo." : "Você não é membro deste workspace."} />
<ErrorState
message={
auth.kind === "no_workspace"
? "Sua identidade não está associada a nenhum workspace."
: "Você não é membro deste workspace."
}
/>
</main>
);
}
+6 -15
View File
@@ -1,24 +1,15 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { createServerClient } from "@supabase/ssr";
import { supabaseServerClient, DEV_COOKIE, WORKSPACE_COOKIE } from "@/server/auth/identity-provider";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST() {
const jar = await cookies();
jar.delete("adapter_dev_session");
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (url && key) {
const supabase = createServerClient(url, key, {
cookies: {
getAll: () => jar.getAll(),
setAll: (items) => {
for (const item of items) jar.set(item.name, item.value, item.options);
},
},
});
await supabase.auth.signOut();
}
jar.delete(DEV_COOKIE);
jar.delete(WORKSPACE_COOKIE);
const supabase = supabaseServerClient(jar);
if (supabase) await supabase.auth.signOut();
return NextResponse.json({ ok: true });
}
+60
View File
@@ -0,0 +1,60 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { stagingAuthEnabled } from "@/config/env";
import { supabaseServerClient } from "@/server/auth/identity-provider";
import { hitRateLimit, requestSubject } from "@/server/rate-limit";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Login de homologação com Supabase Auth real do projeto de homologação.
* Não é o dev auth: há senha, a sessão é emitida e validada pelo Supabase e o
* Adaptador continua sem tabela de usuário ou de senha própria. Indisponível
* em development e em produção.
*/
export async function POST(request: Request) {
if (!stagingAuthEnabled()) {
return NextResponse.json({ error: { code: "NOT_FOUND", message: "Indisponível." } }, { status: 404 });
}
const limit = await hitRateLimit("login", requestSubject(request));
if (!limit.allowed) {
return NextResponse.json(
{ error: { code: "RATE_LIMITED", message: "Muitas tentativas. Aguarde e tente de novo." } },
{ status: 429, headers: { "Retry-After": String(limit.retryAfterSeconds) } },
);
}
let body: { email?: string; password?: string };
try {
body = (await request.json()) as { email?: string; password?: string };
} catch {
return NextResponse.json({ error: { code: "INVALID_BODY", message: "Corpo inválido." } }, { status: 400 });
}
const email = body.email?.trim();
const password = body.password;
if (!email || !password) {
return NextResponse.json(
{ error: { code: "UNAUTHENTICATED", message: "Informe e-mail e senha de homologação." } },
{ status: 400 },
);
}
const jar = await cookies();
const supabase = supabaseServerClient(jar);
if (!supabase) {
return NextResponse.json(
{ error: { code: "MISCONFIGURED", message: "Supabase de homologação não configurado." } },
{ status: 503 },
);
}
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
if (error || !data.user) {
// Nunca devolve o motivo exato: não confirma existência de conta.
return NextResponse.json(
{ error: { code: "UNAUTHENTICATED", message: "Credenciais não reconhecidas." } },
{ status: 401 },
);
}
return NextResponse.json({ ok: true });
}
+60
View File
@@ -0,0 +1,60 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { isProductionEnv } from "@/config/env";
import { getDb } from "@/infra/db/client";
import { currentMemberships, WORKSPACE_COOKIE } from "@/server/auth/context";
import { assertMembership } from "@/server/auth/workspace";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Associações do usuário autenticado. */
export async function GET() {
const current = await currentMemberships();
if (!current) {
return NextResponse.json({ error: { code: "UNAUTHENTICATED", message: "Sessão necessária." } }, { status: 401 });
}
return NextResponse.json({ memberships: current.memberships });
}
/**
* Grava a preferência de workspace ativo. O cookie resultante é só preferência:
* toda requisição revalida a associação no servidor. Gravar aqui sem checar
* membership transformaria o cookie em credencial.
*/
export async function POST(request: Request) {
const current = await currentMemberships();
if (!current) {
return NextResponse.json({ error: { code: "UNAUTHENTICATED", message: "Sessão necessária." } }, { status: 401 });
}
let body: { workspaceId?: string };
try {
body = (await request.json()) as { workspaceId?: string };
} catch {
return NextResponse.json({ error: { code: "INVALID_BODY", message: "Corpo inválido." } }, { status: 400 });
}
if (!body.workspaceId) {
return NextResponse.json(
{ error: { code: "WORKSPACE_REQUIRED", message: "Informe o workspace." } },
{ status: 400 },
);
}
const db = await getDb();
try {
await assertMembership(db, current.userId, body.workspaceId);
} catch {
return NextResponse.json(
{ error: { code: "WORKSPACE_FORBIDDEN", message: "Você não é membro deste workspace." } },
{ status: 403 },
);
}
const jar = await cookies();
jar.set(WORKSPACE_COOKIE, body.workspaceId, {
httpOnly: true,
sameSite: "lax",
secure: isProductionEnv() || process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 12,
});
return NextResponse.json({ ok: true, workspaceId: body.workspaceId });
}
+23 -4
View File
@@ -1,9 +1,11 @@
import { redirect } from "next/navigation";
import { adapterEnv } from "@/config/env";
import { DEV_IDENTITIES } from "@/dev/fixtures";
import { resolveAuth } from "@/server/auth/context";
import { devAuthEnabled } from "@/server/auth/dev-session";
import { authMode } from "@/server/auth/identity-provider";
import { buildCentralLoginUrl, safeReturnPath } from "@/server/auth/return-to";
import { DevLogin } from "@/components/dev-login";
import { StagingLogin } from "@/components/staging-login";
export const dynamic = "force-dynamic";
@@ -12,10 +14,14 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
const auth = await resolveAuth();
const nextPath = safeReturnPath(params.next);
if (auth.kind === "ok") redirect(nextPath);
if (!devAuthEnabled() && process.env.ELO_PRO_LOGIN_URL) {
if (auth.kind === "workspace_choice") redirect(`/selecionar-workspace?next=${encodeURIComponent(nextPath)}`);
const mode = authMode();
if (mode === "elo-pro-sso" && process.env.ELO_PRO_LOGIN_URL) {
redirect(buildCentralLoginUrl(process.env.ELO_PRO_LOGIN_URL, nextPath, process.env.ELO_PRO_APP_URL));
}
const expired = auth.kind === "session_expired" || params.reason === "expired";
return (
<main className="mx-auto max-w-xl p-8">
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
@@ -23,8 +29,21 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
<p className="mt-3 text-sm text-ink/80">
O Adaptador não cria senha, cadastro ou sessão própria. A identidade é a do Elo Pro.
</p>
{expired ? <p className="mt-4 text-sm">A sessão expirou. Entre novamente pelo Elo Pro.</p> : null}
{devAuthEnabled() ? <DevLogin identities={DEV_IDENTITIES} /> : <p className="mt-6 text-sm">Defina ELO_PRO_LOGIN_URL com o endereço do login central.</p>}
{adapterEnv() === "staging" ? (
<p className="mt-3 inline-flex rounded-full bg-warning/15 px-3 py-1 text-xs font-semibold">
Ambiente de homologação
</p>
) : null}
{expired ? <p className="mt-4 text-sm">A sessão expirou. Entre novamente.</p> : null}
{mode === "dev" ? <DevLogin identities={DEV_IDENTITIES} /> : null}
{mode === "staging-supabase" ? <StagingLogin next={nextPath} /> : null}
{mode === "unconfigured" ? (
<p className="mt-6 text-sm">
Autenticação não configurada. Em produção defina <code>ELO_PRO_LOGIN_URL</code>; em homologação defina{" "}
<code>ADAPTER_STAGING_AUTH=1</code> com o projeto Supabase de homologação.
</p>
) : null}
</main>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import { currentMemberships } from "@/server/auth/context";
import { safeReturnPath } from "@/server/auth/return-to";
import { WorkspacePicker } from "@/components/workspace-picker";
export const dynamic = "force-dynamic";
export default async function SelecionarWorkspacePage({
searchParams,
}: {
searchParams: Promise<{ next?: string }>;
}) {
const params = await searchParams;
const nextPath = safeReturnPath(params.next);
const current = await currentMemberships();
if (!current) redirect(`/login?next=${encodeURIComponent(nextPath)}`);
if (current.memberships.length === 0) {
return (
<main className="mx-auto max-w-xl p-8">
<h1 className="text-2xl font-semibold">Nenhum workspace</h1>
<p className="mt-3 text-sm text-ink/80">
Sua identidade não está associada a nenhum workspace. Peça a um administrador do Elo Pro para incluir seu
acesso.
</p>
</main>
);
}
return (
<main className="mx-auto max-w-xl p-8">
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
<h1 className="mt-2 text-2xl font-semibold">Workspace ativo</h1>
<WorkspacePicker memberships={current.memberships} next={nextPath} />
</main>
);
}