* fix: companion extension oauth * fix: ios build * address cubics comments
373 lines
11 KiB
TypeScript
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);
|
|
}
|