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:
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+82
@@ -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);
|
||||
});
|
||||
});
|
||||
+37
@@ -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>;
|
||||
};
|
||||
};
|
||||
+13
-17
@@ -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,
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
Reference in New Issue
Block a user