Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-04-18 01:53:13 +00:00
parent d18e1ad27c
commit dd9f97cf79
6 changed files with 1177 additions and 0 deletions

View file

@ -0,0 +1,470 @@
// Shared OAuth provider helpers para Fase 4 (Integrações).
// IMPORTANTE: NUNCA logar response body de trocas de token. Apenas status HTTP e error codes.
export type ProviderSlug =
| "google_workspace"
| "notion"
| "todoist"
| "calcom"
| "microsoft_365";
export interface TokenExchangeResult {
access_token: string;
refresh_token?: string | null;
expires_in?: number | null; // segundos
account_email?: string | null;
account_name?: string | null;
granted_scopes?: string[];
}
export interface ProviderEnv {
clientId: string;
clientSecret: string;
redirectUri: string;
}
export function getProviderEnv(slug: ProviderSlug, redirectUri: string): ProviderEnv {
const map: Record<ProviderSlug, [string, string]> = {
google_workspace: ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"],
notion: ["NOTION_CLIENT_ID", "NOTION_CLIENT_SECRET"],
todoist: ["TODOIST_CLIENT_ID", "TODOIST_CLIENT_SECRET"],
calcom: ["CALCOM_CLIENT_ID", "CALCOM_CLIENT_SECRET"],
microsoft_365: ["MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET"],
};
const [idKey, secretKey] = map[slug];
const clientId = Deno.env.get(idKey) ?? "";
const clientSecret = Deno.env.get(secretKey) ?? "";
if (!clientId || !clientSecret) {
throw new Error(`Credenciais OAuth ausentes para ${slug} (${idKey}/${secretKey})`);
}
return { clientId, clientSecret, redirectUri };
}
/**
* Monta a URL de autorização para iniciar o fluxo OAuth.
*/
export function buildAuthorizeUrl(
slug: ProviderSlug,
authorizeUrl: string,
scopes: string[],
state: string,
env: ProviderEnv,
): string {
const u = new URL(authorizeUrl);
u.searchParams.set("client_id", env.clientId);
u.searchParams.set("redirect_uri", env.redirectUri);
u.searchParams.set("response_type", "code");
u.searchParams.set("state", state);
switch (slug) {
case "google_workspace": {
u.searchParams.set("scope", scopes.join(" "));
u.searchParams.set("access_type", "offline");
u.searchParams.set("prompt", "consent");
break;
}
case "microsoft_365": {
// Garante offline_access para receber refresh_token
const withOffline = scopes.includes("offline_access")
? scopes
: ["offline_access", ...scopes];
u.searchParams.set("scope", withOffline.join(" "));
u.searchParams.set("response_mode", "query");
break;
}
case "notion": {
u.searchParams.set("owner", "user");
break;
}
case "todoist": {
u.searchParams.set("scope", scopes.join(","));
break;
}
case "calcom": {
u.searchParams.set("scope", scopes.join(" "));
break;
}
}
return u.toString();
}
/**
* Troca o `code` por tokens. NUNCA loga response body.
*/
export async function exchangeCodeForTokens(
slug: ProviderSlug,
code: string,
tokenUrl: string,
env: ProviderEnv,
): Promise<TokenExchangeResult> {
switch (slug) {
case "google_workspace": {
const body = new URLSearchParams({
code,
client_id: env.clientId,
client_secret: env.clientSecret,
redirect_uri: env.redirectUri,
grant_type: "authorization_code",
});
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) {
console.error(`google token exchange status=${res.status}`);
throw new Error("provider_error");
}
const tokens = await res.json() as {
access_token: string;
refresh_token?: string;
expires_in?: number;
scope?: string;
};
// userinfo
let email: string | null = null;
let name: string | null = null;
try {
const ui = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
if (ui.ok) {
const u = await ui.json() as { email?: string; name?: string };
email = u.email ?? null;
name = u.name ?? null;
}
} catch (_) { /* best effort */ }
return {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token ?? null,
expires_in: tokens.expires_in ?? null,
account_email: email,
account_name: name,
granted_scopes: tokens.scope ? tokens.scope.split(" ") : [],
};
}
case "notion": {
const basic = btoa(`${env.clientId}:${env.clientSecret}`);
const res = await fetch(tokenUrl, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
code,
redirect_uri: env.redirectUri,
}),
});
if (!res.ok) {
console.error(`notion token exchange status=${res.status}`);
throw new Error("provider_error");
}
const t = await res.json() as {
access_token: string;
workspace_name?: string;
workspace_id?: string;
owner?: { user?: { person?: { email?: string }; name?: string } };
};
return {
access_token: t.access_token,
refresh_token: null,
expires_in: null,
account_email: t.owner?.user?.person?.email ?? null,
account_name: t.workspace_name ?? t.owner?.user?.name ?? null,
};
}
case "microsoft_365": {
const body = new URLSearchParams({
client_id: env.clientId,
client_secret: env.clientSecret,
code,
redirect_uri: env.redirectUri,
grant_type: "authorization_code",
});
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) {
console.error(`microsoft token exchange status=${res.status}`);
throw new Error("provider_error");
}
const t = await res.json() as {
access_token: string;
refresh_token?: string;
expires_in?: number;
id_token?: string;
scope?: string;
};
let email: string | null = null;
let name: string | null = null;
if (t.id_token) {
try {
const payload = JSON.parse(
atob(t.id_token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")),
) as { preferred_username?: string; email?: string; name?: string };
email = payload.preferred_username ?? payload.email ?? null;
name = payload.name ?? null;
} catch (_) { /* ignore */ }
}
return {
access_token: t.access_token,
refresh_token: t.refresh_token ?? null,
expires_in: t.expires_in ?? null,
account_email: email,
account_name: name,
granted_scopes: t.scope ? t.scope.split(" ") : [],
};
}
case "calcom": {
const body = new URLSearchParams({
client_id: env.clientId,
client_secret: env.clientSecret,
code,
redirect_uri: env.redirectUri,
grant_type: "authorization_code",
});
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) {
console.error(`calcom token exchange status=${res.status}`);
throw new Error("provider_error");
}
const t = await res.json() as {
access_token: string;
refresh_token?: string;
expires_in?: number;
};
let email: string | null = null;
let name: string | null = null;
try {
const me = await fetch("https://api.cal.com/v2/me", {
headers: { Authorization: `Bearer ${t.access_token}` },
});
if (me.ok) {
const u = await me.json() as { data?: { email?: string; name?: string } };
email = u.data?.email ?? null;
name = u.data?.name ?? null;
}
} catch (_) { /* best effort */ }
return {
access_token: t.access_token,
refresh_token: t.refresh_token ?? null,
expires_in: t.expires_in ?? null,
account_email: email,
account_name: name,
};
}
case "todoist": {
const body = new URLSearchParams({
client_id: env.clientId,
client_secret: env.clientSecret,
code,
});
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) {
console.error(`todoist token exchange status=${res.status}`);
throw new Error("provider_error");
}
const t = await res.json() as { access_token: string };
let email: string | null = null;
let name: string | null = null;
try {
const me = await fetch("https://api.todoist.com/rest/v2/user", {
headers: { Authorization: `Bearer ${t.access_token}` },
});
if (me.ok) {
const u = await me.json() as { email?: string; full_name?: string };
email = u.email ?? null;
name = u.full_name ?? null;
}
} catch (_) { /* best effort */ }
return {
access_token: t.access_token,
refresh_token: null,
expires_in: null,
account_email: email,
account_name: name,
};
}
}
}
/**
* Refresh access_token usando refresh_token. NUNCA loga body.
*/
export async function refreshAccessToken(
slug: ProviderSlug,
refreshToken: string,
tokenUrl: string,
env: ProviderEnv,
): Promise<{ access_token: string; refresh_token?: string | null; expires_in?: number | null }> {
if (slug === "notion" || slug === "todoist") {
throw new Error("not_supported");
}
const body = new URLSearchParams({
client_id: env.clientId,
client_secret: env.clientSecret,
refresh_token: refreshToken,
grant_type: "refresh_token",
});
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (res.status === 400 || res.status === 401) {
console.error(`refresh ${slug} invalid_grant status=${res.status}`);
throw new Error("invalid_grant");
}
if (!res.ok) {
console.error(`refresh ${slug} status=${res.status}`);
throw new Error("provider_error");
}
const t = await res.json() as {
access_token: string;
refresh_token?: string;
expires_in?: number;
};
return {
access_token: t.access_token,
refresh_token: t.refresh_token ?? null,
expires_in: t.expires_in ?? null,
};
}
/**
* Faz uma chamada de validação leve (não consome refresh).
*/
export async function testProviderConnection(
slug: ProviderSlug,
accessToken: string,
): Promise<{ ok: boolean; status: number; account?: { email?: string; name?: string } }> {
const calls: Record<ProviderSlug, { url: string; headers?: Record<string, string> }> = {
google_workspace: {
url: "https://www.googleapis.com/oauth2/v2/userinfo",
},
notion: {
url: "https://api.notion.com/v1/users/me",
headers: { "Notion-Version": "2022-06-28" },
},
microsoft_365: {
url: "https://graph.microsoft.com/v1.0/me",
},
calcom: {
url: "https://api.cal.com/v2/me",
},
todoist: {
url: "https://api.todoist.com/rest/v2/user",
},
};
const cfg = calls[slug];
const res = await fetch(cfg.url, {
headers: {
Authorization: `Bearer ${accessToken}`,
...(cfg.headers ?? {}),
},
});
if (!res.ok) return { ok: false, status: res.status };
let account: { email?: string; name?: string } | undefined;
try {
const data = await res.json() as Record<string, unknown>;
const email = (data.email ?? data.mail ?? data.userPrincipalName ?? data.full_name) as string | undefined;
const name = (data.name ?? data.displayName ?? data.full_name) as string | undefined;
account = { email, name };
} catch (_) { /* ignore */ }
return { ok: true, status: res.status, account };
}
/**
* Revoga token no provider. Best-effort com 1 retry em timeout.
*/
export async function revokeToken(
slug: ProviderSlug,
accessToken: string,
revokeUrl: string | null,
): Promise<{ ok: boolean; status: number; serverError: boolean }> {
if (!revokeUrl || slug === "notion") {
return { ok: true, status: 200, serverError: false };
}
const doCall = async (): Promise<Response> => {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 8000);
try {
switch (slug) {
case "google_workspace": {
const u = new URL(revokeUrl);
u.searchParams.set("token", accessToken);
return await fetch(u.toString(), { method: "POST", signal: ctrl.signal });
}
case "todoist": {
// Todoist precisa client_id/secret + access_token no body
const clientId = Deno.env.get("TODOIST_CLIENT_ID") ?? "";
const clientSecret = Deno.env.get("TODOIST_CLIENT_SECRET") ?? "";
const body = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
access_token: accessToken,
});
return await fetch(revokeUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
signal: ctrl.signal,
});
}
case "microsoft_365":
case "calcom":
default: {
return await fetch(revokeUrl, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}` },
signal: ctrl.signal,
});
}
}
} finally {
clearTimeout(t);
}
};
try {
const res = await doCall();
const serverError = res.status >= 500;
if (serverError) {
// 1 retry
try {
const res2 = await doCall();
return { ok: res2.ok, status: res2.status, serverError: res2.status >= 500 };
} catch (_) {
return { ok: false, status: res.status, serverError: true };
}
}
return { ok: res.ok || res.status >= 400 && res.status < 500, status: res.status, serverError: false };
} catch (e) {
console.error(`revoke ${slug} error`, e instanceof Error ? e.message : "unknown");
// timeout — 1 retry
try {
const res2 = await doCall();
return { ok: res2.ok, status: res2.status, serverError: res2.status >= 500 };
} catch (_) {
return { ok: false, status: 0, serverError: true };
}
}
}

View file

@ -0,0 +1,175 @@
// disconnect-integration (autenticada JWT)
// Lógica:
// 1. Checa cronjobs dependentes — se houver e force_pause_jobs=false, retorna 409.
// 2. Se force_pause_jobs=true, pausa todos os jobs dependentes.
// 3. Revoga token no provider (1 retry em timeout).
// 4. Se revoke 5xx/timeout: NÃO deleta nada, marca status='error', retorna 503.
// 5. Sucesso: deleta secrets do Vault e a row de user_integrations.
//
// TODO Fase 5: notify Hermes container to invalidate MCP config after disconnect.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { corsHeaders } from "../_shared/cors.ts";
import { revokeToken, type ProviderSlug } from "../_shared/oauth-providers.ts";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
async function getDecryptedSecret(
admin: ReturnType<typeof createClient>,
secretId: string,
): Promise<string | null> {
const { data, error } = await admin
.rpc("vault_decrypt_secret", { secret_id: secretId })
.single();
if (!error && data) {
// deno-lint-ignore no-explicit-any
return (data as any).decrypted_secret ?? (data as unknown as string);
}
return null;
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
const authHeader = req.headers.get("Authorization") ?? "";
const userClient = createClient(supabaseUrl, anonKey, {
global: { headers: { Authorization: authHeader } },
});
const { data: userData, error: userErr } = await userClient.auth.getUser();
if (userErr || !userData.user) {
return jsonResponse({ error: "Não autenticado" }, 401);
}
const userId = userData.user.id;
const { integration_id, force_pause_jobs } = await req.json() as {
integration_id?: string;
force_pause_jobs?: boolean;
};
if (!integration_id) {
return jsonResponse({ error: "integration_id é obrigatório" }, 400);
}
const admin = createClient(supabaseUrl, serviceKey);
const { data: integ, error: iErr } = await admin
.from("user_integrations")
.select(
"id, user_id, access_token_vault_id, refresh_token_vault_id, mcp:available_mcps(slug, oauth_revoke_url)",
)
.eq("id", integration_id)
.eq("user_id", userId)
.maybeSingle();
if (iErr || !integ) {
return jsonResponse({ error: "Integração não encontrada" }, 404);
}
// deno-lint-ignore no-explicit-any
const mcp = (integ as any).mcp as { slug: string; oauth_revoke_url: string | null };
const slug = mcp.slug;
// 1. Checa dependências
const { data: dependentJobs } = await admin
.from("scheduled_jobs")
.select("id, name")
.eq("user_id", userId)
.neq("status", "archived")
.contains("required_mcp_slugs", [slug]);
if (dependentJobs && dependentJobs.length > 0 && !force_pause_jobs) {
return jsonResponse(
{
error: "has_dependencies",
dependent_jobs: dependentJobs,
},
409,
);
}
let pausedCount = 0;
if (dependentJobs && dependentJobs.length > 0 && force_pause_jobs) {
const { error: pErr } = await admin
.from("scheduled_jobs")
.update({
status: "paused",
auto_paused_reason: `Integração ${slug} foi desconectada`,
})
.eq("user_id", userId)
.neq("status", "archived")
.contains("required_mcp_slugs", [slug]);
if (!pErr) pausedCount = dependentJobs.length;
}
// 2. Busca tokens ANTES de qualquer delete
const accessToken = integ.access_token_vault_id
? await getDecryptedSecret(admin, integ.access_token_vault_id)
: null;
// 3. Revoga no provider
if (accessToken) {
const revokeResult = await revokeToken(
slug as ProviderSlug,
accessToken,
mcp.oauth_revoke_url,
);
if (revokeResult.serverError) {
// NÃO deleta nada. Marca como erro.
await admin
.from("user_integrations")
.update({
status: "error",
error_message: "Revoke falhou no provider. Tente novamente em alguns segundos.",
})
.eq("id", integration_id);
return jsonResponse(
{ error: "revoke_failed", message: "O provider está indisponível. Tente novamente em instantes." },
503,
);
}
}
// 4. Sucesso: deleta secrets e row
const vaultIds = [integ.access_token_vault_id, integ.refresh_token_vault_id]
.filter((id): id is string => Boolean(id));
for (const vid of vaultIds) {
try {
await admin.rpc("vault_delete_secret", { secret_id: vid });
} catch (e) {
console.warn("vault_delete_secret ignored", e instanceof Error ? e.message : "unknown");
}
}
const { error: dErr } = await admin
.from("user_integrations")
.delete()
.eq("id", integration_id);
if (dErr) {
console.error("delete user_integrations error", dErr.message);
return jsonResponse({ error: "Falha ao remover integração" }, 500);
}
// TODO Fase 5: notify Hermes container that MCP was disconnected
return jsonResponse({ success: true, paused_jobs_count: pausedCount });
} catch (err) {
console.error("disconnect-integration fatal", err instanceof Error ? err.message : "unknown");
return jsonResponse(
{ error: err instanceof Error ? err.message : "Erro inesperado" },
500,
);
}
});

View file

@ -0,0 +1,184 @@
// oauth-callback (PÚBLICA — verify_jwt = false)
// Recebe code & state via GET query params, troca por tokens, salva no Vault, redireciona para o painel.
//
// CRÍTICO: NUNCA logar response body de troca de tokens. Apenas status HTTP.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import {
exchangeCodeForTokens,
getProviderEnv,
type ProviderSlug,
} from "../_shared/oauth-providers.ts";
function siteUrl(): string {
return Deno.env.get("SITE_URL") ?? "https://798b89e5-0dc6-412a-81be-a4b6dfea7b6c.lovable.app";
}
function redirect(path: string): Response {
return new Response(null, {
status: 302,
headers: { Location: `${siteUrl()}${path}` },
});
}
Deno.serve(async (req) => {
const url = new URL(req.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const oauthError = url.searchParams.get("error");
if (oauthError) {
console.error(`provider returned error=${oauthError}`);
return redirect(`/painel/integracoes?error=${encodeURIComponent(oauthError)}`);
}
if (!code || !state) {
return redirect("/painel/integracoes?error=missing_params");
}
try {
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const admin = createClient(supabaseUrl, serviceKey);
// 1. Consome state atomicamente (previne replay)
const { data: stateRow, error: stErr } = await admin
.from("oauth_state_tokens")
.update({ consumed: true })
.eq("state_token", state)
.eq("consumed", false)
.gt("expires_at", new Date().toISOString())
.select("user_id, mcp_id")
.maybeSingle();
if (stErr || !stateRow) {
console.error("invalid_state", stErr?.message);
return redirect("/painel/integracoes?error=invalid_state");
}
// 2. Busca MCP
const { data: mcp } = await admin
.from("available_mcps")
.select("id, slug, oauth_token_url, supports_refresh_token")
.eq("id", stateRow.mcp_id)
.maybeSingle();
if (!mcp) {
return redirect("/painel/integracoes?error=mcp_not_found");
}
const slug = mcp.slug as ProviderSlug;
const redirectUri = `${supabaseUrl}/functions/v1/oauth-callback`;
// 3. Troca code por tokens
let tokens;
try {
const env = getProviderEnv(slug, redirectUri);
tokens = await exchangeCodeForTokens(slug, code, mcp.oauth_token_url, env);
} catch (e) {
const msg = e instanceof Error ? e.message : "provider_error";
return redirect(`/painel/integracoes?error=${encodeURIComponent(msg)}`);
}
// 4. Busca integração existente para preservar refresh_token antigo se necessário
const { data: existing } = await admin
.from("user_integrations")
.select("id, access_token_vault_id, refresh_token_vault_id")
.eq("user_id", stateRow.user_id)
.eq("mcp_id", mcp.id)
.maybeSingle();
// 5. Salva access_token no Vault
const ts = Math.floor(Date.now() / 1000);
const { data: accessVault, error: accessErr } = await admin
.rpc("vault_create_secret", {
secret_value: tokens.access_token,
secret_name: `oauth_access_${stateRow.user_id}_${slug}_${ts}`,
secret_description: `OAuth access token (${slug})`,
})
.single();
if (accessErr || !accessVault) {
console.error("vault access error", accessErr?.message);
return redirect("/painel/integracoes?error=vault_error");
}
const accessVaultId = (accessVault as { secret_id: string }).secret_id;
// 6. Refresh token: salva novo OU preserva antigo no caso Google sem refresh retornado
let refreshVaultId: string | null = null;
if (tokens.refresh_token) {
const { data: rv, error: rErr } = await admin
.rpc("vault_create_secret", {
secret_value: tokens.refresh_token,
secret_name: `oauth_refresh_${stateRow.user_id}_${slug}_${ts}`,
secret_description: `OAuth refresh token (${slug})`,
})
.single();
if (rErr || !rv) {
console.error("vault refresh error", rErr?.message);
} else {
refreshVaultId = (rv as { secret_id: string }).secret_id;
}
// Se reconexão e tinha refresh antigo, deletar
if (existing?.refresh_token_vault_id) {
try {
await admin.rpc("vault_delete_secret", {
secret_id: existing.refresh_token_vault_id,
});
} catch (_) { /* ignore */ }
}
} else if (existing?.refresh_token_vault_id) {
// Preserva refresh antigo (caso típico Google sem prompt=consent re-emitir)
refreshVaultId = existing.refresh_token_vault_id;
}
// 7. Deleta access_token antigo
if (existing?.access_token_vault_id) {
try {
await admin.rpc("vault_delete_secret", {
secret_id: existing.access_token_vault_id,
});
} catch (_) { /* ignore */ }
}
const expiresAt = tokens.expires_in
? new Date(Date.now() + tokens.expires_in * 1000).toISOString()
: null;
// 8. Upsert user_integrations
const { error: upErr } = await admin
.from("user_integrations")
.upsert(
{
user_id: stateRow.user_id,
mcp_id: mcp.id,
status: "active",
access_token_vault_id: accessVaultId,
refresh_token_vault_id: refreshVaultId,
token_expires_at: expiresAt,
connected_account_email: tokens.account_email,
connected_account_name: tokens.account_name,
granted_scopes: tokens.granted_scopes ?? [],
error_message: null,
last_refreshed_at: new Date().toISOString(),
},
{ onConflict: "user_id,mcp_id" },
);
if (upErr) {
console.error("user_integrations upsert error", upErr.message, upErr.code);
// Mapeia códigos de erro do trigger
const code = upErr.code;
if (code === "P0001") return redirect("/painel/integracoes?error=no_subscription");
if (code === "P0002") return redirect("/painel/integracoes?error=limit_reached");
if (code === "P0003") return redirect("/painel/integracoes?error=plan_not_allowed");
if (code === "P0005") return redirect("/painel/integracoes?error=agent_inactive");
return redirect("/painel/integracoes?error=db_error");
}
// TODO Fase 5: notify Hermes container that MCP is now available
return redirect(`/painel/integracoes?status=success&mcp=${encodeURIComponent(slug)}`);
} catch (err) {
console.error("oauth-callback fatal", err instanceof Error ? err.message : "unknown");
return redirect("/painel/integracoes?error=internal_error");
}
});

View file

@ -0,0 +1,152 @@
// oauth-start (autenticada via JWT)
// Recebe { mcp_slug }, valida plano e retorna { auth_url } para o frontend redirecionar.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { corsHeaders } from "../_shared/cors.ts";
import {
buildAuthorizeUrl,
getProviderEnv,
type ProviderSlug,
} from "../_shared/oauth-providers.ts";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
function generateStateToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
const authHeader = req.headers.get("Authorization") ?? "";
const userClient = createClient(supabaseUrl, anonKey, {
global: { headers: { Authorization: authHeader } },
});
const { data: userData, error: userErr } = await userClient.auth.getUser();
if (userErr || !userData.user) {
return jsonResponse({ error: "Não autenticado" }, 401);
}
const userId = userData.user.id;
const { mcp_slug } = await req.json() as { mcp_slug?: string };
if (!mcp_slug) {
return jsonResponse({ error: "mcp_slug é obrigatório" }, 400);
}
const admin = createClient(supabaseUrl, serviceKey);
// Busca MCP
const { data: mcp, error: mcpErr } = await admin
.from("available_mcps")
.select("id, slug, oauth_authorize_url, required_scopes, available_in_plans, is_active")
.eq("slug", mcp_slug)
.maybeSingle();
if (mcpErr || !mcp || !mcp.is_active) {
return jsonResponse({ error: "Integração não encontrada" }, 404);
}
// Valida agente ativo
const { data: agent } = await admin
.from("agent_instances")
.select("status")
.eq("user_id", userId)
.maybeSingle();
if (!agent || agent.status !== "active") {
return jsonResponse(
{ error: "agent_inactive", message: "Seu agente ainda não está ativo. Complete o onboarding." },
403,
);
}
// Valida plano
const { data: limits } = await admin
.from("user_integration_limits")
.select("plan_slug, max_integrations, current_integrations_count")
.eq("user_id", userId)
.maybeSingle();
if (!limits || !limits.plan_slug || !limits.max_integrations) {
return jsonResponse(
{ error: "no_subscription", message: "Você precisa de uma assinatura ativa." },
403,
);
}
const availableIn = (mcp.available_in_plans as string[]) ?? [];
if (!availableIn.includes(limits.plan_slug)) {
return jsonResponse(
{
error: "plan_not_allowed",
message: `A integração ${mcp.slug} não está disponível no plano ${limits.plan_slug}.`,
},
403,
);
}
// Verifica se já não atingiu limite (evita iniciar fluxo que vai falhar)
if (
(limits.current_integrations_count ?? 0) >= limits.max_integrations
) {
// Permite reconectar uma existente; mas se for nova vai falhar.
const { data: existing } = await admin
.from("user_integrations")
.select("id")
.eq("user_id", userId)
.eq("mcp_id", mcp.id)
.maybeSingle();
if (!existing) {
return jsonResponse(
{ error: "limit_reached", message: "Você atingiu o limite de integrações do seu plano." },
403,
);
}
}
// Gera state token
const stateToken = generateStateToken();
const { error: stErr } = await admin.from("oauth_state_tokens").insert({
state_token: stateToken,
user_id: userId,
mcp_id: mcp.id,
});
if (stErr) {
console.error("oauth_state insert error", stErr);
return jsonResponse({ error: "Falha ao iniciar OAuth" }, 500);
}
// Monta URL de autorização
const redirectUri = `${supabaseUrl}/functions/v1/oauth-callback`;
const env = getProviderEnv(mcp.slug as ProviderSlug, redirectUri);
const authUrl = buildAuthorizeUrl(
mcp.slug as ProviderSlug,
mcp.oauth_authorize_url,
(mcp.required_scopes as string[]) ?? [],
stateToken,
env,
);
return jsonResponse({ auth_url: authUrl });
} catch (err) {
console.error("oauth-start fatal", err instanceof Error ? err.message : "unknown");
return jsonResponse(
{ error: err instanceof Error ? err.message : "Erro inesperado" },
500,
);
}
});

View file

@ -0,0 +1,184 @@
// refresh-integration-token (autenticada JWT)
// Renova access_token usando refresh_token. Marca como 'revoked' se invalid_grant.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { corsHeaders } from "../_shared/cors.ts";
import {
getProviderEnv,
type ProviderSlug,
refreshAccessToken,
} from "../_shared/oauth-providers.ts";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
async function getDecryptedSecret(
admin: ReturnType<typeof createClient>,
secretId: string,
): Promise<string | null> {
const { data, error } = await admin
.rpc("vault_decrypt_secret", { secret_id: secretId })
.single();
if (!error && data) {
// deno-lint-ignore no-explicit-any
return (data as any).decrypted_secret ?? (data as unknown as string);
}
return null;
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
const authHeader = req.headers.get("Authorization") ?? "";
const userClient = createClient(supabaseUrl, anonKey, {
global: { headers: { Authorization: authHeader } },
});
const { data: userData, error: userErr } = await userClient.auth.getUser();
if (userErr || !userData.user) {
return jsonResponse({ error: "Não autenticado" }, 401);
}
const userId = userData.user.id;
const { integration_id } = await req.json() as { integration_id?: string };
if (!integration_id) {
return jsonResponse({ error: "integration_id é obrigatório" }, 400);
}
const admin = createClient(supabaseUrl, serviceKey);
const { data: integ, error: iErr } = await admin
.from("user_integrations")
.select(
"id, user_id, mcp_id, access_token_vault_id, refresh_token_vault_id, mcp:available_mcps(slug, oauth_token_url, supports_refresh_token)",
)
.eq("id", integration_id)
.eq("user_id", userId)
.maybeSingle();
if (iErr || !integ) {
return jsonResponse({ error: "Integração não encontrada" }, 404);
}
// deno-lint-ignore no-explicit-any
const mcp = (integ as any).mcp as { slug: string; oauth_token_url: string; supports_refresh_token: boolean };
if (!mcp.supports_refresh_token) {
return jsonResponse(
{ error: "Provider não suporta refresh token" },
400,
);
}
if (!integ.refresh_token_vault_id) {
return jsonResponse(
{ error: "Sem refresh token salvo. Reconecte a integração." },
400,
);
}
const refreshToken = await getDecryptedSecret(admin, integ.refresh_token_vault_id);
if (!refreshToken) {
return jsonResponse({ error: "Falha ao ler refresh token do Vault" }, 500);
}
const slug = mcp.slug as ProviderSlug;
const redirectUri = `${supabaseUrl}/functions/v1/oauth-callback`;
const env = getProviderEnv(slug, redirectUri);
let result;
try {
result = await refreshAccessToken(slug, refreshToken, mcp.oauth_token_url, env);
} catch (e) {
const msg = e instanceof Error ? e.message : "unknown";
if (msg === "invalid_grant") {
await admin
.from("user_integrations")
.update({
status: "revoked",
error_message: "Refresh token revogado pelo provider. Reconecte a integração.",
})
.eq("id", integration_id);
return jsonResponse({ error: "Refresh token revogado. Reconecte." }, 401);
}
return jsonResponse({ error: "Falha ao renovar token" }, 502);
}
// Salva novo access_token no Vault, deleta antigo
const ts = Math.floor(Date.now() / 1000);
const { data: newAccess, error: vErr } = await admin
.rpc("vault_create_secret", {
secret_value: result.access_token,
secret_name: `oauth_access_${userId}_${slug}_${ts}`,
secret_description: `OAuth access token (${slug}) refreshed`,
})
.single();
if (vErr || !newAccess) {
return jsonResponse({ error: "Falha ao salvar novo token" }, 500);
}
const newAccessId = (newAccess as { secret_id: string }).secret_id;
if (integ.access_token_vault_id) {
try {
await admin.rpc("vault_delete_secret", {
secret_id: integ.access_token_vault_id,
});
} catch (_) { /* ignore */ }
}
// Se provider rotou refresh_token, salva novo
let newRefreshId = integ.refresh_token_vault_id;
if (result.refresh_token && result.refresh_token !== refreshToken) {
const { data: nrv } = await admin
.rpc("vault_create_secret", {
secret_value: result.refresh_token,
secret_name: `oauth_refresh_${userId}_${slug}_${ts}`,
secret_description: `OAuth refresh token (${slug}) rotated`,
})
.single();
if (nrv) {
const id = (nrv as { secret_id: string }).secret_id;
try {
await admin.rpc("vault_delete_secret", {
secret_id: integ.refresh_token_vault_id,
});
} catch (_) { /* ignore */ }
newRefreshId = id;
}
}
const expiresAt = result.expires_in
? new Date(Date.now() + result.expires_in * 1000).toISOString()
: null;
await admin
.from("user_integrations")
.update({
status: "active",
access_token_vault_id: newAccessId,
refresh_token_vault_id: newRefreshId,
token_expires_at: expiresAt,
last_refreshed_at: new Date().toISOString(),
error_message: null,
})
.eq("id", integration_id);
return jsonResponse({ success: true, expires_at: expiresAt });
} catch (err) {
console.error("refresh-integration-token fatal", err instanceof Error ? err.message : "unknown");
return jsonResponse(
{ error: err instanceof Error ? err.message : "Erro inesperado" },
500,
);
}
});