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
@@ -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,