fix(companion): Fix Gmail OAuth integration (#25978)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
co-authored by
Peer Richelsen
parent
15b1675db1
commit
471d1caac9
@@ -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
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user