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
+36
-32
@@ -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<AuthState> {
|
||||
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<AuthState> {
|
||||
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<AuthState> {
|
||||
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<AuthState> {
|
||||
};
|
||||
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) };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ReturnType<typeof cookies>>;
|
||||
|
||||
/** 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<Identity | null>;
|
||||
/** 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";
|
||||
}
|
||||
@@ -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<Membership[]> {
|
||||
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<Membership> {
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user