Files
calendar/companion/services/oauthService.ts
T

494 lines
16 KiB
TypeScript

/// <reference types="chrome" />
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<string> {
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<OAuthTokens> {
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<string> {
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<AuthSession.AuthSessionResult | { type: string; params: Record<string, string> }> {
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<string, string>;
};
}
} 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<string, string> };
}
}
private async getDiscoveryEndpoints(): Promise<AuthSession.DiscoveryDocument> {
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<string, string> {
const urlObj = new URL(url);
const params: Record<string, string> = {};
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<OAuthTokens> {
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<OAuthTokens> {
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<OAuthTokens> {
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<OAuthConfig> = {}): 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);
}