Skip to content
Open
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) {
Copy link

Choose a reason for hiding this comment

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

logic: Missing HTTP method validation allows GET/PUT/DELETE requests

Suggested change
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ message: "Method not allowed" });
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/pages/api/webhook/app-credential.ts
Line: 17:17

Comment:
**logic:** Missing HTTP method validation allows GET/PUT/DELETE requests

```suggestion
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).json({ message: "Method not allowed" });
  }
```

How can I resolve this? If you propose a fix, please make it concise.

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

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 || "")
);
Comment on lines +57 to +59
Copy link

Choose a reason for hiding this comment

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

logic: Empty string fallback for encryption key will cause decryption to silently fail or produce incorrect results

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/pages/api/webhook/app-credential.ts
Line: 57:59

Comment:
**logic:** Empty string fallback for encryption key will cause decryption to silently fail or produce incorrect results

How can I resolve this? If you propose a fix, please make it concise.


// 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(),
Copy link

Choose a reason for hiding this comment

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

syntax: Invalid Zod schema - bracket notation with z.string().toString() is not valid Zod syntax and will fail at runtime

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts
Line: 8:8

Comment:
**syntax:** Invalid Zod schema - bracket notation with `z.string().toString()` is not valid Zod syntax and will fail at runtime

How can I resolve this? If you propose a fix, please make it concise.

// 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";
}
Comment on lines +25 to +27
Copy link

Choose a reason for hiding this comment

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

logic: Silently sets placeholder value on missing refresh_token - this will break OAuth flows that require valid refresh tokens

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts
Line: 25:27

Comment:
**logic:** Silently sets placeholder value on missing refresh_token - this will break OAuth flows that require valid refresh tokens

How can I resolve this? If you propose a fix, please make it concise.


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,
}),
});
Comment on lines +8 to +14
Copy link

Choose a reason for hiding this comment

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

style: Response from external sync endpoint is returned without validation or error handling

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/app-store/_utils/oauth/refreshOAuthTokens.ts
Line: 8:14

Comment:
**style:** Response from external sync endpoint is returned without validation or error handling

How can I resolve this? If you propose a fix, please make it concise.

return response;
} else {
const response = await refreshFunction();
return response;
}
};

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
);
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
);

// 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