mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
1080 lines
32 KiB
TypeScript
1080 lines
32 KiB
TypeScript
/// <reference types="chrome" />
|
|
import type { OAuthTokens } from "../../../services/oauthService";
|
|
import type { Booking } from "../../../services/types/bookings.types";
|
|
|
|
const DEV_API_KEY = import.meta.env.EXPO_PUBLIC_CAL_API_KEY as string | undefined;
|
|
const IS_DEV_MODE = Boolean(DEV_API_KEY && DEV_API_KEY.length > 0);
|
|
const BROWSER_TARGET = import.meta.env.BROWSER_TARGET || "chrome";
|
|
|
|
const devLog = {
|
|
log: (...args: unknown[]) => IS_DEV_MODE && console.log("[Cal.com]", ...args),
|
|
warn: (...args: unknown[]) => IS_DEV_MODE && console.warn("[Cal.com]", ...args),
|
|
error: (...args: unknown[]) => console.error("[Cal.com]", ...args),
|
|
};
|
|
|
|
/**
|
|
* Request timeout in milliseconds (30 seconds)
|
|
*/
|
|
const REQUEST_TIMEOUT_MS = 30000;
|
|
|
|
/**
|
|
* Fetch with timeout to prevent hanging requests
|
|
*/
|
|
async function fetchWithTimeout(
|
|
url: string,
|
|
options: RequestInit = {},
|
|
timeoutMs: number = REQUEST_TIMEOUT_MS
|
|
): Promise<Response> {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
...options,
|
|
signal: controller.signal,
|
|
});
|
|
return response;
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Safely parse JSON with validation for OAuthTokens
|
|
*/
|
|
function safeParseOAuthTokens(jsonString: string): OAuthTokens | null {
|
|
if (typeof jsonString !== "string" || !jsonString.trim()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parsed: unknown = JSON.parse(jsonString);
|
|
|
|
// Validate it's a plain object (not null, array, or primitive)
|
|
if (
|
|
parsed === null ||
|
|
typeof parsed !== "object" ||
|
|
Array.isArray(parsed) ||
|
|
Object.getPrototypeOf(parsed) !== Object.prototype
|
|
) {
|
|
devLog.warn("Invalid OAuth tokens structure - not a plain object");
|
|
return null;
|
|
}
|
|
|
|
const obj = parsed as Record<string, unknown>;
|
|
|
|
// Validate required fields: accessToken and tokenType must be strings
|
|
if (typeof obj.accessToken !== "string") {
|
|
devLog.warn("Invalid OAuth tokens structure - missing accessToken");
|
|
return null;
|
|
}
|
|
|
|
if (typeof obj.tokenType !== "string") {
|
|
devLog.warn("Invalid OAuth tokens structure - missing tokenType");
|
|
return null;
|
|
}
|
|
|
|
// Validate optional fields have correct types if present
|
|
if (obj.refreshToken !== undefined && typeof obj.refreshToken !== "string") {
|
|
devLog.warn("Invalid OAuth tokens structure - invalid refreshToken type");
|
|
return null;
|
|
}
|
|
|
|
if (obj.expiresAt !== undefined && typeof obj.expiresAt !== "number") {
|
|
devLog.warn("Invalid OAuth tokens structure - invalid expiresAt type");
|
|
return null;
|
|
}
|
|
|
|
if (obj.scope !== undefined && typeof obj.scope !== "string") {
|
|
devLog.warn("Invalid OAuth tokens structure - invalid scope type");
|
|
return null;
|
|
}
|
|
|
|
// Construct validated OAuthTokens object
|
|
const tokens: OAuthTokens = {
|
|
accessToken: obj.accessToken,
|
|
tokenType: obj.tokenType,
|
|
};
|
|
|
|
if (typeof obj.refreshToken === "string") {
|
|
tokens.refreshToken = obj.refreshToken;
|
|
}
|
|
|
|
if (typeof obj.expiresAt === "number") {
|
|
tokens.expiresAt = obj.expiresAt;
|
|
}
|
|
|
|
if (typeof obj.scope === "string") {
|
|
tokens.scope = obj.scope;
|
|
}
|
|
|
|
return tokens;
|
|
} catch (error) {
|
|
devLog.warn("Failed to parse OAuth tokens:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Safely parse JSON error response with structure validation.
|
|
* Validates the expected error response structure before returning.
|
|
*/
|
|
function safeParseErrorJson(
|
|
jsonString: string
|
|
): { error?: { message?: string }; message?: string } | null {
|
|
if (typeof jsonString !== "string" || !jsonString.trim()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parsed: unknown = JSON.parse(jsonString);
|
|
|
|
// Validate it's a plain object
|
|
if (
|
|
parsed === null ||
|
|
typeof parsed !== "object" ||
|
|
Array.isArray(parsed) ||
|
|
Object.getPrototypeOf(parsed) !== Object.prototype
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const obj = parsed as Record<string, unknown>;
|
|
|
|
// Validate expected error structure - must have error or message property with correct types
|
|
const hasValidErrorProp =
|
|
obj.error === undefined ||
|
|
(typeof obj.error === "object" &&
|
|
obj.error !== null &&
|
|
!Array.isArray(obj.error) &&
|
|
((obj.error as Record<string, unknown>).message === undefined ||
|
|
typeof (obj.error as Record<string, unknown>).message === "string"));
|
|
|
|
const hasValidMessageProp = obj.message === undefined || typeof obj.message === "string";
|
|
|
|
if (hasValidErrorProp && hasValidMessageProp) {
|
|
return obj as { error?: { message?: string }; message?: string };
|
|
}
|
|
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Browser type enum (inlined to avoid import issues in service worker)
|
|
*/
|
|
enum BrowserType {
|
|
Chrome = "chrome",
|
|
Firefox = "firefox",
|
|
Safari = "safari",
|
|
Edge = "edge",
|
|
Brave = "brave",
|
|
Unknown = "unknown",
|
|
}
|
|
|
|
/**
|
|
* Detect browser type based on user agent and APIs
|
|
*/
|
|
function detectBrowser(): BrowserType {
|
|
if (typeof navigator === "undefined") {
|
|
return BrowserType.Unknown;
|
|
}
|
|
|
|
const userAgent = navigator.userAgent.toLowerCase();
|
|
|
|
// Check for Brave
|
|
if (navigator.brave && typeof navigator.brave.isBrave === "function") {
|
|
return BrowserType.Brave;
|
|
}
|
|
|
|
// Check for Edge
|
|
if (userAgent.includes("edg/")) {
|
|
return BrowserType.Edge;
|
|
}
|
|
|
|
// Check for Firefox
|
|
if (userAgent.includes("firefox")) {
|
|
return BrowserType.Firefox;
|
|
}
|
|
|
|
// Check for Safari
|
|
if (
|
|
userAgent.includes("safari") &&
|
|
!userAgent.includes("chrome") &&
|
|
!userAgent.includes("chromium")
|
|
) {
|
|
return BrowserType.Safari;
|
|
}
|
|
|
|
// Default to Chrome
|
|
if (userAgent.includes("chrome") || userAgent.includes("chromium")) {
|
|
return BrowserType.Chrome;
|
|
}
|
|
|
|
return BrowserType.Unknown;
|
|
}
|
|
|
|
/**
|
|
* Get browser display name for logging
|
|
*/
|
|
function getBrowserDisplayName(): string {
|
|
const browser = detectBrowser();
|
|
switch (browser) {
|
|
case BrowserType.Chrome:
|
|
return "Chrome";
|
|
case BrowserType.Firefox:
|
|
return "Firefox";
|
|
case BrowserType.Safari:
|
|
return "Safari";
|
|
case BrowserType.Edge:
|
|
return "Microsoft Edge";
|
|
case BrowserType.Brave:
|
|
return "Brave";
|
|
default:
|
|
return "Unknown Browser";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cross-browser API helpers
|
|
* These provide unified access to browser extension APIs across Chrome, Firefox, Safari, and Edge
|
|
*/
|
|
|
|
/**
|
|
* Extended browser API interface that includes browserAction (Manifest V2)
|
|
* Safari uses browserAction instead of action
|
|
*/
|
|
interface BrowserAPIWithLegacyAction {
|
|
action?: typeof chrome.action;
|
|
browserAction?: typeof chrome.action;
|
|
}
|
|
|
|
// Get the appropriate browser API namespace
|
|
function getBrowserAPI(): typeof chrome {
|
|
if (typeof browser !== "undefined" && browser?.runtime) {
|
|
return browser;
|
|
}
|
|
return chrome;
|
|
}
|
|
|
|
// Get identity API with cross-browser support
|
|
function getIdentityAPI(): typeof chrome.identity | null {
|
|
const api = getBrowserAPI();
|
|
return api?.identity || null;
|
|
}
|
|
|
|
// Get storage API with cross-browser support
|
|
function getStorageAPI(): typeof chrome.storage | null {
|
|
const api = getBrowserAPI();
|
|
return api?.storage || null;
|
|
}
|
|
|
|
// Get runtime API with cross-browser support
|
|
function getRuntimeAPI(): typeof chrome.runtime | null {
|
|
const api = getBrowserAPI();
|
|
return api?.runtime || null;
|
|
}
|
|
|
|
// Get tabs API with cross-browser support
|
|
function getTabsAPI(): typeof chrome.tabs | null {
|
|
const api = getBrowserAPI();
|
|
return api?.tabs || null;
|
|
}
|
|
|
|
// Get action API with cross-browser support
|
|
function getActionAPI(): typeof chrome.action | null {
|
|
const api = getBrowserAPI();
|
|
// Safari uses browserAction (Manifest V2), Chrome uses action (Manifest V3)
|
|
const apiWithLegacyAction = api as unknown as BrowserAPIWithLegacyAction;
|
|
return api?.action || apiWithLegacyAction?.browserAction || null;
|
|
}
|
|
|
|
// Check if the URL is a restricted page where content scripts can't run
|
|
function isRestrictedUrl(url: string | undefined): boolean {
|
|
if (!url) return true;
|
|
|
|
// List of restricted URL patterns for all supported browsers
|
|
const restrictedPatterns = [
|
|
/^chrome:\/\//i, // Chrome internal pages (newtab, settings, extensions, etc.)
|
|
/^chrome-extension:\/\//i, // Chrome extension pages
|
|
/^edge:\/\//i, // Edge internal pages
|
|
/^about:/i, // about:blank, about:newtab, etc.
|
|
/^brave:\/\//i, // Brave internal pages
|
|
/^opera:\/\//i, // Opera internal pages
|
|
/^vivaldi:\/\//i, // Vivaldi internal pages
|
|
/^file:\/\//i, // Local files (content scripts often blocked)
|
|
/^view-source:/i, // View source pages
|
|
/^devtools:\/\//i, // DevTools pages
|
|
/^data:/i, // Data URLs
|
|
/^blob:/i, // Blob URLs
|
|
/^moz-extension:\/\//i, // Firefox extension pages
|
|
/^safari-extension:\/\//i, // Safari extension pages
|
|
/^safari-web-extension:\/\//i, // Safari web extension pages
|
|
];
|
|
|
|
return restrictedPatterns.some((pattern) => pattern.test(url));
|
|
}
|
|
|
|
// Open cal.com/app (Framer marketing page) in a new tab with auto-open parameter
|
|
function openAppPage(): void {
|
|
const tabsAPI = getTabsAPI();
|
|
if (tabsAPI) {
|
|
tabsAPI.create({ url: "https://cal.com/app?openExtension=true" });
|
|
}
|
|
}
|
|
|
|
export default defineBackground(() => {
|
|
const _browserType = detectBrowser();
|
|
const browserName = getBrowserDisplayName();
|
|
|
|
if (IS_DEV_MODE) {
|
|
devLog.log("DEV MODE - API Key authentication enabled for testing");
|
|
devLog.log(`Browser: ${browserName}, Target: ${BROWSER_TARGET}`);
|
|
}
|
|
|
|
const actionAPI = getActionAPI();
|
|
const runtimeAPI = getRuntimeAPI();
|
|
const tabsAPI = getTabsAPI();
|
|
const storageAPI = getStorageAPI();
|
|
|
|
if (actionAPI) {
|
|
actionAPI.onClicked.addListener((tab) => {
|
|
// Check if this is a restricted URL where content scripts can't run
|
|
if (isRestrictedUrl(tab.url)) {
|
|
devLog.log("Restricted URL detected, opening app page:", tab.url);
|
|
openAppPage();
|
|
return;
|
|
}
|
|
|
|
if (tab.id && tabsAPI) {
|
|
tabsAPI.sendMessage(tab.id, { action: "icon-clicked" }, () => {
|
|
// Ignore errors - expected on pages where content script hasn't loaded yet
|
|
// The restricted URL check above handles pages where content scripts can't run
|
|
const runtime = getRuntimeAPI();
|
|
if (runtime) void runtime.lastError;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
if (runtimeAPI) {
|
|
runtimeAPI.onMessage.addListener((message, _sender, sendResponse) => {
|
|
if (message.action === "fetch-event-types") {
|
|
fetchEventTypes()
|
|
.then((eventTypes) => sendResponse({ data: eventTypes }))
|
|
.catch((error) => sendResponse({ error: error.message }));
|
|
return true;
|
|
}
|
|
|
|
if (message.action === "start-extension-oauth") {
|
|
const authUrl = message.authUrl as string;
|
|
const state = new URL(authUrl).searchParams.get("state");
|
|
|
|
// Store state before starting OAuth to prevent race conditions
|
|
const storeState = async () => {
|
|
if (!state) {
|
|
throw new Error("No state parameter in auth URL");
|
|
}
|
|
if (!storageAPI?.local) {
|
|
throw new Error("Storage API not available");
|
|
}
|
|
try {
|
|
await storageAPI.local.set({ oauth_state: state });
|
|
} catch (error) {
|
|
devLog.error("Failed to store OAuth state:", error);
|
|
throw new Error("Failed to initialize OAuth flow - cannot store state");
|
|
}
|
|
};
|
|
|
|
storeState()
|
|
.then(() => handleExtensionOAuth(authUrl))
|
|
.then((responseUrl) => sendResponse({ success: true, responseUrl }))
|
|
.catch((error) => sendResponse({ success: false, error: error.message }));
|
|
return true;
|
|
}
|
|
|
|
if (message.action === "exchange-oauth-tokens") {
|
|
handleTokenExchange(message.tokenRequest, message.tokenEndpoint, message.state)
|
|
.then((tokens) => sendResponse({ success: true, tokens }))
|
|
.catch((error) => sendResponse({ success: false, error: error.message }));
|
|
return true;
|
|
}
|
|
|
|
if (message.action === "sync-oauth-tokens") {
|
|
const tokens = message.tokens as OAuthTokens | null;
|
|
|
|
if (isRateLimited()) {
|
|
devLog.warn("Token sync rate limited");
|
|
sendResponse({ success: false, error: "Rate limited. Please try again later." });
|
|
return true;
|
|
}
|
|
|
|
if (!tokens || !tokens.accessToken) {
|
|
sendResponse({ success: false, error: "No valid tokens provided" });
|
|
return true;
|
|
}
|
|
|
|
validateTokens(tokens)
|
|
.then((isValid) => {
|
|
if (!isValid) {
|
|
devLog.warn("Token sync rejected: invalid tokens");
|
|
sendResponse({ success: false, error: "Invalid tokens" });
|
|
return;
|
|
}
|
|
|
|
recordTokenOperation();
|
|
if (storageAPI?.local) {
|
|
storageAPI.local.set({ cal_oauth_tokens: JSON.stringify(tokens) }, () => {
|
|
const runtime = getRuntimeAPI();
|
|
if (runtime?.lastError) {
|
|
devLog.error("Failed to sync OAuth tokens:", runtime.lastError.message);
|
|
sendResponse({ success: false, error: runtime.lastError.message });
|
|
} else {
|
|
devLog.log("OAuth tokens synced to storage (validated)");
|
|
sendResponse({ success: true });
|
|
}
|
|
});
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
devLog.error("Token validation failed:", error);
|
|
sendResponse({ success: false, error: "Token validation failed" });
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
if (message.action === "clear-oauth-tokens") {
|
|
if (isRateLimited()) {
|
|
devLog.warn("Token clear rate limited");
|
|
sendResponse({ success: false, error: "Rate limited. Please try again later." });
|
|
return true;
|
|
}
|
|
|
|
recordTokenOperation();
|
|
if (storageAPI?.local) {
|
|
storageAPI.local.remove(["cal_oauth_tokens", "oauth_state"], () => {
|
|
const runtime = getRuntimeAPI();
|
|
if (runtime?.lastError) {
|
|
devLog.error("Failed to clear OAuth tokens:", runtime.lastError.message);
|
|
sendResponse({ success: false, error: runtime.lastError.message });
|
|
} else {
|
|
devLog.log("OAuth tokens cleared from storage");
|
|
sendResponse({ success: true });
|
|
}
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (message.action === "check-auth-status") {
|
|
checkAuthStatus()
|
|
.then((isAuthenticated) => sendResponse({ isAuthenticated }))
|
|
.catch((error) => sendResponse({ isAuthenticated: false, error: error.message }));
|
|
return true;
|
|
}
|
|
|
|
if (message.action === "get-booking-status") {
|
|
const { bookingUid } = message.payload as { bookingUid: string };
|
|
|
|
getBookingStatus(bookingUid)
|
|
.then((result) => sendResponse({ success: true, data: result }))
|
|
.catch((error) => {
|
|
devLog.error("Get booking status failed:", error);
|
|
sendResponse({ success: false, error: error.message });
|
|
});
|
|
return true;
|
|
}
|
|
|
|
if (message.action === "mark-no-show") {
|
|
const { bookingUid, attendeeEmail, absent } = message.payload as {
|
|
bookingUid: string;
|
|
attendeeEmail: string;
|
|
absent: boolean;
|
|
};
|
|
|
|
markAttendeeNoShow(bookingUid, attendeeEmail, absent)
|
|
.then((result) => sendResponse({ success: true, data: result }))
|
|
.catch((error) => {
|
|
devLog.error("Mark no-show failed:", error);
|
|
sendResponse({ success: false, error: error.message });
|
|
});
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
}
|
|
});
|
|
|
|
async function handleExtensionOAuth(authUrl: string): Promise<string> {
|
|
const browserType = detectBrowser();
|
|
|
|
// Safari uses tabs-based OAuth flow
|
|
if (browserType === BrowserType.Safari) {
|
|
return handleSafariTabsOAuth(authUrl);
|
|
}
|
|
|
|
const identityAPI = getIdentityAPI();
|
|
|
|
if (!identityAPI) {
|
|
throw new Error(`Identity API not available in ${getBrowserDisplayName()}`);
|
|
}
|
|
|
|
if (IS_DEV_MODE && identityAPI.getRedirectURL) {
|
|
const expectedRedirectUrl = identityAPI.getRedirectURL();
|
|
const redirectUri = new URL(authUrl).searchParams.get("redirect_uri");
|
|
|
|
devLog.log("Starting OAuth flow");
|
|
devLog.log("Browser:", getBrowserDisplayName());
|
|
devLog.log("Expected redirect URL:", expectedRedirectUrl);
|
|
devLog.log("Auth URL redirect_uri:", redirectUri);
|
|
|
|
if (redirectUri && !redirectUri.startsWith(expectedRedirectUrl.replace(/\/$/, ""))) {
|
|
devLog.warn(
|
|
"MISMATCH! redirect_uri does not match expected URL.",
|
|
"\nExpected:",
|
|
expectedRedirectUrl,
|
|
"\nGot:",
|
|
redirectUri
|
|
);
|
|
}
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
// Firefox uses Promise-based API
|
|
if (browserType === BrowserType.Firefox) {
|
|
try {
|
|
const result = identityAPI.launchWebAuthFlow({
|
|
url: authUrl,
|
|
interactive: true,
|
|
}) as Promise<string | undefined> | undefined;
|
|
|
|
if (result && typeof result.then === "function") {
|
|
result
|
|
.then((responseUrl: string | undefined) => {
|
|
if (responseUrl) {
|
|
devLog.log("OAuth successful");
|
|
resolve(responseUrl);
|
|
} else {
|
|
devLog.error("OAuth flow returned no response URL");
|
|
reject(new Error("OAuth flow cancelled or failed"));
|
|
}
|
|
})
|
|
.catch((error: Error) => {
|
|
devLog.error("OAuth flow failed:", error.message);
|
|
reject(new Error(`OAuth flow failed: ${error.message}`));
|
|
});
|
|
return;
|
|
}
|
|
} catch {
|
|
// Fall through to callback-based API
|
|
}
|
|
}
|
|
|
|
// Chrome/Edge use callback-based API
|
|
identityAPI.launchWebAuthFlow({ url: authUrl, interactive: true }, (responseUrl) => {
|
|
const runtimeAPI = getRuntimeAPI();
|
|
if (runtimeAPI?.lastError) {
|
|
devLog.error("OAuth flow failed:", runtimeAPI.lastError.message);
|
|
reject(new Error(`OAuth flow failed: ${runtimeAPI.lastError.message}`));
|
|
} else if (responseUrl) {
|
|
devLog.log("OAuth successful");
|
|
resolve(responseUrl);
|
|
} else {
|
|
devLog.error("OAuth flow returned no response URL");
|
|
reject(new Error("OAuth flow cancelled or failed"));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle Safari OAuth using tabs-based flow
|
|
* Opens OAuth in a new tab and captures the redirect callback
|
|
*/
|
|
async function handleSafariTabsOAuth(authUrl: string): Promise<string> {
|
|
// Extract the redirect URI to know what to watch for
|
|
const redirectUri = new URL(authUrl).searchParams.get("redirect_uri");
|
|
if (!redirectUri) {
|
|
throw new Error("redirect_uri not found in auth URL");
|
|
}
|
|
|
|
const redirectUrlObj = new URL(redirectUri);
|
|
const redirectOrigin = redirectUrlObj.origin; // scheme + host + port
|
|
const redirectPath = redirectUrlObj.pathname;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const tabsAPI = getTabsAPI();
|
|
if (!tabsAPI) {
|
|
reject(new Error("Tabs API not available"));
|
|
return;
|
|
}
|
|
|
|
let oauthTabId: number | null = null;
|
|
let isResolved = false;
|
|
|
|
const timeoutId = setTimeout(
|
|
() => {
|
|
if (!isResolved) {
|
|
cleanup();
|
|
reject(new Error("OAuth flow timed out after 5 minutes"));
|
|
}
|
|
},
|
|
5 * 60 * 1000
|
|
);
|
|
|
|
const tabUpdateListener = (tabId: number, _changeInfo: unknown, tab: chrome.tabs.Tab) => {
|
|
// Only process updates for our OAuth tab
|
|
if (tabId !== oauthTabId) return;
|
|
|
|
// Check if the tab navigated to the redirect URI
|
|
if (tab.url) {
|
|
try {
|
|
const tabUrl = new URL(tab.url);
|
|
|
|
// Check if this is our OAuth callback URL - exact origin and path match
|
|
if (tabUrl.origin === redirectOrigin && tabUrl.pathname === redirectPath) {
|
|
if (!isResolved) {
|
|
const error = tabUrl.searchParams.get("error");
|
|
if (error) {
|
|
isResolved = true;
|
|
cleanup();
|
|
const errorDescription = tabUrl.searchParams.get("error_description") || error;
|
|
|
|
// Close the tab after capturing error
|
|
if (oauthTabId !== null) {
|
|
tabsAPI.remove(oauthTabId).catch(() => {
|
|
// Ignore errors when closing tab
|
|
});
|
|
}
|
|
|
|
reject(new Error(`OAuth error: ${errorDescription}`));
|
|
return;
|
|
}
|
|
|
|
const code = tabUrl.searchParams.get("code");
|
|
if (!code) {
|
|
// No code yet, keep listening (might be an intermediate redirect)
|
|
return;
|
|
}
|
|
|
|
const state = tabUrl.searchParams.get("state");
|
|
if (!state) {
|
|
isResolved = true;
|
|
cleanup();
|
|
|
|
// Close the tab
|
|
if (oauthTabId !== null) {
|
|
tabsAPI.remove(oauthTabId).catch(() => {
|
|
// Ignore errors when closing tab
|
|
});
|
|
}
|
|
|
|
reject(new Error("No state parameter in OAuth callback"));
|
|
return;
|
|
}
|
|
|
|
// State validation - only close tab after validation succeeds
|
|
isResolved = true;
|
|
cleanup();
|
|
|
|
validateOAuthStateWithoutCleanup(state)
|
|
.then(() => {
|
|
// State is valid, close the tab and resolve
|
|
// Note: State is NOT cleaned up here, token exchange will clean it up
|
|
if (oauthTabId !== null) {
|
|
tabsAPI.remove(oauthTabId).catch(() => {
|
|
// Ignore errors when closing tab
|
|
});
|
|
}
|
|
if (!tab.url) {
|
|
reject(new Error("Tab URL is undefined"));
|
|
return;
|
|
}
|
|
resolve(tab.url);
|
|
})
|
|
.catch((error) => {
|
|
// State validation failed, close the tab and reject
|
|
if (oauthTabId !== null) {
|
|
tabsAPI.remove(oauthTabId).catch(() => {
|
|
// Ignore errors when closing tab
|
|
});
|
|
}
|
|
reject(error);
|
|
});
|
|
}
|
|
}
|
|
} catch (_error) {
|
|
// Invalid URL, ignore
|
|
}
|
|
}
|
|
};
|
|
|
|
// Listen for tab removal (user closed the OAuth tab)
|
|
const tabRemovedListener = (tabId: number) => {
|
|
if (tabId === oauthTabId && !isResolved) {
|
|
isResolved = true;
|
|
cleanup();
|
|
reject(new Error("OAuth cancelled by user"));
|
|
}
|
|
};
|
|
|
|
const cleanup = () => {
|
|
clearTimeout(timeoutId);
|
|
tabsAPI.onUpdated.removeListener(tabUpdateListener);
|
|
tabsAPI.onRemoved.removeListener(tabRemovedListener);
|
|
};
|
|
|
|
// Register listeners
|
|
tabsAPI.onUpdated.addListener(tabUpdateListener);
|
|
tabsAPI.onRemoved.addListener(tabRemovedListener);
|
|
|
|
// Open OAuth URL in a new tab
|
|
tabsAPI
|
|
.create({
|
|
url: authUrl,
|
|
active: true,
|
|
})
|
|
.then((tab) => {
|
|
if (tab.id) {
|
|
oauthTabId = tab.id;
|
|
} else {
|
|
cleanup();
|
|
reject(new Error("Failed to create OAuth tab"));
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
cleanup();
|
|
reject(new Error(`Failed to open OAuth tab: ${error.message}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function handleTokenExchange(
|
|
tokenRequest: Record<string, string>,
|
|
tokenEndpoint: string,
|
|
state?: string
|
|
): Promise<OAuthTokens> {
|
|
if (state) {
|
|
await validateOAuthState(state);
|
|
}
|
|
|
|
const body = new URLSearchParams(tokenRequest);
|
|
|
|
const response = await fetchWithTimeout(tokenEndpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
},
|
|
body: body.toString(),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.text();
|
|
devLog.error("Token exchange error response:", errorData);
|
|
throw new Error(`Token exchange failed: ${response.status} ${errorData}`);
|
|
}
|
|
|
|
const tokenData = await response.json();
|
|
|
|
const tokens: OAuthTokens = {
|
|
accessToken: tokenData.access_token,
|
|
refreshToken: tokenData.refresh_token,
|
|
tokenType: tokenData.token_type || "Bearer",
|
|
expiresAt: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined,
|
|
scope: tokenData.scope,
|
|
};
|
|
|
|
try {
|
|
const storageAPI = getStorageAPI();
|
|
if (storageAPI?.local) {
|
|
await storageAPI.local.set({ cal_oauth_tokens: JSON.stringify(tokens) });
|
|
devLog.log("OAuth tokens stored in storage");
|
|
}
|
|
} catch (storageError) {
|
|
devLog.error("Failed to store OAuth tokens:", storageError);
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
/**
|
|
* Validates OAuth state without cleaning it up (for Safari flow)
|
|
* Token exchange will clean up the state after successful validation
|
|
*/
|
|
async function validateOAuthStateWithoutCleanup(state: string): Promise<void> {
|
|
const storageAPI = getStorageAPI();
|
|
if (!storageAPI?.local) {
|
|
// Fail closed: if we can't access storage, we can't validate state
|
|
throw new Error("Storage API not available - cannot validate OAuth state");
|
|
}
|
|
|
|
const result = await storageAPI.local.get(["oauth_state"]);
|
|
const storedState = result.oauth_state as string | undefined;
|
|
|
|
if (!storedState) {
|
|
throw new Error("No stored OAuth state found - possible CSRF attack");
|
|
}
|
|
|
|
if (storedState !== state) {
|
|
devLog.error("State parameter mismatch - possible CSRF attack");
|
|
throw new Error("Invalid state parameter - possible CSRF attack");
|
|
}
|
|
}
|
|
|
|
async function validateOAuthState(state: string): Promise<void> {
|
|
const storageAPI = getStorageAPI();
|
|
if (!storageAPI?.local) {
|
|
// Fail closed: if we can't access storage, we can't validate state
|
|
throw new Error("Storage API not available - cannot validate OAuth state");
|
|
}
|
|
|
|
try {
|
|
const result = await storageAPI.local.get(["oauth_state"]);
|
|
const storedState = result.oauth_state as string | undefined;
|
|
|
|
// Fail closed: state must exist and match exactly
|
|
if (!storedState) {
|
|
throw new Error("No stored OAuth state found");
|
|
}
|
|
|
|
if (storedState !== state) {
|
|
await storageAPI.local.remove("oauth_state");
|
|
devLog.error("State parameter mismatch - possible CSRF attack");
|
|
throw new Error("Invalid state parameter - possible CSRF attack");
|
|
}
|
|
|
|
await storageAPI.local.remove("oauth_state");
|
|
} catch (error) {
|
|
// Clean up on any error
|
|
try {
|
|
await storageAPI.local.remove("oauth_state");
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const API_BASE_URL = "https://api.cal.com/v2";
|
|
|
|
const tokenOperationTimestamps: number[] = [];
|
|
const TOKEN_RATE_LIMIT_WINDOW_MS = 60000;
|
|
const TOKEN_RATE_LIMIT_MAX_OPS = 5;
|
|
|
|
function isRateLimited(): boolean {
|
|
const now = Date.now();
|
|
while (
|
|
tokenOperationTimestamps.length > 0 &&
|
|
tokenOperationTimestamps[0] < now - TOKEN_RATE_LIMIT_WINDOW_MS
|
|
) {
|
|
tokenOperationTimestamps.shift();
|
|
}
|
|
return tokenOperationTimestamps.length >= TOKEN_RATE_LIMIT_MAX_OPS;
|
|
}
|
|
|
|
function recordTokenOperation(): void {
|
|
tokenOperationTimestamps.push(Date.now());
|
|
}
|
|
|
|
async function validateTokens(tokens: OAuthTokens): Promise<boolean> {
|
|
if (!tokens.accessToken) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const response = await fetchWithTimeout(`${API_BASE_URL}/me`, {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
"Content-Type": "application/json",
|
|
"cal-api-version": "2024-06-11",
|
|
},
|
|
});
|
|
|
|
if (response.ok) {
|
|
devLog.log("Token validation successful");
|
|
return true;
|
|
}
|
|
|
|
devLog.warn("Token validation failed:", response.status);
|
|
return false;
|
|
} catch (error) {
|
|
devLog.error("Token validation error:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function getAuthHeader(): Promise<string> {
|
|
const storageAPI = getStorageAPI();
|
|
|
|
if (storageAPI?.local) {
|
|
const result = await storageAPI.local.get(["cal_oauth_tokens"]);
|
|
const oauthTokens = result.cal_oauth_tokens
|
|
? safeParseOAuthTokens(result.cal_oauth_tokens as string)
|
|
: null;
|
|
|
|
if (oauthTokens?.accessToken) {
|
|
return `Bearer ${oauthTokens.accessToken}`;
|
|
}
|
|
}
|
|
|
|
if (IS_DEV_MODE && DEV_API_KEY) {
|
|
devLog.log("Using API Key for authentication (DEV MODE)");
|
|
return `Bearer ${DEV_API_KEY}`;
|
|
}
|
|
|
|
throw new Error("No OAuth access token found. Please sign in with OAuth.");
|
|
}
|
|
|
|
async function fetchEventTypes(): Promise<unknown[]> {
|
|
const authHeader = await getAuthHeader();
|
|
|
|
// For authenticated users, no username/orgSlug params needed - API uses auth token
|
|
// This also ensures hidden event types are returned (they're filtered out when username is provided)
|
|
const response = await fetchWithTimeout(`${API_BASE_URL}/event-types`, {
|
|
headers: {
|
|
Authorization: authHeader,
|
|
"Content-Type": "application/json",
|
|
"cal-api-version": "2024-06-14",
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch event types: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (Array.isArray(data)) {
|
|
return data;
|
|
}
|
|
if (data?.data && Array.isArray(data.data)) {
|
|
return data.data;
|
|
}
|
|
if (data?.eventTypes && Array.isArray(data.eventTypes)) {
|
|
return data.eventTypes;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Check if user is authenticated by verifying tokens exist in storage
|
|
*/
|
|
async function checkAuthStatus(): Promise<boolean> {
|
|
const storageAPI = getStorageAPI();
|
|
|
|
if (!storageAPI?.local) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const result = await storageAPI.local.get(["cal_oauth_tokens"]);
|
|
const oauthTokens = result.cal_oauth_tokens
|
|
? safeParseOAuthTokens(result.cal_oauth_tokens as string)
|
|
: null;
|
|
|
|
return Boolean(oauthTokens?.accessToken);
|
|
} catch (error) {
|
|
devLog.error("Failed to check auth status:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get booking status to check attendee no-show status
|
|
*/
|
|
async function getBookingStatus(bookingUid: string): Promise<Booking> {
|
|
const authHeader = await getAuthHeader();
|
|
|
|
const response = await fetchWithTimeout(`${API_BASE_URL}/bookings/${bookingUid}`, {
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: authHeader,
|
|
"Content-Type": "application/json",
|
|
"cal-api-version": "2024-08-13",
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorBody = await response.text();
|
|
let errorMessage = response.statusText;
|
|
|
|
const errorJson = safeParseErrorJson(errorBody);
|
|
if (errorJson) {
|
|
errorMessage = errorJson?.error?.message || errorJson?.message || response.statusText;
|
|
} else {
|
|
errorMessage = errorBody || response.statusText;
|
|
}
|
|
|
|
if (response.status === 401) {
|
|
throw new Error("Session expired. Please login again.");
|
|
}
|
|
if (response.status === 403) {
|
|
throw new Error("You don't have permission to view this booking.");
|
|
}
|
|
if (response.status === 404) {
|
|
throw new Error("Booking not found in Cal.com.");
|
|
}
|
|
|
|
throw new Error(`Failed to get booking status: ${errorMessage}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return (data?.data ?? data) as Booking;
|
|
}
|
|
|
|
/**
|
|
* Mark an attendee as no-show for a booking
|
|
*/
|
|
async function markAttendeeNoShow(
|
|
bookingUid: string,
|
|
attendeeEmail: string,
|
|
absent: boolean
|
|
): Promise<Booking> {
|
|
const authHeader = await getAuthHeader();
|
|
|
|
const response = await fetchWithTimeout(`${API_BASE_URL}/bookings/${bookingUid}/mark-absent`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: authHeader,
|
|
"Content-Type": "application/json",
|
|
"cal-api-version": "2024-08-13",
|
|
},
|
|
body: JSON.stringify({
|
|
attendees: [{ email: attendeeEmail, absent }],
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorBody = await response.text();
|
|
let errorMessage = response.statusText;
|
|
|
|
const errorJson = safeParseErrorJson(errorBody);
|
|
if (errorJson) {
|
|
errorMessage = errorJson?.error?.message || errorJson?.message || response.statusText;
|
|
} else {
|
|
errorMessage = errorBody || response.statusText;
|
|
}
|
|
|
|
if (response.status === 401) {
|
|
throw new Error("Session expired. Please login again.");
|
|
}
|
|
if (response.status === 403) {
|
|
throw new Error("You don't have permission to modify this booking.");
|
|
}
|
|
if (response.status === 404) {
|
|
throw new Error("Booking not found in Cal.com.");
|
|
}
|
|
|
|
throw new Error(`Failed to mark no-show: ${errorMessage}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return (data?.data ?? data) as Booking;
|
|
}
|