-
Notifications
You must be signed in to change notification settings - Fork 0
OAuth credential sync and app integration enhancements #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: oauth-security-base
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❓ Verification inconclusiveAdd HTTP method guard and make webhook secret check header‑case safe Right now the handler:
You can make this stricter and more robust as follows: 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" });
- }
+ if (req.method !== "POST") {
+ res.setHeader("Allow", "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" });
+ }
+
+ const webhookHeaderName = (process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret").toLowerCase();
+ const providedSecret = req.headers[webhookHeaderName];
+ const expectedSecret = process.env.CALCOM_WEBHOOK_SECRET;
+
+ // Check that the webhook secret matches
+ if (
+ !expectedSecret ||
+ (Array.isArray(providedSecret)
+ ? !providedSecret.includes(expectedSecret)
+ : providedSecret !== expectedSecret)
+ ) {
+ return res.status(403).json({ message: "Invalid webhook secret" });
+ }This keeps the check case‑insensitive, deals with I'll verify the key claims in this review by searching for documentation on how Node.js and Next.js handle HTTP headers. Based on my verification, the review comment identifies valid and important issues. Here is the rewritten review: Add HTTP method guard and make webhook secret check header-case safe The handler currently:
Suggested improvements: export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== "POST") {
+ res.setHeader("Allow", "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" });
}
+ const webhookHeaderName = (process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret").toLowerCase();
+ const providedSecret = req.headers[webhookHeaderName];
+ const expectedSecret = process.env.CALCOM_WEBHOOK_SECRET;
+
// Check that the webhook secret matches
if (
- req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !==
- process.env.CALCOM_WEBHOOK_SECRET
+ !expectedSecret ||
+ (Array.isArray(providedSecret)
+ ? !providedSecret.includes(expectedSecret)
+ : providedSecret !== expectedSecret)
) {
return res.status(403).json({ message: "Invalid webhook secret" });
}This makes the check case-insensitive, handles the 🤖 Prompt for AI Agents |
||
|
|
||
| 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 }, | ||
| }); | ||
|
|
||
| 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 || "") | ||
| ); | ||
|
|
||
| // 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 |
|---|---|---|
| @@ -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(), | ||
| }); | ||
|
|
||
| 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"; | ||
| } | ||
|
|
||
| return refreshTokenResponse; | ||
| }; | ||
|
|
||
| export default parseRefreshTokenResponse; | ||
|
Comment on lines
+1
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chainFix There are a few serious issues here:
Using computed keys like: [z.string().toString()]: z.number(),
[z.string().optional().toString()]: z.unknown().optional(),produces literal property names like
Callers such as the Google calendar service pass the returned value directly into
If a response genuinely omits At minimum, this helper should:
A safer implementation could look like this: 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(),
-});
-
-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";
- }
-
- return refreshTokenResponse;
-};
+const minimumTokenResponseSchema = z
+ .object({
+ access_token: z.string(),
+ // Common expiry fields used by providers; both optional.
+ expires_in: z.number().optional(),
+ expiry_date: z.number().optional(),
+ refresh_token: z.string().optional(),
+ })
+ // Allow provider‑specific extras without failing validation.
+ .passthrough();
+
+const parseRefreshTokenResponse = (response: unknown, schema: z.ZodTypeAny) => {
+ const tokenSchema =
+ APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT
+ ? minimumTokenResponseSchema
+ : schema;
+
+ const parsed = tokenSchema.safeParse(response);
+
+ if (!parsed.success) {
+ throw new Error("Invalid refreshed tokens were returned");
+ }
+
+ const data = parsed.data as any;
+
+ // Do not fabricate a refresh_token here; callers can decide how to handle
+ // missing refresh tokens (e.g., by reusing the previous one).
+
+ return data;
+};With this change, callers will receive a plain token object (matching either the provider schema or the minimal schema), and existing usages like the Google calendar service can safely persist it as 🏁 Script executed: #!/bin/bash
# After applying the change, run TypeScript build/tests focusing on OAuth flows.
pnpm test --filter "app-store..." -- --runInBand || pnpm test -- --runInBandLength of output: 385 Let me verify the codebase and search for how this function is being used. 🏁 Script executed: #!/bin/bash
# Verify current implementation and find all call sites
echo "=== Current implementation ==="
cat packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts 2>/dev/null || echo "File not found"
echo -e "\n=== Call sites of parseRefreshTokenResponse ==="
rg -n "parseRefreshTokenResponse" --type ts --type tsx -g "!node_modules" -C 2Length of output: 1138 🏁 Script executed: #!/bin/bash
# Try running tests with yarn (which the project uses)
cd /repo && yarn test --help 2>&1 | head -20Length of output: 55 Let me search for call sites with corrected syntax: 🏁 Script executed: #!/bin/bash
# Find all call sites of parseRefreshTokenResponse
echo "=== Searching for parseRefreshTokenResponse imports and usage ==="
rg -n "parseRefreshTokenResponse" -g "!node_modules" -C 3
echo -e "\n=== Checking if APP_CREDENTIAL_SHARING_ENABLED is used anywhere ==="
rg -n "APP_CREDENTIAL_SHARING_ENABLED" -g "!node_modules"
echo -e "\n=== Checking CALCOM_CREDENTIAL_SYNC_ENDPOINT usage ==="
rg -n "CALCOM_CREDENTIAL_SYNC_ENDPOINT" -g "!node_modules"Length of output: 8800 🏁 Script executed: #!/bin/bash
# Get full context around Google Calendar usage
echo "=== Google Calendar usage context ==="
rg -n "parseRefreshTokenResponse" packages/app-store/googlecalendar/lib/CalendarService.ts -A 10 -B 5
echo -e "\n=== Salesforce usage context ==="
rg -n "parseRefreshTokenResponse" packages/app-store/salesforce/lib/CalendarService.ts -A 10 -B 5
echo -e "\n=== Office365 usage context ==="
rg -n "parseRefreshTokenResponse" packages/app-store/office365calendar/lib/CalendarService.ts -A 10 -B 5Length of output: 3713 🏁 Script executed: #!/bin/bash
# Get Zoom usage context
echo "=== Zoom usage context ==="
rg -n "parseRefreshTokenResponse" packages/app-store/zoomvideo/lib/VideoApiAdapter.ts -A 15 -B 5
# Check what happens when credentials are read from database
echo -e "\n=== Check how credentials are used after stored ==="
rg -n "googleCredentialSchema" packages/app-store/googlecalendar/lib/CalendarService.ts -B 2 -A 2Length of output: 2987 🏁 Script executed: #!/bin/bash
# Verify what happens when Google credentials are parsed after being stored with wrapper
echo "=== Google credential schema definition ==="
cat packages/app-store/googlecalendar/lib/googleCredentialSchema.ts 2>/dev/null || rg -n "googleCredentialSchema" packages/app-store/googlecalendar/lib/ -A 5 -B 2
# Check if there's any defensive parsing or error handling
echo -e "\n=== Check error handling around line 75 ==="
rg -n "googleCredentialSchema.parse" packages/app-store/googlecalendar/lib/CalendarService.ts -A 3 -B 3Length of output: 591 Fix The review correctly identifies three critical issues:
Critical note on the proposed fix: The suggestion to return A proper fix requires either:
At minimum, |
||
| 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) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Replace The function signature uses Consider defining an interface for the token response: +interface TokenRefreshResponse {
+ access_token: string;
+ expiry_date: number;
+ refresh_token?: string;
+ [key: string]: any;
+}
+
-const refreshOAuthTokens = async (refreshFunction: () => any, appSlug: string, userId: number | null) => {
+const refreshOAuthTokens = async (
+ refreshFunction: () => Promise<Response>,
+ appSlug: string,
+ userId: number | null
+): Promise<Response> => {
🤖 Prompt for AI Agents |
||
| // 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add error handling and timeout for the credential sync endpoint call. The fetch request to
Apply this diff to add proper error handling and timeout: - const response = await fetch(process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT, {
- method: "POST",
- body: new URLSearchParams({
- calcomUserId: userId.toString(),
- appSlug,
- }),
- });
- return response;
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
+
+ const response = await fetch(process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT, {
+ method: "POST",
+ body: new URLSearchParams({
+ calcomUserId: userId.toString(),
+ appSlug,
+ }),
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ console.error(`Credential sync failed: ${response.status} ${response.statusText}`);
+ // Fall back to local refresh on sync endpoint failure
+ return await refreshFunction();
+ }
+
+ return response;
+ } catch (error) {
+ console.error('Credential sync endpoint error:', error);
+ // Fall back to local refresh on network error
+ return await refreshFunction();
+ }
🤖 Prompt for AI Agents |
||
| } else { | ||
| const response = await refreshFunction(); | ||
| return response; | ||
| } | ||
| }; | ||
|
|
||
| export default refreshOAuthTokens; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
Comment on lines
+26
to
27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chainConfirm Here Please verify that, for the Also applies to: 175-205 🏁 Script executed: #!/bin/bash
# Quickly inspect the implementation of refreshOAuthTokens and confirm branching per provider.
fd 'refreshOAuthTokens.ts' -a
if [ -f "packages/app-store/_utils/oauth/refreshOAuthTokens.ts" ]; then
sed -n '1,260p' packages/app-store/_utils/oauth/refreshOAuthTokens.ts
fiLength of output: 923 I need to examine how the return value from I need to inspect the actual HubSpot CalendarService implementation to see how the return value from [/run_scripts] 🏁 Script executed: #!/bin/bash
# Inspect HubSpot CalendarService refreshAccessToken method (lines 175-205)
if [ -f "packages/app-store/hubspot/lib/CalendarService.ts" ]; then
sed -n '175,205p' packages/app-store/hubspot/lib/CalendarService.ts
fiLength of output: 1011 I need to understand what [/run_scripts] 🏁 Script executed: #!/bin/bash
# Search for tokensApi.createToken usage and check other provider implementations
rg -n "tokensApi\.createToken" --type=ts -A2 -B2 packages/app-store/hubspot/
# Also check other providers for pattern comparison
echo "=== Zoho pattern ==="
rg -n "refreshOAuthTokens" packages/app-store/zoho/ --type=ts -A5 -B2 | head -30
echo "=== Webex pattern ==="
rg -n "refreshOAuthTokens" packages/app-store/webex/ --type=ts -A5 -B2 | head -30Length of output: 2499 I need to see how Webex actually handles the response to understand the expected pattern. [/run_scripts] 🏁 Script executed: #!/bin/bash
# See how Webex uses the response from refreshOAuthTokens
sed -n '60,85p' packages/app-store/webex/lib/VideoApiAdapter.ts
# Also check if there's any .json() or .data extraction in Webex
rg -n "response\." packages/app-store/webex/lib/VideoApiAdapter.ts -A2 -B1 | head -40Length of output: 2576 The verification confirms the issue raised in the review comment. Let me check the HubSpot token type definition to be thorough. [/run_scripts] 🏁 Script executed: #!/bin/bash
# Verify HubspotToken type definition
cat packages/app-store/hubspot/api/callback.ts | grep -A10 "type HubspotToken"
# Also verify tokensApi.createToken return type by checking the import
rg -n "hubspotClient" packages/app-store/hubspot/lib/CalendarService.ts -B5 | head -20Length of output: 615 Confirm The review comment is correct.
The HubSpot code at lines 191–192 directly accesses
Unlike Webex (which calls |
||
|
|
||
| const hubspotClient = new hubspot.Client(); | ||
|
|
@@ -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 | ||
| ); | ||
|
|
||
| // set expiry date as offset from current time. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Use
safeParsefor body validation and harden key decryption/parsingTwo related robustness issues:
appCredentialWebhookRequestBodySchema.parse(req.body)will throw on invalid input, returning a 500 instead of a clear 4xx.JSON.parse(symmetricDecrypt(...))is unguarded and passes""as a fallback encryption key, which can also throw and end up as 500s.A more defensive pattern:
This keeps malformed bodies and invalid/mis‑encrypted
keysfrom bubbling up as unhandled exceptions and gives clients clear 4xx/5xx feedback.Also applies to: 31-32, 56-59, 72-92
I'll verify the review comment by examining the actual file and code patterns.
Length of output: 2782
Use
safeParsefor body validation and harden key decryption/parsingTwo related robustness issues are confirmed in the code:
appCredentialWebhookRequestBodySchema.parse(req.body)will throw on invalid input, returning a 500 instead of a clear 4xx.JSON.parse(symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || ""))is unguarded and passes""as a fallback encryption key, which can also throw and result in 500s instead of clear error responses.A more defensive pattern:
This keeps malformed bodies and invalid/mis-encrypted
keysfrom bubbling up as unhandled exceptions and gives clients clear 4xx/5xx feedback.