From 110491ecb99db1471a5fc6a4afbbcd8cf78d2020 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:21:30 +0530 Subject: [PATCH] fix: generate compliant passwords using meeting_password_requirement (#26148) * fix(zoomvideo): generate compliant passwords using meeting_password_requirement - Add meetingPasswordRequirementSchema to parse Zoom's password policy settings - Implement validatePasswordAgainstRequirements() to check if a password meets the policy - Implement generateCompliantPassword() to create passwords that comply with the policy - Update translateEvent() to use getCompliantPassword() which validates the default password or generates a new compliant one based on meeting_password_requirement - Add meeting_password_requirement to the settings API filter - Improve error handling for non-JSON responses in OAuthManager (XML validation errors) This fixes the frequent 'Error in JSON parsing Zoom API response' errors caused by Zoom returning XML validation errors when the password doesn't comply with the account's password policy (e.g., numeric-only requirement). Co-Authored-By: anik@cal.com * refactor: use cleaner single-pass consecutive character check - Replace nested-loop O(n*k) implementation with single-pass O(n) helper - Add proper guard for consecutiveLength < 4 (Zoom allows 0, 4-8) - Move consecutive check before only_allow_numeric to apply it for all cases - Fix edge-case bug where consecutiveLength 1-3 could incorrectly reject passwords Co-Authored-By: anik@cal.com * Optimize OAuth response handling Refactor OAuth response validation to read body only once. * Improve error handling in Zoom API response parsing Refactor error handling for Zoom API response parsing to improve logging and clarity. * Improve compliant password generation logic Enhance password generation to avoid consecutive characters when numeric only is required. * Remove password requirement handling from VideoApiAdapter Removed meeting password requirement schema and related functions for password validation and generation. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../zoomvideo/lib/VideoApiAdapter.test.ts | 66 ++++++++ .../zoomvideo/lib/VideoApiAdapter.ts | 146 +++++++++++++++++- 2 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 packages/app-store/zoomvideo/lib/VideoApiAdapter.test.ts diff --git a/packages/app-store/zoomvideo/lib/VideoApiAdapter.test.ts b/packages/app-store/zoomvideo/lib/VideoApiAdapter.test.ts new file mode 100644 index 0000000000..1060891abd --- /dev/null +++ b/packages/app-store/zoomvideo/lib/VideoApiAdapter.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "vitest"; + +import { hasInvalidConsecutiveChars } from "./VideoApiAdapter"; + +describe("hasInvalidConsecutiveChars", () => { + const minConsecutiveLength = 4; + + test("returns false when consecutiveLength is undefined or < 4", () => { + expect(hasInvalidConsecutiveChars("1234", undefined)).toBe(false); + expect(hasInvalidConsecutiveChars("1234", 3)).toBe(false); + }); + + test("detects ascending numeric sequences", () => { + expect(hasInvalidConsecutiveChars("1234", minConsecutiveLength)).toBe(true); + expect(hasInvalidConsecutiveChars("xx1234yy", minConsecutiveLength)).toBe( + true + ); + }); + + test("detects ascending alphabetic sequences", () => { + expect(hasInvalidConsecutiveChars("abcd", minConsecutiveLength)).toBe(true); + expect(hasInvalidConsecutiveChars("xxabcdyy", minConsecutiveLength)).toBe( + true + ); + }); + + test("detects descending sequences", () => { + expect(hasInvalidConsecutiveChars("4321", minConsecutiveLength)).toBe(true); + expect(hasInvalidConsecutiveChars("xxfedcyy", minConsecutiveLength)).toBe( + true + ); + }); + + test("detects repetitive characters", () => { + expect(hasInvalidConsecutiveChars("1111", minConsecutiveLength)).toBe(true); + expect(hasInvalidConsecutiveChars("aaaa", minConsecutiveLength)).toBe(true); + expect(hasInvalidConsecutiveChars("xx1111yy", minConsecutiveLength)).toBe( + true + ); + }); + + test("detects keyboard sequences from QWERTY rows", () => { + expect(hasInvalidConsecutiveChars("qwert", minConsecutiveLength)).toBe( + true + ); + expect(hasInvalidConsecutiveChars("poiuy", minConsecutiveLength)).toBe( + true + ); + expect(hasInvalidConsecutiveChars("asdf", minConsecutiveLength)).toBe(true); + expect(hasInvalidConsecutiveChars("lkjh", minConsecutiveLength)).toBe(true); + expect(hasInvalidConsecutiveChars("zxcv", minConsecutiveLength)).toBe(true); + expect(hasInvalidConsecutiveChars("vcxz", minConsecutiveLength)).toBe(true); + }); + + test("returns false for passwords without invalid consecutive patterns", () => { + expect(hasInvalidConsecutiveChars("1a2b3c4d", minConsecutiveLength)).toBe( + false + ); + expect(hasInvalidConsecutiveChars("a1b2c3d4", minConsecutiveLength)).toBe( + false + ); + expect(hasInvalidConsecutiveChars("pass-9wX!", minConsecutiveLength)).toBe( + false + ); + }); +}); diff --git a/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts b/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts index 23198c8103..4ae9a58612 100644 --- a/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts @@ -63,6 +63,141 @@ export const zoomMeetingsSchema = z.object({ export type ZoomUserSettings = z.infer; +const meetingPasswordRequirementSchema = z + .object({ + length: z.number().optional(), + have_letter: z.boolean().optional(), + have_number: z.boolean().optional(), + have_special_character: z.boolean().optional(), + have_upper_and_lower_characters: z.boolean().optional(), + only_allow_numeric: z.boolean().optional(), + consecutive_characters_length: z.number().optional(), + }) + .passthrough() + .optional(); + +export type MeetingPasswordRequirement = z.infer; + +export function hasInvalidConsecutiveChars( + password: string, + consecutiveLength: number | undefined +): boolean { + if (!consecutiveLength || consecutiveLength < 4) return false; + + const maxRun = consecutiveLength - 1; + const lowerPassword = password.toLowerCase(); + + // Define QWERTY keyboard sequences (both rows and columns) + const qwertySequences = [ + "qwertyuiop", + "asdfghjkl", + "zxcvbnm", + "qwertyuiop".split("").reverse().join(""), + "asdfghjkl".split("").reverse().join(""), + "zxcvbnm".split("").reverse().join(""), + ]; + + // Check for keyboard sequences + for (const sequence of qwertySequences) { + for (let i = 0; i <= sequence.length - consecutiveLength; i++) { + const seq = sequence.substring(i, i + consecutiveLength); + if (lowerPassword.includes(seq)) { + return true; + } + } + } + + // Check for repetitive characters (e.g., "11111", "aaaaa") + let repetitiveRun = 1; + for (let i = 1; i < password.length; i++) { + if (password[i] === password[i - 1]) { + repetitiveRun++; + if (repetitiveRun > maxRun) return true; + } else { + repetitiveRun = 1; + } + } + + // Check for sequential characters (ascending: "12345", "abcde") + let ascendingRun = 1; + for (let i = 1; i < password.length; i++) { + if (password.charCodeAt(i) === password.charCodeAt(i - 1) + 1) { + ascendingRun++; + if (ascendingRun > maxRun) return true; + } else { + ascendingRun = 1; + } + } + + // Check for sequential characters (descending: "54321", "edcba") + let descendingRun = 1; + for (let i = 1; i < password.length; i++) { + if (password.charCodeAt(i) === password.charCodeAt(i - 1) - 1) { + descendingRun++; + if (descendingRun > maxRun) return true; + } else { + descendingRun = 1; + } + } + + return false; +} + +function validatePasswordAgainstRequirements( + password: string, + requirements: NonNullable +): boolean { + if (requirements.length && password.length < requirements.length) { + return false; + } + + if (hasInvalidConsecutiveChars(password, requirements.consecutive_characters_length)) { + return false; + } + + if (requirements.only_allow_numeric) { + return /^\d+$/.test(password); + } + + if (requirements.have_letter && !/[a-zA-Z]/.test(password)) { + return false; + } + + if (requirements.have_number && !/\d/.test(password)) { + return false; + } + + if ( + requirements.have_special_character && + !/[!@#$%^&*()_\-+=[\]{}|;:,.<>?]/.test(password) + ) { + return false; + } + + if (requirements.have_upper_and_lower_characters) { + if (!/[a-z]/.test(password) || !/[A-Z]/.test(password)) { + return false; + } + } + + return true; +} + +function getCompliantPassword( + defaultPassword: string | null | undefined, + requirements: MeetingPasswordRequirement +): string | undefined { + if (!requirements ) { + return defaultPassword ?? undefined; + } + + if (defaultPassword && validatePasswordAgainstRequirements(defaultPassword, requirements)) { + return defaultPassword; + } + + return undefined; +} + /** @link https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings */ export const zoomUserSettingsSchema = z.object({ recording: z @@ -73,6 +208,7 @@ export const zoomUserSettingsSchema = z.object({ schedule_meeting: z .object({ default_password_for_scheduled_meetings: z.string().nullish(), + meeting_password_requirement: meetingPasswordRequirementSchema, }) .nullish(), in_meeting: z @@ -84,7 +220,8 @@ export const zoomUserSettingsSchema = z.object({ // https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings // append comma separated settings here, to retrieve only these specific settings -const settingsApiFilterResp = "default_password_for_scheduled_meetings,auto_recording,waiting_room"; +const settingsApiFilterResp = + "default_password_for_scheduled_meetings,meeting_password_requirement,auto_recording,waiting_room"; type ZoomRecurrence = { end_date_time?: string; @@ -176,6 +313,11 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => const userSettings = await getUserSettings(); const recurrence = getRecurrence(event); const waitingRoomEnabled = userSettings?.in_meeting?.waiting_room ?? false; + + const passwordRequirements = userSettings?.schedule_meeting?.meeting_password_requirement; + const defaultPassword = userSettings?.schedule_meeting?.default_password_for_scheduled_meetings; + const password = getCompliantPassword(defaultPassword, passwordRequirements); + // Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate return { topic: event.title, @@ -184,8 +326,8 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => duration: (new Date(event.endTime).getTime() - new Date(event.startTime).getTime()) / 60000, //schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?) timezone: event.organizer.timeZone, - password: userSettings?.schedule_meeting?.default_password_for_scheduled_meetings ?? undefined, agenda: truncateAgenda(event.description), + ...(password && { password }), settings: { host_video: true, participant_video: true,