diff --git a/.env.example b/.env.example index 9d3259b328..8ccabe455b 100644 --- a/.env.example +++ b/.env.example @@ -297,7 +297,7 @@ E2E_TEST_CALCOM_GCAL_KEYS= # You can use: `openssl rand -base64 32` to generate one CALCOM_CREDENTIAL_SYNC_SECRET="" # This is the header name that will be used to verify the webhook secret. Should be in lowercase -CALCOM_CREDENTIAL_SYNC_SECRET_HEADER_NAME="calcom-webhook-secret" +CALCOM_CREDENTIAL_SYNC_HEADER_NAME="calcom-credential-sync-secret" CALCOM_CREDENTIAL_SYNC_ENDPOINT="" # Key should match on Cal.com and your application # must be 24 bytes for AES256 encryption algorithm diff --git a/apps/api/lib/helpers/verifyCredentialSyncEnabled.ts b/apps/api/lib/helpers/verifyCredentialSyncEnabled.ts new file mode 100644 index 0000000000..a797bb2db2 --- /dev/null +++ b/apps/api/lib/helpers/verifyCredentialSyncEnabled.ts @@ -0,0 +1,24 @@ +import type { NextMiddleware } from "next-api-middleware"; + +import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; + +export const verifyCredentialSyncEnabled: NextMiddleware = async (req, res, next) => { + const { isAdmin } = req; + + if (!isAdmin) { + return res.status(403).json({ error: "Only admin API keys can access credential syncing endpoints" }); + } + + if (!APP_CREDENTIAL_SHARING_ENABLED) { + return res.status(501).json({ error: "Credential syncing is not enabled" }); + } + + if ( + req.headers[process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME || "calcom-credential-sync-secret"] !== + process.env.CALCOM_CREDENTIAL_SYNC_SECRET + ) { + return res.status(401).json({ message: "Invalid credential sync secret" }); + } + + await next(); +}; diff --git a/apps/api/lib/helpers/withMiddleware.ts b/apps/api/lib/helpers/withMiddleware.ts index 43fcb46f88..b0d0922afb 100644 --- a/apps/api/lib/helpers/withMiddleware.ts +++ b/apps/api/lib/helpers/withMiddleware.ts @@ -13,6 +13,7 @@ import { } from "./httpMethods"; import { rateLimitApiKey } from "./rateLimitApiKey"; import { verifyApiKey } from "./verifyApiKey"; +import { verifyCredentialSyncEnabled } from "./verifyCredentialSyncEnabled"; import { withPagination } from "./withPagination"; const middleware = { @@ -28,6 +29,7 @@ const middleware = { extendRequest, pagination: withPagination, captureErrors, + verifyCredentialSyncEnabled, }; type Middleware = keyof typeof middleware; diff --git a/apps/api/lib/validations/credential-sync.ts b/apps/api/lib/validations/credential-sync.ts new file mode 100644 index 0000000000..04e248c047 --- /dev/null +++ b/apps/api/lib/validations/credential-sync.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +import { HttpError } from "@calcom/lib/http-error"; + +const userId = z.string().transform((val) => { + const userIdInt = parseInt(val); + + if (isNaN(userIdInt)) { + throw new HttpError({ message: "userId is not a valid number", statusCode: 400 }); + } + + return userIdInt; +}); +const appSlug = z.string(); +const credentialId = z.string().transform((val) => { + const credentialIdInt = parseInt(val); + + if (isNaN(credentialIdInt)) { + throw new HttpError({ message: "credentialId is not a valid number", statusCode: 400 }); + } + + return credentialIdInt; +}); +const encryptedKey = z.string(); + +export const schemaCredentialGetParams = z.object({ + userId, + appSlug: appSlug.optional(), +}); + +export const schemaCredentialPostParams = z.object({ + userId, + createSelectedCalendar: z + .string() + .optional() + .transform((val) => { + return val === "true"; + }), + createDestinationCalendar: z + .string() + .optional() + .transform((val) => { + return val === "true"; + }), +}); + +export const schemaCredentialPostBody = z.object({ + appSlug, + encryptedKey, +}); + +export const schemaCredentialPatchParams = z.object({ + userId, + credentialId, +}); + +export const schemaCredentialPatchBody = z.object({ + encryptedKey, +}); + +export const schemaCredentialDeleteParams = z.object({ + userId, + credentialId, +}); diff --git a/apps/api/pages/api/credential-sync/_delete.ts b/apps/api/pages/api/credential-sync/_delete.ts new file mode 100644 index 0000000000..cfcbd719b0 --- /dev/null +++ b/apps/api/pages/api/credential-sync/_delete.ts @@ -0,0 +1,64 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; + +import { schemaCredentialDeleteParams } from "~/lib/validations/credential-sync"; + +/** + * @swagger + * /credential-sync: + * delete: + * operationId: deleteUserAppCredential + * summary: Delete a credential record for a user + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: userId + * required: true + * schema: + * type: string + * description: ID of the user to fetch the credentials for + * - in: query + * name: credentialId + * required: true + * schema: + * type: string + * description: ID of the credential to update + * tags: + * - credentials + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 505: + * description: Credential syncing not enabled + */ +async function handler(req: NextApiRequest) { + const { prisma } = req; + + const { userId: reqUserId, credentialId: reqCredentialId } = schemaCredentialDeleteParams.parse(req.query); + + const userId = parseInt(reqUserId); + const credentialId = parseInt(reqCredentialId); + + const credential = await prisma.credential.delete({ + where: { + id: credentialId, + userId, + }, + select: { + id: true, + appId: true, + }, + }); + + return { credentialDeleted: credential }; +} + +export default defaultResponder(handler); diff --git a/apps/api/pages/api/credential-sync/_get.ts b/apps/api/pages/api/credential-sync/_get.ts new file mode 100644 index 0000000000..b2d56102c4 --- /dev/null +++ b/apps/api/pages/api/credential-sync/_get.ts @@ -0,0 +1,63 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; + +import { schemaCredentialGetParams } from "~/lib/validations/credential-sync"; + +/** + * @swagger + * /credential-sync: + * get: + * operationId: getUserAppCredentials + * summary: Get all app credentials for a user + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: userId + * required: true + * schema: + * type: string + * description: ID of the user to fetch the credentials for + * tags: + * - credentials + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 505: + * description: Credential syncing not enabled + */ +async function handler(req: NextApiRequest) { + const { prisma } = req; + + const { appSlug, userId } = schemaCredentialGetParams.parse(req.query); + + let credentials = await prisma.credential.findMany({ + where: { + userId, + ...(appSlug && { appId: appSlug }), + }, + select: { + id: true, + appId: true, + }, + }); + + // For apps we're transitioning to using the term slug to keep things consistent + credentials = credentials.map((credential) => { + return { + ...credential, + appSlug: credential.appId, + }; + }); + + return { credentials }; +} + +export default defaultResponder(handler); diff --git a/apps/api/pages/api/credential-sync/_patch.ts b/apps/api/pages/api/credential-sync/_patch.ts new file mode 100644 index 0000000000..3ccd20d35a --- /dev/null +++ b/apps/api/pages/api/credential-sync/_patch.ts @@ -0,0 +1,86 @@ +import type { NextApiRequest } from "next"; + +import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse"; +import { symmetricDecrypt } from "@calcom/lib/crypto"; +import { defaultResponder } from "@calcom/lib/server"; + +import { schemaCredentialPatchParams, schemaCredentialPatchBody } from "~/lib/validations/credential-sync"; + +/** + * @swagger + * /credential-sync: + * patch: + * operationId: updateUserAppCredential + * summary: Update a credential record for a user + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: userId + * required: true + * schema: + * type: string + * description: ID of the user to fetch the credentials for + * - in: query + * name: credentialId + * required: true + * schema: + * type: string + * description: ID of the credential to update + * tags: + * - credentials + * requestBody: + * description: Update a new credential + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - encryptedKey + * properties: + * encryptedKey: + * type: string + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 505: + * description: Credential syncing not enabled + */ +async function handler(req: NextApiRequest) { + const { prisma } = req; + + const { userId, credentialId } = schemaCredentialPatchParams.parse(req.query); + + const { encryptedKey } = schemaCredentialPatchBody.parse(req.body); + + const decryptedKey = JSON.parse( + symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "") + ); + + const key = minimumTokenResponseSchema.parse(decryptedKey); + + const credential = await prisma.credential.update({ + where: { + id: credentialId, + userId, + }, + data: { + key, + }, + select: { + id: true, + appId: true, + }, + }); + + return { credential }; +} + +export default defaultResponder(handler); diff --git a/apps/api/pages/api/credential-sync/_post.ts b/apps/api/pages/api/credential-sync/_post.ts new file mode 100644 index 0000000000..a7e5b85137 --- /dev/null +++ b/apps/api/pages/api/credential-sync/_post.ts @@ -0,0 +1,142 @@ +import type { NextApiRequest } from "next"; + +import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; +import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse"; +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { symmetricDecrypt } from "@calcom/lib/crypto"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; + +import { schemaCredentialPostParams, schemaCredentialPostBody } from "~/lib/validations/credential-sync"; + +/** + * @swagger + * /credential-sync: + * post: + * operationId: createUserAppCredential + * summary: Create a credential record for a user + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: userId + * required: true + * schema: + * type: string + * description: ID of the user to fetch the credentials for + * tags: + * - credentials + * requestBody: + * description: Create a new credential + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - encryptedKey + * - appSlug + * properties: + * encryptedKey: + * type: string + * appSlug: + * type: string + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 505: + * description: Credential syncing not enabled + */ +async function handler(req: NextApiRequest) { + const { prisma } = req; + + if (!req.body) { + throw new HttpError({ message: "Request body is missing", statusCode: 400 }); + } + + const { userId, createSelectedCalendar, createDestinationCalendar } = schemaCredentialPostParams.parse( + req.query + ); + + const { appSlug, encryptedKey } = schemaCredentialPostBody.parse(req.body); + + const decryptedKey = JSON.parse( + symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "") + ); + + const key = minimumTokenResponseSchema.parse(decryptedKey); + + // Need to get app type + const app = await prisma.app.findUnique({ + where: { slug: appSlug }, + select: { dirName: true, categories: true }, + }); + + if (!app) { + throw new HttpError({ message: "App not found", statusCode: 500 }); + } + + const createCalendarResources = + app.categories.some((category) => category === "calendar") && + (createSelectedCalendar || createDestinationCalendar); + + const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata]; + + const credential = await prisma.credential.create({ + data: { + userId, + appId: appSlug, + key, + type: appMetadata.type, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + if (createCalendarResources) { + const calendar = await getCalendar(credential); + const calendars = await calendar.listCalendars(); + + const calendarToCreate = calendars.find((calendar) => calendar.primary) || calendars[0]; + + if (createSelectedCalendar) { + await prisma.selectedCalendar.createMany({ + data: [ + { + userId, + integration: appMetadata.type, + externalId: calendarToCreate.externalId, + credentialId: credential.id, + }, + ], + skipDuplicates: true, + }); + } + if (createDestinationCalendar) { + await prisma.destinationCalendar.create({ + data: { + integration: appMetadata.type, + externalId: calendarToCreate.externalId, + credential: { connect: { id: credential.id } }, + primaryEmail: calendarToCreate.email || credential.user.email, + user: { connect: { id: userId } }, + }, + }); + } + } + + return { credential: { id: credential.id, type: credential.type } }; +} + +export default defaultResponder(handler); diff --git a/apps/api/pages/api/credential-sync/index.ts b/apps/api/pages/api/credential-sync/index.ts new file mode 100644 index 0000000000..8def51c2bd --- /dev/null +++ b/apps/api/pages/api/credential-sync/index.ts @@ -0,0 +1,12 @@ +import { defaultHandler } from "@calcom/lib/server"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; + +export default withMiddleware("verifyCredentialSyncEnabled")( + defaultHandler({ + GET: import("./_get"), + POST: import("./_post"), + PATCH: import("./_patch"), + DELETE: import("./_delete"), + }) +); diff --git a/packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts b/packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts index 958205707a..c148234ffa 100644 --- a/packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts +++ b/packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts @@ -2,13 +2,20 @@ 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(), -}); +export const minimumTokenResponseSchema = z + .object({ + access_token: z.string(), + }) + .passthrough() + .superRefine((tokenObject, ctx) => { + if (!Object.values(tokenObject).some((value) => typeof value === "number")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Missing a field that defines a token expiry date. Check the specific app package to see how token expiry is defined.", + }); + } + }); export type ParseRefreshTokenResponse = | z.infer diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index bf251817a2..e689309efb 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -106,10 +106,11 @@ export const ORGANIZATION_MIN_SEATS = 30; 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 APP_CREDENTIAL_SHARING_ENABLED = + !!process.env.CALCOM_CREDENTIAL_SYNC_SECRET && !!process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY; export const CREDENTIAL_SYNC_SECRET = process.env.CALCOM_CREDENTIAL_SYNC_SECRET; export const CREDENTIAL_SYNC_SECRET_HEADER_NAME = - process.env.CALCOM_CREDENTIAL_SYNC_SECRET_HEADER_NAME || "calcom-credential-sync-secret"; -export const APP_CREDENTIAL_SHARING_ENABLED = CREDENTIAL_SYNC_SECRET && CREDENTIAL_SYNC_SECRET_HEADER_NAME; + process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME || "calcom-credential-sync-secret"; export const DEFAULT_LIGHT_BRAND_COLOR = "#292929"; export const DEFAULT_DARK_BRAND_COLOR = "#fafafa"; diff --git a/turbo.json b/turbo.json index 686658d1a6..a5174f8d29 100644 --- a/turbo.json +++ b/turbo.json @@ -229,7 +229,9 @@ "CALCOM_QA_EMAIL", "CALCOM_QA_PASSWORD", "CALCOM_TELEMETRY_DISABLED", + "CALCOM_CREDENTIAL_SYNC_HEADER_NAME", "CALENDSO_ENCRYPTION_KEY", + "CALCOM_CREDENTIAL_SYNC_SECRET", "CI", "CLOSECOM_API_KEY", "CRON_API_KEY",