/// import { fromByteArray } from "base64-js"; import * as AuthSession from "expo-auth-session"; import * as Crypto from "expo-crypto"; import * as WebBrowser from "expo-web-browser"; import { Platform } from "react-native"; // Complete warm up for WebBrowser on mobile WebBrowser.maybeCompleteAuthSession(); export interface OAuthTokens { accessToken: string; refreshToken?: string; tokenType: string; expiresAt?: number; scope?: string; } export interface OAuthConfig { clientId: string; redirectUri: string; calcomBaseUrl: string; } export class CalComOAuthService { private config: OAuthConfig; private codeVerifier: string | null = null; private state: string | null = null; constructor(config: OAuthConfig) { this.config = config; } private async generatePKCEParams(): Promise<{ codeVerifier: string; codeChallenge: string; state: string; }> { const codeVerifier = this.generateRandomBase64Url(); const codeChallenge = await this.generateCodeChallenge(codeVerifier); const state = this.generateRandomBase64Url(); this.codeVerifier = codeVerifier; this.state = state; return { codeVerifier, codeChallenge, state }; } private async generateCodeChallenge(codeVerifier: string): Promise { try { const base64Hash = await Crypto.digestStringAsync( Crypto.CryptoDigestAlgorithm.SHA256, codeVerifier, { encoding: Crypto.CryptoEncoding.BASE64 } ); return base64Hash.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } catch (error) { console.error("Failed to generate code challenge:", error); throw new Error("Failed to generate OAuth code challenge"); } } private generateRandomBase64Url(): string { const bytes = new Uint8Array(Crypto.getRandomBytes(32)); const base64 = typeof btoa !== "undefined" ? btoa(String.fromCharCode(...bytes)) : fromByteArray(bytes); return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } private buildAuthorizationUrl(codeChallenge: string, state: string): string { const params = new URLSearchParams({ client_id: this.config.clientId, response_type: "code", redirect_uri: this.config.redirectUri, state: state, code_challenge: codeChallenge, code_challenge_method: "S256", }); return `${this.config.calcomBaseUrl}/auth/oauth2/authorize?${params.toString()}`; } async startAuthorizationFlow(): Promise { try { const { codeChallenge, state } = await this.generatePKCEParams(); // Web only: check for stored callback with state-specific keys to prevent race conditions if (Platform.OS === "web" && typeof window !== "undefined") { const storedCode = window.localStorage.getItem(`oauth_callback_code_${state}`); const storedState = window.localStorage.getItem(`oauth_callback_state_${state}`); if (storedCode && storedState) { // CSRF protection: verify state matches if (storedState !== state) { window.localStorage.removeItem(`oauth_callback_code_${state}`); window.localStorage.removeItem(`oauth_callback_state_${state}`); throw new Error("Invalid state parameter - possible CSRF attack"); } window.localStorage.removeItem(`oauth_callback_code_${state}`); window.localStorage.removeItem(`oauth_callback_state_${state}`); return await this.exchangeCodeForTokens(storedCode, state); } } // Note: State stored in background script (iframe may not have chrome.storage access) const authResult = await this.getAuthorizationResult(codeChallenge, state); if (authResult.type === "success") { const code = authResult.params?.code || authResult.params?.authorizationCode; const returnedState = authResult.params?.state; if (returnedState !== state) { throw new Error("Invalid state parameter - possible CSRF attack"); } if (!code) { throw new Error("No authorization code received"); } return await this.exchangeCodeForTokens(code, state); } if (authResult.type === "error") { const errorDescription = authResult.params?.error_description || ("error" in authResult ? authResult.error?.message : undefined) || "Unknown error"; throw new Error(`OAuth error: ${errorDescription}`); } if (authResult.type === "cancel") { throw new Error("OAuth flow was cancelled by user"); } throw new Error("OAuth flow failed or was dismissed"); } catch (error) { console.error("OAuth authorization error:", error); throw error; } } // Detect browser extension environment using reliable indicators private isBrowserExtension(): boolean { if (Platform.OS !== "web" || typeof window === "undefined") { return false; } if (typeof chrome !== "undefined" && chrome.runtime?.id) { return true; } if (window.location.protocol === "chrome-extension:") { return true; } // Extension content script injects companion app as iframe if (window.parent !== window) { try { const parentOrigin = window.parent.location.origin; const currentOrigin = window.location.origin; if (parentOrigin !== currentOrigin && window.location.hostname === "localhost") { return true; } } catch { // Cross-origin access blocked - expected for extension iframes if (window.location.hostname === "localhost") { return true; } } } return false; } private async launchExtensionAuthFlow(authUrl: string): Promise { return new Promise((resolve, reject) => { if (typeof chrome !== "undefined" && chrome.identity) { chrome.identity.launchWebAuthFlow( { url: authUrl, interactive: true, }, (responseUrl) => { if (chrome.runtime.lastError) { reject(new Error(`OAuth flow failed: ${chrome.runtime.lastError.message}`)); } else if (responseUrl) { resolve(responseUrl); } else { reject(new Error("OAuth flow cancelled or failed")); } } ); return; } // Iframe: communicate with parent window if (window.parent !== window) { let timeoutId: NodeJS.Timeout | null = null; const messageHandler = (event: MessageEvent) => { // Security: only accept messages from parent if (event.source !== window.parent) { return; } if (event.data.type === "cal-extension-oauth-result") { window.removeEventListener("message", messageHandler); if (timeoutId) { clearTimeout(timeoutId); } if (event.data.success) { resolve(event.data.responseUrl); } else { console.error("OAuth flow failed:", event.data.error); reject(new Error(event.data.error || "OAuth flow failed")); } } }; window.addEventListener("message", messageHandler); window.parent.postMessage( { type: "cal-extension-oauth-request", authUrl: authUrl, }, "*" ); timeoutId = setTimeout(() => { console.error("OAuth flow timeout - no response from extension"); window.removeEventListener("message", messageHandler); reject(new Error("OAuth flow timeout - no response from extension")); }, 30000); } else { reject(new Error("Chrome extension context not detected")); } }); } private async getAuthorizationResult( codeChallenge: string, state: string ): Promise }> { const authUrl = this.buildAuthorizationUrl(codeChallenge, state); const isExtension = this.isBrowserExtension(); if (isExtension) { try { const responseUrl = await this.launchExtensionAuthFlow(authUrl); const params = this.parseCallbackUrl(responseUrl); return { type: "success" as const, params }; } catch (error) { console.error("Extension OAuth flow failed:", error); return { type: "error", params: { error: error.message } } as { type: string; params: Record; }; } } else if (Platform.OS === "web") { const discovery = await this.getDiscoveryEndpoints(); const request = new AuthSession.AuthRequest({ clientId: this.config.clientId, redirectUri: this.config.redirectUri, responseType: AuthSession.ResponseType.Code, state: state, codeChallenge: codeChallenge, codeChallengeMethod: AuthSession.CodeChallengeMethod.S256, }); return await request.promptAsync(discovery); } else { const result = await WebBrowser.openAuthSessionAsync(authUrl, this.config.redirectUri); if (result.type === "success") { const params = this.parseCallbackUrl(result.url); return { type: "success" as const, params }; } return { type: result.type, params: {} } as { type: string; params: Record }; } } private async getDiscoveryEndpoints(): Promise { try { const discovery = await AuthSession.fetchDiscoveryAsync(this.config.calcomBaseUrl); return discovery; } catch { return { authorizationEndpoint: `${this.config.calcomBaseUrl}/auth/oauth2/authorize`, tokenEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/token`, } as AuthSession.DiscoveryDocument; } } private parseCallbackUrl(url: string): Record { const urlObj = new URL(url); const params: Record = {}; urlObj.searchParams.forEach((value, key) => { params[key] = value; }); if (Object.keys(params).length === 0 && urlObj.hash) { const hashParams = new URLSearchParams(urlObj.hash.substring(1)); hashParams.forEach((value, key) => { params[key] = value; }); } return params; } private async exchangeCodeForTokens(code: string, state?: string): Promise { if (!this.codeVerifier) { throw new Error("No code verifier available"); } // Extension: use APIs to avoid CORS if (this.isBrowserExtension() && window.parent !== window) { return await this.exchangeTokensViaExtension(code, state); } const tokenEndpoint = `${this.config.calcomBaseUrl}/api/auth/oauth/token`; const body = new URLSearchParams(); body.append("grant_type", "authorization_code"); body.append("client_id", this.config.clientId); body.append("code", code); body.append("redirect_uri", this.config.redirectUri); body.append("code_verifier", this.codeVerifier); const response = await fetch(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(); console.error("Token exchange error response:", errorData); try { const errorJson = JSON.parse(errorData); console.error("Parsed error:", errorJson); } catch { console.error("Could not parse error response as JSON"); } 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, }; return tokens; } private async exchangeTokensViaExtension(code: string, state?: string): Promise { return new Promise((resolve, reject) => { let timeoutId: NodeJS.Timeout | null = null; const messageHandler = (event: MessageEvent) => { // Security: only accept messages from parent if (event.source !== window.parent) { return; } if (event.data.type === "cal-extension-token-exchange-result") { window.removeEventListener("message", messageHandler); if (timeoutId) { clearTimeout(timeoutId); } if (event.data.success) { resolve(event.data.tokens); } else { console.error("Token exchange failed via extension:", event.data.error); reject(new Error(event.data.error || "Token exchange failed")); } } }; window.addEventListener("message", messageHandler); window.parent.postMessage( { type: "cal-extension-token-exchange-request", tokenRequest: { grant_type: "authorization_code", client_id: this.config.clientId, code: code, redirect_uri: this.config.redirectUri, code_verifier: this.codeVerifier, }, state: state, // CSRF validation in background script tokenEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/token`, }, "*" ); timeoutId = setTimeout(() => { window.removeEventListener("message", messageHandler); reject(new Error("Token exchange timeout")); }, 30000); }); } async refreshAccessToken(refreshToken: string): Promise { const tokenEndpoint = `${this.config.calcomBaseUrl}/api/auth/oauth/refreshToken`; const body = new URLSearchParams({ grant_type: "refresh_token", client_id: this.config.clientId, refresh_token: refreshToken, }); const response = await fetch(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(); throw new Error(`Token refresh failed: ${response.status} ${errorData}`); } const tokenData = await response.json(); const tokens: OAuthTokens = { accessToken: tokenData.access_token, refreshToken: tokenData.refresh_token || refreshToken, tokenType: tokenData.token_type || "Bearer", expiresAt: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined, scope: tokenData.scope, }; return tokens; } isTokenExpired(tokens: OAuthTokens): boolean { if (!tokens.expiresAt) { return false; } // 5-minute buffer before expiry return Date.now() >= tokens.expiresAt - 5 * 60 * 1000; } clearPKCEParams(): void { this.codeVerifier = null; this.state = null; } } export function createCalComOAuthService(overrides: Partial = {}): CalComOAuthService { let defaultRedirectUri: string; const defaultConfig: OAuthConfig = { clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID || "", redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI, calcomBaseUrl: "https://app.cal.com", ...overrides, }; if (!defaultConfig.clientId) { throw new Error( "OAuth client ID is required. Set EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID environment variable." ); } return new CalComOAuthService(defaultConfig); }