Skip to content

Automated Test: oauth-security-enhanced #379

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,19 @@ AUTH_BEARER_TOKEN_VERCEL=
E2E_TEST_APPLE_CALENDAR_EMAIL=""
E2E_TEST_APPLE_CALENDAR_PASSWORD=""

# - APP CREDENTIAL SYNC ***********************************************************************************
# Used for self-hosters that are implementing Cal.com into their applications that already have certain integrations
# Under settings/admin/apps ensure that all app secrets are set the same as the parent application
# You can use: `openssl rand -base64 32` to generate one
CALCOM_WEBHOOK_SECRET=""
# This is the header name that will be used to verify the webhook secret. Should be in lowercase
CALCOM_WEBHOOK_HEADER_NAME="calcom-webhook-secret"
CALCOM_CREDENTIAL_SYNC_ENDPOINT=""
# Key should match on Cal.com and your application
# must be 32 bytes for AES256 encryption algorithm
# You can use: `openssl rand -base64 24` to generate one
CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY=""

# - OIDC E2E TEST *******************************************************************************************

# Ensure this ADMIN EMAIL is present in the SAML_ADMINS list
Expand All @@ -243,4 +256,4 @@ E2E_TEST_OIDC_PROVIDER_DOMAIN=
E2E_TEST_OIDC_USER_EMAIL=
E2E_TEST_OIDC_USER_PASSWORD=

# ***********************************************************************************************************
# ***********************************************************************************************************
93 changes: 93 additions & 0 deletions apps/web/pages/api/webhook/app-credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { NextApiRequest, NextApiResponse } from "next";
import z from "zod";

import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import prisma from "@calcom/prisma";

const appCredentialWebhookRequestBodySchema = z.object({
// UserId of the cal.com user
userId: z.number().int(),
appSlug: z.string(),
// Keys should be AES256 encrypted with the CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY
keys: z.string(),
});
/** */
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
) {
return res.status(403).json({ message: "Invalid webhook secret" });
}
Comment on lines +17 to +29

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing HTTP method restriction and brittle webhook secret validation.

  1. No method check: This handler accepts any HTTP method. It should restrict to POST only.

  2. Webhook secret bypass when unset: If CALCOM_WEBHOOK_SECRET is undefined and the request omits the header, both sides are undefined, so undefined !== undefined evaluates to false and the check passes. The APP_CREDENTIAL_SHARING_ENABLED guard on line 19 mitigates this (since it requires the env var to be set), but defense-in-depth is warranted.

🛡️ Proposed fix
 export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+  if (req.method !== "POST") {
+    return res.status(405).json({ message: "Method not allowed" });
+  }
+
   // 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
+  const webhookSecret = process.env.CALCOM_WEBHOOK_SECRET;
+  if (!webhookSecret) {
+    return res.status(500).json({ message: "Webhook secret is not configured" });
+  }
   if (
     req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !==
-    process.env.CALCOM_WEBHOOK_SECRET
+    webhookSecret
   ) {
     return res.status(403).json({ message: "Invalid webhook secret" });
   }
🤖 Prompt for AI Agents
In `@apps/web/pages/api/webhook/app-credential.ts` around lines 17 - 29, The
handler must reject non-POSTs and must not allow a missing secret to bypass
validation: in the exported handler function, first enforce req.method ===
"POST" and return 405 for others; then ensure process.env.CALCOM_WEBHOOK_SECRET
is defined (if not, return 403) before comparing it to the incoming header (use
process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret" to read the
header); finally perform the strict compare between the known secret and the
header value and return 403 on mismatch. Reference: handler,
APP_CREDENTIAL_SHARING_ENABLED, CALCOM_WEBHOOK_SECRET,
CALCOM_WEBHOOK_HEADER_NAME.


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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unhandled ZodError on invalid request body.

z.parse() throws a ZodError if the body doesn't match the schema, resulting in an unhandled 500 with a stack trace. Use .safeParse() and return a 400 for invalid input.

🛡️ Proposed fix
-  const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body);
+  const parsed = appCredentialWebhookRequestBodySchema.safeParse(req.body);
+  if (!parsed.success) {
+    return res.status(400).json({ message: "Invalid request body" });
+  }
+  const reqBody = parsed.data;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body);
const parsed = appCredentialWebhookRequestBodySchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ message: "Invalid request body" });
}
const reqBody = parsed.data;
🤖 Prompt for AI Agents
In `@apps/web/pages/api/webhook/app-credential.ts` at line 31, Replace the
unconditional call to appCredentialWebhookRequestBodySchema.parse with a safe
parse flow: call appCredentialWebhookRequestBodySchema.safeParse(req.body),
check the result.success flag, and if false respond with a 400 (Bad Request)
including the validation errors; otherwise assign the parsed data to reqBody and
continue. Update references to reqBody and preserve existing handler logic so
only invalid bodies return early with a 400 and validation details.


// 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 },
});

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

// Search for the app's slug and type
const appMetadata = appStoreMetadata[app.slug as keyof typeof appStoreMetadata];

if (!appMetadata) {
return res.status(404).json({ message: "App not found. Ensure that you have the correct app slug" });
}

// Decrypt the keys
const keys = JSON.parse(
symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
);
Comment on lines +57 to +59

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unhandled errors from decryption and JSON parsing.

Both symmetricDecrypt (on invalid ciphertext) and JSON.parse (on malformed JSON) can throw. Without a try-catch, these surface as 500 errors with potentially sensitive stack traces.

🛡️ Proposed fix
-  const keys = JSON.parse(
-    symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
-  );
+  let keys;
+  try {
+    keys = JSON.parse(
+      symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
+    );
+  } catch (error) {
+    return res.status(400).json({ message: "Failed to decrypt or parse credential keys" });
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const keys = JSON.parse(
symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
);
let keys;
try {
keys = JSON.parse(
symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
);
} catch (error) {
return res.status(400).json({ message: "Failed to decrypt or parse credential keys" });
}
🤖 Prompt for AI Agents
In `@apps/web/pages/api/webhook/app-credential.ts` around lines 57 - 59, The
decryption and JSON parsing of reqBody.keys using symmetricDecrypt(...) and
JSON.parse(...) can throw and are currently unhandled; wrap the call that
assigns keys in a try-catch around symmetricDecrypt(reqBody.keys,
process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "") and JSON.parse(...) (the
code that assigns the keys variable), catch errors, log a non-sensitive error
message, and return a safe client error response (e.g., 400 with a generic
"invalid credentials" message) instead of letting the exception propagate;
ensure no sensitive details or stack traces are returned to the client.


// 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}` });
} 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}` });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";

import { decodeOAuthState } from "./decodeOAuthState";
import { throwIfNotHaveAdminAccessToTeam } from "./throwIfNotHaveAdminAccessToTeam";
import { decodeOAuthState } from "../oauth/decodeOAuthState";
import { throwIfNotHaveAdminAccessToTeam } from "../throwIfNotHaveAdminAccessToTeam";

/**
* This function is used to create app credentials for either a user or a team
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { NextApiRequest } from "next";

import type { IntegrationOAuthCallbackState } from "../types";
import type { IntegrationOAuthCallbackState } from "../../types";

export function decodeOAuthState(req: NextApiRequest) {
if (typeof req.query.state !== "string") {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { NextApiRequest } from "next";

import type { IntegrationOAuthCallbackState } from "../types";
import type { IntegrationOAuthCallbackState } from "../../types";

export function encodeOAuthState(req: NextApiRequest) {
if (typeof req.query.state !== "string") {
Expand Down
32 changes: 32 additions & 0 deletions packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { z } from "zod";

import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants";

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(),
});
Comment on lines +5 to +11

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, verify the file exists and check its content
find . -name "parseRefreshTokenResponse.ts" -type f

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 152


🏁 Script executed:

#!/bin/bash
# Test the Zod schema behavior directly
cd /tmp
cat > test_schema.mjs << 'EOF'
import { z } from "zod";

console.log("=== Testing z.string().toString() ===");
console.log("z.string().toString() =>", z.string().toString());
console.log("z.string().optional().toString() =>", z.string().optional().toString());

console.log("\n=== Testing current schema ===");
const currentSchema = z.object({
  access_token: z.string(),
  [z.string().toString()]: z.number(),
  [z.string().optional().toString()]: z.unknown().optional(),
});

const testResponse = {
  access_token: "test_token",
  refresh_token: "test_refresh",
  expires_in: 3600,
  expiry_date: 1700000000,
};

const result = currentSchema.safeParse(testResponse);
console.log("Parse success:", result.success);
console.log("Input keys:", Object.keys(testResponse));
console.log("Output keys:", result.success ? Object.keys(result.data) : "N/A");
console.log("Full output:", JSON.stringify(result.data || result.error.issues, null, 2));

console.log("\n=== Testing with passthrough ===");
const passthroughSchema = z.object({
  access_token: z.string(),
}).passthrough();

const result2 = passthroughSchema.safeParse(testResponse);
console.log("Parse success:", result2.success);
console.log("Output keys:", result2.success ? Object.keys(result2.data) : "N/A");
console.log("Full output:", JSON.stringify(result2.data, null, 2));
EOF

# Try to run with node
node test_schema.mjs 2>&1 || echo "Node execution failed or not available"

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 1039


🏁 Script executed:

#!/bin/bash
# Read the file to verify the schema definition
cat -n ./packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 1344


minimumTokenResponseSchema does not work as intended — computed-key syntax is not a Zod catch-all.

[z.string().toString()] evaluates to a literal string key (the toString() representation of the Zod schema object), not a dynamic record key matcher. The schema ends up defining two oddly-named literal keys rather than "any string key → number" and "any optional key → unknown". Additionally, z.object() strips unrecognized keys by default, so expiry fields, refresh tokens, and other provider-specific properties will be silently dropped from the parsed output.

To accept access_token plus arbitrary additional fields, use .passthrough():

Proposed fix
-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(),
+  })
+  .passthrough();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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(),
})
.passthrough();
🤖 Prompt for AI Agents
In `@packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts` around lines 5
- 11, minimumTokenResponseSchema is incorrectly using computed-key syntax which
creates literal strange keys and also drops unknown properties; replace the
object schema with a schema that accepts access_token plus arbitrary provider
fields by defining z.object({ access_token: z.string() }).passthrough() (or, if
you need to enforce numeric expiry values, validate those separately with
z.record(z.number()) or a refinement/transform), and remove the computed-key
entries so expiry/refresh/provider-specific fields are preserved by
parseRefreshTokenResponse.


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) {
refreshTokenResponse.data.refresh_token = "refresh_token";
}
Comment on lines +25 to +27

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Setting refresh_token to the literal string "refresh_token" is a risky sentinel value.

If a provider legitimately omits refresh_token (e.g., because it doesn't rotate refresh tokens), injecting a dummy string will cause a hard failure on the next token refresh attempt — but with a confusing error from the provider rather than a clear local error. Consider leaving it undefined or null so downstream code can distinguish "no refresh token" from "has a refresh token."

🤖 Prompt for AI Agents
In `@packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts` around lines 25
- 27, The code sets refreshTokenResponse.data.refresh_token to the literal
"refresh_token", which masks the absence of a real token; instead, stop
injecting that sentinel: leave refreshTokenResponse.data.refresh_token as
undefined or null when the provider omits it so downstream logic can detect "no
refresh token". Update the conditional in parseRefreshTokenResponse to remove
the assignment and ensure downstream consumers (any code reading
refreshTokenResponse.data.refresh_token) handle undefined/null distinctly from a
real token.


return refreshTokenResponse;
};

export default parseRefreshTokenResponse;
22 changes: 22 additions & 0 deletions packages/app-store/_utils/oauth/refreshOAuthTokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants";

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,
}),
});
return response;
Comment on lines +8 to +15

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

No error handling on credential sync endpoint request.

The fetch to CALCOM_CREDENTIAL_SYNC_ENDPOINT has no error handling — network failures will throw unhandled exceptions, and non-2xx HTTP responses are returned silently to the caller without any status check. Consider at minimum checking response.ok and throwing a descriptive error on failure, and wrapping in try/catch for network-level errors.

Proposed improvement
     const response = await fetch(process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT, {
       method: "POST",
       body: new URLSearchParams({
         calcomUserId: userId.toString(),
         appSlug,
       }),
     });
-    return response;
+    if (!response.ok) {
+      throw new Error(
+        `Credential sync endpoint returned ${response.status}: ${response.statusText}`
+      );
+    }
+    const data = await response.json();
+    return data;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = await fetch(process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT, {
method: "POST",
body: new URLSearchParams({
calcomUserId: userId.toString(),
appSlug,
}),
});
return response;
const response = await fetch(process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT, {
method: "POST",
body: new URLSearchParams({
calcomUserId: userId.toString(),
appSlug,
}),
});
if (!response.ok) {
throw new Error(
`Credential sync endpoint returned ${response.status}: ${response.statusText}`
);
}
const data = await response.json();
return data;
🤖 Prompt for AI Agents
In `@packages/app-store/_utils/oauth/refreshOAuthTokens.ts` around lines 8 - 15,
Wrap the fetch to process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT in a try/catch,
check response.ok after the await fetch, and throw a descriptive error
(including response.status and response.statusText or response body) when not
ok; specifically update the code around the fetch call (the use of fetch,
URLSearchParams, and the returned response) so network errors are caught and a
clear error is thrown on non-2xx responses before returning the successful
response or parsed body.

} else {
const response = await refreshFunction();
return response;
}
};
Comment on lines +3 to +20

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Return type mismatch between credential-sharing and direct-refresh branches will cause runtime failures.

When APP_CREDENTIAL_SHARING_ENABLED is true, the function returns a raw fetch Response (line 15). When false, it returns whatever refreshFunction() yields — which varies by caller: a fetch Response (Lark, Webex, Office365), an AxiosResponse (Zoho-bigin, ZohoCRM), or a HubSpot token object.

Callers downstream access provider-specific shapes (e.g., tokenInfo.data.error for axios, .expiryDate for HubSpot). When credential sharing is enabled, those callers will receive a raw fetch Response instead and crash at runtime — .data doesn't exist on Response, nor do HubSpot token fields.

The Promise<any> return type masks this at compile time.

To fix this, the credential-sharing branch should either:

  1. Parse the response JSON and conform to each caller's expected shape, or
  2. Have each caller normalize the return value, or
  3. Return a common, well-typed contract that all callers adapt to.
🤖 Prompt for AI Agents
In `@packages/app-store/_utils/oauth/refreshOAuthTokens.ts` around lines 3 - 20,
refreshOAuthTokens currently returns a raw fetch Response when
APP_CREDENTIAL_SHARING_ENABLED is true but returns the provider-specific value
from refreshFunction() otherwise, causing downstream callers (expecting
AxiosResponse, HubSpot token object, etc.) to break; update refreshOAuthTokens
to always return a normalized contract: call
fetch(process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT, ...) then await
response.json(), validate/transform that JSON into the same shape(s) your
callers expect (or into a simple common wrapper { provider: string, data: any,
expiryDate?: string, error?: any }), and return that normalized object so code
using refreshFunction(), refreshOAuthTokens, and callers (which inspect
tokenInfo.data, .expiryDate, or .data.error) receive a consistent structure;
keep the refreshFunction() fallback behavior but adapt/normalize its result to
the same contract as well.


export default refreshOAuthTokens;
2 changes: 1 addition & 1 deletion packages/app-store/googlecalendar/api/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";

import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";

import { encodeOAuthState } from "../../_utils/encodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";

const scopes = [
"https://www.googleapis.com/auth/calendar.readonly",
Expand Down
2 changes: 1 addition & 1 deletion packages/app-store/googlecalendar/api/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { WEBAPP_URL_FOR_OAUTH, CAL_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";

import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";

let client_id = "";
let client_secret = "";
Expand Down
13 changes: 11 additions & 2 deletions packages/app-store/googlecalendar/lib/CalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import type {
} from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";

import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import { getGoogleAppKeys } from "./getGoogleAppKeys";
import { googleCredentialSchema } from "./googleCredentialSchema";

Expand Down Expand Up @@ -81,11 +83,18 @@ export default class GoogleCalendarService implements Calendar {

const refreshAccessToken = async (myGoogleAuth: Awaited<ReturnType<typeof getGoogleAuth>>) => {
try {
const { res } = await myGoogleAuth.refreshToken(googleCredentials.refresh_token);
const res = await refreshOAuthTokens(
async () => {
const fetchTokens = await myGoogleAuth.refreshToken(googleCredentials.refresh_token);
return fetchTokens.res;
},
"google-calendar",
credential.userId
);
Comment on lines +86 to +93

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find refreshOAuthTokens function
fd -type f -name "*.ts" | xargs rg -l "refreshOAuthTokens" | head -20

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 813


🏁 Script executed:

# Look for the refreshOAuthTokens implementation
rg -A 20 "function refreshOAuthTokens|export.*refreshOAuthTokens" --type ts

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 178


🏁 Script executed:

# Check the Google Calendar implementation context
cat -n packages/app-store/googlecalendar/lib/CalendarService.ts | sed -n '80,100p'

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 1070


🏁 Script executed:

# Find Zoom and Office365 integrations to compare token refresh
fd -type f -path "*zoom*" -name "*.ts" | xargs rg -l "refreshOAuthTokens"
fd -type f -path "*office365*" -name "*.ts" | xargs rg -l "refreshOAuthTokens"

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 1520


🏁 Script executed:

# Check imports in the Google Calendar file
cat -n packages/app-store/googlecalendar/lib/CalendarService.ts | sed -n '1,30p'

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 1426


🏁 Script executed:

# Read the refreshOAuthTokens implementation
cat -n packages/app-store/_utils/oauth/refreshOAuthTokens.ts

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 1094


🏁 Script executed:

# Check how Office365 uses refreshOAuthTokens
grep -A 10 -B 2 "refreshOAuthTokens" packages/app-store/office365calendar/lib/CalendarService.ts | head -30

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 1163


🏁 Script executed:

# Check how Zoom uses refreshOAuthTokens
grep -A 10 -B 2 "refreshOAuthTokens" packages/app-store/zoomvideo/lib/VideoApiAdapter.ts | head -30

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 1101


🏁 Script executed:

# Check how Office365 extracts token after refreshOAuthTokens call
grep -A 15 "const response = await refreshOAuthTokens" packages/app-store/office365calendar/lib/CalendarService.ts | head -25

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 715


🏁 Script executed:

# Check how Zoom extracts token after refreshOAuthTokens call
grep -A 15 "const response = await refreshOAuthTokens" packages/app-store/zoomvideo/lib/VideoApiAdapter.ts | head -25

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 567


🏁 Script executed:

# Check what GaxiosResponse structure looks like - search for myGoogleAuth.refreshToken usage
grep -B 5 -A 10 "refreshToken" packages/app-store/googlecalendar/lib/CalendarService.ts | head -40

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 939


🏁 Script executed:

# Get more context after refreshOAuthTokens in Office365
grep -A 25 "const response = await refreshOAuthTokens" packages/app-store/office365calendar/lib/CalendarService.ts | grep -A 25 "office365-calendar"

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 577


🏁 Script executed:

# Get more context after refreshOAuthTokens in Zoom
grep -A 25 "const response = await refreshOAuthTokens" packages/app-store/zoomvideo/lib/VideoApiAdapter.ts | grep -A 25 "zoomvideo"

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 567


🏁 Script executed:

# Check parseRefreshTokenResponse
cat -n packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 1344


🏁 Script executed:

# Check what handleErrorsJson does in Office365
grep -B 5 -A 10 "handleErrorsJson" packages/app-store/office365calendar/lib/CalendarService.ts

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 4653


🏁 Script executed:

# Check what handleZoomResponse does
grep -B 5 -A 10 "handleZoomResponse" packages/app-store/zoomvideo/lib/VideoApiAdapter.ts

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 1798


🏁 Script executed:

# Search for GaxiosResponse type or google-auth-library usage
rg "GaxiosResponse|googleapis" packages/app-store/googlecalendar/ -A 3

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 2330


🏁 Script executed:

# Check the full Office365 refresh token handling to see complete flow
sed -n '80,140p' packages/app-store/office365calendar/lib/CalendarService.ts

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 1806


🏁 Script executed:

# Check if Google code has any handling for Response type
grep -B 5 -A 15 "const token = res" packages/app-store/googlecalendar/lib/CalendarService.ts

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 1001


🏁 Script executed:

# Check if there's a type definition or handling for the response in Google Calendar
grep -B 10 "res?.data" packages/app-store/googlecalendar/lib/CalendarService.ts

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 519


Google Calendar's response handling is incompatible with the credential sync endpoint.

The code assumes res?.data exists (provided by GaxiosResponse), but when APP_CREDENTIAL_SHARING_ENABLED and the credential sync endpoint is configured, refreshOAuthTokens returns a standard fetch Response without a .data property. This causes token.access_token to fail with an undefined access. Office365 and Zoom avoid this by using helper functions (handleErrorsJson / handleZoomResponse) to parse responses; Google should do the same.

🤖 Prompt for AI Agents
In `@packages/app-store/googlecalendar/lib/CalendarService.ts` around lines 86 -
93, The Google token refresh call in CalendarService is assuming a
GaxiosResponse shape (res?.data) but refreshOAuthTokens can return a standard
fetch Response when APP_CREDENTIAL_SHARING_ENABLED, so update the handling
around the result of refreshOAuthTokens (the call wrapping
myGoogleAuth.refreshToken and the subsequent use of token.access_token) to
detect and normalize both shapes: if res has a .data property use that,
otherwise parse the fetch Response body (use the existing helper
handleErrorsJson or similar) to extract the JSON and assign it to token before
accessing token.access_token; adjust the code paths around refreshOAuthTokens,
myGoogleAuth.refreshToken, googleCredentials and token to ensure a unified token
object is used.

const token = res?.data;
googleCredentials.access_token = token.access_token;
googleCredentials.expiry_date = token.expiry_date;
const key = googleCredentialSchema.parse(googleCredentials);
const key = parseRefreshTokenResponse(googleCredentials, googleCredentialSchema);
await prisma.credential.update({
where: { id: credential.id },
data: { key },
Expand Down
2 changes: 1 addition & 1 deletion packages/app-store/hubspot/api/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";

import { WEBAPP_URL } from "@calcom/lib/constants";

import { encodeOAuthState } from "../../_utils/encodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";

const scopes = ["crm.objects.contacts.read", "crm.objects.contacts.write"];

Expand Down
4 changes: 2 additions & 2 deletions packages/app-store/hubspot/api/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";

import createOAuthAppCredential from "../../_utils/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";

let client_id = "";
let client_secret = "";
Expand Down
20 changes: 13 additions & 7 deletions packages/app-store/hubspot/lib/CalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
import type { CredentialPayload } from "@calcom/types/Credential";

import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import type { HubspotToken } from "../api/callback";

const hubspotClient = new hubspot.Client();
Expand Down Expand Up @@ -173,13 +174,18 @@ export default class HubspotCalendarService implements Calendar {

const refreshAccessToken = async (refreshToken: string) => {
try {
const hubspotRefreshToken: HubspotToken = await hubspotClient.oauth.tokensApi.createToken(
"refresh_token",
undefined,
WEBAPP_URL + "/api/integrations/hubspot/callback",
this.client_id,
this.client_secret,
refreshToken
const hubspotRefreshToken: HubspotToken = await refreshOAuthTokens(
async () =>
await hubspotClient.oauth.tokensApi.createToken(
"refresh_token",
undefined,
WEBAPP_URL + "/api/integrations/hubspot/callback",
this.client_id,
this.client_secret,
refreshToken
),
"hubspot",
credential.userId
);
Comment on lines +177 to 189

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Integration is correct for the default (non-sharing) path, but the type mismatch is particularly severe here.

When credential sharing is enabled, refreshOAuthTokens returns a raw fetch Response, but line 192 immediately accesses hubspotRefreshToken.expiresIn (which would be undefined on a Response object), resulting in NaN for expiryDate and a broken credential update. The HubSpot SDK's TokenResponseIF shape (camelCase fields like accessToken, expiresIn) is completely different from a fetch Response.

This is the most fragile integration point among all the callers. The fix should be coordinated with the core module's return type contract (raised in refreshOAuthTokens.ts).

🤖 Prompt for AI Agents
In `@packages/app-store/hubspot/lib/CalendarService.ts` around lines 177 - 189,
refreshOAuthTokens can return either a Token-like object or a raw fetch Response
when credential sharing is enabled, but CalendarService.ts immediately reads
hubspotRefreshToken.expiresIn (and other Token fields), causing NaN/undefined
behavior; update the code around the call to refreshOAuthTokens (the invocation
that passes hubspotClient.oauth.tokensApi.createToken) to detect if the result
is a fetch Response and, if so, await response.json() and map/normalize its
fields to the HubspotToken shape (accessToken, expiresIn, refreshToken, etc.)
before using expiresIn to compute expiryDate and before updating credentials;
alternatively add a type guard/assertion to ensure hubspotRefreshToken matches
the expected Token shape and throw/log a clear error if not.


// set expiry date as offset from current time.
Expand Down
2 changes: 1 addition & 1 deletion packages/app-store/larkcalendar/api/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { z } from "zod";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";

import { encodeOAuthState } from "../../_utils/encodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
import { LARK_HOST } from "../common";

const larkKeysSchema = z.object({
Expand Down
2 changes: 1 addition & 1 deletion packages/app-store/larkcalendar/api/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import logger from "@calcom/lib/logger";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";

import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import { LARK_HOST } from "../common";
import { getAppAccessToken } from "../lib/AppAccessToken";
import type { LarkAuthCredentials } from "../types/LarkCalendar";
Expand Down
28 changes: 17 additions & 11 deletions packages/app-store/larkcalendar/lib/CalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
} from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";

import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import { handleLarkError, isExpired, LARK_HOST } from "../common";
import type {
CreateAttendeesResp,
Expand Down Expand Up @@ -63,17 +64,22 @@ export default class LarkCalendarService implements Calendar {
}
try {
const appAccessToken = await getAppAccessToken();
const resp = await fetch(`${this.url}/authen/v1/refresh_access_token`, {
method: "POST",
headers: {
Authorization: `Bearer ${appAccessToken}`,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
const resp = await refreshOAuthTokens(
async () =>
await fetch(`${this.url}/authen/v1/refresh_access_token`, {
method: "POST",
headers: {
Authorization: `Bearer ${appAccessToken}`,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
}),
"lark-calendar",
credential.userId
);

const data = await handleLarkError<RefreshTokenResp>(resp, this.log);
this.log.debug(
Expand Down
2 changes: 1 addition & 1 deletion packages/app-store/office365calendar/api/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { stringify } from "querystring";

import { WEBAPP_URL } from "@calcom/lib/constants";

import { encodeOAuthState } from "../../_utils/encodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";

const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];

Expand Down
2 changes: 1 addition & 1 deletion packages/app-store/office365calendar/api/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";

import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";

const scopes = ["offline_access", "Calendars.Read", "Calendars.ReadWrite"];

Expand Down
Loading