mika-agent-assist/supabase/functions/oauth-callback/index.ts

210 lines
7.3 KiB
TypeScript

// 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";
import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts";
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
function siteUrl(): string {
return (Deno.env.get("SITE_URL") ?? "https://mika.domco.ai").replace(/\/$/, "");
}
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");
}
const { data: agent } = await admin
.from("agent_instances")
.select("id")
.eq("user_id", stateRow.user_id)
.maybeSingle();
if (agent?.id) {
try {
await syncAgentRuntimeSnapshot({
supabase: admin,
agentInstanceId: agent.id,
railwayToken: RAILWAY_API_TOKEN,
apiKey: HERMES_API_SERVER_KEY,
scope: "all",
});
} catch (syncErr) {
console.error(
"oauth-callback runtime sync warning",
syncErr instanceof Error ? syncErr.message : String(syncErr),
);
}
}
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");
}
});