mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 15:56:50 +00:00
Criou edge functions OAuth
X-Lovable-Edit-ID: edt-96ff5915-01c0-476b-9f35-6ec42fbb3c97 Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
commit
6078ed674c
6 changed files with 1177 additions and 0 deletions
|
|
@ -26,3 +26,15 @@ verify_jwt = true
|
||||||
|
|
||||||
[functions.telegram-webhook]
|
[functions.telegram-webhook]
|
||||||
verify_jwt = false
|
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
|
||||||
|
|
|
||||||
470
supabase/functions/_shared/oauth-providers.ts
Normal file
470
supabase/functions/_shared/oauth-providers.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
supabase/functions/disconnect-integration/index.ts
Normal file
175
supabase/functions/disconnect-integration/index.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
184
supabase/functions/oauth-callback/index.ts
Normal file
184
supabase/functions/oauth-callback/index.ts
Normal 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
152
supabase/functions/oauth-start/index.ts
Normal file
152
supabase/functions/oauth-start/index.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
184
supabase/functions/refresh-integration-token/index.ts
Normal file
184
supabase/functions/refresh-integration-token/index.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue