diff --git a/integrations/calendly/integration.definition.ts b/integrations/calendly/integration.definition.ts index 4298f31696f..398a3c49d7a 100644 --- a/integrations/calendly/integration.definition.ts +++ b/integrations/calendly/integration.definition.ts @@ -10,14 +10,24 @@ export default new IntegrationDefinition({ icon: 'icon.svg', description: 'Schedule meetings and manage events using the Calendly scheduling platform.', configuration: { - schema: z.object({ - accessToken: z - .string() - .secret() - .min(1) - .describe('Your Calendly Personal Access Token') - .title('Personal Access Token'), - }), + identifier: { + linkTemplateScript: 'linkTemplate.vrl', + }, + schema: z.object({}), + }, + configurations: { + manual: { + title: 'Manual Configuration', + description: 'Configure by manually supplying a Calendly Personal Access Token', + schema: z.object({ + accessToken: z + .string() + .secret() + .min(1) + .describe('Your Calendly Personal Access Token') + .title('Personal Access Token'), + }), + }, }, actions: { scheduleEvent: { @@ -49,4 +59,45 @@ export default new IntegrationDefinition({ schema: inviteeEventOutputSchema, }, }, + secrets: { + OAUTH_CLIENT_ID: { + description: "The unique identifier that's used to initiate the OAuth flow", + }, + OAUTH_CLIENT_SECRET: { + description: "A secret that's used to establish and refresh the OAuth authentication", + }, + OAUTH_WEBHOOK_SIGNING_KEY: { + description: "The signing key used to validate Calendly's OAuth webhook request payloads", + }, + }, + states: { + configuration: { + type: 'integration', + schema: z.object({ + oauth: z + .object({ + refreshToken: z.string().describe('The refresh token for the integration').title('Refresh Token'), + accessToken: z.string().describe('The access token for the integration').title('Access Token'), + expiresAt: z + .number() + .min(0) + .describe('The expiry time of the access token represented as a Unix timestamp (milliseconds)') + .title('Expires At'), + }) + .describe('The parameters used for accessing the Calendly API and refreshing the access token') + .title('OAuth Parameters') + .nullable(), + }), + }, + webhooks: { + type: 'integration', + schema: z.object({ + signingKey: z + .string() + .secret() + .describe('The signing key used for webhook event signatures') + .title('Webhook Signing Key'), + }), + }, + }, }) diff --git a/integrations/calendly/linkTemplate.vrl b/integrations/calendly/linkTemplate.vrl new file mode 100644 index 00000000000..f2232065367 --- /dev/null +++ b/integrations/calendly/linkTemplate.vrl @@ -0,0 +1,11 @@ +webhookId = to_string!(.webhookId) +webhookUrl = to_string!(.webhookUrl) +env = to_string!(.env) + +clientId = "wrG-UfzdRXU6f68l-QSZpDzpdVErsC0QUBmszx6WFLQ" + +if env == "production" { + clientId = "GgPD_8KkRRtV8FhQrA5C6EsukYg9pxGLzML-FrxlCgg" +} + +"https://auth.calendly.com/oauth/authorize?client_id={{ clientId }}&response_type=code&state={{ webhookId }}&redirect_uri={{ webhookUrl }}/oauth" \ No newline at end of file diff --git a/integrations/calendly/src/actions/schedule-event.ts b/integrations/calendly/src/actions/schedule-event.ts index 0e5b9737341..41c039f524a 100644 --- a/integrations/calendly/src/actions/schedule-event.ts +++ b/integrations/calendly/src/actions/schedule-event.ts @@ -4,14 +4,13 @@ import { parseError } from '../utils' import type * as bp from '.botpress' export const scheduleEvent: bp.IntegrationProps['actions']['scheduleEvent'] = async (props) => { - const { ctx, input } = props + const { eventTypeUrl, conversationId } = props.input try { - const calendlyClient = new CalendlyClient(ctx.configuration.accessToken) - + const calendlyClient = await CalendlyClient.create(props) const currentUser = await calendlyClient.getCurrentUser() const eventTypes = await calendlyClient.getEventTypesList(currentUser.resource.uri) - const eventType = eventTypes.collection.find((eventType) => eventType.scheduling_url === input.eventTypeUrl) + const eventType = eventTypes.collection.find((eventType) => eventType.scheduling_url === eventTypeUrl) if (!eventType) { throw new RuntimeError('Event type not found') @@ -20,9 +19,7 @@ export const scheduleEvent: bp.IntegrationProps['actions']['scheduleEvent'] = as const resp = (await calendlyClient.createSingleUseSchedulingLink(eventType)).resource const searchParams = new URLSearchParams({ - utm_source: 'chatbot', - utm_medium: 'conversation', - utm_content: `id=${input.conversationId}`, + utm_content: `conversationId=${conversationId}`, }) return { diff --git a/integrations/calendly/src/calendly-api/auth.ts b/integrations/calendly/src/calendly-api/auth.ts new file mode 100644 index 00000000000..1c9f01ecb98 --- /dev/null +++ b/integrations/calendly/src/calendly-api/auth.ts @@ -0,0 +1,125 @@ +import { RuntimeError } from '@botpress/sdk' +import axios, { type AxiosInstance } from 'axios' +import type { CommonHandlerProps, Result } from '../types' +import { type CalendlyUri, type GetOAuthAccessTokenResp, getOAuthAccessTokenRespSchema, uuidSchema } from './schemas' +import * as bp from '.botpress' + +const AUTH_BASE_URL = 'https://auth.calendly.com' as const + +export class CalendlyAuthClient { + private _axiosClient: AxiosInstance + + public constructor() { + const { OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET } = bp.secrets + + this._axiosClient = axios.create({ + baseURL: AUTH_BASE_URL, + headers: { + Authorization: `Basic ${Buffer.from(`${OAUTH_CLIENT_ID}:${OAUTH_CLIENT_SECRET}`).toString('base64')}`, + }, + }) + } + + private async _getAccessToken(params: GetAccessTokenParams): Promise> { + // The Calendly API docs states that it only accepts + // `application/x-www-form-urlencoded` for this endpoint. + const formData = new FormData() + Object.entries(params).forEach(([key, value]) => formData.append(key, value)) + const resp = await this._axiosClient.post('/oauth/token', formData) + + if (resp.status < 200 || resp.status >= 300) { + return { + success: false, + error: new RuntimeError( + `Failed to retrieve access token w/${params.grant_type} | Invalid Status '${resp.status}'` + ), + } + } + + const result = getOAuthAccessTokenRespSchema.safeParse(resp.data) + if (!result.success) { + return { + success: false, + error: new RuntimeError(`Failed to retrieve access token w/${params.grant_type} | Schema Parse Failure`), + } + } + + return { success: true, data: result.data } + } + + public async getAccessTokenWithCode(code: string): Promise> { + return this._getAccessToken({ + grant_type: 'authorization_code', + code, + redirect_uri: `${process.env.BP_WEBHOOK_URL}/oauth`, + }) + } + + public async getAccessTokenWithRefreshToken(refreshToken: string): Promise> { + return this._getAccessToken({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }) + } +} + +type GetAccessTokenParams = + | { + grant_type: 'authorization_code' + code: string + redirect_uri: string + } + | { + grant_type: 'refresh_token' + refresh_token: string + } + +const _extractUserUuid = (userUri: CalendlyUri): string => { + const match = userUri.match(/\/users\/(.+)$/) + + if (!match) { + throw new Error('Failed to extract user UUID from URI') + } + + const parsed = uuidSchema.safeParse(match[1]) + if (!parsed.success) { + throw new Error('Failed to extract user UUID from URI') + } + + return parsed.data +} + +export const applyOAuthState = async ({ client, ctx }: CommonHandlerProps, resp: GetOAuthAccessTokenResp) => { + const { access_token, refresh_token, created_at, expires_in, owner: userUri } = resp + const { state } = await client.setState({ + type: 'integration', + name: 'configuration', + id: ctx.integrationId, + payload: { + oauth: { + accessToken: access_token, + refreshToken: refresh_token, + expiresAt: (created_at + expires_in) * 1000, + }, + }, + }) + + if (!state.payload.oauth) { + throw new Error('Failed to store OAuth state') + } + + return { oauth: state.payload.oauth, userUri } +} + +export const exchangeAuthCodeForRefreshToken = async (props: bp.HandlerProps, oAuthCode: string): Promise => { + const authClient = new CalendlyAuthClient() + const resp = await authClient.getAccessTokenWithCode(oAuthCode) + if (!resp.success) throw resp.error + + const { userUri } = await applyOAuthState(props, resp.data) + + const userId = _extractUserUuid(userUri) + await props.client.configureIntegration({ + identifier: userId, + }) +} diff --git a/integrations/calendly/src/calendly-api/index.ts b/integrations/calendly/src/calendly-api/index.ts index cb9b9418037..42173c8f1d8 100644 --- a/integrations/calendly/src/calendly-api/index.ts +++ b/integrations/calendly/src/calendly-api/index.ts @@ -1,5 +1,7 @@ import { RuntimeError } from '@botpress/sdk' -import axios, { AxiosInstance } from 'axios' +import axios, { type AxiosInstance } from 'axios' +import type { CommonHandlerProps } from '../types' +import { applyOAuthState, CalendlyAuthClient } from './auth' import { type CalendlyUri, type CreateSchedulingLinkResp, @@ -14,8 +16,9 @@ import { type GetWebhooksListResp, getWebhooksListRespSchema, } from './schemas' +import type { ContextOfType, RegisterWebhookParams, WebhooksListParams } from './types' -const BASE_URL = 'https://api.calendly.com' as const +const API_BASE_URL = 'https://api.calendly.com' as const // ------ Status Codes ------ const NO_CONTENT = 204 as const @@ -23,14 +26,18 @@ const NO_CONTENT = 204 as const export class CalendlyClient { private _axiosClient: AxiosInstance - public constructor(accessToken: string) { + private constructor(accessToken: string) { this._axiosClient = axios.create({ - baseURL: BASE_URL, + baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, }, }) + + this._axiosClient.interceptors.request.use(async (config) => { + config.headers.Authorization = `Bearer ${accessToken}` + return config + }) } public async getCurrentUser(): Promise { @@ -64,13 +71,14 @@ export class CalendlyClient { } public async createWebhook(params: RegisterWebhookParams): Promise { - const { webhookUrl, events, organization, scope, user } = params + const { webhookUrl, events, organization, scope, user, signingKey } = params const resp = await this._axiosClient.post('/webhook_subscriptions', { url: webhookUrl, events, organization, user, scope, + signing_key: signingKey, }) try { @@ -99,6 +107,29 @@ export class CalendlyClient { throw new RuntimeError('Failed to create scheduling link due to unexpected api response') } } + + private static async _createFromManualConfig(ctx: ContextOfType<'manual'>) { + return new CalendlyClient(ctx.configuration.accessToken) + } + + private static async _createFromOAuthConfig(props: CommonHandlerProps) { + const accessToken = await _getOAuthAccessToken(props) + return new CalendlyClient(accessToken) + } + + public static async create(props: CommonHandlerProps): Promise { + const { ctx } = props + switch (ctx.configurationType) { + case 'manual': + return this._createFromManualConfig(ctx) + case null: + return this._createFromOAuthConfig(props) + default: + ctx satisfies never + } + + throw new RuntimeError(`Unsupported configuration type: ${props.ctx.configurationType}`) + } } const _extractWebhookUuid = (webhookUri: CalendlyUri) => { @@ -106,37 +137,30 @@ const _extractWebhookUuid = (webhookUri: CalendlyUri) => { return match ? match[1] : null } -type WebhooksListParams = - | { - scope: 'organization' - organization: CalendlyUri - } - | { - scope: 'user' - organization: CalendlyUri - user: CalendlyUri - } +const FIVE_MINUTES_IN_MS = 300000 as const +const _getOAuthAccessToken = async (props: CommonHandlerProps) => { + const { state } = await props.client.getOrSetState({ + type: 'integration', + name: 'configuration', + id: props.ctx.integrationId, + payload: { + oauth: null, + }, + }) + let oauthState = state.payload.oauth + + if (!oauthState) { + throw new RuntimeError('User authentication has not been completed') + } -type WebhookScopes = 'organization' | 'user' -type WebhookEvents = - | 'invitee.created' - | 'invitee.canceled' - | 'invitee_no_show.created' - | 'invitee_no_show.deleted' - | (Scope extends 'organization' ? 'routing_form_submission.created' : never) - -type RegisterWebhookParams = - | { - scope: 'organization' - organization: CalendlyUri - events: WebhookEvents<'organization'>[] - user?: undefined - webhookUrl: string - } - | { - scope: 'user' - organization: CalendlyUri - user: CalendlyUri - events: WebhookEvents<'user'>[] - webhookUrl: string - } + const { expiresAt, refreshToken } = oauthState + if (expiresAt - FIVE_MINUTES_IN_MS <= Date.now()) { + const authClient = new CalendlyAuthClient() + const resp = await authClient.getAccessTokenWithRefreshToken(refreshToken) + if (!resp.success) throw resp.error + + oauthState = (await applyOAuthState(props, resp.data)).oauth + } + + return oauthState.accessToken +} diff --git a/integrations/calendly/src/calendly-api/schemas.ts b/integrations/calendly/src/calendly-api/schemas.ts index 8723021ada2..0bf5c646acc 100644 --- a/integrations/calendly/src/calendly-api/schemas.ts +++ b/integrations/calendly/src/calendly-api/schemas.ts @@ -3,6 +3,8 @@ import { z } from '@botpress/sdk' export const calendlyUri = z.string().url().brand('CalendlyUri') export type CalendlyUri = z.infer +export const uuidSchema = z.string().uuid() + export const paginationSchema = z .object({ count: z.number(), @@ -158,3 +160,15 @@ export const createWebhookRespSchema = z }) .passthrough() export type CreateWebhookResp = z.infer + +export const getOAuthAccessTokenRespSchema = z.object({ + access_token: z.string().min(1), + token_type: z.string().min(1), + expires_in: z.number().min(0), + refresh_token: z.string().min(1), + scope: z.string().min(1), + created_at: z.number().min(0), + owner: calendlyUri, + organization: calendlyUri, +}) +export type GetOAuthAccessTokenResp = z.infer diff --git a/integrations/calendly/src/calendly-api/types.ts b/integrations/calendly/src/calendly-api/types.ts new file mode 100644 index 00000000000..c25815b5a0d --- /dev/null +++ b/integrations/calendly/src/calendly-api/types.ts @@ -0,0 +1,41 @@ +import type { CalendlyUri } from './schemas' +import type * as bp from '.botpress' + +export type WebhooksListParams = + | { + scope: 'organization' + organization: CalendlyUri + } + | { + scope: 'user' + organization: CalendlyUri + user: CalendlyUri + } + +type WebhookScopes = 'organization' | 'user' +type WebhookEvents = + | 'invitee.created' + | 'invitee.canceled' + | 'invitee_no_show.created' + | 'invitee_no_show.deleted' + | (Scope extends 'organization' ? 'routing_form_submission.created' : never) + +export type RegisterWebhookParams = + | { + scope: 'organization' + organization: CalendlyUri + events: WebhookEvents<'organization'>[] + user?: undefined + webhookUrl: string + signingKey?: string + } + | { + scope: 'user' + organization: CalendlyUri + user: CalendlyUri + events: WebhookEvents<'user'>[] + webhookUrl: string + signingKey?: string + } + +export type ContextOfType = Extract diff --git a/integrations/calendly/src/handler.ts b/integrations/calendly/src/handler.ts new file mode 100644 index 00000000000..7b516b25662 --- /dev/null +++ b/integrations/calendly/src/handler.ts @@ -0,0 +1,30 @@ +import { exchangeAuthCodeForRefreshToken } from './calendly-api/auth' +import { dispatchIntegrationEvent } from './webhooks/event-dispatcher' +import { parseWebhookEvent, verifyWebhookSignature } from './webhooks/webhook-utils' +import * as bp from '.botpress' + +const _isOauthRequest = ({ req }: bp.HandlerProps) => req.path === '/oauth' + +export const handler = async (props: bp.HandlerProps) => { + if (_isOauthRequest(props)) { + const oAuthCode = new URLSearchParams(props.req.query).get('code') + if (oAuthCode === null) throw new Error('Missing authentication code') + + await exchangeAuthCodeForRefreshToken(props, oAuthCode) + return + } + + const signatureResult = await verifyWebhookSignature(props) + if (!signatureResult.success) { + props.logger.forBot().error(signatureResult.error.message, signatureResult.error) + return + } + + const result = parseWebhookEvent(props) + if (!result.success) { + props.logger.forBot().error(result.error.message, result.error) + return + } + + await dispatchIntegrationEvent(props, result.data) +} diff --git a/integrations/calendly/src/index.ts b/integrations/calendly/src/index.ts index 09676446ab2..6a181556439 100644 --- a/integrations/calendly/src/index.ts +++ b/integrations/calendly/src/index.ts @@ -1,7 +1,6 @@ import actions from './actions' +import { handler } from './handler' import { register, unregister } from './setup' -import { dispatchIntegrationEvent } from './webhooks/event-dispatcher' -import { parseWebhookData } from './webhooks/webhook-utils' import * as bp from '.botpress' export default new bp.Integration({ @@ -9,13 +8,5 @@ export default new bp.Integration({ unregister, actions, channels: {}, - handler: async (props: bp.HandlerProps) => { - const result = parseWebhookData(props) - if (!result.success) { - props.logger.forBot().error(result.error.message, result.error) - return - } - - await dispatchIntegrationEvent(props, result.data) - }, + handler, }) diff --git a/integrations/calendly/src/setup.ts b/integrations/calendly/src/setup.ts index 6ca969626c0..804c7ba07bd 100644 --- a/integrations/calendly/src/setup.ts +++ b/integrations/calendly/src/setup.ts @@ -1,6 +1,7 @@ import { CalendlyClient } from './calendly-api' import type { GetCurrentUserResp, WebhookDetails } from './calendly-api/schemas' -import type * as bp from '.botpress' +import { getWebhookSigningKey } from './webhooks/signing-key' +import * as bp from '.botpress' const performUnregistration = async ( calendlyClient: CalendlyClient, @@ -23,18 +24,20 @@ const performUnregistration = async ( } } -export const unregister: bp.Integration['unregister'] = async ({ ctx, webhookUrl }) => { - const calendlyClient = new CalendlyClient(ctx.configuration.accessToken) +export const unregister: bp.Integration['unregister'] = async (props) => { + const calendlyClient = await CalendlyClient.create(props) const currentUser = await calendlyClient.getCurrentUser() - await performUnregistration(calendlyClient, currentUser, webhookUrl) + await performUnregistration(calendlyClient, currentUser, props.webhookUrl) } -export const register: bp.Integration['register'] = async ({ ctx, webhookUrl }) => { - const calendlyClient = new CalendlyClient(ctx.configuration.accessToken) +export const register: bp.Integration['register'] = async (props) => { + const calendlyClient = await CalendlyClient.create(props) const userResp = await calendlyClient.getCurrentUser() try { - await performUnregistration(calendlyClient, userResp, webhookUrl) + // Simply checking if webhook subscriptions exists then skipping following logic won't work here. + // This is because such an approach may lead to a de-synchronization of the webhook signing key. + await performUnregistration(calendlyClient, userResp, props.webhookUrl) } catch { // Do nothing since if it's the first time there's nothing to unregister } @@ -42,10 +45,11 @@ export const register: bp.Integration['register'] = async ({ ctx, webhookUrl }) const { current_organization: organizationUri, uri: userUri } = userResp.resource await calendlyClient.createWebhook({ - webhookUrl, + webhookUrl: props.webhookUrl, events: ['invitee.created', 'invitee.canceled', 'invitee_no_show.created', 'invitee_no_show.deleted'], organization: organizationUri, user: userUri, scope: 'user', + signingKey: await getWebhookSigningKey(props), }) } diff --git a/integrations/calendly/src/types.ts b/integrations/calendly/src/types.ts index 4736f50f9cd..1b8df5b26b8 100644 --- a/integrations/calendly/src/types.ts +++ b/integrations/calendly/src/types.ts @@ -1 +1,11 @@ +import type * as bp from '.botpress' + +export type Supplier = () => T + export type Result = { success: true; data: T } | { success: false; error: Error } + +export type CommonHandlerProps = { + ctx: bp.Context + client: bp.Client + logger: bp.Logger +} diff --git a/integrations/calendly/src/utils.ts b/integrations/calendly/src/utils.ts index 91fe0bc58b5..73a4ff1aa80 100644 --- a/integrations/calendly/src/utils.ts +++ b/integrations/calendly/src/utils.ts @@ -1,6 +1,6 @@ import { RuntimeError, ZodError } from '@botpress/sdk' import axios from 'axios' -import { Result } from './types' +import type { Result } from './types' const _isZodError = (error: any): error is ZodError => { return error && typeof error === 'object' && error instanceof ZodError && 'errors' in error diff --git a/integrations/calendly/src/webhooks/event-dispatcher.ts b/integrations/calendly/src/webhooks/event-dispatcher.ts index 032a988c9db..e958a47d023 100644 --- a/integrations/calendly/src/webhooks/event-dispatcher.ts +++ b/integrations/calendly/src/webhooks/event-dispatcher.ts @@ -1,6 +1,6 @@ -import * as inviteeHandlers from './handlers' -import { InviteeEvent } from './schemas' -import * as bp from '.botpress' +import * as inviteeHandlers from './event-handlers' +import type { InviteeEvent } from './schemas' +import type * as bp from '.botpress' export const dispatchIntegrationEvent = async (props: bp.HandlerProps, webhookEvent: InviteeEvent) => { switch (webhookEvent.event) { diff --git a/integrations/calendly/src/webhooks/event-handlers.ts b/integrations/calendly/src/webhooks/event-handlers.ts new file mode 100644 index 00000000000..4a58a8a27e4 --- /dev/null +++ b/integrations/calendly/src/webhooks/event-handlers.ts @@ -0,0 +1,68 @@ +import { z } from '@botpress/sdk' +import { safeParseJson } from 'src/utils' +import { CalendlyClient } from '../calendly-api' +import type { InviteeEvent } from './schemas' +import type * as bp from '.botpress' + +const trackingParameterSchema = z.union([z.string(), z.array(z.string())]) + +const _parseTrackingParameter = (trackingParameter: string | null): string[] => { + if (trackingParameter === null) return [] + + const parseResult = safeParseJson(trackingParameter) + if (!parseResult.success) { + return [trackingParameter] + } + + const zodResult = trackingParameterSchema.safeParse(parseResult.data) + if (!zodResult.success) { + return [String(parseResult.data)] + } + + const { data } = zodResult + return Array.isArray(data) ? data : [data] +} + +export const handleInviteeEvent = async ( + props: bp.HandlerProps, + eventType: keyof bp.events.Events, + event: InviteeEvent +) => { + const { start_time, end_time, location, name: eventName, uri: scheduledEventUri } = event.payload.scheduled_event + + const calendlyClient = await CalendlyClient.create(props) + const currentUser = await calendlyClient.getCurrentUser() + + const { tracking } = event.payload + const utmContentValues = _parseTrackingParameter(tracking.utm_content) + + if (utmContentValues.length === 0) { + props.logger.forBot().warn('The event did not have an associated utm_content value with a conversation id') + } + + const conversationIdPattern = /conversationId=([\w]+)/ + const conversationId = + utmContentValues + .find((contentValue) => conversationIdPattern.test(contentValue)) + ?.match(conversationIdPattern)?.[1] ?? null + + if (!conversationId) { + props.logger.forBot().warn('Could not extract the conversation id from the utm_content parameter') + } + + return await props.client.createEvent({ + type: eventType, + payload: { + scheduledEventUri, + eventName: eventName ?? `Meeting between ${currentUser.resource.name} and ${event.payload.name}`, + startTime: start_time.toISOString(), + endTime: end_time.toISOString(), + locationType: location.type, + organizerName: currentUser.resource.name, + organizerEmail: currentUser.resource.email, + inviteeName: event.payload.name, + inviteeEmail: event.payload.email, + conversationId, + }, + }) +} diff --git a/integrations/calendly/src/webhooks/handlers.ts b/integrations/calendly/src/webhooks/handlers.ts deleted file mode 100644 index 039b4e0e3b6..00000000000 --- a/integrations/calendly/src/webhooks/handlers.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { CalendlyClient } from '../calendly-api' -import { InviteeEvent } from './schemas' -import * as bp from '.botpress' - -export const handleInviteeEvent = async ( - { client, ctx }: bp.HandlerProps, - eventType: keyof bp.events.Events, - event: InviteeEvent -) => { - const { start_time, end_time, location, name: eventName, uri: scheduledEventUri } = event.payload.scheduled_event - - const calendlyClient = new CalendlyClient(ctx.configuration.accessToken) - const currentUser = await calendlyClient.getCurrentUser() - - let conversationId: string | null = null - const { utm_content, utm_medium } = event.payload.tracking - if (utm_medium === 'conversation' && utm_content?.startsWith('id=')) { - conversationId = utm_content.replace(/id=/, '') - } - - return await client.createEvent({ - type: eventType, - payload: { - scheduledEventUri, - eventName: eventName ?? `Meeting between ${currentUser.resource.name} and ${event.payload.name}`, - startTime: start_time.toISOString(), - endTime: end_time.toISOString(), - locationType: location.type, - organizerName: currentUser.resource.name, - organizerEmail: currentUser.resource.email, - inviteeName: event.payload.name, - inviteeEmail: event.payload.email, - conversationId, - }, - }) -} diff --git a/integrations/calendly/src/webhooks/signing-key.ts b/integrations/calendly/src/webhooks/signing-key.ts new file mode 100644 index 00000000000..35193668e75 --- /dev/null +++ b/integrations/calendly/src/webhooks/signing-key.ts @@ -0,0 +1,42 @@ +import crypto from 'crypto' +import { CommonHandlerProps } from '../types' +import * as bp from '.botpress' + +const SIGNING_KEY_BYTES = 32 + +export const getWebhookSigningKey = async ({ client, ctx }: CommonHandlerProps): Promise => { + switch (ctx.configurationType) { + case 'manual': + return await _getManualPatSigningKey(client, ctx) + case null: + return _getOAuthSigningKey(client, ctx) + default: + // @ts-ignore + throw new Error(`Unsupported configuration type: ${props.ctx.configurationType}`) + } +} + +/** Generate a 256-bit signing key (For Manual PAT Authentication). */ +function _generateSigningKey(): string { + const raw = crypto.randomBytes(SIGNING_KEY_BYTES) + return raw.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +const _getSigningKey = async (client: bp.Client, ctx: bp.Context, fallbackValue: string) => { + const { state } = await client.getOrSetState({ + type: 'integration', + name: 'webhooks', + id: ctx.integrationId, + payload: { + signingKey: fallbackValue, + }, + }) + + return state.payload.signingKey +} + +const _getOAuthSigningKey = async (client: bp.Client, ctx: bp.Context) => + _getSigningKey(client, ctx, bp.secrets.OAUTH_WEBHOOK_SIGNING_KEY) + +const _getManualPatSigningKey = async (client: bp.Client, ctx: bp.Context) => + _getSigningKey(client, ctx, _generateSigningKey()) diff --git a/integrations/calendly/src/webhooks/webhook-utils.ts b/integrations/calendly/src/webhooks/webhook-utils.ts index 856ad7a2116..d9fb5508d34 100644 --- a/integrations/calendly/src/webhooks/webhook-utils.ts +++ b/integrations/calendly/src/webhooks/webhook-utils.ts @@ -1,26 +1,99 @@ -import { Result } from 'src/types' +import crypto from 'crypto' +import type { Result } from '../types' import { safeParseJson } from '../utils' -import { InviteeEvent, inviteeEventSchema } from './schemas' -import * as bp from '.botpress' +import { type InviteeEvent, inviteeEventSchema } from './schemas' +import { getWebhookSigningKey } from './signing-key' +import type * as bp from '.botpress' -export const parseWebhookData = (props: bp.HandlerProps): Result => { - if (!props.req.body?.trim()) { +const MS_PER_SECOND = 1000 as const +const MS_PER_MINUTE = 60 * MS_PER_SECOND +const WEBHOOK_SIGNATURE_TOLERANCE_MS = 3 * MS_PER_MINUTE +const WEBHOOK_SIGNATURE_HEADER = 'calendly-webhook-signature' as const + +export const parseWebhookEvent = (props: bp.HandlerProps): Result => { + const { body } = props.req + if (!body?.trim()) { return { success: false, error: new Error('Received empty webhook payload') } } - const result = safeParseJson(props.req.body) - if (!result.success) { - return { success: false, error: new Error('Unable to parse Calendly Webhook Payload', result.error) } + const parseResult = safeParseJson(body) + if (!parseResult.success) { + return { success: false, error: new Error('Unable to parse Calendly Webhook Payload', parseResult.error) } } - const parseResult = inviteeEventSchema.safeParse(result.data) - if (!parseResult.success) { - props.logger.error('Webhook handler received unexpected payload', parseResult.error) - return { success: false, error: new Error('Invalid webhook payload structure', parseResult.error) } + const zodResult = inviteeEventSchema.safeParse(parseResult.data) + if (!zodResult.success) { + props.logger.error('Webhook handler received unexpected payload', zodResult.error) + return { success: false, error: new Error('Invalid webhook payload structure', zodResult.error) } + } + + return { + success: true, + data: zodResult.data, + } +} + +export const verifyWebhookSignature = async ( + props: bp.HandlerProps +): Promise<{ success: true } | { success: false; error: Error }> => { + const headerResult = _parseSignatureHeader(props.req.headers) + if (!headerResult.success) { + return headerResult + } + const { timestamp, signature } = headerResult.data + + const signingKey = await getWebhookSigningKey(props) + + const payload = `${timestamp}.${props.req.body}` + const expected = crypto.createHmac('sha256', signingKey).update(payload, 'utf8').digest('hex') + + if (expected !== signature) { + return { success: false, error: new Error('Webhook event did not match the expected signature') } + } + + // Prevent replay attacks + if (timestamp * MS_PER_SECOND < Date.now() - WEBHOOK_SIGNATURE_TOLERANCE_MS) { + return { success: false, error: new Error('Webhook event was received outside of the accepted tolerance zone') } + } + + return { success: true } +} + +const _malformedSignatureHeaderError = () => new Error('Calendly webhook signature header is malformed') + +type ParseSignatureHeaderData = { + timestamp: number + signature: string +} + +const TIMESTAMP_PREFIX = 't=' as const +const SIGNATURE_PREFIX = 'v1=' as const +const _parseSignatureHeader = (headers: Record): Result => { + const signatureHeader = headers[WEBHOOK_SIGNATURE_HEADER]?.trim() ?? '' + if (signatureHeader.length === 0) { + return { success: false, error: new Error('Calendly webhook signature header is missing from the request') } + } + + const signatureHeaderParts = signatureHeader.split(',') + if (signatureHeaderParts.length !== 2) { + return { success: false, error: _malformedSignatureHeaderError() } + } + + const [rawTimestamp, rawSignature] = signatureHeaderParts as [string, string] + if (!rawTimestamp.startsWith(TIMESTAMP_PREFIX) || !rawSignature.startsWith(SIGNATURE_PREFIX)) { + return { success: false, error: _malformedSignatureHeaderError() } + } + + const timestampSeconds = parseInt(rawTimestamp.slice(TIMESTAMP_PREFIX.length), 10) + if (isNaN(timestampSeconds)) { + return { success: false, error: _malformedSignatureHeaderError() } } return { success: true, - data: parseResult.data, + data: { + timestamp: timestampSeconds, + signature: rawSignature.slice(SIGNATURE_PREFIX.length), + }, } }