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 <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Joe Au-Yeung 2023-09-19 16:46:50 -04:00 committed by GitHub
parent bc89fe00ea
commit 824145b0e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 375 additions and 119 deletions

View File

@ -230,6 +230,19 @@ AUTH_BEARER_TOKEN_VERCEL=
E2E_TEST_APPLE_CALENDAR_EMAIL="" E2E_TEST_APPLE_CALENDAR_EMAIL=""
E2E_TEST_APPLE_CALENDAR_PASSWORD="" 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 ******************************************************************************************* # - OIDC E2E TEST *******************************************************************************************
# Ensure this ADMIN EMAIL is present in the SAML_ADMINS list # Ensure this ADMIN EMAIL is present in the SAML_ADMINS list
@ -243,4 +256,4 @@ E2E_TEST_OIDC_PROVIDER_DOMAIN=
E2E_TEST_OIDC_USER_EMAIL= E2E_TEST_OIDC_USER_EMAIL=
E2E_TEST_OIDC_USER_PASSWORD= E2E_TEST_OIDC_USER_PASSWORD=
# *********************************************************************************************************** # ***********************************************************************************************************

View File

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

View File

@ -3,8 +3,8 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { decodeOAuthState } from "./decodeOAuthState"; import { decodeOAuthState } from "../oauth/decodeOAuthState";
import { throwIfNotHaveAdminAccessToTeam } from "./throwIfNotHaveAdminAccessToTeam"; import { throwIfNotHaveAdminAccessToTeam } from "../throwIfNotHaveAdminAccessToTeam";
/** /**
* This function is used to create app credentials for either a user or a team * This function is used to create app credentials for either a user or a team

View File

@ -1,6 +1,6 @@
import type { NextApiRequest } from "next"; import type { NextApiRequest } from "next";
import type { IntegrationOAuthCallbackState } from "../types"; import type { IntegrationOAuthCallbackState } from "../../types";
export function decodeOAuthState(req: NextApiRequest) { export function decodeOAuthState(req: NextApiRequest) {
if (typeof req.query.state !== "string") { if (typeof req.query.state !== "string") {

View File

@ -1,6 +1,6 @@
import type { NextApiRequest } from "next"; import type { NextApiRequest } from "next";
import type { IntegrationOAuthCallbackState } from "../types"; import type { IntegrationOAuthCallbackState } from "../../types";
export function encodeOAuthState(req: NextApiRequest) { export function encodeOAuthState(req: NextApiRequest) {
if (typeof req.query.state !== "string") { if (typeof req.query.state !== "string") {

View File

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

View File

@ -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;
} else {
const response = await refreshFunction();
return response;
}
};
export default refreshOAuthTokens;

View File

@ -3,8 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { encodeOAuthState } from "../../_utils/encodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
const scopes = [ const scopes = [
"https://www.googleapis.com/auth/calendar.readonly", "https://www.googleapis.com/auth/calendar.readonly",

View File

@ -5,9 +5,9 @@ import { WEBAPP_URL_FOR_OAUTH, CAL_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
let client_id = ""; let client_id = "";
let client_secret = ""; let client_secret = "";

View File

@ -18,6 +18,8 @@ import type {
} from "@calcom/types/Calendar"; } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential"; import type { CredentialPayload } from "@calcom/types/Credential";
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import { getGoogleAppKeys } from "./getGoogleAppKeys"; import { getGoogleAppKeys } from "./getGoogleAppKeys";
import { googleCredentialSchema } from "./googleCredentialSchema"; import { googleCredentialSchema } from "./googleCredentialSchema";
@ -81,11 +83,18 @@ export default class GoogleCalendarService implements Calendar {
const refreshAccessToken = async (myGoogleAuth: Awaited<ReturnType<typeof getGoogleAuth>>) => { const refreshAccessToken = async (myGoogleAuth: Awaited<ReturnType<typeof getGoogleAuth>>) => {
try { 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; const token = res?.data;
googleCredentials.access_token = token.access_token; googleCredentials.access_token = token.access_token;
googleCredentials.expiry_date = token.expiry_date; googleCredentials.expiry_date = token.expiry_date;
const key = googleCredentialSchema.parse(googleCredentials); const key = parseRefreshTokenResponse(googleCredentials, googleCredentialSchema);
await prisma.credential.update({ await prisma.credential.update({
where: { id: credential.id }, where: { id: credential.id },
data: { key }, data: { key },

View File

@ -3,8 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { encodeOAuthState } from "../../_utils/encodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
const scopes = ["crm.objects.contacts.read", "crm.objects.contacts.write"]; const scopes = ["crm.objects.contacts.read", "crm.objects.contacts.write"];

View File

@ -5,10 +5,10 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import createOAuthAppCredential from "../../_utils/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
let client_id = ""; let client_id = "";
let client_secret = ""; let client_secret = "";

View File

@ -23,6 +23,7 @@ import type {
import type { CredentialPayload } from "@calcom/types/Credential"; import type { CredentialPayload } from "@calcom/types/Credential";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import type { HubspotToken } from "../api/callback"; import type { HubspotToken } from "../api/callback";
const hubspotClient = new hubspot.Client(); const hubspotClient = new hubspot.Client();
@ -173,13 +174,18 @@ export default class HubspotCalendarService implements Calendar {
const refreshAccessToken = async (refreshToken: string) => { const refreshAccessToken = async (refreshToken: string) => {
try { try {
const hubspotRefreshToken: HubspotToken = await hubspotClient.oauth.tokensApi.createToken( const hubspotRefreshToken: HubspotToken = await refreshOAuthTokens(
"refresh_token", async () =>
undefined, await hubspotClient.oauth.tokensApi.createToken(
WEBAPP_URL + "/api/integrations/hubspot/callback", "refresh_token",
this.client_id, undefined,
this.client_secret, WEBAPP_URL + "/api/integrations/hubspot/callback",
refreshToken this.client_id,
this.client_secret,
refreshToken
),
"hubspot",
credential.userId
); );
// set expiry date as offset from current time. // set expiry date as offset from current time.

View File

@ -5,8 +5,8 @@ import { z } from "zod";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { encodeOAuthState } from "../../_utils/encodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
import { LARK_HOST } from "../common"; import { LARK_HOST } from "../common";
const larkKeysSchema = z.object({ const larkKeysSchema = z.object({

View File

@ -6,8 +6,8 @@ import logger from "@calcom/lib/logger";
import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import { LARK_HOST } from "../common"; import { LARK_HOST } from "../common";
import { getAppAccessToken } from "../lib/AppAccessToken"; import { getAppAccessToken } from "../lib/AppAccessToken";
import type { LarkAuthCredentials } from "../types/LarkCalendar"; import type { LarkAuthCredentials } from "../types/LarkCalendar";

View File

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

View File

@ -3,8 +3,8 @@ import { stringify } from "querystring";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { encodeOAuthState } from "../../_utils/encodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"]; const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];

View File

@ -4,9 +4,9 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
const scopes = ["offline_access", "Calendars.Read", "Calendars.ReadWrite"]; const scopes = ["offline_access", "Calendars.Read", "Calendars.ReadWrite"];

View File

@ -17,6 +17,8 @@ import type {
} from "@calcom/types/Calendar"; } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential"; import type { CredentialPayload } from "@calcom/types/Credential";
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import type { O365AuthCredentials } from "../types/Office365Calendar"; import type { O365AuthCredentials } from "../types/Office365Calendar";
import { getOfficeAppKeys } from "./getOfficeAppKeys"; import { getOfficeAppKeys } from "./getOfficeAppKeys";
@ -241,28 +243,25 @@ export default class Office365CalendarService implements Calendar {
const refreshAccessToken = async (o365AuthCredentials: O365AuthCredentials) => { const refreshAccessToken = async (o365AuthCredentials: O365AuthCredentials) => {
const { client_id, client_secret } = await getOfficeAppKeys(); const { client_id, client_secret } = await getOfficeAppKeys();
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { const response = await refreshOAuthTokens(
method: "POST", async () =>
headers: { "Content-Type": "application/x-www-form-urlencoded" }, await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
body: new URLSearchParams({ method: "POST",
scope: "User.Read Calendars.Read Calendars.ReadWrite", headers: { "Content-Type": "application/x-www-form-urlencoded" },
client_id, body: new URLSearchParams({
refresh_token: o365AuthCredentials.refresh_token, scope: "User.Read Calendars.Read Calendars.ReadWrite",
grant_type: "refresh_token", client_id,
client_secret, refresh_token: o365AuthCredentials.refresh_token,
}), grant_type: "refresh_token",
}); client_secret,
}),
}),
"office365-calendar",
credential.userId
);
const responseJson = await handleErrorsJson(response); const responseJson = await handleErrorsJson(response);
const tokenResponse = refreshTokenResponseSchema.safeParse(responseJson); const tokenResponse = parseRefreshTokenResponse(responseJson, refreshTokenResponseSchema);
o365AuthCredentials = { ...o365AuthCredentials, ...(tokenResponse.success && tokenResponse.data) }; o365AuthCredentials = { ...o365AuthCredentials, ...(tokenResponse.success && tokenResponse.data) };
if (!tokenResponse.success) {
console.error(
"Outlook error grabbing new tokens ~ zodError:",
tokenResponse.error,
"MS response:",
responseJson
);
}
await prisma.credential.update({ await prisma.credential.update({
where: { where: {
id: credential.id, id: credential.id,

View File

@ -3,8 +3,8 @@ import { stringify } from "querystring";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { encodeOAuthState } from "../../_utils/encodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
const scopes = ["OnlineMeetings.ReadWrite", "offline_access"]; const scopes = ["OnlineMeetings.ReadWrite", "offline_access"];

View File

@ -4,10 +4,10 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import createOAuthAppCredential from "../../_utils/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
const scopes = ["OnlineMeetings.ReadWrite", "offline_access"]; const scopes = ["OnlineMeetings.ReadWrite", "offline_access"];

View File

@ -9,6 +9,7 @@ import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter"; import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
let client_id = ""; let client_id = "";
let client_secret = ""; let client_secret = "";
@ -57,16 +58,21 @@ const o365Auth = async (credential: CredentialPayload) => {
const o365AuthCredentials = credential.key as unknown as O365AuthCredentials; const o365AuthCredentials = credential.key as unknown as O365AuthCredentials;
const refreshAccessToken = async (refreshToken: string) => { const refreshAccessToken = async (refreshToken: string) => {
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { const response = await refreshOAuthTokens(
method: "POST", async () =>
headers: { "Content-Type": "application/x-www-form-urlencoded" }, await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
body: new URLSearchParams({ method: "POST",
client_id, headers: { "Content-Type": "application/x-www-form-urlencoded" },
refresh_token: refreshToken, body: new URLSearchParams({
grant_type: "refresh_token", client_id,
client_secret, refresh_token: refreshToken,
}), grant_type: "refresh_token",
}); client_secret,
}),
}),
"msteams",
credential.userId
);
const responseBody = await handleErrorsJson<ITokenResponse>(response); const responseBody = await handleErrorsJson<ITokenResponse>(response);

View File

@ -3,8 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { encodeOAuthState } from "../../_utils/encodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
let consumer_key = ""; let consumer_key = "";

View File

@ -4,10 +4,10 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import createOAuthAppCredential from "../../_utils/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
let consumer_key = ""; let consumer_key = "";
let consumer_secret = ""; let consumer_secret = "";

View File

@ -1,6 +1,7 @@
import type { TokenResponse } from "jsforce"; import type { TokenResponse } from "jsforce";
import jsforce from "jsforce"; import jsforce from "jsforce";
import { RRule } from "rrule"; import { RRule } from "rrule";
import { z } from "zod";
import { getLocation } from "@calcom/lib/CalEventParser"; import { getLocation } from "@calcom/lib/CalEventParser";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
@ -16,6 +17,7 @@ import type {
import type { CredentialPayload } from "@calcom/types/Credential"; import type { CredentialPayload } from "@calcom/types/Credential";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
type ExtendedTokenResponse = TokenResponse & { type ExtendedTokenResponse = TokenResponse & {
instance_url: string; instance_url: string;
@ -34,6 +36,16 @@ const sfApiErrors = {
INVALID_EVENTWHOIDS: "INVALID_FIELD: No such column 'EventWhoIds' on sobject of type Event", INVALID_EVENTWHOIDS: "INVALID_FIELD: No such column 'EventWhoIds' on sobject of type Event",
}; };
const salesforceTokenSchema = z.object({
id: z.string(),
issued_at: z.string(),
instance_url: z.string(),
signature: z.string(),
access_token: z.string(),
scope: z.string(),
token_type: z.string(),
});
export default class SalesforceCalendarService implements Calendar { export default class SalesforceCalendarService implements Calendar {
private integrationName = ""; private integrationName = "";
private conn: Promise<jsforce.Connection>; private conn: Promise<jsforce.Connection>;
@ -60,6 +72,32 @@ export default class SalesforceCalendarService implements Calendar {
const credentialKey = credential.key as unknown as ExtendedTokenResponse; const credentialKey = credential.key as unknown as ExtendedTokenResponse;
const response = await fetch("https://login.salesforce.com/services/oauth2/token", {
method: "POST",
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: consumer_key,
client_secret: consumer_secret,
refresh_token: credentialKey.refresh_token,
format: "json",
}),
});
if (response.statusText !== "OK") throw new HttpError({ statusCode: 400, message: response.statusText });
const accessTokenJson = await response.json();
const accessTokenParsed = parseRefreshTokenResponse(accessTokenJson, salesforceTokenSchema);
if (!accessTokenParsed.success) {
return Promise.reject(new Error("Invalid refreshed tokens were returned"));
}
await prisma.credential.update({
where: { id: credential.id },
data: { key: { ...accessTokenParsed.data, refresh_token: credentialKey.refresh_token } },
});
return new jsforce.Connection({ return new jsforce.Connection({
clientId: consumer_key, clientId: consumer_key,
clientSecret: consumer_secret, clientSecret: consumer_secret,

View File

@ -2,8 +2,8 @@ import type { Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { stringify } from "querystring"; import { stringify } from "querystring";
import createOAuthAppCredential from "../../_utils/createOAuthAppCredential";
import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import type { StripeData } from "../lib/server"; import type { StripeData } from "../lib/server";
import stripe from "../lib/server"; import stripe from "../lib/server";

View File

@ -2,9 +2,9 @@ import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import createOAuthAppCredential from "../../_utils/createOAuthAppCredential";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
let client_id = ""; let client_id = "";
let client_secret = ""; let client_secret = "";

View File

@ -3,8 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import createOAuthAppCredential from "../../_utils/createOAuthAppCredential";
import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import config from "../config.json"; import config from "../config.json";
import { getWebexAppKeys } from "../lib/getWebexAppKeys"; import { getWebexAppKeys } from "../lib/getWebexAppKeys";

View File

@ -8,6 +8,7 @@ import type { CredentialPayload } from "@calcom/types/Credential";
import type { PartialReference } from "@calcom/types/EventManager"; import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter"; import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import { getWebexAppKeys } from "./getWebexAppKeys"; import { getWebexAppKeys } from "./getWebexAppKeys";
/** @link https://developer.webex.com/docs/meetings **/ /** @link https://developer.webex.com/docs/meetings **/
@ -58,18 +59,23 @@ const webexAuth = (credential: CredentialPayload) => {
const refreshAccessToken = async (refreshToken: string) => { const refreshAccessToken = async (refreshToken: string) => {
const { client_id, client_secret } = await getWebexAppKeys(); const { client_id, client_secret } = await getWebexAppKeys();
const response = await fetch("https://webexapis.com/v1/access_token", { const response = await refreshOAuthTokens(
method: "POST", async () =>
headers: { await fetch("https://webexapis.com/v1/access_token", {
"Content-type": "application/x-www-form-urlencoded", method: "POST",
}, headers: {
body: new URLSearchParams({ "Content-type": "application/x-www-form-urlencoded",
grant_type: "refresh_token", },
client_id: client_id, body: new URLSearchParams({
client_secret: client_secret, grant_type: "refresh_token",
refresh_token: refreshToken, client_id: client_id,
}), client_secret: client_secret,
}); refresh_token: refreshToken,
}),
}),
"webex",
credential.userId
);
const responseBody = await handleWebexResponse(response, credential.id); const responseBody = await handleWebexResponse(response, credential.id);

View File

@ -3,8 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { encodeOAuthState } from "../../_utils/encodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
import appConfig from "../config.json"; import appConfig from "../config.json";
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@ -14,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const clientId = typeof appKeys.client_id === "string" ? appKeys.client_id : ""; const clientId = typeof appKeys.client_id === "string" ? appKeys.client_id : "";
if (!clientId) return res.status(400).json({ message: "Zoho Bigin client_id missing." }); if (!clientId) return res.status(400).json({ message: "Zoho Bigin client_id missing." });
const redirectUri = WEBAPP_URL + `/api/integrations/${appConfig.slug}/callback`; const redirectUri = WEBAPP_URL + `/api/integrations/zoho-bigin/callback`;
const authUrl = axios.getUri({ const authUrl = axios.getUri({
url: "https://accounts.zoho.com/oauth/v2/auth", url: "https://accounts.zoho.com/oauth/v2/auth",

View File

@ -5,10 +5,10 @@ import qs from "qs";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import createOAuthAppCredential from "../../_utils/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import appConfig from "../config.json"; import appConfig from "../config.json";
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {

View File

@ -15,6 +15,7 @@ import type {
import type { CredentialPayload } from "@calcom/types/Credential"; import type { CredentialPayload } from "@calcom/types/Credential";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import { appKeysSchema } from "../zod"; import { appKeysSchema } from "../zod";
export type BiginToken = { export type BiginToken = {
@ -81,11 +82,16 @@ export default class BiginCalendarService implements Calendar {
refresh_token: credentialKey.refresh_token, refresh_token: credentialKey.refresh_token,
}; };
const tokenInfo = await axios.post(accountsUrl, qs.stringify(formData), { const tokenInfo = await refreshOAuthTokens(
headers: { async () =>
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8", await axios.post(accountsUrl, qs.stringify(formData), {
}, headers: {
}); "Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
}),
"zoho-bigin",
credentialId
);
if (!tokenInfo.data.error) { if (!tokenInfo.data.error) {
// set expiry date as offset from current time. // set expiry date as offset from current time.

View File

@ -3,8 +3,8 @@ import { stringify } from "querystring";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { encodeOAuthState } from "../../_utils/encodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
let client_id = ""; let client_id = "";

View File

@ -5,10 +5,10 @@ import qs from "qs";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import createOAuthAppCredential from "../../_utils/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
let client_id = ""; let client_id = "";
let client_secret = ""; let client_secret = "";

View File

@ -16,6 +16,7 @@ import type {
import type { CredentialPayload } from "@calcom/types/Credential"; import type { CredentialPayload } from "@calcom/types/Credential";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
export type ZohoToken = { export type ZohoToken = {
scope: string; scope: string;
@ -200,14 +201,19 @@ export default class ZohoCrmCalendarService implements Calendar {
client_secret: this.client_secret, client_secret: this.client_secret,
refresh_token: credentialKey.refresh_token, refresh_token: credentialKey.refresh_token,
}; };
const zohoCrmTokenInfo = await axios({ const zohoCrmTokenInfo = await refreshOAuthTokens(
method: "post", async () =>
url: url, await axios({
data: qs.stringify(formData), method: "post",
headers: { url: url,
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8", data: qs.stringify(formData),
}, headers: {
}); "Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
}),
"zohocrm",
credential.userId
);
if (!zohoCrmTokenInfo.data.error) { if (!zohoCrmTokenInfo.data.error) {
// set expiry date as offset from current time. // set expiry date as offset from current time.
zohoCrmTokenInfo.data.expiryDate = Math.round(Date.now() + 60 * 60); zohoCrmTokenInfo.data.expiryDate = Math.round(Date.now() + 60 * 60);

View File

@ -5,7 +5,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { encodeOAuthState } from "../../_utils/encodeOAuthState"; import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
import { getZoomAppKeys } from "../lib"; import { getZoomAppKeys } from "../lib";
async function handler(req: NextApiRequest) { async function handler(req: NextApiRequest) {

View File

@ -3,8 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import createOAuthAppCredential from "../../_utils/createOAuthAppCredential";
import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { getZoomAppKeys } from "../lib"; import { getZoomAppKeys } from "../lib";
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {

View File

@ -9,6 +9,8 @@ import type { CredentialPayload } from "@calcom/types/Credential";
import type { PartialReference } from "@calcom/types/EventManager"; import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter"; import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import { getZoomAppKeys } from "./getZoomAppKeys"; import { getZoomAppKeys } from "./getZoomAppKeys";
/** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate */ /** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate */
@ -74,17 +76,22 @@ const zoomAuth = (credential: CredentialPayload) => {
const { client_id, client_secret } = await getZoomAppKeys(); const { client_id, client_secret } = await getZoomAppKeys();
const authHeader = "Basic " + Buffer.from(client_id + ":" + client_secret).toString("base64"); const authHeader = "Basic " + Buffer.from(client_id + ":" + client_secret).toString("base64");
const response = await fetch("https://zoom.us/oauth/token", { const response = await refreshOAuthTokens(
method: "POST", async () =>
headers: { await fetch("https://zoom.us/oauth/token", {
Authorization: authHeader, method: "POST",
"Content-Type": "application/x-www-form-urlencoded", headers: {
}, Authorization: authHeader,
body: new URLSearchParams({ "Content-Type": "application/x-www-form-urlencoded",
refresh_token: refreshToken, },
grant_type: "refresh_token", body: new URLSearchParams({
}), refresh_token: refreshToken,
}); grant_type: "refresh_token",
}),
}),
"zoomvideo",
credential.userId
);
const responseBody = await handleZoomResponse(response, credential.id); const responseBody = await handleZoomResponse(response, credential.id);
@ -94,7 +101,7 @@ const zoomAuth = (credential: CredentialPayload) => {
} }
} }
// We check the if the new credentials matches the expected response structure // We check the if the new credentials matches the expected response structure
const parsedToken = zoomRefreshedTokenSchema.safeParse(responseBody); const parsedToken = parseRefreshTokenResponse(responseBody, zoomRefreshedTokenSchema);
// TODO: If the new token is invalid, initiate the fallback sequence instead of throwing // TODO: If the new token is invalid, initiate the fallback sequence instead of throwing
// Expanding on this we can use server-to-server app and create meeting from admin calcom account // Expanding on this we can use server-to-server app and create meeting from admin calcom account

View File

@ -99,3 +99,6 @@ export const ORGANIZATION_MIN_SEATS = 30;
// Needed for emails in E2E // Needed for emails in E2E
export const IS_MAILHOG_ENABLED = process.env.E2E_TEST_MAILHOG_ENABLED === "1"; export const IS_MAILHOG_ENABLED = process.env.E2E_TEST_MAILHOG_ENABLED === "1";
export const CALCOM_VERSION = process.env.NEXT_PUBLIC_CALCOM_VERSION as string; export const CALCOM_VERSION = process.env.NEXT_PUBLIC_CALCOM_VERSION as string;
export const APP_CREDENTIAL_SHARING_ENABLED =
process.env.CALCOM_WEBHOOK_SECRET && process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY;

View File

@ -197,10 +197,14 @@
"BASECAMP3_USER_AGENT", "BASECAMP3_USER_AGENT",
"AUTH_BEARER_TOKEN_VERCEL", "AUTH_BEARER_TOKEN_VERCEL",
"BUILD_ID", "BUILD_ID",
"CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY",
"CALCOM_CREDENTIAL_SYNC_ENDPOINT",
"CALCOM_ENV", "CALCOM_ENV",
"CALCOM_LICENSE_KEY", "CALCOM_LICENSE_KEY",
"CALCOM_TELEMETRY_DISABLED", "CALCOM_TELEMETRY_DISABLED",
"CALCOM_WEBHOOK_HEADER_NAME",
"CALENDSO_ENCRYPTION_KEY", "CALENDSO_ENCRYPTION_KEY",
"CALCOM_WEBHOOK_SECRET",
"CI", "CI",
"CLOSECOM_API_KEY", "CLOSECOM_API_KEY",
"CRON_API_KEY", "CRON_API_KEY",