refactor: Moving credential syncing to API (#13963)

* Implement credential sync secret

* Create auth middleware for credential syncing

* Create GET endpoint

* Update swagger comment

* Add verifyCredentialSyncEnabled middleware

* Fix middleware input

* Fix APP_CREDENTIAL_SHARING_ENABLED to return boolean

* Add check for admin API key

* Add GET method

* Middleware require credential sync secret

* Return only the credentialId

* Update default CALCOM_CREDENTIAL_SYNC_HEADER_NAME value

* Add post endpoint

* Return credential object for get endpoint

* Require a userId in the param

* POST add keys to body

* Add PATCH endpoint

* Add DELETE endpoint

* Fix minimumTokenResponseSchema

* Add encrypted key

* Remove prototype

* Add swagger doc comments

* Fix parsing and error handling

* Add create selectedCalendar and destinationCalendar params
This commit is contained in:
Joe Au-Yeung
2024-03-15 16:03:49 +00:00
committed by GitHub
parent 8fe8beb686
commit 7a790aaabe
12 changed files with 477 additions and 10 deletions
+1 -1
View File
@@ -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
@@ -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();
};
+2
View File
@@ -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;
@@ -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,
});
@@ -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);
@@ -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);
@@ -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);
+142
View File
@@ -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);
@@ -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"),
})
);
@@ -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<S extends z.ZodTypeAny> =
| z.infer<S>
+3 -2
View File
@@ -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";
+2
View File
@@ -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",