diff --git a/supabase/config.toml b/supabase/config.toml index dea520e..0c8771a 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -26,3 +26,15 @@ verify_jwt = true [functions.telegram-webhook] verify_jwt = false + +[functions.oauth-start] +verify_jwt = true + +[functions.oauth-callback] +verify_jwt = false + +[functions.refresh-integration-token] +verify_jwt = true + +[functions.disconnect-integration] +verify_jwt = true diff --git a/supabase/functions/_shared/oauth-providers.ts b/supabase/functions/_shared/oauth-providers.ts new file mode 100644 index 0000000..677adc9 --- /dev/null +++ b/supabase/functions/_shared/oauth-providers.ts @@ -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 = { + 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 { + 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 }> = { + 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; + 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 => { + 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 }; + } + } +} diff --git a/supabase/functions/disconnect-integration/index.ts b/supabase/functions/disconnect-integration/index.ts new file mode 100644 index 0000000..4760b5b --- /dev/null +++ b/supabase/functions/disconnect-integration/index.ts @@ -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, + secretId: string, +): Promise { + 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, + ); + } +}); diff --git a/supabase/functions/oauth-callback/index.ts b/supabase/functions/oauth-callback/index.ts new file mode 100644 index 0000000..17d0d2d --- /dev/null +++ b/supabase/functions/oauth-callback/index.ts @@ -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"); + } +}); diff --git a/supabase/functions/oauth-start/index.ts b/supabase/functions/oauth-start/index.ts new file mode 100644 index 0000000..0fa9be6 --- /dev/null +++ b/supabase/functions/oauth-start/index.ts @@ -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, + ); + } +}); diff --git a/supabase/functions/refresh-integration-token/index.ts b/supabase/functions/refresh-integration-token/index.ts new file mode 100644 index 0000000..5cd6a04 --- /dev/null +++ b/supabase/functions/refresh-integration-token/index.ts @@ -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, + secretId: string, +): Promise { + 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, + ); + } +});