diff --git a/packages/app-store/delegationCredential.ts b/packages/app-store/delegationCredential.ts index c342ec8eae..8a11133a00 100644 --- a/packages/app-store/delegationCredential.ts +++ b/packages/app-store/delegationCredential.ts @@ -301,20 +301,17 @@ async function _getDelegationCredentialsMapPerUser({ return credentialsByUserId; } -export async function checkIfSuccessfullyConfiguredInWorkspace({ +export async function assertSuccessfullyConfiguredInWorkspace({ delegationCredential, user, }: { delegationCredential: DelegationCredentialWithSensitiveServiceAccountKey; user: User; -}) { +}): Promise { if (!isValidWorkspaceSlug(delegationCredential.workspacePlatform.slug)) { - log.warn( - `Only ${WORKSPACE_PLATFORM_SLUGS.toString()} Platforms are supported here, skipping ${ - delegationCredential.workspacePlatform.slug - }` - ); - return false; + const errorMessage = `Unsupported workspace platform: ${delegationCredential.workspacePlatform.slug}. Only ${WORKSPACE_PLATFORM_SLUGS.toString()} are supported.`; + log.warn(errorMessage); + throw new Error(errorMessage); } const credential = _buildDelegatedCalendarCredentialWithServiceAccountKey({ @@ -325,9 +322,10 @@ export async function checkIfSuccessfullyConfiguredInWorkspace({ const calendar = await getCalendar(credential, "none"); if (!calendar) { - throw new Error("Google Calendar App not found"); + throw new Error("Calendar App not found for the workspace platform"); } - return await calendar?.testDelegationCredentialSetup?.(); + + await calendar.testDelegationCredentialSetup?.(); } export async function getAllDelegationCredentialsForUserByAppType({ @@ -383,26 +381,29 @@ export const buildAllCredentials = ({ ...buildNonDelegationCredentials(nonDelegationCredentials), ]; - const uniqueAllCredentials = allCredentials.reduce((acc, credential) => { - if (!credential.delegatedToId) { - // Regular credential go as is - acc.push(credential); + const uniqueAllCredentials = allCredentials.reduce( + (acc, credential) => { + if (!credential.delegatedToId) { + // Regular credential go as is + acc.push(credential); + return acc; + } + const existingDelegationCredential = acc.find( + (c) => c.delegatedToId === credential.delegatedToId && c.appId === credential.appId + ); + if (!existingDelegationCredential) { + acc.push(credential); + } return acc; - } - const existingDelegationCredential = acc.find( - (c) => c.delegatedToId === credential.delegatedToId && c.appId === credential.appId - ); - if (!existingDelegationCredential) { - acc.push(credential); - } - return acc; - }, [] as typeof allCredentials); + }, + [] as typeof allCredentials + ); return uniqueAllCredentials; }; export async function enrichUsersWithDelegationCredentials< - TUser extends { id: number; email: string; credentials: CredentialPayload[] } + TUser extends { id: number; email: string; credentials: CredentialPayload[] }, >({ orgId, users }: { orgId: number | null; users: TUser[] }) { const delegationCredentialsMap = await _getDelegationCredentialsMapPerUser({ organizationId: orgId, @@ -426,7 +427,7 @@ export async function enrichUsersWithDelegationCredentials< export const enrichHostsWithDelegationCredentials = async < THost extends Host, - TUser extends { id: number; email: string; credentials: CredentialPayload[] } + TUser extends { id: number; email: string; credentials: CredentialPayload[] }, >({ orgId, hosts, @@ -467,7 +468,7 @@ export const enrichHostsWithDelegationCredentials = async < }; export const enrichUserWithDelegationCredentialsIncludeServiceAccountKey = async < - TUser extends { id: number; email: string; credentials: CredentialPayload[] } + TUser extends { id: number; email: string; credentials: CredentialPayload[] }, >({ user, }: { @@ -487,7 +488,7 @@ export const enrichUserWithDelegationCredentialsIncludeServiceAccountKey = async }; export const enrichUserWithDelegationCredentials = async < - TUser extends { id: number; email: string; credentials: CredentialPayload[] } + TUser extends { id: number; email: string; credentials: CredentialPayload[] }, >({ user, }: { @@ -503,7 +504,7 @@ export const enrichUserWithDelegationCredentials = async < }; export async function enrichUserWithDelegationConferencingCredentialsWithoutOrgId< - TUser extends { id: number; email: string; credentials: CredentialPayload[] } + TUser extends { id: number; email: string; credentials: CredentialPayload[] }, >({ user }: { user: TUser }) { const { credentials, ...restUser } = await enrichUserWithDelegationCredentialsIncludeServiceAccountKey({ user, @@ -518,7 +519,7 @@ export async function enrichUserWithDelegationConferencingCredentialsWithoutOrgI * Either get Delegation credential from delegationCredentials or find regular credential from Credential table */ export async function getDelegationCredentialOrFindRegularCredential< - TDelegationCredential extends { delegatedToId?: string | null } + TDelegationCredential extends { delegatedToId?: string | null }, >({ id, delegationCredentials, @@ -532,17 +533,17 @@ export async function getDelegationCredentialOrFindRegularCredential< return id.delegationCredentialId ? delegationCredentials.find((cred) => cred.delegatedToId === id.delegationCredentialId) : id.credentialId - ? await CredentialRepository.findCredentialForCalendarServiceById({ - id: id.credentialId, - }) - : null; + ? await CredentialRepository.findCredentialForCalendarServiceById({ + id: id.credentialId, + }) + : null; } /** * Utility function to find a credential from a list of credentials, supporting both regular and DelegationCredential credentials */ export function getDelegationCredentialOrRegularCredential< - TCredential extends { delegatedToId?: string | null; id: number } + TCredential extends { delegatedToId?: string | null; id: number }, >({ credentials, id, diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index c74f9a50e8..c53f7aed5a 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -1,19 +1,17 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { calendar_v3 } from "@googleapis/calendar"; -import type { GaxiosResponse } from "googleapis-common"; -import { RRule } from "rrule"; + import { MeetLocationType } from "@calcom/app-store/constants"; +import { getDestinationCalendarRepository } from "@calcom/features/di/containers/DestinationCalendar"; import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { getDestinationCalendarRepository } from "@calcom/features/di/containers/DestinationCalendar"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; import type { Prisma } from "@calcom/prisma/client"; import type { Calendar, - CalendarServiceEvent, CalendarEvent, + CalendarServiceEvent, EventBusyDate, GetAvailabilityParams, IntegrationCalendar, @@ -21,6 +19,9 @@ import type { SelectedCalendarEventTypeIds, } from "@calcom/types/Calendar"; import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; +import type { calendar_v3 } from "@googleapis/calendar"; +import type { GaxiosResponse } from "googleapis-common"; +import { RRule } from "rrule"; import { AxiosLikeResponseToFetchResponse } from "../../_utils/oauth/AxiosLikeResponseToFetchResponse"; import { CalendarAuth } from "./CalendarAuth"; @@ -68,7 +69,8 @@ interface GoogleCalError extends Error { code?: number; } -const isGaxiosResponse= (error: unknown): error is GaxiosResponse => +const isGaxiosResponse = (error: unknown): error is GaxiosResponse => + // biome-ignore lint/suspicious/noPrototypeBuiltins: Object.hasOwn not available in all build targets typeof error === "object" && !!error && Object.prototype.hasOwnProperty.call(error, "config"); class GoogleCalendarService implements Calendar { @@ -497,9 +499,7 @@ class GoogleCalendarService implements Calendar { return apiResponse.json; } - async getFreeBusyResult( - args: FreeBusyArgs, - ): Promise { + async getFreeBusyResult(args: FreeBusyArgs): Promise { return await this.fetchAvailability(args); } @@ -516,22 +516,23 @@ class GoogleCalendarService implements Calendar { return validCals[0]; } - async getFreeBusyData( - args: FreeBusyArgs, - ): Promise<(EventBusyDate & { id: string })[] | null> { + async getFreeBusyData(args: FreeBusyArgs): Promise<(EventBusyDate & { id: string })[] | null> { const freeBusyResult = await this.getFreeBusyResult(args); if (!freeBusyResult.calendars) return null; - const result = Object.entries(freeBusyResult.calendars).reduce((c, [id, i]) => { - i.busy?.forEach((busyTime) => { - c.push({ - id, - start: busyTime.start || "", - end: busyTime.end || "", + const result = Object.entries(freeBusyResult.calendars).reduce( + (c, [id, i]) => { + i.busy?.forEach((busyTime) => { + c.push({ + id, + start: busyTime.start || "", + end: busyTime.end || "", + }); }); - }); - return c; - }, [] as (EventBusyDate & { id: string })[]); + return c; + }, + [] as (EventBusyDate & { id: string })[] + ); return result; } @@ -646,7 +647,7 @@ class GoogleCalendarService implements Calendar { private async fetchAvailabilityData( calendarIds: string[], dateFrom: string, - dateTo: string, + dateTo: string ): Promise { // More efficient date difference calculation using native Date objects // Use Math.floor to match dayjs diff behavior (truncates, doesn't round up) @@ -657,13 +658,11 @@ class GoogleCalendarService implements Calendar { // Google API only allows a date range of 90 days for /freebusy if (diff <= 90) { - const freeBusyData = await this.getFreeBusyData( - { - timeMin: dateFrom, - timeMax: dateTo, - items: calendarIds.map((id) => ({ id })), - } - ); + const freeBusyData = await this.getFreeBusyData({ + timeMin: dateFrom, + timeMax: dateTo, + items: calendarIds.map((id) => ({ id })), + }); if (!freeBusyData) throw new Error("No response from google calendar"); return freeBusyData.map((freeBusy) => ({ start: freeBusy.start, end: freeBusy.end })); @@ -685,13 +684,11 @@ class GoogleCalendarService implements Calendar { currentEndTime = originalEndTime; } - const chunkData = await this.getFreeBusyData( - { - timeMin: new Date(currentStartTime).toISOString(), - timeMax: new Date(currentEndTime).toISOString(), - items: calendarIds.map((id) => ({ id })), - } - ); + const chunkData = await this.getFreeBusyData({ + timeMin: new Date(currentStartTime).toISOString(), + timeMax: new Date(currentEndTime).toISOString(), + items: calendarIds.map((id) => ({ id })), + }); if (chunkData) { busyData.push(...chunkData.map((freeBusy) => ({ start: freeBusy.start, end: freeBusy.end }))); @@ -757,7 +754,7 @@ class GoogleCalendarService implements Calendar { primary: cal.primary ?? false, readOnly: !(cal.accessRole === "writer" || cal.accessRole === "owner") && true, email: cal.id ?? "", - } satisfies IntegrationCalendar) + }) satisfies IntegrationCalendar ); } catch (error) { this.log.error("There was an error getting calendars: ", safeStringify(error)); @@ -765,12 +762,28 @@ class GoogleCalendarService implements Calendar { } } - // It would error if the delegation credential is not set up correctly - async testDelegationCredentialSetup() { + async testDelegationCredentialSetup(): Promise { log.debug("Testing delegation credential setup"); - const calendar = await this.authedCalendar(); - const cals = await calendar.calendarList.list({ fields: "items(id)" }); - return !!cals.data.items; + try { + const calendar = await this.authedCalendar(); + const cals = await calendar.calendarList.list({ fields: "items(id)" }); + if (!cals.data.items) { + throw new Error("No calendars found - delegation credential may not have proper access"); + } + } catch (error) { + const googleError = error as { code?: number; message?: string }; + if (googleError.code === 401) { + throw new Error( + `Google API authentication failed: ${googleError.message || "Invalid credentials or insufficient permissions"}` + ); + } + if (googleError.code === 403) { + throw new Error( + `Google API access denied: ${googleError.message || "The service account may not have domain-wide delegation enabled or the required scopes"}` + ); + } + throw error; + } } async createSelectedCalendar( @@ -885,9 +898,7 @@ class GoogleCalendarService implements Calendar { * from leaking into the emitted .d.ts file, which would cause TypeScript to load * all Google API SDK declaration files when type-checking dependent packages. */ -export default function BuildCalendarService( - credential: CredentialForCalendarServiceWithEmail -): Calendar { +export default function BuildCalendarService(credential: CredentialForCalendarServiceWithEmail): Calendar { return new GoogleCalendarService(credential); } diff --git a/packages/app-store/office365calendar/lib/CalendarService.ts b/packages/app-store/office365calendar/lib/CalendarService.ts index 38db840316..881972b98a 100644 --- a/packages/app-store/office365calendar/lib/CalendarService.ts +++ b/packages/app-store/office365calendar/lib/CalendarService.ts @@ -1,14 +1,10 @@ -import type { Calendar as OfficeCalendar, User, Event } from "@microsoft/microsoft-graph-types-beta"; -import type { DefaultBodyType } from "msw"; -import { findIana } from "windows-iana"; - import { MSTeamsLocationType } from "@calcom/app-store/constants"; import dayjs from "@calcom/dayjs"; import { triggerDelegationCredentialErrorWebhook } from "@calcom/features/webhooks/lib/triggerDelegationCredentialErrorWebhook"; import { getLocation, getRichDescriptionHTML } from "@calcom/lib/CalEventParser"; import { - CalendarAppDelegationCredentialInvalidGrantError, CalendarAppDelegationCredentialConfigurationError, + CalendarAppDelegationCredentialInvalidGrantError, } from "@calcom/lib/CalendarAppError"; import { handleErrorsJson, handleErrorsRaw } from "@calcom/lib/errors"; import logger from "@calcom/lib/logger"; @@ -22,9 +18,11 @@ import type { NewCalendarEventType, } from "@calcom/types/Calendar"; import type { CredentialForCalendarServiceWithTenantId } from "@calcom/types/Credential"; - -import { OAuthManager } from "../../_utils/oauth/OAuthManager"; +import type { Event, Calendar as OfficeCalendar, User } from "@microsoft/microsoft-graph-types-beta"; +import type { DefaultBodyType } from "msw"; +import { findIana } from "windows-iana"; import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential"; +import { OAuthManager } from "../../_utils/oauth/OAuthManager"; import { oAuthManagerHelper } from "../../_utils/oauth/oAuthManagerHelper"; import metadata from "../_metadata"; import { getOfficeAppKeys } from "./getOfficeAppKeys"; @@ -102,12 +100,12 @@ class Office365CalendarService implements Calendar { body: new URLSearchParams(bodyParams), }); }, - isTokenObjectUnusable: async function () { + isTokenObjectUnusable: async () => { // TODO: Implement this. As current implementation of CalendarService doesn't handle it. It hasn't been handled in the OAuthManager implementation as well. // This is a placeholder for future implementation. return null; }, - isAccessTokenUnusable: async function () { + isAccessTokenUnusable: async () => { // TODO: Implement this return null; }, @@ -251,8 +249,7 @@ class Office365CalendarService implements Calendar { return this.azureUserId; } - // It would error if the delegation credential is not set up correctly - async testDelegationCredentialSetup(): Promise { + async testDelegationCredentialSetup(): Promise { const delegationCredentialClientId = this.credential.delegatedTo?.serviceAccountKey?.client_id; const delegationCredentialClientSecret = this.credential.delegatedTo?.serviceAccountKey?.private_key; const url = await this.getAuthUrl( @@ -261,7 +258,9 @@ class Office365CalendarService implements Calendar { ); if (!delegationCredentialClientId || !delegationCredentialClientSecret) { - return false; + throw new CalendarAppDelegationCredentialConfigurationError( + "Delegation credential is missing client_id or client_secret" + ); } const loginResponse = await fetch(url, { method: "POST", @@ -274,7 +273,13 @@ class Office365CalendarService implements Calendar { }), }); const parsedLoginResponse = await loginResponse.json(); - return Boolean(parsedLoginResponse?.access_token); + if (!parsedLoginResponse?.access_token) { + const errorDescription = + parsedLoginResponse?.error_description || parsedLoginResponse?.error || "Unknown error"; + throw new CalendarAppDelegationCredentialInvalidGrantError( + `Failed to obtain access token from Microsoft: ${errorDescription}` + ); + } } async getUserEndpoint(): Promise { @@ -284,8 +289,8 @@ class Office365CalendarService implements Calendar { async createEvent(event: CalendarServiceEvent, credentialId: number): Promise { const mainHostDestinationCalendar = event.destinationCalendar - ? event.destinationCalendar.find((cal) => cal.credentialId === credentialId) ?? - event.destinationCalendar[0] + ? (event.destinationCalendar.find((cal) => cal.credentialId === credentialId) ?? + event.destinationCalendar[0]) : undefined; try { const eventsUrl = mainHostDestinationCalendar?.externalId @@ -502,8 +507,8 @@ class Office365CalendarService implements Calendar { organizer: { emailAddress: { address: event.destinationCalendar - ? event.destinationCalendar.find((cal) => cal.userId === event.organizer.id)?.externalId ?? - event.organizer.email + ? (event.destinationCalendar.find((cal) => cal.userId === event.organizer.id)?.externalId ?? + event.organizer.email) : event.organizer.email, name: event.organizer.name, }, @@ -794,8 +799,6 @@ class Office365CalendarService implements Calendar { * from leaking into the emitted .d.ts file, which would cause TypeScript to load * all Microsoft Graph SDK declaration files when type-checking dependent packages. */ -export default function BuildCalendarService( - credential: CredentialForCalendarServiceWithTenantId -): Calendar { +export default function BuildCalendarService(credential: CredentialForCalendarServiceWithTenantId): Calendar { return new Office365CalendarService(credential); } diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts index 67b113c511..5ab2424d2d 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts @@ -214,7 +214,7 @@ export class CalendarCacheWrapper implements Calendar { return this.deps.originalCalendar.listCalendars(event); } - testDelegationCredentialSetup?(): Promise { - return this.deps.originalCalendar.testDelegationCredentialSetup?.() || Promise.resolve(false); + testDelegationCredentialSetup?(): Promise { + return this.deps.originalCalendar.testDelegationCredentialSetup?.() || Promise.resolve(); } } diff --git a/packages/features/calendar-subscription/lib/telemetry/CalendarTelemetryWrapper.ts b/packages/features/calendar-subscription/lib/telemetry/CalendarTelemetryWrapper.ts index ff9564e9ca..b5dde69cf5 100644 --- a/packages/features/calendar-subscription/lib/telemetry/CalendarTelemetryWrapper.ts +++ b/packages/features/calendar-subscription/lib/telemetry/CalendarTelemetryWrapper.ts @@ -190,7 +190,7 @@ export class CalendarTelemetryWrapper implements Calendar { return this.deps.originalCalendar.listCalendars(event); } - testDelegationCredentialSetup?(): Promise { - return this.deps.originalCalendar.testDelegationCredentialSetup?.() || Promise.resolve(false); + testDelegationCredentialSetup?(): Promise { + return this.deps.originalCalendar.testDelegationCredentialSetup?.() || Promise.resolve(); } } diff --git a/packages/trpc/server/routers/viewer/delegationCredential/toggleEnabled.handler.ts b/packages/trpc/server/routers/viewer/delegationCredential/toggleEnabled.handler.ts index d1a061ce64..59a5a17793 100644 --- a/packages/trpc/server/routers/viewer/delegationCredential/toggleEnabled.handler.ts +++ b/packages/trpc/server/routers/viewer/delegationCredential/toggleEnabled.handler.ts @@ -1,11 +1,10 @@ -import type { z } from "zod"; - -import { checkIfSuccessfullyConfiguredInWorkspace } from "@calcom/app-store/delegationCredential"; +import { assertSuccessfullyConfiguredInWorkspace } from "@calcom/app-store/delegationCredential"; import { sendDelegationCredentialDisabledEmail } from "@calcom/emails/integration-email-service"; -import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository"; import type { ServiceAccountKey } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository"; +import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository"; import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server/i18n"; +import type { z } from "zod"; import { getAffectedMembersForDisable } from "./getAffectedMembersForDisable.handler"; import type { DelegationCredentialToggleEnabledSchema } from "./schema"; @@ -142,14 +141,10 @@ const assertWorkspaceConfigured = async ({ throw new Error("Domain wide delegation doesn't have service account key"); } - const isSuccessfullyConfigured = await checkIfSuccessfullyConfiguredInWorkspace({ + await assertSuccessfullyConfiguredInWorkspace({ delegationCredential, user, }); - - if (!isSuccessfullyConfigured) { - throw new Error("Workspace not successfully configured"); - } }; function hasServiceAccountKey( diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 504928d727..23e4cea36d 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -316,7 +316,7 @@ export interface Calendar { listCalendars(event?: CalendarEvent): Promise; - testDelegationCredentialSetup?(): Promise; + testDelegationCredentialSetup?(): Promise; } /**