From 93e183e6c381de2480a9152b941e29a62a85b68b Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Sat, 28 Jun 2025 13:41:25 +0530 Subject: [PATCH] fix: Zoom - More logging and fix for unexpected errors seen in logs (#22118) * fix: Use WEBAPP_URL_FOR_OAUTH to be able to use http://localhost:3000 for local testing * In case of JSON.parse crash that could happen if zoom reponsed with xml * Fix cases where auto_recording and default_password_for_scheduled_meetings could be nullish --- .../app-store/_utils/oauth/OAuthManager.ts | 17 ++++- packages/app-store/zoomvideo/api/add.ts | 4 +- packages/app-store/zoomvideo/api/callback.ts | 4 +- .../zoomvideo/lib/VideoApiAdapter.ts | 76 +++++++++++++++---- 4 files changed, 81 insertions(+), 20 deletions(-) diff --git a/packages/app-store/_utils/oauth/OAuthManager.ts b/packages/app-store/_utils/oauth/OAuthManager.ts index 91893208eb..94a4d66bd6 100644 --- a/packages/app-store/_utils/oauth/OAuthManager.ts +++ b/packages/app-store/_utils/oauth/OAuthManager.ts @@ -1,7 +1,12 @@ /** - * Manages OAuth2.0 tokens for an app and resourceOwner. It automatically refreshes the token when needed. - * It is aware of the credential sync endpoint and can sync the token from the third party source. - * It is unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods + * Manages OAuth2.0 as well as JWT tokens(For JWT tokens, only Google Calendar use it at the moment) for an app and resourceOwner. + * What it does + * - It automatically refreshes the token if needed when making a request. + * - It is aware of the credential sync endpoint and can sync the token from the third party source. + * - It is kept unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods + * + * What it doesn't do yet + * - It doesn't have a flow to re-send the request if the access-token had been communicated as invalid after making the request itself. It relies on the caller to make the next request in which it will actually refresh the token. * * For a recommended usage example, see Zoom VideoApiAdapter.ts */ @@ -89,7 +94,13 @@ export class OAuthManager { currentTokenObject, fetchNewTokenObject, updateTokenObject, + /** + * The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return + */ isTokenObjectUnusable, + /** + * The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return + */ isAccessTokenUnusable, invalidateTokenObject, expireAccessToken, diff --git a/packages/app-store/zoomvideo/api/add.ts b/packages/app-store/zoomvideo/api/add.ts index c460b8e120..7b70919c3a 100644 --- a/packages/app-store/zoomvideo/api/add.ts +++ b/packages/app-store/zoomvideo/api/add.ts @@ -1,7 +1,7 @@ import type { NextApiRequest } from "next"; import { stringify } from "querystring"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; import { defaultHandler } from "@calcom/lib/server/defaultHandler"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; import prisma from "@calcom/prisma"; @@ -26,7 +26,7 @@ async function handler(req: NextApiRequest) { const params = { response_type: "code", client_id, - redirect_uri: `${WEBAPP_URL}/api/integrations/zoomvideo/callback`, + redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoomvideo/callback`, state, }; const query = stringify(params); diff --git a/packages/app-store/zoomvideo/api/callback.ts b/packages/app-store/zoomvideo/api/callback.ts index af5651eb11..02756ac448 100644 --- a/packages/app-store/zoomvideo/api/callback.ts +++ b/packages/app-store/zoomvideo/api/callback.ts @@ -1,6 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import prisma from "@calcom/prisma"; @@ -14,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const { code } = req.query; const { client_id, client_secret } = await getZoomAppKeys(); - const redirectUri = encodeURI(`${WEBAPP_URL}/api/integrations/zoomvideo/callback`); + const redirectUri = encodeURI(`${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoomvideo/callback`); const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`; const result = await fetch( `https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`, diff --git a/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts b/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts index 5abe923545..86249e30dd 100644 --- a/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts @@ -8,6 +8,7 @@ import { CREDENTIAL_SYNC_SECRET_HEADER_NAME, } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; +import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; import { Frequency } from "@calcom/prisma/zod-utils"; @@ -65,12 +66,12 @@ export type ZoomUserSettings = z.infer; export const zoomUserSettingsSchema = z.object({ recording: z .object({ - auto_recording: z.string(), + auto_recording: z.string().nullish(), }) .nullish(), schedule_meeting: z .object({ - default_password_for_scheduled_meetings: z.string(), + default_password_for_scheduled_meetings: z.string().nullish(), }) .nullish(), }); @@ -185,6 +186,32 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => }; }; + /** + * Zoom is known to return xml response in some cases. + * e.g. Wrong request or some special case of invalid token + */ + const handleZoomResponseJsonParseError = async ({ + error, + clonedResponse, + }: { + error: unknown; + clonedResponse: Response; + }) => { + // In some cases, Zoom responds with xml response, so we log the response for debugging + // We need to see why that error occurs exactly and then later we decide if mark the access token and token object unusable or not + log.error( + "Error in JSON parsing Zoom API response", + safeStringify({ + error: safeStringify(error), + // Log Raw response body here. + responseBody: await clonedResponse.text(), + status: clonedResponse.status, + }) + ); + + return null; + }; + const fetchZoomApi = async (endpoint: string, options?: RequestInit) => { const auth = new OAuthManager({ credentialSyncVariables: { @@ -220,9 +247,18 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => }, isTokenObjectUnusable: async function (response) { const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isTokenObjectUnusable"] }); - myLog.debug(safeStringify({ status: response.status, ok: response.ok })); - if (!response.ok || (response.status < 200 && response.status >= 300)) { - const responseBody = await response.json(); + myLog.info(safeStringify({ status: response.status, ok: response.ok })); + if (!response.ok) { + let responseBody; + const responseToUseInCaseOfError = response.clone(); + try { + responseBody = await response.json(); + } catch (e) { + return await handleZoomResponseJsonParseError({ + error: e, + clonedResponse: responseToUseInCaseOfError, + }); + } myLog.debug(safeStringify({ responseBody })); if (responseBody.error === "invalid_grant") { @@ -233,11 +269,20 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => }, isAccessTokenUnusable: async function (response) { const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isAccessTokenUnusable"] }); - myLog.debug(safeStringify({ status: response.status, ok: response.ok })); - if (!response.ok || (response.status < 200 && response.status >= 300)) { - const responseBody = await response.json(); + myLog.info(safeStringify({ status: response.status, ok: response.ok })); + if (!response.ok) { + let responseBody; + const responseToUseInCaseOfError = response.clone(); + try { + responseBody = await response.json(); + } catch (e) { + return await handleZoomResponseJsonParseError({ + error: e, + clonedResponse: responseToUseInCaseOfError, + }); + } myLog.debug(safeStringify({ responseBody })); - + // 124 is the error code for invalid access token from Zoom API if (responseBody.code === 124) { return { reason: responseBody.message ?? "" }; } @@ -284,7 +329,7 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => end: new Date(new Date(meeting.start_time).getTime() + meeting.duration * 60000).toISOString(), })); } catch (err) { - console.error(err); + log.error("Failed to get availability", safeStringify(err)); /* Prevents booking failure when Zoom Token is expired */ return []; } @@ -311,8 +356,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => } throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`); } catch (err) { - console.error(err); - log.error("Zoom meeting creation failed", safeStringify({ error: err, event })); + log.error( + "Zoom meeting creation failed", + safeStringify({ error: safeStringify(err), event: getPiiFreeCalendarEvent(event) }) + ); /* Prevents meeting creation failure when Zoom Token is expired */ throw new Error("Unexpected error"); } @@ -347,7 +394,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => url: result.join_url, }; } catch (err) { - log.error("Failed to update meeting", safeStringify(err)); + log.error( + "Failed to update meeting", + safeStringify({ error: err, event: getPiiFreeCalendarEvent(event) }) + ); return Promise.reject(new Error("Failed to update meeting")); } },