fix(companion): Fix Gmail OAuth integration (#25978)

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Dhairyashil Shinde
2025-12-17 15:13:54 -03:00
committed by GitHub
co-authored by Peer Richelsen
parent 15b1675db1
commit 471d1caac9
6 changed files with 520 additions and 51 deletions
+26 -2
View File
@@ -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
@@ -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<void> {
@@ -201,6 +271,52 @@ async function validateOAuthState(state: string): Promise<void> {
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 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<string> {
const result = await chrome.storage.local.get(["cal_oauth_tokens"]);
const oauthTokens = result.cal_oauth_tokens
+217 -40
View File
@@ -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 = `
<div style="padding: 16px; text-align: center; color: #ea4335;">
Failed to load event types
</div>
<div style="padding: 0 16px; text-align: center; color: #5f6368; font-size: 12px;">
Error: ${(error as Error).message}
</div>
<div style="padding: 16px 16px; text-align: center;">
<button onclick="this.parentElement.parentElement.remove()" style="
background: #1a73e8;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
const errorMessage = (error as Error).message || "";
const isAuthError =
errorMessage.includes("OAuth") ||
errorMessage.includes("access token") ||
errorMessage.includes("sign in") ||
errorMessage.includes("authentication");
if (isAuthError) {
// Not logged in - open sidebar directly
tooltipsToCleanup.forEach((tooltip) => tooltip.remove());
menu.remove();
openSidebar();
return;
} else {
// Show generic error for non-auth errors
menu.innerHTML = `
<div style="padding: 20px; text-align: center;">
<div style="margin-bottom: 12px;">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#EF4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</div>
<div style="font-size: 14px; font-weight: 600; color: #111827; margin-bottom: 8px;">
Something went wrong
</div>
<div style="font-size: 13px; color: #6B7280; margin-bottom: 16px;">
${errorMessage || "Failed to load event types"}
</div>
<button class="cal-gmail-close-btn" style="
background: #F3F4F6;
color: #374151;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
">Close</button>
</div>
`;
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 = `
<div style="margin-bottom: 12px;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="${isContextInvalidated ? "#ff6b6b" : "#666"}" stroke-width="1.5" style="margin: 0 auto;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#ff6b6b" stroke-width="1.5" style="margin: 0 auto;">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
</div>
<div style="font-size: 14px; font-weight: 600; color: #000; margin-bottom: 8px;">
${isContextInvalidated ? "Extension Reloaded" : "Failed to Load"}
Extension Reloaded
</div>
<div style="font-size: 13px; color: #666; margin-bottom: 16px; line-height: 1.5;">
${
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.
</div>
${
isContextInvalidated
? `<button class="reload-page-btn" style="
<button class="reload-page-btn" style="
background: #ff6b6b;
color: #fff;
border: none;
@@ -2743,15 +2905,11 @@ export default defineContentScript({
transition: background 0.15s;
">
Reload Page
</button>`
: ""
}
</button>
`;
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 = `
<div style="margin-bottom: 12px;">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#EF4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin: 0 auto; display: block;">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</div>
<div style="font-size: 14px; font-weight: 600; color: #111827; margin-bottom: 8px;">
Something went wrong
</div>
<div style="font-size: 13px; color: #6B7280; margin-bottom: 16px;">
Failed to load event types. Please try again.
</div>
`;
contentContainer.appendChild(errorDiv);
}
}
}
+1 -1
View File
@@ -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",
+158 -3
View File
@@ -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<string> | 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<string | null> {
if (extensionSessionToken) {
return extensionSessionToken;
}
if (sessionTokenPromise) {
return sessionTokenPromise;
}
if (typeof window === "undefined" || window.parent === window) {
return null;
}
sessionTokenPromise = new Promise<string>((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<void> {
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<void> {
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<OAuthTokens> {
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<OAuthConfig> = {}): CalComOAuthService {
+1 -4
View File
@@ -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: {