diff --git a/companion/contexts/AuthContext.tsx b/companion/contexts/AuthContext.tsx index 0f8aa1d20a..0bfd077a9e 100644 --- a/companion/contexts/AuthContext.tsx +++ b/companion/contexts/AuthContext.tsx @@ -84,15 +84,29 @@ export function AuthProvider({ children }: AuthProviderProps) { } }; - // Save OAuth tokens to storage const saveOAuthTokens = async (tokens: OAuthTokens) => { await storage.set(OAUTH_TOKENS_KEY, JSON.stringify(tokens)); await storage.set(AUTH_TYPE_KEY, "oauth"); + + if (oauthService) { + try { + await oauthService.syncTokensToExtension(tokens); + } catch (error) { + console.warn("Failed to sync tokens to extension:", error); + } + } }; - // Clear all auth data from storage const clearAuth = async () => { await storage.removeAll([ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, OAUTH_TOKENS_KEY, AUTH_TYPE_KEY]); + + if (oauthService) { + try { + await oauthService.clearTokensFromExtension(); + } catch (error) { + console.warn("Failed to clear tokens from extension:", error); + } + } }; // Reset all auth state @@ -147,11 +161,13 @@ export function AuthProvider({ children }: AuthProviderProps) { // Refresh token if expired let tokens = storedTokens; + let tokensWereRefreshed = false; if (oauthService.isTokenExpired(storedTokens) && storedTokens.refreshToken) { try { console.log("Access token expired, refreshing..."); tokens = await oauthService.refreshAccessToken(storedTokens.refreshToken); await saveOAuthTokens(tokens); + tokensWereRefreshed = true; } catch (refreshError) { console.error("Failed to refresh token:", refreshError); await clearAuth(); @@ -170,6 +186,14 @@ export function AuthProvider({ children }: AuthProviderProps) { if (tokens.refreshToken) { setupRefreshTokenFunction(oauthService); } + + if (!tokensWereRefreshed) { + try { + await oauthService.syncTokensToExtension(tokens); + } catch (error) { + console.warn("Failed to sync tokens to extension on init:", error); + } + } }; // Handle web session authentication diff --git a/companion/extension/entrypoints/background/index.ts b/companion/extension/entrypoints/background/index.ts index abcfde8b7a..c390c33ee1 100644 --- a/companion/extension/entrypoints/background/index.ts +++ b/companion/extension/entrypoints/background/index.ts @@ -95,6 +95,67 @@ export default defineBackground(() => { 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(); + chrome.storage.local.set({ cal_oauth_tokens: JSON.stringify(tokens) }, () => { + if (chrome.runtime.lastError) { + devLog.error("Failed to sync OAuth tokens:", chrome.runtime.lastError.message); + sendResponse({ success: false, error: chrome.runtime.lastError.message }); + } else { + devLog.log("OAuth tokens synced to chrome.storage.local (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(); + chrome.storage.local.remove(["cal_oauth_tokens", "oauth_state"], () => { + if (chrome.runtime.lastError) { + devLog.error("Failed to clear OAuth tokens:", chrome.runtime.lastError.message); + sendResponse({ success: false, error: chrome.runtime.lastError.message }); + } else { + devLog.log("OAuth tokens cleared from chrome.storage.local"); + sendResponse({ success: true }); + } + }); + return true; + } + return false; }); }); @@ -168,13 +229,22 @@ async function handleTokenExchange( const tokenData = await response.json(); - return { + 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 { + await chrome.storage.local.set({ cal_oauth_tokens: JSON.stringify(tokens) }); + devLog.log("OAuth tokens stored in chrome.storage.local"); + } catch (storageError) { + devLog.error("Failed to store OAuth tokens:", storageError); + } + + return tokens; } async function validateOAuthState(state: string): Promise { @@ -201,6 +271,52 @@ async function validateOAuthState(state: string): Promise { 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 { + if (!tokens.accessToken) { + return false; + } + + try { + const response = await fetch(`${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 { const result = await chrome.storage.local.get(["cal_oauth_tokens"]); const oauthTokens = result.cal_oauth_tokens diff --git a/companion/extension/entrypoints/content.ts b/companion/extension/entrypoints/content.ts index 1cbe21cacb..af7876f4e7 100644 --- a/companion/extension/entrypoints/content.ts +++ b/companion/extension/entrypoints/content.ts @@ -20,6 +20,15 @@ export default defineContentScript({ } } + const sessionToken = generateSecureToken(); + let iframeSessionValidated = false; + + function generateSecureToken(): string { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(""); + } + let isVisible = false; let isClosed = true; @@ -69,6 +78,30 @@ export default defineContentScript({ return; } + if (event.data.type === "cal-extension-request-session") { + iframeSessionValidated = true; + iframe.contentWindow?.postMessage( + { + type: "cal-extension-session-token", + sessionToken: sessionToken, + }, + iframeOrigin + ); + return; + } + + const validateSessionToken = (providedToken: string | undefined): boolean => { + if (!iframeSessionValidated) { + console.warn("Cal.com: Session not validated yet"); + return false; + } + if (providedToken !== sessionToken) { + console.warn("Cal.com: Invalid session token"); + return false; + } + return true; + }; + if (event.data.type === "cal-companion-expand") { // Disable transition for instant expansion iframe.style.transition = "none"; @@ -92,6 +125,32 @@ export default defineContentScript({ event.data.state, // Pass state for CSRF validation iframe.contentWindow ); + } else if (event.data.type === "cal-extension-sync-tokens") { + if (!validateSessionToken(event.data.sessionToken)) { + iframe.contentWindow?.postMessage( + { + type: "cal-extension-sync-tokens-result", + success: false, + error: "Invalid session token", + }, + iframeOrigin + ); + return; + } + handleTokenSyncRequest(event.data.tokens, iframe.contentWindow); + } else if (event.data.type === "cal-extension-clear-tokens") { + if (!validateSessionToken(event.data.sessionToken)) { + iframe.contentWindow?.postMessage( + { + type: "cal-extension-clear-tokens-result", + success: false, + error: "Invalid session token", + }, + iframeOrigin + ); + return; + } + handleClearTokensRequest(iframe.contentWindow); } }); @@ -197,6 +256,64 @@ export default defineContentScript({ ); } + function handleTokenSyncRequest(tokens: any, iframeWindow: Window | null) { + chrome.runtime.sendMessage({ action: "sync-oauth-tokens", tokens }, (response) => { + if (chrome.runtime.lastError) { + console.error( + "Failed to communicate with background script:", + chrome.runtime.lastError.message + ); + iframeWindow?.postMessage( + { + type: "cal-extension-sync-tokens-result", + success: false, + error: `Extension communication failed: ${chrome.runtime.lastError.message}`, + }, + "*" + ); + return; + } + + iframeWindow?.postMessage( + { + type: "cal-extension-sync-tokens-result", + success: response?.success ?? false, + error: response?.error, + }, + "*" + ); + }); + } + + function handleClearTokensRequest(iframeWindow: Window | null) { + chrome.runtime.sendMessage({ action: "clear-oauth-tokens" }, (response) => { + if (chrome.runtime.lastError) { + console.error( + "Failed to communicate with background script:", + chrome.runtime.lastError.message + ); + iframeWindow?.postMessage( + { + type: "cal-extension-clear-tokens-result", + success: false, + error: `Extension communication failed: ${chrome.runtime.lastError.message}`, + }, + "*" + ); + return; + } + + iframeWindow?.postMessage( + { + type: "cal-extension-clear-tokens-result", + success: response?.success ?? false, + error: response?.error, + }, + "*" + ); + }); + } + sidebarContainer.appendChild(iframeContainer); // Create floating buttons container @@ -554,15 +671,15 @@ export default defineContentScript({ bottom: 100%; left: 0; width: 400px; - max-height: 250px; + max-height: 280px; background: white; - border-radius: 8px; - box-shadow: 0 1px 2px 0 rgba(60,64,67,.3),0 2px 6px 2px rgba(60,64,67,.15); - font-family: "Google Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0,0,0,0.15); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; z-index: 9999; overflow-y: auto; - margin-bottom: 4px; + margin-bottom: 8px; `; // Show loading state @@ -1013,25 +1130,64 @@ export default defineContentScript({ `; } } catch (error) { - menu.innerHTML = ` -
- Failed to load event types -
-
- Error: ${(error as Error).message} -
-
-
`; + + const closeBtn = menu.querySelector(".cal-gmail-close-btn") as HTMLButtonElement; + if (closeBtn) { + closeBtn.addEventListener("mouseenter", () => { + closeBtn.style.background = "#E5E7EB"; + }); + closeBtn.addEventListener("mouseleave", () => { + closeBtn.style.background = "#F3F4F6"; + }); + closeBtn.addEventListener("click", () => { + tooltipsToCleanup.forEach((tooltip) => tooltip.remove()); + menu.remove(); + }); + } + } } } @@ -2703,34 +2859,40 @@ export default defineContentScript({ console.error("Error showing Cal.com suggestion menu:", error); loadingDiv.remove(); + const errorMessage = error instanceof Error ? error.message : ""; + const isContextInvalidated = errorMessage.includes("Extension context invalidated"); + const isAuthError = + errorMessage.includes("OAuth") || + errorMessage.includes("access token") || + errorMessage.includes("sign in") || + errorMessage.includes("authentication"); + // Show user-friendly error message const errorDiv = document.createElement("div"); - errorDiv.style.cssText = "padding: 20px 16px; text-align: center;"; + errorDiv.style.cssText = "padding: 24px 20px; text-align: center;"; - const isContextInvalidated = - error instanceof Error && error.message.includes("Extension context invalidated"); - - errorDiv.innerHTML = ` + if (isAuthError) { + // Not logged in - close backdrop and open sidebar directly + backdrop.remove(); + openSidebar(); + return; + } else if (isContextInvalidated) { + // Extension reloaded error + errorDiv.innerHTML = `
- +
- ${isContextInvalidated ? "Extension Reloaded" : "Failed to Load"} + Extension Reloaded
- ${ - isContextInvalidated - ? "The extension was updated. Please reload this page to continue." - : "Failed to load event types. Please try again." - } + The extension was updated. Please reload this page to continue.
- ${ - isContextInvalidated - ? `` - : "" - } + `; - contentContainer.appendChild(errorDiv); + contentContainer.appendChild(errorDiv); - // Add reload handler if context invalidated - if (isContextInvalidated) { const reloadBtn = errorDiv.querySelector(".reload-page-btn"); reloadBtn?.addEventListener("click", () => { window.location.reload(); @@ -2762,6 +2920,25 @@ export default defineContentScript({ reloadBtn?.addEventListener("mouseleave", (e) => { (e.target as HTMLElement).style.backgroundColor = "#ff6b6b"; }); + } else { + // Generic error + errorDiv.innerHTML = ` +
+ + + + + +
+
+ Something went wrong +
+
+ Failed to load event types. Please try again. +
+ `; + + contentContainer.appendChild(errorDiv); } } } diff --git a/companion/package.json b/companion/package.json index 3e1188afcf..390117da38 100644 --- a/companion/package.json +++ b/companion/package.json @@ -1,7 +1,7 @@ { "name": "cal-companion", "displayName": "Cal.com Companion", - "version": "1.7.0", + "version": "1.7.1", "main": "index.js", "scripts": { "start": "expo start", diff --git a/companion/services/oauthService.ts b/companion/services/oauthService.ts index dfabdb1f49..a89e308491 100644 --- a/companion/services/oauthService.ts +++ b/companion/services/oauthService.ts @@ -13,11 +13,63 @@ const EXTENSION_MESSAGE_TYPES = { OAUTH_RESULT: "cal-extension-oauth-result", TOKEN_EXCHANGE_REQUEST: "cal-extension-token-exchange-request", TOKEN_EXCHANGE_RESULT: "cal-extension-token-exchange-result", + SYNC_TOKENS: "cal-extension-sync-tokens", + SYNC_TOKENS_RESULT: "cal-extension-sync-tokens-result", + CLEAR_TOKENS: "cal-extension-clear-tokens", + CLEAR_TOKENS_RESULT: "cal-extension-clear-tokens-result", + REQUEST_SESSION: "cal-extension-request-session", + SESSION_TOKEN: "cal-extension-session-token", } as const; -// Timeouts -const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes for user interaction -const TOKEN_EXCHANGE_TIMEOUT_MS = 30 * 1000; // 30 seconds for API call +let extensionSessionToken: string | null = null; +let sessionTokenPromise: Promise | null = null; + +const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; +const TOKEN_EXCHANGE_TIMEOUT_MS = 30 * 1000; +const SESSION_TOKEN_TIMEOUT_MS = 5000; + +async function getExtensionSessionToken(): Promise { + if (extensionSessionToken) { + return extensionSessionToken; + } + + if (sessionTokenPromise) { + return sessionTokenPromise; + } + + if (typeof window === "undefined" || window.parent === window) { + return null; + } + + sessionTokenPromise = new Promise((resolve) => { + const timeoutId = setTimeout(() => { + window.removeEventListener("message", messageHandler); + sessionTokenPromise = null; + resolve(""); + }, SESSION_TOKEN_TIMEOUT_MS); + + const messageHandler = (event: MessageEvent) => { + if (event.source !== window.parent) { + return; + } + if (event.data.type !== EXTENSION_MESSAGE_TYPES.SESSION_TOKEN) { + return; + } + + clearTimeout(timeoutId); + window.removeEventListener("message", messageHandler); + + extensionSessionToken = event.data.sessionToken || ""; + sessionTokenPromise = null; + resolve(extensionSessionToken); + }; + + window.addEventListener("message", messageHandler); + window.parent.postMessage({ type: EXTENSION_MESSAGE_TYPES.REQUEST_SESSION }, "*"); + }); + + return sessionTokenPromise; +} export interface OAuthTokens { accessToken: string; @@ -354,6 +406,109 @@ export class CalComOAuthService { this.codeVerifier = null; this.state = null; } + + async syncTokensToExtension(tokens: OAuthTokens): Promise { + if (!this.isRunningInIframe()) { + return; + } + + const sessionToken = await getExtensionSessionToken(); + if (!sessionToken) { + console.warn("No session token available for token sync"); + return; + } + + return new Promise((resolve) => { + const timeoutId = setTimeout(() => { + window.removeEventListener("message", messageHandler); + resolve(); + }, 5000); + + const messageHandler = (event: MessageEvent) => { + if (event.source !== window.parent) { + return; + } + if (event.data.type !== EXTENSION_MESSAGE_TYPES.SYNC_TOKENS_RESULT) { + return; + } + + clearTimeout(timeoutId); + window.removeEventListener("message", messageHandler); + + if (event.data.success) { + resolve(); + } else { + console.warn("Failed to sync tokens to extension:", event.data.error); + resolve(); + } + }; + + window.addEventListener("message", messageHandler); + window.parent.postMessage( + { type: EXTENSION_MESSAGE_TYPES.SYNC_TOKENS, tokens, sessionToken }, + "*" + ); + }); + } + + async clearTokensFromExtension(): Promise { + if (!this.isRunningInIframe()) { + return; + } + + const sessionToken = await getExtensionSessionToken(); + if (!sessionToken) { + console.warn("No session token available for token clear"); + return; + } + + return new Promise((resolve) => { + const timeoutId = setTimeout(() => { + window.removeEventListener("message", messageHandler); + resolve(); + }, 5000); + + const messageHandler = (event: MessageEvent) => { + if (event.source !== window.parent) { + return; + } + if (event.data.type !== EXTENSION_MESSAGE_TYPES.CLEAR_TOKENS_RESULT) { + return; + } + + clearTimeout(timeoutId); + window.removeEventListener("message", messageHandler); + + if (event.data.success) { + resolve(); + } else { + console.warn("Failed to clear tokens from extension:", event.data.error); + resolve(); + } + }; + + window.addEventListener("message", messageHandler); + window.parent.postMessage({ type: EXTENSION_MESSAGE_TYPES.CLEAR_TOKENS, sessionToken }, "*"); + }); + } + + async refreshAccessToken(refreshToken: string): Promise { + const tokenRequest = { + grant_type: "refresh_token", + client_id: this.config.clientId, + refresh_token: refreshToken, + }; + + const tokenEndpoint = `${this.config.calcomBaseUrl}/api/auth/oauth/token`; + + if (this.isRunningInIframe()) { + const tokens = await this.exchangeCodeForTokensViaExtension(tokenRequest, tokenEndpoint); + await this.syncTokensToExtension(tokens); + return tokens; + } + + return this.exchangeCodeForTokensDirect(tokenRequest, tokenEndpoint); + } } export function createCalComOAuthService(overrides: Partial = {}): CalComOAuthService { diff --git a/companion/wxt.config.ts b/companion/wxt.config.ts index 74fa753d4e..abb7d00e32 100644 --- a/companion/wxt.config.ts +++ b/companion/wxt.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ outDir: ".output", manifest: { name: "Cal.com Companion", - version: "1.7.0", + version: "1.7.1", description: "Your calendar companion for quick booking and scheduling", permissions: ["activeTab", "storage", "identity"], host_permissions: [ @@ -34,9 +34,6 @@ export default defineConfig({ ? "script-src 'self'; object-src 'self'; frame-src 'self' https://companion.cal.com http://localhost:*" : "script-src 'self'; object-src 'self'; frame-src 'self' https://companion.cal.com", }, - data_collection_permissions: { - collects_data: false, - }, action: { default_title: "Cal.com Companion", default_icon: {