mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 11:16:46 +00:00
Changes
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
parent
d18e1ad27c
commit
dd9f97cf79
6 changed files with 1177 additions and 0 deletions
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue