Files
cal.diy/companion/services/oauthService.ts
T
Dhairyashil Shinde a2a77376b3 fix: companion extension oauth (#25939)
* fix: companion extension oauth

* fix: ios build

* address cubics comments
2025-12-16 22:22:44 +00:00

373 lines
11 KiB
TypeScript

/// <reference types="chrome" />
import { fromByteArray } from "base64-js";
import type * as AuthSession from "expo-auth-session";
import * as Crypto from "expo-crypto";
import * as WebBrowser from "expo-web-browser";
import { Platform } from "react-native";
WebBrowser.maybeCompleteAuthSession();
// Message types for extension communication
const EXTENSION_MESSAGE_TYPES = {
OAUTH_REQUEST: "cal-extension-oauth-request",
OAUTH_RESULT: "cal-extension-oauth-result",
TOKEN_EXCHANGE_REQUEST: "cal-extension-token-exchange-request",
TOKEN_EXCHANGE_RESULT: "cal-extension-token-exchange-result",
} 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
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() {
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> {
const base64Hash = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
codeVerifier,
{ encoding: Crypto.CryptoEncoding.BASE64 }
);
return base64Hash.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
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,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
return `${this.config.calcomBaseUrl}/auth/oauth2/authorize?${params.toString()}`;
}
private parseCallbackUrl(url: string): Record<string, string> {
const parsed = new URL(url);
const params: Record<string, string> = {};
parsed.searchParams.forEach((v, k) => {
params[k] = v;
});
return params;
}
private isMobileApp(): boolean {
return Platform.OS !== "web";
}
private isRunningInIframe(): boolean {
return Platform.OS === "web" && typeof window !== "undefined" && window.parent !== window;
}
async startAuthorizationFlow(): Promise<OAuthTokens> {
const { codeChallenge, state } = await this.generatePKCEParams();
if (Platform.OS === "web" && typeof window !== "undefined") {
const storedTokens = this.checkForStoredCallback(state);
if (storedTokens) {
return storedTokens;
}
}
const result = await this.getAuthorizationResult(codeChallenge, state);
if (result.type !== "success") {
throw new Error("OAuth flow failed");
}
const { code, state: returnedState } = result.params;
if (!code) {
throw new Error("No authorization code received");
}
if (returnedState !== state) {
throw new Error("Invalid state parameter");
}
return this.exchangeCodeForTokens(code, returnedState);
}
private checkForStoredCallback(state: string): Promise<OAuthTokens> | null {
const storedCode = window.localStorage.getItem(`oauth_callback_code_${state}`);
const storedState = window.localStorage.getItem(`oauth_callback_state_${state}`);
if (!storedCode || !storedState) {
return null;
}
if (storedState !== state) {
throw new Error("Invalid state parameter");
}
window.localStorage.removeItem(`oauth_callback_code_${state}`);
window.localStorage.removeItem(`oauth_callback_state_${state}`);
return this.exchangeCodeForTokens(storedCode, storedState);
}
private async getAuthorizationResult(
codeChallenge: string,
state: string
): Promise<{ type: "success"; params: Record<string, string> } | { type: "error" }> {
const authUrl = this.buildAuthorizationUrl(codeChallenge, state);
if (this.isMobileApp()) {
return this.getMobileAuthResult(authUrl);
}
return this.getExtensionAuthResult(authUrl);
}
private async getMobileAuthResult(
authUrl: string
): Promise<{ type: "success"; params: Record<string, string> } | { type: "error" }> {
const result = await WebBrowser.openAuthSessionAsync(authUrl, this.config.redirectUri, {
preferEphemeralSession: false,
});
if (result.type === "success") {
return { type: "success", params: this.parseCallbackUrl(result.url) };
}
return { type: "error" };
}
private async getExtensionAuthResult(
authUrl: string
): Promise<{ type: "success"; params: Record<string, string> } | { type: "error" }> {
try {
const responseUrl = await this.launchExtensionAuthFlow(authUrl);
return { type: "success", params: this.parseCallbackUrl(responseUrl) };
} catch {
return { type: "error" };
}
}
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(chrome.runtime.lastError.message));
} else if (responseUrl) {
resolve(responseUrl);
} else {
reject(new Error("OAuth cancelled"));
}
});
return;
}
if (this.isRunningInIframe()) {
this.requestOAuthViaPostMessage(authUrl, resolve, reject);
return;
}
reject(new Error("Extension context not available"));
});
}
private requestOAuthViaPostMessage(
authUrl: string,
resolve: (url: string) => void,
reject: (error: Error) => void
): void {
const timeoutId = setTimeout(() => {
window.removeEventListener("message", messageHandler);
reject(new Error("OAuth flow timed out"));
}, OAUTH_TIMEOUT_MS);
const messageHandler = (event: MessageEvent) => {
// Validate message source - must come from parent window (content script)
if (event.source !== window.parent) {
return;
}
if (event.data.type !== EXTENSION_MESSAGE_TYPES.OAUTH_RESULT) {
return;
}
clearTimeout(timeoutId);
window.removeEventListener("message", messageHandler);
if (event.data.success && event.data.responseUrl) {
resolve(event.data.responseUrl);
} else {
reject(new Error(event.data.error || "OAuth flow failed"));
}
};
window.addEventListener("message", messageHandler);
// Using "*" for targetOrigin because parent is the host page (e.g., gmail.com).
// Security: source validation above ensures responses only come from parent window.
window.parent.postMessage({ type: EXTENSION_MESSAGE_TYPES.OAUTH_REQUEST, authUrl }, "*");
}
private async exchangeCodeForTokens(code: string, state?: string): Promise<OAuthTokens> {
if (!this.codeVerifier) {
throw new Error("Missing code verifier");
}
const tokenRequest = {
grant_type: "authorization_code",
client_id: this.config.clientId,
code,
redirect_uri: this.config.redirectUri,
code_verifier: this.codeVerifier,
};
const tokenEndpoint = `${this.config.calcomBaseUrl}/api/auth/oauth/token`;
if (this.isRunningInIframe()) {
return this.exchangeCodeForTokensViaExtension(tokenRequest, tokenEndpoint, state);
}
return this.exchangeCodeForTokensDirect(tokenRequest, tokenEndpoint);
}
private async exchangeCodeForTokensDirect(
tokenRequest: Record<string, string>,
tokenEndpoint: string
): Promise<OAuthTokens> {
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams(tokenRequest).toString(),
});
if (!response.ok) {
throw new Error("Token exchange failed");
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
tokenType: data.token_type ?? "Bearer",
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
scope: data.scope,
};
}
private async exchangeCodeForTokensViaExtension(
tokenRequest: Record<string, string>,
tokenEndpoint: string,
state?: string
): Promise<OAuthTokens> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
window.removeEventListener("message", messageHandler);
reject(new Error("Token exchange timed out"));
}, TOKEN_EXCHANGE_TIMEOUT_MS);
const messageHandler = (event: MessageEvent) => {
// Validate message source - must come from parent window (content script)
if (event.source !== window.parent) {
return;
}
if (event.data.type !== EXTENSION_MESSAGE_TYPES.TOKEN_EXCHANGE_RESULT) {
return;
}
clearTimeout(timeoutId);
window.removeEventListener("message", messageHandler);
if (event.data.success && event.data.tokens) {
resolve(event.data.tokens);
} else {
reject(new Error(event.data.error || "Token exchange failed"));
}
};
window.addEventListener("message", messageHandler);
// See comment in requestOAuthViaPostMessage for security rationale
window.parent.postMessage(
{
type: EXTENSION_MESSAGE_TYPES.TOKEN_EXCHANGE_REQUEST,
tokenRequest,
tokenEndpoint,
state,
},
"*"
);
});
}
getDiscoveryEndpoints(): AuthSession.DiscoveryDocument {
return {
authorizationEndpoint: `${this.config.calcomBaseUrl}/auth/oauth2/authorize`,
tokenEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/token`,
revocationEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/revoke`,
};
}
isTokenExpired(tokens: OAuthTokens): boolean {
if (!tokens.expiresAt) return false;
return Date.now() >= tokens.expiresAt - 5 * 60 * 1000;
}
clearPKCEParams(): void {
this.codeVerifier = null;
this.state = null;
}
}
export function createCalComOAuthService(overrides: Partial<OAuthConfig> = {}): CalComOAuthService {
const config: 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 (!config.clientId || !config.redirectUri) {
throw new Error("OAuth configuration incomplete");
}
return new CalComOAuthService(config);
}