CalDAV: support Digest auth (#20135)

Adds digest auth support for CalDAV, mostly used by legacy servers

/closes https://github.com/twentyhq/twenty/issues/19922
This commit is contained in:
neo773
2026-04-29 21:39:21 +05:30
committed by GitHub
parent fd6d5f895d
commit a713f8d87f
6 changed files with 209 additions and 35 deletions
+1 -1
View File
@@ -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: {
+2 -1
View File
@@ -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",
@@ -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);
});
});
@@ -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<Response>;
};
};
@@ -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<DAVCalendar, 'displayName'> & {
displayName?: string | Record<string, unknown>;
})[];
@@ -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,
+74 -16
View File
@@ -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:*"