mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 16:16:50 +00:00
470 lines
14 KiB
TypeScript
470 lines
14 KiB
TypeScript
// 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 };
|
|
}
|
|
}
|
|
}
|