From f5560cc4aca801069c206e4e3a454cb1ac4c4a32 Mon Sep 17 00:00:00 2001 From: camilafagundes Date: Thu, 15 Oct 2026 20:38:45 -0300 Subject: [PATCH] =?UTF-8?q?Autentica=20homologa=C3=A7=C3=A3o=20com=20Supab?= =?UTF-8?q?ase=20real=20e=20resolve=20workspace=20no=20servidor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/adaptador/configuracoes/page.tsx | 1 + src/app/adaptador/operacao/page.tsx | 1 + src/app/adaptador/page.tsx | 9 +- src/app/api/auth/logout/route.ts | 21 ++--- src/app/api/auth/staging/session/route.ts | 60 +++++++++++++ src/app/api/auth/workspace/route.ts | 60 +++++++++++++ src/app/login/page.tsx | 27 +++++- src/app/selecionar-workspace/page.tsx | 36 ++++++++ src/components/staging-login.tsx | 81 +++++++++++++++++ src/components/workspace-picker.tsx | 71 +++++++++++++++ src/proxy.ts | 10 ++- src/server/auth/context.ts | 68 +++++++------- src/server/auth/dev-session.ts | 16 +++- src/server/auth/identity-provider.ts | 104 ++++++++++++++++++++++ src/server/auth/workspace.ts | 76 ++++++++++++++++ 15 files changed, 586 insertions(+), 55 deletions(-) create mode 100644 src/app/api/auth/staging/session/route.ts create mode 100644 src/app/api/auth/workspace/route.ts create mode 100644 src/app/selecionar-workspace/page.tsx create mode 100644 src/components/staging-login.tsx create mode 100644 src/components/workspace-picker.tsx create mode 100644 src/server/auth/identity-provider.ts create mode 100644 src/server/auth/workspace.ts diff --git a/src/app/adaptador/configuracoes/page.tsx b/src/app/adaptador/configuracoes/page.tsx index 5b11a96..5f7c67c 100644 --- a/src/app/adaptador/configuracoes/page.tsx +++ b/src/app/adaptador/configuracoes/page.tsx @@ -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 ( diff --git a/src/app/adaptador/operacao/page.tsx b/src/app/adaptador/operacao/page.tsx index 9e78f5a..e26ccd1 100644 --- a/src/app/adaptador/operacao/page.tsx +++ b/src/app/adaptador/operacao/page.tsx @@ -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 ( diff --git a/src/app/adaptador/page.tsx b/src/app/adaptador/page.tsx index 5917629..aebcd8a 100644 --- a/src/app/adaptador/page.tsx +++ b/src/app/adaptador/page.tsx @@ -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 (
- +
); } diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 6d24771..12e8f4e 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -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 }); } diff --git a/src/app/api/auth/staging/session/route.ts b/src/app/api/auth/staging/session/route.ts new file mode 100644 index 0000000..cff6ac5 --- /dev/null +++ b/src/app/api/auth/staging/session/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/workspace/route.ts b/src/app/api/auth/workspace/route.ts new file mode 100644 index 0000000..b38f09c --- /dev/null +++ b/src/app/api/auth/workspace/route.ts @@ -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 }); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 095ad11..806c0e5 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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 (

ELO PRO

@@ -23,8 +29,21 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis

O Adaptador não cria senha, cadastro ou sessão própria. A identidade é a do Elo Pro.

- {expired ?

A sessão expirou. Entre novamente pelo Elo Pro.

: null} - {devAuthEnabled() ? :

Defina ELO_PRO_LOGIN_URL com o endereço do login central.

} + {adapterEnv() === "staging" ? ( +

+ Ambiente de homologação +

+ ) : null} + {expired ?

A sessão expirou. Entre novamente.

: null} + + {mode === "dev" ? : null} + {mode === "staging-supabase" ? : null} + {mode === "unconfigured" ? ( +

+ Autenticação não configurada. Em produção defina ELO_PRO_LOGIN_URL; em homologação defina{" "} + ADAPTER_STAGING_AUTH=1 com o projeto Supabase de homologação. +

+ ) : null}
); } diff --git a/src/app/selecionar-workspace/page.tsx b/src/app/selecionar-workspace/page.tsx new file mode 100644 index 0000000..2789513 --- /dev/null +++ b/src/app/selecionar-workspace/page.tsx @@ -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 ( +
+

Nenhum workspace

+

+ Sua identidade não está associada a nenhum workspace. Peça a um administrador do Elo Pro para incluir seu + acesso. +

+
+ ); + } + + return ( +
+

ELO PRO

+

Workspace ativo

+ +
+ ); +} diff --git a/src/components/staging-login.tsx b/src/components/staging-login.tsx new file mode 100644 index 0000000..d93eb4c --- /dev/null +++ b/src/components/staging-login.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button, TextInput } from "./ui"; + +/** + * Login de homologação. A credencial vai direto ao Supabase Auth do projeto de + * homologação: o Adaptador não guarda senha nem cria cadastro. + */ +export function StagingLogin({ next }: { next: string }) { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState(null); + const [pending, setPending] = useState(false); + + return ( +
{ + event.preventDefault(); + setPending(true); + setMessage(null); + try { + const response = await fetch("/api/auth/staging/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { error?: { message?: string } } | null; + setMessage(body?.error?.message ?? "Não foi possível entrar."); + return; + } + router.push(next); + router.refresh(); + } finally { + setPending(false); + } + }} + > + + setEmail(event.target.value)} + /> + + setPassword(event.target.value)} + /> + + {message ? ( +

+ {message} +

+ ) : null} +

+ Contas de homologação são criadas no projeto Supabase de homologação pela operação. Em produção esta tela não + existe: a identidade é a do Elo Pro. +

+ + ); +} diff --git a/src/components/workspace-picker.tsx b/src/components/workspace-picker.tsx new file mode 100644 index 0000000..6bd95dd --- /dev/null +++ b/src/components/workspace-picker.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button, Card } from "./ui"; + +export type WorkspaceOption = { + workspaceId: string; + workspaceName: string; + role: string; + moduleEnabled: boolean; +}; + +/** + * Escolha do workspace ativo. A seleção grava apenas preferência: o servidor + * revalida a associação em toda requisição. + */ +export function WorkspacePicker({ memberships, next }: { memberships: WorkspaceOption[]; next: string }) { + const router = useRouter(); + const [pending, setPending] = useState(null); + const [message, setMessage] = useState(null); + + return ( + +

Selecione o workspace

+

+ Você participa de mais de um workspace. O Adaptador trabalha em um por vez. +

+
+ {memberships.map((membership) => ( + + ))} +
+ {message ? ( +

+ {message} +

+ ) : null} +
+ ); +} diff --git a/src/proxy.ts b/src/proxy.ts index e68dddd..ae5601d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,9 +1,14 @@ import { NextResponse, type NextRequest } from "next/server"; +/** Caminhos que exigem alguma sessão. A autorização real é no servidor. */ +const GUARDED = ["/adaptador", "/api/spreadsheet-adapter", "/selecionar-workspace"]; + export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; - const guarded = pathname.startsWith("/adaptador") || pathname.startsWith("/api/spreadsheet-adapter"); + const guarded = GUARDED.some((prefix) => pathname.startsWith(prefix)); if (!guarded) return NextResponse.next(); + // Presença de cookie é só um atalho para evitar render desnecessário. + // Nenhuma decisão de autorização acontece aqui. const hasDev = Boolean(request.cookies.get("adapter_dev_session")?.value); const hasSupabase = request.cookies.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth")); if (hasDev || hasSupabase) return NextResponse.next(); @@ -12,10 +17,11 @@ export function proxy(request: NextRequest) { } const login = request.nextUrl.clone(); login.pathname = "/login"; + login.search = ""; login.searchParams.set("next", pathname); return NextResponse.redirect(login); } export const config = { - matcher: ["/adaptador/:path*", "/api/spreadsheet-adapter/:path*"], + matcher: ["/adaptador/:path*", "/api/spreadsheet-adapter/:path*", "/selecionar-workspace"], }; diff --git a/src/server/auth/context.ts b/src/server/auth/context.ts index df490c8..6650f0d 100644 --- a/src/server/auth/context.ts +++ b/src/server/auth/context.ts @@ -1,49 +1,45 @@ import "server-only"; import { cookies } from "next/headers"; -import { createServerClient } from "@supabase/ssr"; import { adapterPermissions } from "@/domain/spreadsheet-adapter/permissions"; import type { AuthenticatedContext, Role } from "@/domain/spreadsheet-adapter/types"; import { getDb } from "@/infra/db/client"; -import { devAuthEnabled, verifyDevSession } from "./dev-session"; +import { activeProviders, WORKSPACE_COOKIE, type Identity } from "./identity-provider"; +import { listMemberships, resolveWorkspace, type Membership } from "./workspace"; export type AuthState = | { kind: "anonymous" } | { kind: "session_expired" } | { kind: "no_workspace" } | { kind: "not_member" } + | { kind: "workspace_choice"; memberships: Membership[] } | { kind: "ok"; ctx: AuthenticatedContext }; -export async function resolveAuth(): Promise { +export { WORKSPACE_COOKIE }; + +async function resolveIdentity(): Promise<{ identity: Identity | null; hadHint: boolean }> { const jar = await cookies(); - let userId: string | null = null; - let workspaceId = jar.get("elo_pro_workspace_id")?.value ?? null; - const devToken = jar.get("adapter_dev_session")?.value; - const hadAuthCookie = jar.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth")); - if (devToken && devAuthEnabled()) { - const dev = verifyDevSession(devToken); - if (dev) { - userId = dev.userId; - workspaceId = dev.workspaceId; - } + const providers = activeProviders(); + let hadHint = false; + for (const provider of providers) { + if (provider.hadSessionHint(jar)) hadHint = true; + const identity = await provider.resolve(jar); + if (identity) return { identity, hadHint }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - if (!userId && supabaseUrl && supabaseKey) { - const supabase = createServerClient(supabaseUrl, supabaseKey, { - cookies: { - getAll: () => jar.getAll(), - setAll: (items) => { - for (const item of items) jar.set(item.name, item.value, item.options); - }, - }, - }); - const { data } = await supabase.auth.getUser(); - userId = data.user?.id ?? null; - } - if (!userId) return { kind: hadAuthCookie || Boolean(devToken) ? "session_expired" : "anonymous" }; - if (!workspaceId) return { kind: "no_workspace" }; + return { identity: null, hadHint }; +} + +export async function resolveAuth(): Promise { + const { identity, hadHint } = await resolveIdentity(); + if (!identity) return { kind: hadHint ? "session_expired" : "anonymous" }; + const db = await getDb(); - const rows = await db.withUser(userId, (query) => + const memberships = await listMemberships(db, identity.userId); + const resolution = resolveWorkspace(memberships, identity.workspaceHint); + if (resolution.kind === "none") return { kind: "no_workspace" }; + if (resolution.kind === "choice") return { kind: "workspace_choice", memberships: resolution.memberships }; + + const workspaceId = resolution.workspaceId; + const rows = await db.withUser(identity.userId, (query) => query.query<{ display_name: string; is_superadmin: boolean; @@ -61,13 +57,13 @@ export async function resolveAuth(): Promise { left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter' left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id where p.id = $1`, - [userId, workspaceId], + [identity.userId, workspaceId], ), ); const row = rows[0]; if (!row) return { kind: "not_member" }; const ctx = { - userId, + userId: identity.userId, displayName: row.display_name, isSuperadmin: row.is_superadmin, workspaceId, @@ -78,3 +74,11 @@ export async function resolveAuth(): Promise { }; return { kind: "ok", ctx: { ...ctx, permissions: adapterPermissions(ctx) } }; } + +/** Lista as associações do usuário autenticado, para a tela de escolha. */ +export async function currentMemberships(): Promise<{ userId: string; memberships: Membership[] } | null> { + const { identity } = await resolveIdentity(); + if (!identity) return null; + const db = await getDb(); + return { userId: identity.userId, memberships: await listMemberships(db, identity.userId) }; +} diff --git a/src/server/auth/dev-session.ts b/src/server/auth/dev-session.ts index 0df4a7f..fa9c067 100644 --- a/src/server/auth/dev-session.ts +++ b/src/server/auth/dev-session.ts @@ -1,9 +1,23 @@ import { createHmac, timingSafeEqual } from "node:crypto"; +import { adapterEnv } from "@/config/env"; export type DevSession = { userId: string; workspaceId: string; exp: number }; +/** + * Sessão local sem senha. Existe apenas em `ADAPTER_ENV=development`. + * Homologação pública usa Supabase Auth real — ver `stagingAuthEnabled`. + * + * A autoridade é `ADAPTER_ENV`, não `NODE_ENV`: a imagem roda sempre com + * `NODE_ENV=production` (build otimizado), inclusive no compose local. E o + * padrão protege o caso perigoso: sem `ADAPTER_ENV` explícito, um processo com + * `NODE_ENV=production` resolve para `production` e esta função devolve false. + */ export function devAuthEnabled(): boolean { - return process.env.NODE_ENV !== "production" && process.env.ADAPTER_DEV_AUTH === "1" && Boolean(process.env.ADAPTER_DEV_AUTH_SECRET); + return ( + adapterEnv() === "development" && + process.env.ADAPTER_DEV_AUTH === "1" && + Boolean(process.env.ADAPTER_DEV_AUTH_SECRET) + ); } function secret(): string { diff --git a/src/server/auth/identity-provider.ts b/src/server/auth/identity-provider.ts new file mode 100644 index 0000000..2086c92 --- /dev/null +++ b/src/server/auth/identity-provider.ts @@ -0,0 +1,104 @@ +import "server-only"; +import type { cookies } from "next/headers"; +import { createServerClient } from "@supabase/ssr"; +import { adapterEnv, stagingAuthEnabled } from "@/config/env"; +import { devAuthEnabled, verifyDevSession } from "./dev-session"; + +export type CookieJar = Awaited>; + +/** Identidade resolvida. O Adaptador nunca cria usuário, senha ou cadastro. */ +export type Identity = { + userId: string; + /** Workspace sugerido pelo provedor. É dica, nunca autorização. */ + workspaceHint: string | null; + source: "supabase" | "dev"; +}; + +/** + * Interface de autenticação. Hoje há duas implementações sobre o mesmo + * Supabase Auth — o projeto próprio de homologação e o projeto do Elo Pro — + * e uma de desenvolvimento local. Quando o SSO do Elo Pro existir, basta uma + * nova implementação deste contrato: nada fora daqui conhece o mecanismo. + */ +export interface IdentityProvider { + readonly name: string; + /** Identidade da requisição, ou null quando não há sessão válida. */ + resolve(jar: CookieJar): Promise; + /** Indica se havia resquício de sessão, para distinguir expirada de anônima. */ + hadSessionHint(jar: CookieJar): boolean; +} + +export const SUPABASE_COOKIE_HINT = (name: string) => name.startsWith("sb-") && name.includes("auth"); + +export function supabaseServerClient(jar: CookieJar) { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + if (!url || !key) return null; + return createServerClient(url, key, { + cookies: { + getAll: () => jar.getAll(), + setAll: (items) => { + for (const item of items) { + try { + jar.set(item.name, item.value, item.options); + } catch { + // Server Components não deixam gravar cookie. O refresh acontece na próxima rota. + } + } + }, + }, + }); +} + +/** Supabase Auth. Serve o projeto próprio de homologação e o do Elo Pro. */ +export const supabaseIdentityProvider: IdentityProvider = { + name: "supabase", + async resolve(jar) { + const supabase = supabaseServerClient(jar); + if (!supabase) return null; + const { data } = await supabase.auth.getUser(); + const userId = data.user?.id ?? null; + if (!userId) return null; + return { userId, workspaceHint: jar.get(WORKSPACE_COOKIE)?.value ?? null, source: "supabase" }; + }, + hadSessionHint(jar) { + return jar.getAll().some((cookie) => SUPABASE_COOKIE_HINT(cookie.name)); + }, +}; + +/** Sessão local assinada, sem senha. Existe apenas em development. */ +export const devIdentityProvider: IdentityProvider = { + name: "dev", + async resolve(jar) { + if (!devAuthEnabled()) return null; + const token = jar.get(DEV_COOKIE)?.value; + if (!token) return null; + const session = verifyDevSession(token); + if (!session) return null; + return { userId: session.userId, workspaceHint: session.workspaceId, source: "dev" }; + }, + hadSessionHint(jar) { + return devAuthEnabled() && Boolean(jar.get(DEV_COOKIE)?.value); + }, +}; + +export const DEV_COOKIE = "adapter_dev_session"; +export const WORKSPACE_COOKIE = "elo_pro_workspace_id"; + +/** + * Provedores na ordem de precedência do ambiente. Em staging e produção o + * provedor de desenvolvimento não entra na lista — não é questão de + * configuração, é ausência no caminho de código. + */ +export function activeProviders(): IdentityProvider[] { + if (adapterEnv() === "development") return [devIdentityProvider, supabaseIdentityProvider]; + return [supabaseIdentityProvider]; +} + +/** Descreve como o ambiente atual autentica. Usado pela tela de login. */ +export function authMode(): "dev" | "staging-supabase" | "elo-pro-sso" | "unconfigured" { + if (devAuthEnabled()) return "dev"; + if (stagingAuthEnabled()) return "staging-supabase"; + if (process.env.ELO_PRO_LOGIN_URL) return "elo-pro-sso"; + return "unconfigured"; +} diff --git a/src/server/auth/workspace.ts b/src/server/auth/workspace.ts new file mode 100644 index 0000000..785cace --- /dev/null +++ b/src/server/auth/workspace.ts @@ -0,0 +1,76 @@ +import "server-only"; +import type { Db } from "@/infra/db/types"; +import type { Role } from "@/domain/spreadsheet-adapter/types"; + +export type Membership = { + workspaceId: string; + workspaceName: string; + role: Role; + moduleEnabled: boolean; +}; + +export type WorkspaceResolution = + | { kind: "none" } + | { kind: "resolved"; workspaceId: string } + | { kind: "choice"; memberships: Membership[] }; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function isWorkspaceId(value: string | null | undefined): value is string { + return typeof value === "string" && UUID_RE.test(value); +} + +/** + * Associações reais do usuário, lidas com o papel `app_user` sob RLS. + * Esta é a única fonte de autorização de workspace. + */ +export async function listMemberships(db: Db, userId: string): Promise { + const rows = await db.withUser(userId, (query) => + query.query<{ + workspace_id: string; + workspace_name: string; + role: Role; + module_enabled: boolean; + }>( + `select w.id as workspace_id, w.name as workspace_name, m.role, + coalesce(f.enabled, false) as module_enabled + from public.workspace_members m + join public.workspaces w on w.id = m.workspace_id + left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter' + where m.user_id = $1 + order by w.name asc`, + [userId], + ), + ); + return rows.map((row) => ({ + workspaceId: row.workspace_id, + workspaceName: row.workspace_name, + role: row.role, + moduleEnabled: row.module_enabled, + })); +} + +/** + * Resolve o workspace no servidor. O cookie da plataforma é apenas uma dica de + * preferência: se não corresponder a uma associação real ele é ignorado, nunca + * aceito como prova. Com uma única associação a escolha é automática; com mais + * de uma e sem dica válida, quem escolhe é a pessoa. + */ +export function resolveWorkspace(memberships: Membership[], hint: string | null | undefined): WorkspaceResolution { + if (memberships.length === 0) return { kind: "none" }; + if (isWorkspaceId(hint)) { + const match = memberships.find((item) => item.workspaceId.toLowerCase() === hint.toLowerCase()); + if (match) return { kind: "resolved", workspaceId: match.workspaceId }; + } + if (memberships.length === 1) return { kind: "resolved", workspaceId: memberships[0].workspaceId }; + return { kind: "choice", memberships }; +} + +/** Confirma que o usuário é membro antes de gravar a preferência no cookie. */ +export async function assertMembership(db: Db, userId: string, workspaceId: string): Promise { + if (!isWorkspaceId(workspaceId)) throw new Error("workspace inválido"); + const memberships = await listMemberships(db, userId); + const match = memberships.find((item) => item.workspaceId.toLowerCase() === workspaceId.toLowerCase()); + if (!match) throw new Error("associação não encontrada"); + return match; +}