Skip to content

Conversation

@hussam789
Copy link

@hussam789 hussam789 commented Oct 30, 2025

User description

PR #7


PR Type

Enhancement


Description

  • Add OAuth credential synchronization between Cal.com and self-hosted platforms

  • Implement webhook endpoint for secure credential sync with encryption

  • Reorganize OAuth utilities into dedicated folder structure

  • Integrate credential refresh token handling across multiple integrations

  • Add configuration variables for credential sharing and webhook security


Diagram Walkthrough

flowchart LR
  A["OAuth Integrations"] -->|"Refresh Tokens"| B["refreshOAuthTokens"]
  B -->|"Credential Sync Enabled"| C["CALCOM_CREDENTIAL_SYNC_ENDPOINT"]
  B -->|"Default Flow"| D["Provider Token Endpoint"]
  C -->|"POST Request"| E["External Sync Service"]
  E -->|"Response"| F["parseRefreshTokenResponse"]
  F -->|"Decrypt & Validate"| G["Webhook Handler"]
  G -->|"Upsert Credentials"| H["Prisma Database"]
  I["Environment Variables"] -->|"Config"| B
  I -->|"Config"| G
Loading

File Walkthrough

Relevant files
Configuration changes
2 files
.env.example
Add credential sync environment variables                               
+14/-1   
turbo.json
Add credential sync environment variables to global config
+4/-0     
Enhancement
14 files
app-credential.ts
Create webhook endpoint for credential synchronization     
+93/-0   
parseRefreshTokenResponse.ts
Create token response parser with fallback schema               
+32/-0   
refreshOAuthTokens.ts
Create OAuth token refresh wrapper with sync support         
+22/-0   
CalendarService.ts
Integrate credential refresh token handling                           
+11/-2   
CalendarService.ts
Integrate credential refresh token handling                           
+13/-7   
CalendarService.ts
Integrate credential refresh token handling                           
+17/-11 
CalendarService.ts
Integrate credential refresh and token parsing                     
+19/-20 
VideoApiAdapter.ts
Integrate credential refresh token handling                           
+16/-10 
CalendarService.ts
Add token refresh and parsing for Salesforce OAuth             
+38/-0   
VideoApiAdapter.ts
Integrate credential refresh token handling                           
+18/-12 
CalendarService.ts
Integrate credential refresh token handling                           
+11/-5   
CalendarService.ts
Integrate credential refresh token handling                           
+14/-8   
VideoApiAdapter.ts
Integrate credential refresh and token parsing                     
+19/-12 
constants.ts
Add credential sharing feature flag constant                         
+3/-0     
Miscellaneous
24 files
createOAuthAppCredential.ts
Update import paths for OAuth utilities reorganization     
+2/-2     
decodeOAuthState.ts
Fix relative import path for types                                             
+1/-1     
encodeOAuthState.ts
Fix relative import path for types                                             
+1/-1     
add.ts
Update OAuth import paths to new folder structure               
+1/-1     
callback.ts
Update OAuth import paths to new folder structure               
+1/-1     
add.ts
Update OAuth import paths to new folder structure               
+1/-1     
callback.ts
Update OAuth import paths to new folder structure               
+2/-2     
add.ts
Update OAuth import paths to new folder structure               
+1/-1     
callback.ts
Update OAuth import paths to new folder structure               
+1/-1     
add.ts
Update OAuth import paths to new folder structure               
+1/-1     
callback.ts
Update OAuth import paths to new folder structure               
+1/-1     
add.ts
Update OAuth import paths to new folder structure               
+1/-1     
callback.ts
Update OAuth import paths to new folder structure               
+2/-2     
add.ts
Update OAuth import paths to new folder structure               
+1/-1     
callback.ts
Update OAuth import paths to new folder structure               
+2/-2     
callback.ts
Update OAuth import paths to new folder structure               
+1/-1     
callback.ts
Update OAuth import paths to new folder structure               
+1/-1     
callback.ts
Update OAuth import paths to new folder structure               
+1/-1     
add.ts
Update OAuth import paths to new folder structure               
+2/-2     
callback.ts
Update OAuth import paths to new folder structure               
+2/-2     
_getAdd.ts
Update OAuth import paths to new folder structure               
+1/-1     
callback.ts
Update OAuth import paths to new folder structure               
+2/-2     
add.ts
Update OAuth import paths to new folder structure               
+1/-1     
callback.ts
Update OAuth import paths to new folder structure               
+1/-1     

…11059)

* Add credential sync .env variables

* Add webhook to send app credentials

* Upsert credentials when webhook called

* Refresh oauth token from a specific endpoint

* Pass appSlug

* Add credential encryption

* Move oauth helps into a folder

* Create parse token response wrapper

* Add OAuth helpers to apps

* Clean up

* Refactor `appDirName` to `appSlug`

* Address feedback

* Change to safe parse

* Remove console.log

---------

Co-authored-by: Syed Ali Shahbaz <[email protected]>
Co-authored-by: Omar López <[email protected]>
@qodo-code-review
Copy link

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
Webhook replay risk

Description: Webhook authentication relies solely on a static shared secret in a custom header without
replay protection (no timestamp, nonce, or signature), enabling potential replay attacks
if a request is captured.
app-credential.ts [23-29]

Referred Code
// Check that the webhook secret matches
if (
  req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !==
  process.env.CALCOM_WEBHOOK_SECRET
) {
  return res.status(403).json({ message: "Invalid webhook secret" });
}
Weak crypto input validation

Description: Decryption uses an environment key directly without validating presence/length or handling
decryption failures, which can cause crashes or acceptance of malformed data if not
checked.
app-credential.ts [56-59]

Referred Code
// Decrypt the keys
const keys = JSON.parse(
  symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
);
Invalid token fallback

Description: When credential sharing is enabled, the parser fabricates a missing refresh_token by
setting a placeholder string "refresh_token", risking storing invalid tokens and breaking
subsequent refresh flows.
parseRefreshTokenResponse.ts [5-31]

Referred Code
const minimumTokenResponseSchema = z.object({
  access_token: z.string(),
  //   Assume that any property with a number is the expiry
  [z.string().toString()]: z.number(),
  //   Allow other properties in the token response
  [z.string().optional().toString()]: z.unknown().optional(),
});

const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => {
  let refreshTokenResponse;
  if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT) {
    refreshTokenResponse = minimumTokenResponseSchema.safeParse(response);
  } else {
    refreshTokenResponse = schema.safeParse(response);
  }

  if (!refreshTokenResponse.success) {
    throw new Error("Invalid refreshed tokens were returned");
  }

  if (!refreshTokenResponse.data.refresh_token) {


 ... (clipped 6 lines)
Unsigned external sync

Description: External sync POST sends only user and app identifiers without request signing, nonce, or
TLS pinning, making it vulnerable to tampering or MITM if endpoint is misconfigured;
response is trusted without schema validation here.
refreshOAuthTokens.ts [5-15]

Referred Code
if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT && userId) {
  // Customize the payload based on what your endpoint requires
  // The response should only contain the access token and expiry date
  const response = await fetch(process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT, {
    method: "POST",
    body: new URLSearchParams({
      calcomUserId: userId.toString(),
      appSlug,
    }),
  });
  return response;
Unvalidated credential schema

Description: Upserting credentials uses decrypted JSON keys without schema validation against the app’s
expected credential shape, risking inconsistent or malicious key structures being stored.
app-credential.ts [61-90]

Referred Code
// Can't use prisma upsert as we don't know the id of the credential
const appCredential = await prisma.credential.findFirst({
  where: {
    userId: reqBody.userId,
    appId: appMetadata.slug,
  },
  select: {
    id: true,
  },
});

if (appCredential) {
  await prisma.credential.update({
    where: {
      id: appCredential.id,
    },
    data: {
      key: keys,
    },
  });
  return res.status(200).json({ message: `Credentials updated for userId: ${reqBody.userId}` });


 ... (clipped 9 lines)
Ticket Compliance
🎫 No ticket provided
  • Create ticket/issue
Codebase Duplication Compliance
Codebase context is not defined

Follow the guide to enable codebase context checks.

Custom Compliance
🟢
Generic: Meaningful Naming and Self-Documenting Code

Objective: Ensure all identifiers clearly express their purpose and intent, making code
self-documenting

Status: Passed

Generic: Secure Error Handling

Objective: To prevent the leakage of sensitive system information through error messages while
providing sufficient detail for internal debugging.

Status: Passed

Generic: Secure Logging Practices

Objective: To ensure logs are useful for debugging and auditing without exposing sensitive
information like PII, PHI, or cardholder data.

Status: Passed

Generic: Comprehensive Audit Trails

Objective: To create a detailed and reliable record of critical system actions for security analysis
and compliance.

Status:
Missing audit log: The webhook endpoint performs sensitive credential upsert operations without emitting any
audit logs capturing user ID, action, and outcome.

Referred Code
if (appCredential) {
  await prisma.credential.update({
    where: {
      id: appCredential.id,
    },
    data: {
      key: keys,
    },
  });
  return res.status(200).json({ message: `Credentials updated for userId: ${reqBody.userId}` });
} else {
  await prisma.credential.create({
    data: {
      key: keys,
      userId: reqBody.userId,
      appId: appMetadata.slug,
      type: appMetadata.type,
    },
  });
  return res.status(200).json({ message: `Credentials created for userId: ${reqBody.userId}` });
}
Generic: Robust Error Handling and Edge Case Management

Objective: Ensure comprehensive error handling that provides meaningful context and graceful
degradation

Status:
Weak validation: When credential sharing is enabled, token response validation is overly permissive and
silently substitutes a placeholder refresh_token, risking downstream inconsistencies
without explicit error handling or logging.

Referred Code
if (!refreshTokenResponse.success) {
  throw new Error("Invalid refreshed tokens were returned");
}

if (!refreshTokenResponse.data.refresh_token) {
  refreshTokenResponse.data.refresh_token = "refresh_token";
}
Generic: Security-First Input Validation and Data Handling

Objective: Ensure all data inputs are validated, sanitized, and handled securely to prevent
vulnerabilities

Status:
Header auth risk: The webhook authenticates using a static header secret without replay protection or
timing-safe comparison and decrypts incoming keys using an env key without additional
validation of ciphertext format or rotation mechanism.

Referred Code
// Check that the webhook secret matches
if (
  req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !==
  process.env.CALCOM_WEBHOOK_SECRET
) {
  return res.status(403).json({ message: "Invalid webhook secret" });
}

const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body);

// Check that the user exists
const user = await prisma.user.findUnique({ where: { id: reqBody.userId } });

if (!user) {
  return res.status(404).json({ message: "User not found" });
}

const app = await prisma.app.findUnique({
  where: { slug: reqBody.appSlug },
  select: { slug: true },
});


 ... (clipped 16 lines)
Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
⚪ - Requires Further Human Verification
🏷️ - Compliance label

@qodo-code-review
Copy link

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
High-level
The credential sync flow is flawed

The credential sync implementation is inconsistent. The refreshOAuthTokens
function expects a synchronous token response from an external endpoint, but the
architecture also includes an asynchronous webhook for credential updates,
creating a conflicting and confusing design.

Examples:

packages/app-store/_utils/oauth/refreshOAuthTokens.ts [3-20]
const refreshOAuthTokens = async (refreshFunction: () => any, appSlug: string, userId: number | null) => {
  // Check that app syncing is enabled and that the credential belongs to a user
  if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT && userId) {
    // Customize the payload based on what your endpoint requires
    // The response should only contain the access token and expiry date
    const response = await fetch(process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT, {
      method: "POST",
      body: new URLSearchParams({
        calcomUserId: userId.toString(),
        appSlug,

 ... (clipped 8 lines)
apps/web/pages/api/webhook/app-credential.ts [17-93]
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  // Check that credential sharing is enabled
  if (!APP_CREDENTIAL_SHARING_ENABLED) {
    return res.status(403).json({ message: "Credential sharing is not enabled" });
  }

  // Check that the webhook secret matches
  if (
    req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !==
    process.env.CALCOM_WEBHOOK_SECRET

 ... (clipped 67 lines)

Solution Walkthrough:

Before:

// In refreshOAuthTokens.ts
async function refreshOAuthTokens(refreshFunction, appSlug, userId) {
  if (sync_is_enabled) {
    // Makes a request and expects the response to contain the new token.
    const response = await fetch(SYNC_ENDPOINT, { ... });
    return response; // The caller will parse this response for the token.
  }
  return await refreshFunction();
}

// In a separate webhook file: /api/webhook/app-credential.ts
async function handler(req, res) {
  // Receives credentials pushed from an external service.
  const { userId, appSlug, keys } = req.body;
  // Decrypts and saves credentials to the database.
  ...
}

After:

// Suggestion: The flow needs to be redesigned for consistency.
// Option A: Fully Synchronous Flow
async function refreshOAuthTokens(refreshFunction, appSlug, userId) {
  if (sync_is_enabled) {
    // External endpoint MUST synchronously refresh and return the token.
    const response = await fetch(SYNC_ENDPOINT, { ... });
    // The webhook is not used in this refresh flow.
    return response;
  }
  return await refreshFunction();
}

// Option B: Fully Asynchronous Flow
async function refreshOAuthTokens(refreshFunction, appSlug, userId) {
  if (sync_is_enabled) {
    // Fire-and-forget request to trigger the async refresh.
    fetch(SYNC_ENDPOINT, { ... });
    // The caller must handle the fact that the token is not updated yet.
    // This is complex and requires significant changes in callers.
    return await refreshFunction(); // Fallback to old flow for now.
  }
  return await refreshFunction();
}
Suggestion importance[1-10]: 9

__

Why: This suggestion correctly identifies a critical design flaw in the credential synchronization logic, where the code expects a synchronous token response while also providing an asynchronous webhook mechanism, creating a fundamental contradiction.

High
Security
Use constant-time comparison for secrets

Replace the direct string comparison for the webhook secret with a constant-time
comparison using crypto.timingSafeEqual to prevent timing attacks.

apps/web/pages/api/webhook/app-credential.ts [23-29]

 // Check that the webhook secret matches
-if (
-  req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !==
-  process.env.CALCOM_WEBHOOK_SECRET
-) {
-  return res.status(403).json({ message: "Invalid webhook secret" });
+try {
+  const crypto = await import("crypto");
+  const providedSecret = req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"];
+  const expectedSecret = process.env.CALCOM_WEBHOOK_SECRET;
+
+  if (!providedSecret || !expectedSecret) {
+    return res.status(403).json({ message: "Invalid webhook secret" });
+  }
+
+  const providedBuffer = Buffer.from(providedSecret as string);
+  const expectedBuffer = Buffer.from(expectedSecret);
+
+  if (
+    providedBuffer.length !== expectedBuffer.length ||
+    !crypto.timingSafeEqual(providedBuffer, expectedBuffer)
+  ) {
+    return res.status(403).json({ message: "Invalid webhook secret" });
+  }
+} catch (e) {
+  return res.status(500).json({ message: "Error validating webhook secret" });
 }
  • Apply / Chat
Suggestion importance[1-10]: 9

__

Why: The suggestion correctly identifies a timing attack vulnerability in the new webhook secret validation logic and proposes the appropriate fix using crypto.timingSafeEqual, which is a critical security best practice.

High
Possible issue
Refine Zod schema for token validation

Correct the minimumTokenResponseSchema Zod schema to avoid a conflict between
the access_token property and a broad index signature, ensuring proper
validation of token responses.

packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts [5-11]

-const minimumTokenResponseSchema = z.object({
-  access_token: z.string(),
-  //   Assume that any property with a number is the expiry
-  [z.string().toString()]: z.number(),
-  //   Allow other properties in the token response
-  [z.string().optional().toString()]: z.unknown().optional(),
-});
+const minimumTokenResponseSchema = z
+  .object({
+    access_token: z.string(),
+  })
+  .catchall(z.union([z.string(), z.number(), z.unknown()]))
+  .refine(
+    (data) => {
+      // Ensure there is at least one numeric value, which is assumed to be the expiry.
+      return Object.values(data).some((value) => typeof value === "number");
+    },
+    {
+      message: "Token response must contain at least one number value for expiry.",
+    }
+  );
  • Apply / Chat
Suggestion importance[1-10]: 8

__

Why: The suggestion correctly identifies a flaw in the Zod schema where an index signature conflicts with a declared property, which would cause validation to fail, and provides a robust, corrected schema.

Medium
General
Use centralized token refresh utility

Refactor the Salesforce token refresh logic to use the new centralized
refreshOAuthTokens utility, ensuring it aligns with the changes made to other
integrations in this PR.

packages/app-store/salesforce/lib/CalendarService.ts [73-99]

 const credentialKey = credential.key as unknown as ExtendedTokenResponse;
 
-const response = await fetch("https://login.salesforce.com/services/oauth2/token", {
-  method: "POST",
-  body: new URLSearchParams({
-    grant_type: "refresh_token",
-    client_id: consumer_key,
-    client_secret: consumer_secret,
-    refresh_token: credentialKey.refresh_token,
-    format: "json",
-  }),
-});
+const response = await refreshOAuthTokens(
+  async () =>
+    await fetch("https://login.salesforce.com/services/oauth2/token", {
+      method: "POST",
+      body: new URLSearchParams({
+        grant_type: "refresh_token",
+        client_id: consumer_key,
+        client_secret: consumer_secret,
+        refresh_token: credentialKey.refresh_token,
+        format: "json",
+      }),
+    }),
+  "salesforce",
+  credential.userId
+);
 
-if (response.statusText !== "OK") throw new HttpError({ statusCode: 400, message: response.statusText });
+if (!response.ok) throw new HttpError({ statusCode: response.status, message: response.statusText });
 
 const accessTokenJson = await response.json();
 
 const accessTokenParsed = parseRefreshTokenResponse(accessTokenJson, salesforceTokenSchema);
 
 if (!accessTokenParsed.success) {
   return Promise.reject(new Error("Invalid refreshed tokens were returned"));
 }
 
 await prisma.credential.update({
   where: { id: credential.id },
   data: { key: { ...accessTokenParsed.data, refresh_token: credentialKey.refresh_token } },
 });
  • Apply / Chat
Suggestion importance[1-10]: 8

__

Why: The suggestion correctly points out that the Salesforce integration was missed in the refactoring to use the new centralized refreshOAuthTokens utility, which is the main purpose of this PR.

Medium
  • More

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants