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 <adhabal2002@gmail.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 <adhabal2002@gmail.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>
This commit is contained in:
Anik Dhabal Babu
2026-01-07 09:51:30 +00:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 010ac9e8f2
commit 110491ecb9
2 changed files with 210 additions and 2 deletions
@@ -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
);
});
});
@@ -63,6 +63,141 @@ export const zoomMeetingsSchema = z.object({
export type ZoomUserSettings = z.infer<typeof zoomUserSettingsSchema>;
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<typeof meetingPasswordRequirementSchema>;
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<MeetingPasswordRequirement>
): 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,