refactor: reuse the SSO app registration for M365 calendar sync

One Entra enterprise application now covers both interactive login and
the calendar sync's client-credentials Graph access, instead of
requiring a second app registration. GraphClientService reads
OIDC_ISSUER_URL/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET (same as SSO) and
derives the tenant ID from the issuer URL's path, rather than three
dedicated M365_CALENDAR_* env vars. The app registration just needs the
"Calendars.ReadWrite" Application permission added alongside its
existing delegated login scopes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-04 14:35:18 +02:00
1 parent e7b157ec35
commit e133a677ab
6 files changed
+70 -44

No files matched your search

+1 -1
View File
@@ -97,7 +97,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt. 6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. 7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern. 8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`, eigene, von der SSO-App-Registrierung getrennte Zugangsdaten `M365_CALENDAR_TENANT_ID`/`_CLIENT_ID`/`_CLIENT_SECRET`, ausschließlich Env-Variablen) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen. 9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
## 7. Kubernetes ## 7. Kubernetes
+11 -14
View File
@@ -43,25 +43,22 @@ BACKUP_RETENTION_COUNT=14
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start). # (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
# OIDC_ISSUER_URL ist die v2.0-Metadaten-URL des Tenants, OIDC_REDIRECT_URI # OIDC_ISSUER_URL ist die v2.0-Metadaten-URL des Tenants, OIDC_REDIRECT_URI
# muss exakt der in der App-Registrierung hinterlegten Redirect-URI entsprechen. # muss exakt der in der App-Registrierung hinterlegten Redirect-URI entsprechen.
#
# Dieselbe App-Registrierung wird auch fuer die M365-Kalender-Synchronisation
# (Modul Kalender) wiederverwendet - kein zweites Enterprise-App-Objekt noetig.
# Dafuer in der App-Registrierung zusaetzlich die Application-Permission
# "Calendars.ReadWrite" hinzufuegen (Admin-Consent noetig); die Tenant-ID wird
# aus OIDC_ISSUER_URL abgeleitet. Eintraege (Aufgaben, Durchsichten,
# Behandlungen, Fuetterungen, Ernten, Koeniginnen-Zuordnungen) werden
# einseitig (ApiCore -> M365) in den Kalender einer Microsoft-365-Gruppe
# uebertragen. Ohne diese drei Werte bleibt die Synchronisation deaktiviert;
# zusaetzlich muessen die Synchronisation selbst UND die jeweilige Gruppen-ID
# in der GUI (Verwaltung > Automatisierung > Kalender-Sync) gesetzt werden.
OIDC_ISSUER_URL="https://login.microsoftonline.com/<tenant-id>/v2.0" OIDC_ISSUER_URL="https://login.microsoftonline.com/<tenant-id>/v2.0"
OIDC_CLIENT_ID="change-me-entra-client-id" OIDC_CLIENT_ID="change-me-entra-client-id"
OIDC_CLIENT_SECRET="change-me-entra-client-secret" OIDC_CLIENT_SECRET="change-me-entra-client-secret"
OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback" OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
# M365-Kalender-Synchronisation (Modul Kalender). Eintraege (Aufgaben,
# Durchsichten, Behandlungen, Fuetterungen, Ernten, Koeniginnen-Zuordnungen)
# werden einseitig (ApiCore -> M365) in den Kalender einer Microsoft-365-
# Gruppe uebertragen. Eigene, separate App-Registrierung in Azure/Entra
# (NICHT dieselbe wie OIDC_CLIENT_ID oben) mit Application-Permission
# "Calendars.ReadWrite" (Admin-Consent noetig), Client-Credentials-Flow.
# Ohne alle drei Werte bleibt die Synchronisation deaktiviert. Zusaetzlich
# muss die Synchronisation UND die jeweilige Gruppen-ID in der GUI
# (Verwaltung > Automatisierung > Kalender-Sync) aktiviert/gesetzt werden -
# diese drei Env-Variablen allein reichen nicht.
M365_CALENDAR_TENANT_ID="change-me-m365-tenant-id"
M365_CALENDAR_CLIENT_ID="change-me-m365-client-id"
M365_CALENDAR_CLIENT_SECRET="change-me-m365-client-secret"
# Swagger-UI unter /api/v1/docs ist per Default deaktiviert, da sie das # Swagger-UI unter /api/v1/docs ist per Default deaktiviert, da sie das
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde. # gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren. # Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
@@ -17,30 +17,36 @@ describe('GraphClientService', () => {
service = new GraphClientService(); service = new GraphClientService();
fetchMock = jest.fn(); fetchMock = jest.fn();
global.fetch = fetchMock; global.fetch = fetchMock;
process.env.M365_CALENDAR_TENANT_ID = 'tenant-1'; process.env.OIDC_ISSUER_URL =
process.env.M365_CALENDAR_CLIENT_ID = 'client-1'; 'https://login.microsoftonline.com/tenant-1/v2.0';
process.env.M365_CALENDAR_CLIENT_SECRET = 'secret-1'; process.env.OIDC_CLIENT_ID = 'client-1';
process.env.OIDC_CLIENT_SECRET = 'secret-1';
}); });
afterEach(() => { afterEach(() => {
delete process.env.M365_CALENDAR_TENANT_ID; delete process.env.OIDC_ISSUER_URL;
delete process.env.M365_CALENDAR_CLIENT_ID; delete process.env.OIDC_CLIENT_ID;
delete process.env.M365_CALENDAR_CLIENT_SECRET; delete process.env.OIDC_CLIENT_SECRET;
}); });
describe('isConfigured', () => { describe('isConfigured', () => {
it('returns true when all three env vars are set', () => { it('returns true when all three SSO env vars are set', () => {
expect(service.isConfigured()).toBe(true); expect(service.isConfigured()).toBe(true);
}); });
it('returns false when a required env var is missing', () => { it('returns false when a required env var is missing', () => {
delete process.env.M365_CALENDAR_CLIENT_SECRET; delete process.env.OIDC_CLIENT_SECRET;
expect(service.isConfigured()).toBe(false);
});
it('returns false when the issuer URL has no tenant segment', () => {
process.env.OIDC_ISSUER_URL = 'https://login.microsoftonline.com/';
expect(service.isConfigured()).toBe(false); expect(service.isConfigured()).toBe(false);
}); });
}); });
describe('createEvent', () => { describe('createEvent', () => {
it('fetches a token, then posts an all-day event and returns the new id', async () => { it('fetches a token from the tenant derived from OIDC_ISSUER_URL, then posts an all-day event', async () => {
fetchMock fetchMock
.mockResolvedValueOnce( .mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }), jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
@@ -54,10 +60,15 @@ describe('GraphClientService', () => {
expect(result).toBe('graph-event-1'); expect(result).toBe('graph-event-1');
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
const [tokenUrl] = fetchMock.mock.calls[0] as [string]; const [tokenUrl, tokenInit] = fetchMock.mock.calls[0] as [
string,
{ body: URLSearchParams },
];
expect(tokenUrl).toBe( expect(tokenUrl).toBe(
'https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token', 'https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token',
); );
expect(tokenInit.body.get('client_id')).toBe('client-1');
expect(tokenInit.body.get('client_secret')).toBe('secret-1');
const [eventUrl, eventInit] = fetchMock.mock.calls[1] as [ const [eventUrl, eventInit] = fetchMock.mock.calls[1] as [
string, string,
{ headers: Record<string, string>; body: string }, { headers: Record<string, string>; body: string },
@@ -27,15 +27,34 @@ function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
// Reuses the same Entra app registration as interactive SSO login
// (OIDC_ISSUER_URL/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET) for the app-only
// Graph client-credentials flow, instead of a second, separate app
// registration - one enterprise application covers both, as long as it
// also has the "Calendars.ReadWrite" Application permission (admin-
// consented) alongside its delegated login scopes. OIDC_ISSUER_URL is
// "https://login.microsoftonline.com/<tenant-id>/v2.0" - the tenant id is
// its first path segment.
function tenantIdFromIssuerUrl(issuerUrl: string): string | null {
try {
const segments = new URL(issuerUrl).pathname.split('/').filter(Boolean);
return segments[0] ?? null;
} catch {
return null;
}
}
@Injectable() @Injectable()
export class GraphClientService { export class GraphClientService {
private cachedToken: CachedToken | null = null; private cachedToken: CachedToken | null = null;
isConfigured(): boolean { isConfigured(): boolean {
const issuerUrl = process.env.OIDC_ISSUER_URL;
return Boolean( return Boolean(
process.env.M365_CALENDAR_TENANT_ID && issuerUrl &&
process.env.M365_CALENDAR_CLIENT_ID && tenantIdFromIssuerUrl(issuerUrl) &&
process.env.M365_CALENDAR_CLIENT_SECRET, process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET,
); );
} }
@@ -44,12 +63,13 @@ export class GraphClientService {
return this.cachedToken.accessToken; return this.cachedToken.accessToken;
} }
const tenantId = process.env.M365_CALENDAR_TENANT_ID; const issuerUrl = process.env.OIDC_ISSUER_URL;
const clientId = process.env.M365_CALENDAR_CLIENT_ID; const clientId = process.env.OIDC_CLIENT_ID;
const clientSecret = process.env.M365_CALENDAR_CLIENT_SECRET; const clientSecret = process.env.OIDC_CLIENT_SECRET;
const tenantId = issuerUrl ? tenantIdFromIssuerUrl(issuerUrl) : null;
if (!tenantId || !clientId || !clientSecret) { if (!tenantId || !clientId || !clientSecret) {
throw new Error( throw new Error(
'M365-Kalender-Synchronisation ist nicht konfiguriert (Env-Variablen fehlen)', 'M365-Kalender-Synchronisation ist nicht konfiguriert (SSO-Env-Variablen fehlen)',
); );
} }
@@ -74,9 +74,9 @@ function SettingsForm({ settings }: { settings: CalendarSyncSettings }) {
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Überträgt Kalendereinträge einseitig (ApiCore → M365) in den Kalender einer Überträgt Kalendereinträge einseitig (ApiCore → M365) in den Kalender einer
Microsoft-365-Gruppe. Benötigt eine eigene App-Registrierung in Azure/Entra Microsoft-365-Gruppe. Nutzt dieselbe Azure/Entra-App-Registrierung wie die
(Umgebungsvariablen, siehe .env.example) mit Application-Berechtigung Microsoft-Anmeldung (SSO) – dafür muss diese zusätzlich die
„Calendars.ReadWrite“ und Admin-Consent. Application-Berechtigung „Calendars.ReadWrite“ mit Admin-Consent erhalten.
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
@@ -148,8 +148,9 @@ function StatusSection() {
<p className="text-muted-foreground">Lädt…</p> <p className="text-muted-foreground">Lädt…</p>
) : !status?.configured ? ( ) : !status?.configured ? (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Nicht konfiguriert - M365_CALENDAR_TENANT_ID/_CLIENT_ID/_CLIENT_SECRET fehlen als Nicht konfiguriert - die SSO-Umgebungsvariablen (OIDC_ISSUER_URL/OIDC_CLIENT_ID/
Umgebungsvariable. OIDC_CLIENT_SECRET) fehlen, oder die App-Registrierung hat noch keine
Application-Berechtigung „Calendars.ReadWrite“.
</p> </p>
) : !canRunNow ? ( ) : !canRunNow ? (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
+4 -7
View File
@@ -16,12 +16,9 @@ stringData:
JWT_ACCESS_SECRET: "change-me-to-a-random-value-at-least-32-characters-long" JWT_ACCESS_SECRET: "change-me-to-a-random-value-at-least-32-characters-long"
JWT_REFRESH_SECRET: "change-me-refresh-secret" JWT_REFRESH_SECRET: "change-me-refresh-secret"
# Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml # Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI. # fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI. Wird auch fuer die
# M365-Kalender-Synchronisation wiederverwendet - dafuer in der
# App-Registrierung zusaetzlich die Application-Permission
# "Calendars.ReadWrite" hinzufuegen (Admin-Consent noetig).
OIDC_CLIENT_ID: "change-me-entra-client-id" OIDC_CLIENT_ID: "change-me-entra-client-id"
OIDC_CLIENT_SECRET: "change-me-entra-client-secret" OIDC_CLIENT_SECRET: "change-me-entra-client-secret"
# Separate App-Registrierung fuer die M365-Kalender-Synchronisation
# (Application-Permission "Calendars.ReadWrite", Admin-Consent noetig) -
# NICHT dieselbe wie OIDC_CLIENT_ID/-SECRET oben.
M365_CALENDAR_TENANT_ID: "change-me-m365-tenant-id"
M365_CALENDAR_CLIENT_ID: "change-me-m365-client-id"
M365_CALENDAR_CLIENT_SECRET: "change-me-m365-client-secret"