diff --git a/packages/twenty-server/jest.config.mjs b/packages/twenty-server/jest.config.mjs index 22d28de204..bc6775f700 100644 --- a/packages/twenty-server/jest.config.mjs +++ b/packages/twenty-server/jest.config.mjs @@ -11,7 +11,7 @@ const jestConfig = { testEnvironment: 'node', setupFilesAfterEnv: ['./setupTests.ts'], transformIgnorePatterns: [ - '/node_modules/(?!(file-type|@file-type|strtok3|token-types|@borewit|@tokenizer|uint8array-extras|read-next-line)/)', + '/node_modules/(?!(file-type|@file-type|strtok3|token-types|@borewit|@tokenizer|uint8array-extras|read-next-line|digest-fetch|md5|js-sha256|js-sha512|base-64|charenc|crypt)/)', ], testRegex: '.*\\.spec\\.ts$', transform: { diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index f60494cb99..f041d815bd 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -104,6 +104,7 @@ "dataloader": "2.2.2", "date-fns": "2.30.0", "deep-equal": "2.2.3", + "digest-fetch": "^3.1.1", "dompurify": "3.3.3", "dotenv": "16.4.5", "exa-js": "^2.11.0", @@ -179,7 +180,7 @@ "temporal-polyfill": "^0.3.0", "transliteration": "2.3.5", "tsconfig-paths": "^4.2.0", - "tsdav": "^2.1.5", + "tsdav": "^2.2.0", "tslib": "2.8.1", "type-fest": "4.10.1", "typeorm": "patch:typeorm@0.3.20#./patches/typeorm+0.3.20.patch", diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/auth/__tests__/create-basic-digest-auth-fetch.spec.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/auth/__tests__/create-basic-digest-auth-fetch.spec.ts new file mode 100644 index 0000000000..05e06a46e1 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/auth/__tests__/create-basic-digest-auth-fetch.spec.ts @@ -0,0 +1,82 @@ +import { createBasicDigestAuthFetch } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/auth/create-basic-digest-auth-fetch'; + +const mockFetch = (handle: (init?: RequestInit) => Response) => { + const calls: (RequestInit | undefined)[] = []; + const fn = jest.fn(async (_input, init) => { + calls.push(init); + + return handle(init); + }); + + return { calls, fn: fn as unknown as typeof fetch }; +}; + +const digest401 = () => + new Response('', { + status: 401, + headers: { + 'WWW-Authenticate': + 'Digest realm="NMMDav", qop="auth", nonce="n", opaque="o"', + }, + }); + +describe('createBasicDigestAuthFetch', () => { + it('attaches Basic on the first request so Basic-only servers succeed in one round trip', async () => { + const { calls, fn } = mockFetch(() => new Response('ok', { status: 207 })); + + await createBasicDigestAuthFetch( + 'Aladdin', + 'open sesame', + fn, + )('https://example.test/cal/'); + + expect(calls).toHaveLength(1); + expect(new Headers(calls[0]?.headers).get('Authorization')).toBe( + `Basic ${Buffer.from('Aladdin:open sesame', 'utf8').toString('base64')}`, + ); + }); + + it('upgrades to Digest on 401 and routes the retry through the supplied fetch, never globalThis.fetch', async () => { + const forbiddenGlobal = jest.fn(async () => { + throw new Error('globalThis.fetch must not be called'); + }); + const original = globalThis.fetch; + + globalThis.fetch = forbiddenGlobal as unknown as typeof fetch; + + try { + const { calls, fn } = mockFetch((init) => + new Headers(init?.headers).get('Authorization')?.startsWith('Digest ') + ? new Response('ok', { status: 207 }) + : digest401(), + ); + + const response = await createBasicDigestAuthFetch( + 'user', + 'pass', + fn, + )('https://example.test/cal/', { method: 'PROPFIND' }); + + expect(response.status).toBe(207); + expect(calls).toHaveLength(2); + expect(new Headers(calls[1]?.headers).get('Authorization')).toMatch( + /^Digest .*realm="NMMDav".*opaque="o"/, + ); + expect(forbiddenGlobal).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = original; + } + }); + + it('surfaces the 401 when the Digest retry is also rejected', async () => { + const { fn } = mockFetch(() => digest401()); + + const response = await createBasicDigestAuthFetch( + 'user', + 'wrong-pass', + fn, + )('https://example.test/'); + + expect(response.status).toBe(401); + }); +}); diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/auth/create-basic-digest-auth-fetch.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/auth/create-basic-digest-auth-fetch.ts new file mode 100644 index 0000000000..a13e39fb9b --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/auth/create-basic-digest-auth-fetch.ts @@ -0,0 +1,37 @@ +import DigestFetch from 'digest-fetch'; +import { getBasicAuthHeaders } from 'tsdav'; +import { isDefined } from 'twenty-shared/utils'; + +/** + * Decorates a base fetch with HTTP Basic + Digest authentication. + * + * Delegates RFC 7235 / RFC 7616 challenge parsing, hash computation, + * and 401-then-retry orchestration to `digest-fetch` + */ +export const createBasicDigestAuthFetch = ( + username: string, + password: string, + baseFetch: typeof globalThis.fetch = globalThis.fetch, +): typeof globalThis.fetch => { + const digestClient = new DigestFetch(username, password); + + digestClient.getClient = async () => baseFetch; + + const { authorization: basicAuthorization } = getBasicAuthHeaders({ + username, + password, + }); + + return async (input, init) => { + const headers = new Headers(init?.headers); + + if (!headers.has('Authorization') && isDefined(basicAuthorization)) { + headers.set('Authorization', basicAuthorization); + } + + return digestClient.fetch(input, { + ...init, + headers, + }) as Promise; + }; +}; diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client.ts index 7a19208c06..1e1aca1c2c 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client.ts @@ -9,10 +9,10 @@ import { DAVNamespaceShort, type DAVObject, fetchCalendars, - getBasicAuthHeaders, syncCollection, } from 'tsdav'; +import { createBasicDigestAuthFetch } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/auth/create-basic-digest-auth-fetch'; import { icalDataExtractPropertyValue } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/icalDataExtractPropertyValue'; import { CalDavGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service'; import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity'; @@ -62,20 +62,16 @@ type CalDAVGetEventsResponse = { export class CalDAVClient { private credentials: CalendarCredentials; private logger: Logger; + private fetchOverride: typeof fetch; constructor(credentials: CalendarCredentials) { this.credentials = credentials; this.logger = new Logger(CalDAVClient.name); - } - - private getTsdavRequestConfig() { - return { - headers: getBasicAuthHeaders({ - username: this.credentials.username, - password: this.credentials.password, - }), - fetch: this.credentials.fetch, - }; + this.fetchOverride = createBasicDigestAuthFetch( + credentials.username, + credentials.password, + credentials.fetch ?? globalThis.fetch, + ); } private hasFileExtension(url: string): boolean { @@ -110,7 +106,7 @@ export class CalDAVClient { password: this.credentials.password, }, }, - ...this.getTsdavRequestConfig(), + fetch: this.fetchOverride, }); } @@ -120,7 +116,7 @@ export class CalDAVClient { const calendars = (await fetchCalendars({ account, - ...this.getTsdavRequestConfig(), + fetch: this.fetchOverride, })) as (Omit & { displayName?: string | Record; })[]; @@ -156,7 +152,7 @@ export class CalDAVClient { const calendars = await fetchCalendars({ account, - ...this.getTsdavRequestConfig(), + fetch: this.fetchOverride, }); const eventCalendar = calendars.find((calendar) => @@ -365,7 +361,7 @@ export class CalDAVClient { }, syncLevel: 1, ...(syncToken ? { syncToken } : {}), - ...this.getTsdavRequestConfig(), + fetch: this.fetchOverride, }); const allEvents: FetchedCalendarEvent[] = []; @@ -384,7 +380,7 @@ export class CalDAVClient { }, objectUrls: objectUrls, depth: '1', - ...this.getTsdavRequestConfig(), + fetch: this.fetchOverride, }); for (const calendarObject of calendarObjects) { @@ -432,7 +428,7 @@ export class CalDAVClient { const account = await this.getAccount(); const updatedCalendars = await fetchCalendars({ account, - ...this.getTsdavRequestConfig(), + fetch: this.fetchOverride, }); const updatedCalendar = updatedCalendars.find( (cal) => cal.url === calendar.url, diff --git a/yarn.lock b/yarn.lock index 4a3054ca98..9b56874d56 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31351,6 +31351,13 @@ __metadata: languageName: node linkType: hard +"base-64@npm:^0.1.0": + version: 0.1.0 + resolution: "base-64@npm:0.1.0" + checksum: 10c0/fe0dcf076e823f04db7ee9b02495be08a91c445fbc6db03cb9913be9680e2fcc0af8b74459041fe08ad16800b1f65a549501d8f08696a8a6d32880789b7de69d + languageName: node + linkType: hard + "base32-encode@npm:^0.1.0 || ^1.0.0": version: 1.2.0 resolution: "base32-encode@npm:1.2.0" @@ -32676,6 +32683,13 @@ __metadata: languageName: node linkType: hard +"charenc@npm:0.0.2": + version: 0.0.2 + resolution: "charenc@npm:0.0.2" + checksum: 10c0/a45ec39363a16799d0f9365c8dd0c78e711415113c6f14787a22462ef451f5013efae8a28f1c058f81fc01f2a6a16955f7a5fd0cd56247ce94a45349c89877d8 + languageName: node + linkType: hard + "check-disk-space@npm:3.4.0": version: 3.4.0 resolution: "check-disk-space@npm:3.4.0" @@ -34430,15 +34444,6 @@ __metadata: languageName: node linkType: hard -"cross-fetch@npm:4.1.0, cross-fetch@npm:~4.1.0": - version: 4.1.0 - resolution: "cross-fetch@npm:4.1.0" - dependencies: - node-fetch: "npm:^2.7.0" - checksum: 10c0/628b134ea27cfcada67025afe6ef1419813fffc5d63d175553efa75a2334522d450300a0f3f0719029700da80e96327930709d5551cf6deb39bb62f1d536642e - languageName: node - linkType: hard - "cross-fetch@npm:^3.0.4, cross-fetch@npm:^3.1.5": version: 3.1.8 resolution: "cross-fetch@npm:3.1.8" @@ -34448,6 +34453,15 @@ __metadata: languageName: node linkType: hard +"cross-fetch@npm:~4.1.0": + version: 4.1.0 + resolution: "cross-fetch@npm:4.1.0" + dependencies: + node-fetch: "npm:^2.7.0" + checksum: 10c0/628b134ea27cfcada67025afe6ef1419813fffc5d63d175553efa75a2334522d450300a0f3f0719029700da80e96327930709d5551cf6deb39bb62f1d536642e + languageName: node + linkType: hard + "cross-inspect@npm:1.0.0": version: 1.0.0 resolution: "cross-inspect@npm:1.0.0" @@ -34497,6 +34511,13 @@ __metadata: languageName: node linkType: hard +"crypt@npm:0.0.2": + version: 0.0.2 + resolution: "crypt@npm:0.0.2" + checksum: 10c0/adbf263441dd801665d5425f044647533f39f4612544071b1471962209d235042fb703c27eea2795c7c53e1dfc242405173003f83cf4f4761a633d11f9653f18 + languageName: node + linkType: hard + "crypto-browserify@npm:^3.0.0": version: 3.12.0 resolution: "crypto-browserify@npm:3.12.0" @@ -35848,6 +35869,18 @@ __metadata: languageName: node linkType: hard +"digest-fetch@npm:^3.1.1": + version: 3.1.1 + resolution: "digest-fetch@npm:3.1.1" + dependencies: + base-64: "npm:^0.1.0" + js-sha256: "npm:^0.9.0" + js-sha512: "npm:^0.8.0" + md5: "npm:^2.3.0" + checksum: 10c0/c1f409a4a11406ea4161aef91236b326c8a366d7769ca100c18d7a19942af8f7e0d439734e83e53097ebda793f4baf6acb1664d1eaffc11bfcc0ec5808a3c0fb + languageName: node + linkType: hard + "dir-compare@npm:^4.2.0": version: 4.2.0 resolution: "dir-compare@npm:4.2.0" @@ -42785,7 +42818,7 @@ __metadata: languageName: node linkType: hard -"is-buffer@npm:^1.1.0": +"is-buffer@npm:^1.1.0, is-buffer@npm:~1.1.6": version: 1.1.6 resolution: "is-buffer@npm:1.1.6" checksum: 10c0/ae18aa0b6e113d6c490ad1db5e8df9bdb57758382b313f5a22c9c61084875c6396d50bbf49315f5b1926d142d74dfb8d31b40d993a383e0a158b15fea7a82234 @@ -45262,6 +45295,20 @@ __metadata: languageName: node linkType: hard +"js-sha256@npm:^0.9.0": + version: 0.9.0 + resolution: "js-sha256@npm:0.9.0" + checksum: 10c0/f20b9245f6ebe666f42ca05536f777301132fb1aa7fbc22f10578fa302717a6cca507344894efdeaf40a011256eb2f7d517b94ac7105bd5cf087fa61551ad634 + languageName: node + linkType: hard + +"js-sha512@npm:^0.8.0": + version: 0.8.0 + resolution: "js-sha512@npm:0.8.0" + checksum: 10c0/066fab16e14bd5dccff7c1cfca5961067ab00bb5e4eb0666d4fab8a669c0448663ccc982491cfe05f55cb693ed2722d40e6ee679a7b12e4006a6b358c17ddce8 + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -47397,6 +47444,17 @@ __metadata: languageName: node linkType: hard +"md5@npm:^2.3.0": + version: 2.3.0 + resolution: "md5@npm:2.3.0" + dependencies: + charenc: "npm:0.0.2" + crypt: "npm:0.0.2" + is-buffer: "npm:~1.1.6" + checksum: 10c0/14a21d597d92e5b738255fbe7fe379905b8cb97e0a49d44a20b58526a646ec5518c337b817ce0094ca94d3e81a3313879c4c7b510d250c282d53afbbdede9110 + languageName: node + linkType: hard + "mdast-util-find-and-replace@npm:^3.0.0": version: 3.0.2 resolution: "mdast-util-find-and-replace@npm:3.0.2" @@ -60148,15 +60206,14 @@ __metadata: languageName: node linkType: hard -"tsdav@npm:^2.1.5": - version: 2.1.8 - resolution: "tsdav@npm:2.1.8" +"tsdav@npm:^2.2.0": + version: 2.2.0 + resolution: "tsdav@npm:2.2.0" dependencies: base-64: "npm:1.0.0" - cross-fetch: "npm:4.1.0" debug: "npm:4.4.3" xml-js: "npm:1.6.11" - checksum: 10c0/bb0ff3021483ca2a1878c45aeb86cf27c0d03973dabd1dd547825e98de865b840d7a47662e277a251fabf9a39d521c13ea8a520807ce62fc3c8cd7a1a13710a2 + checksum: 10c0/39dbe70c7e0bb1e5d48d776a15221d1f43c0e8165c7a1af3679e1c0517474ad133daf34763e19f79b7e365e54e3ff24b777f2198d2aedb6422c127a9841bf91c languageName: node linkType: hard @@ -60702,6 +60759,7 @@ __metadata: dataloader: "npm:2.2.2" date-fns: "npm:2.30.0" deep-equal: "npm:2.2.3" + digest-fetch: "npm:^3.1.1" dompurify: "npm:3.3.3" dotenv: "npm:16.4.5" exa-js: "npm:^2.11.0" @@ -60778,7 +60836,7 @@ __metadata: temporal-polyfill: "npm:^0.3.0" transliteration: "npm:2.3.5" tsconfig-paths: "npm:^4.2.0" - tsdav: "npm:^2.1.5" + tsdav: "npm:^2.2.0" tslib: "npm:2.8.1" twenty-client-sdk: "workspace:*" twenty-emails: "workspace:*"