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:
1 parent
b972589949
commit
f5560cc4ac
15 files changed
+586
-55
No files matched your search
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user