Skip to content

Commit 824145b

Browse files
joeauyeungalishaz-polymathzomars
authored
feat: Sync app credentials between Cal.com & self-hosted platforms (#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]>
1 parent bc89fe0 commit 824145b

File tree

40 files changed

+375
-119
lines changed

40 files changed

+375
-119
lines changed

.env.example

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,19 @@ AUTH_BEARER_TOKEN_VERCEL=
230230
E2E_TEST_APPLE_CALENDAR_EMAIL=""
231231
E2E_TEST_APPLE_CALENDAR_PASSWORD=""
232232

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

235248
# Ensure this ADMIN EMAIL is present in the SAML_ADMINS list
@@ -243,4 +256,4 @@ E2E_TEST_OIDC_PROVIDER_DOMAIN=
243256
E2E_TEST_OIDC_USER_EMAIL=
244257
E2E_TEST_OIDC_USER_PASSWORD=
245258

246-
# ***********************************************************************************************************
259+
# ***********************************************************************************************************
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { NextApiRequest, NextApiResponse } from "next";
2+
import z from "zod";
3+
4+
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
5+
import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants";
6+
import { symmetricDecrypt } from "@calcom/lib/crypto";
7+
import prisma from "@calcom/prisma";
8+
9+
const appCredentialWebhookRequestBodySchema = z.object({
10+
// UserId of the cal.com user
11+
userId: z.number().int(),
12+
appSlug: z.string(),
13+
// Keys should be AES256 encrypted with the CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY
14+
keys: z.string(),
15+
});
16+
/** */
17+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
18+
// Check that credential sharing is enabled
19+
if (!APP_CREDENTIAL_SHARING_ENABLED) {
20+
return res.status(403).json({ message: "Credential sharing is not enabled" });
21+
}
22+
23+
// Check that the webhook secret matches
24+
if (
25+
req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !==
26+
process.env.CALCOM_WEBHOOK_SECRET
27+
) {
28+
return res.status(403).json({ message: "Invalid webhook secret" });
29+
}
30+
31+
const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body);
32+
33+
// Check that the user exists
34+
const user = await prisma.user.findUnique({ where: { id: reqBody.userId } });
35+
36+
if (!user) {
37+
return res.status(404).json({ message: "User not found" });
38+
}
39+
40+
const app = await prisma.app.findUnique({
41+
where: { slug: reqBody.appSlug },
42+
select: { slug: true },
43+
});
44+
45+
if (!app) {
46+
return res.status(404).json({ message: "App not found" });
47+
}
48+
49+
// Search for the app's slug and type
50+
const appMetadata = appStoreMetadata[app.slug as keyof typeof appStoreMetadata];
51+
52+
if (!appMetadata) {
53+
return res.status(404).json({ message: "App not found. Ensure that you have the correct app slug" });
54+
}
55+
56+
// Decrypt the keys
57+
const keys = JSON.parse(
58+
symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
59+
);
60+
61+
// Can't use prisma upsert as we don't know the id of the credential
62+
const appCredential = await prisma.credential.findFirst({
63+
where: {
64+
userId: reqBody.userId,
65+
appId: appMetadata.slug,
66+
},
67+
select: {
68+
id: true,
69+
},
70+
});
71+
72+
if (appCredential) {
73+
await prisma.credential.update({
74+
where: {
75+
id: appCredential.id,
76+
},
77+
data: {
78+
key: keys,
79+
},
80+
});
81+
return res.status(200).json({ message: `Credentials updated for userId: ${reqBody.userId}` });
82+
} else {
83+
await prisma.credential.create({
84+
data: {
85+
key: keys,
86+
userId: reqBody.userId,
87+
appId: appMetadata.slug,
88+
type: appMetadata.type,
89+
},
90+
});
91+
return res.status(200).json({ message: `Credentials created for userId: ${reqBody.userId}` });
92+
}
93+
}

packages/app-store/_utils/createOAuthAppCredential.ts renamed to packages/app-store/_utils/oauth/createOAuthAppCredential.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import type { NextApiRequest } from "next";
33
import { HttpError } from "@calcom/lib/http-error";
44
import prisma from "@calcom/prisma";
55

6-
import { decodeOAuthState } from "./decodeOAuthState";
7-
import { throwIfNotHaveAdminAccessToTeam } from "./throwIfNotHaveAdminAccessToTeam";
6+
import { decodeOAuthState } from "../oauth/decodeOAuthState";
7+
import { throwIfNotHaveAdminAccessToTeam } from "../throwIfNotHaveAdminAccessToTeam";
88

99
/**
1010
* This function is used to create app credentials for either a user or a team

packages/app-store/_utils/decodeOAuthState.ts renamed to packages/app-store/_utils/oauth/decodeOAuthState.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { NextApiRequest } from "next";
22

3-
import type { IntegrationOAuthCallbackState } from "../types";
3+
import type { IntegrationOAuthCallbackState } from "../../types";
44

55
export function decodeOAuthState(req: NextApiRequest) {
66
if (typeof req.query.state !== "string") {

packages/app-store/_utils/encodeOAuthState.ts renamed to packages/app-store/_utils/oauth/encodeOAuthState.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { NextApiRequest } from "next";
22

3-
import type { IntegrationOAuthCallbackState } from "../types";
3+
import type { IntegrationOAuthCallbackState } from "../../types";
44

55
export function encodeOAuthState(req: NextApiRequest) {
66
if (typeof req.query.state !== "string") {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { z } from "zod";
2+
3+
import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants";
4+
5+
const minimumTokenResponseSchema = z.object({
6+
access_token: z.string(),
7+
// Assume that any property with a number is the expiry
8+
[z.string().toString()]: z.number(),
9+
// Allow other properties in the token response
10+
[z.string().optional().toString()]: z.unknown().optional(),
11+
});
12+
13+
const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => {
14+
let refreshTokenResponse;
15+
if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT) {
16+
refreshTokenResponse = minimumTokenResponseSchema.safeParse(response);
17+
} else {
18+
refreshTokenResponse = schema.safeParse(response);
19+
}
20+
21+
if (!refreshTokenResponse.success) {
22+
throw new Error("Invalid refreshed tokens were returned");
23+
}
24+
25+
if (!refreshTokenResponse.data.refresh_token) {
26+
refreshTokenResponse.data.refresh_token = "refresh_token";
27+
}
28+
29+
return refreshTokenResponse;
30+
};
31+
32+
export default parseRefreshTokenResponse;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants";
2+
3+
const refreshOAuthTokens = async (refreshFunction: () => any, appSlug: string, userId: number | null) => {
4+
// Check that app syncing is enabled and that the credential belongs to a user
5+
if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT && userId) {
6+
// Customize the payload based on what your endpoint requires
7+
// The response should only contain the access token and expiry date
8+
const response = await fetch(process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT, {
9+
method: "POST",
10+
body: new URLSearchParams({
11+
calcomUserId: userId.toString(),
12+
appSlug,
13+
}),
14+
});
15+
return response;
16+
} else {
17+
const response = await refreshFunction();
18+
return response;
19+
}
20+
};
21+
22+
export default refreshOAuthTokens;

packages/app-store/googlecalendar/api/add.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
33

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

6-
import { encodeOAuthState } from "../../_utils/encodeOAuthState";
76
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
7+
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
88

99
const scopes = [
1010
"https://www.googleapis.com/auth/calendar.readonly",

packages/app-store/googlecalendar/api/callback.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import { WEBAPP_URL_FOR_OAUTH, CAL_URL } from "@calcom/lib/constants";
55
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
66
import prisma from "@calcom/prisma";
77

8-
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
98
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
109
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
10+
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
1111

1212
let client_id = "";
1313
let client_secret = "";

packages/app-store/googlecalendar/lib/CalendarService.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import type {
1818
} from "@calcom/types/Calendar";
1919
import type { CredentialPayload } from "@calcom/types/Credential";
2020

21+
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
22+
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
2123
import { getGoogleAppKeys } from "./getGoogleAppKeys";
2224
import { googleCredentialSchema } from "./googleCredentialSchema";
2325

@@ -81,11 +83,18 @@ export default class GoogleCalendarService implements Calendar {
8183

8284
const refreshAccessToken = async (myGoogleAuth: Awaited<ReturnType<typeof getGoogleAuth>>) => {
8385
try {
84-
const { res } = await myGoogleAuth.refreshToken(googleCredentials.refresh_token);
86+
const res = await refreshOAuthTokens(
87+
async () => {
88+
const fetchTokens = await myGoogleAuth.refreshToken(googleCredentials.refresh_token);
89+
return fetchTokens.res;
90+
},
91+
"google-calendar",
92+
credential.userId
93+
);
8594
const token = res?.data;
8695
googleCredentials.access_token = token.access_token;
8796
googleCredentials.expiry_date = token.expiry_date;
88-
const key = googleCredentialSchema.parse(googleCredentials);
97+
const key = parseRefreshTokenResponse(googleCredentials, googleCredentialSchema);
8998
await prisma.credential.update({
9099
where: { id: credential.id },
91100
data: { key },

0 commit comments

Comments
 (0)