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:
+1
-1
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user