494 lines
16 KiB
TypeScript
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);
|
|
}
|