mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 07:36:41 +00:00
570 lines
17 KiB
TypeScript
570 lines
17 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;
|
|
}
|
|
|
|
function sanitizeProviderLogValue(value: unknown): string | null {
|
|
if (typeof value !== "string") return null;
|
|
const normalized = value.replace(/\s+/g, " ").trim();
|
|
if (!normalized) return null;
|
|
|
|
return normalized
|
|
.replace(/[A-Za-z0-9_-]{24,}/g, "[redacted]")
|
|
.slice(0, 220);
|
|
}
|
|
|
|
async function logOAuthHttpFailure(
|
|
label: string,
|
|
res: Response,
|
|
tokenUrl: string,
|
|
redirectUri?: string,
|
|
): Promise<void> {
|
|
let errorCode: string | null = null;
|
|
let errorDescription: string | null = null;
|
|
|
|
try {
|
|
const contentType = res.headers.get("content-type") ?? "";
|
|
if (contentType.includes("application/json")) {
|
|
const payload = await res.json() as {
|
|
error?: unknown;
|
|
error_description?: unknown;
|
|
error_uri?: unknown;
|
|
};
|
|
errorCode = sanitizeProviderLogValue(payload.error);
|
|
errorDescription = sanitizeProviderLogValue(payload.error_description ?? payload.error_uri);
|
|
} else {
|
|
errorDescription = sanitizeProviderLogValue(await res.text());
|
|
}
|
|
} catch (_) {
|
|
errorDescription = "unreadable_error_payload";
|
|
}
|
|
|
|
let tokenHost = "invalid_token_url";
|
|
try {
|
|
tokenHost = new URL(tokenUrl).host;
|
|
} catch (_) { /* keep fallback */ }
|
|
|
|
console.error(
|
|
[
|
|
`${label} status=${res.status}`,
|
|
`error=${errorCode ?? "unknown"}`,
|
|
`description=${errorDescription ?? "unavailable"}`,
|
|
`token_host=${tokenHost}`,
|
|
redirectUri ? `redirect_uri=${redirectUri}` : null,
|
|
].filter(Boolean).join(" "),
|
|
);
|
|
}
|
|
|
|
interface EnvLookupResult {
|
|
key: string | null;
|
|
value: string;
|
|
trimmed: boolean;
|
|
}
|
|
|
|
function readFirstEnv(keys: string[]): EnvLookupResult {
|
|
for (const key of keys) {
|
|
const value = Deno.env.get(key);
|
|
if (!value) continue;
|
|
|
|
const trimmed = value.trim();
|
|
if (trimmed) return { key, value: trimmed, trimmed: trimmed !== value };
|
|
}
|
|
|
|
return { key: null, value: "", trimmed: false };
|
|
}
|
|
|
|
export function getProviderEnv(slug: ProviderSlug, redirectUri: string): ProviderEnv {
|
|
const map: Record<ProviderSlug, { id: string[]; secret: string[] }> = {
|
|
google_workspace: {
|
|
id: ["GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_CLIENT_ID"],
|
|
secret: ["GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_CLIENT_SECRET"],
|
|
},
|
|
notion: {
|
|
id: ["NOTION_OAUTH_CLIENT_ID", "NOTION_CLIENT_ID"],
|
|
secret: ["NOTION_OAUTH_CLIENT_SECRET", "NOTION_CLIENT_SECRET"],
|
|
},
|
|
todoist: {
|
|
id: ["TODOIST_OAUTH_CLIENT_ID", "TODOIST_CLIENT_ID"],
|
|
secret: ["TODOIST_OAUTH_CLIENT_SECRET", "TODOIST_CLIENT_SECRET"],
|
|
},
|
|
calcom: {
|
|
id: ["CALCOM_OAUTH_CLIENT_ID", "CALCOM_CLIENT_ID"],
|
|
secret: ["CALCOM_OAUTH_CLIENT_SECRET", "CALCOM_CLIENT_SECRET"],
|
|
},
|
|
microsoft_365: {
|
|
id: ["MICROSOFT_OAUTH_CLIENT_ID", "MICROSOFT_CLIENT_ID"],
|
|
secret: ["MICROSOFT_OAUTH_CLIENT_SECRET", "MICROSOFT_CLIENT_SECRET"],
|
|
},
|
|
};
|
|
const keys = map[slug];
|
|
const clientId = readFirstEnv(keys.id);
|
|
const clientSecret = readFirstEnv(keys.secret);
|
|
if (!clientId.value || !clientSecret.value) {
|
|
throw new Error(
|
|
`Credenciais OAuth ausentes para ${slug} (${keys.id.join(" ou ")}/${keys.secret.join(" ou ")})`,
|
|
);
|
|
}
|
|
|
|
console.info(
|
|
[
|
|
`oauth env selected provider=${slug}`,
|
|
`client_id_key=${clientId.key ?? "missing"}`,
|
|
`client_secret_key=${clientSecret.key ?? "missing"}`,
|
|
`client_id_trimmed=${String(clientId.trimmed)}`,
|
|
`client_secret_trimmed=${String(clientSecret.trimmed)}`,
|
|
].join(" "),
|
|
);
|
|
|
|
return { clientId: clientId.value, clientSecret: clientSecret.value, 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 bruto nem tokens.
|
|
*/
|
|
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) {
|
|
await logOAuthHttpFailure("google token exchange", res, tokenUrl, env.redirectUri);
|
|
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) {
|
|
await logOAuthHttpFailure("notion token exchange", res, tokenUrl, env.redirectUri);
|
|
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) {
|
|
await logOAuthHttpFailure("microsoft token exchange", res, tokenUrl, env.redirectUri);
|
|
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) {
|
|
await logOAuthHttpFailure("calcom token exchange", res, tokenUrl, env.redirectUri);
|
|
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) {
|
|
await logOAuthHttpFailure("todoist token exchange", res, tokenUrl);
|
|
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 bruto nem tokens.
|
|
*/
|
|
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) {
|
|
await logOAuthHttpFailure(`refresh ${slug} invalid_grant`, res, tokenUrl);
|
|
throw new Error("invalid_grant");
|
|
}
|
|
if (!res.ok) {
|
|
await logOAuthHttpFailure(`refresh ${slug}`, res, tokenUrl);
|
|
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 = readFirstEnv(["TODOIST_OAUTH_CLIENT_ID", "TODOIST_CLIENT_ID"]).value;
|
|
const clientSecret = readFirstEnv(["TODOIST_OAUTH_CLIENT_SECRET", "TODOIST_CLIENT_SECRET"]).value;
|
|
|
|
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 };
|
|
}
|
|
}
|
|
}
|