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,