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:
+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,
|
||||
|
||||
Reference in New Issue
Block a user