Files
ApiCore/ApiCore-Backend/src/calendar-sync/graph-client.service.ts
T
Nick SzaunigandClaude Sonnet 5 e133a677ab 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]>
2026-09-04 14:35:18 +02:00

195 lines
6.0 KiB
TypeScript

import { Injectable } from '@nestjs/common';
export interface GraphCalendarEventInput {
title: string;
date: Date;
}
interface CachedToken {
accessToken: string;
expiresAt: number;
}
const GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0';
// All-day events render on the correct date regardless of viewer timezone,
// but Graph still requires a timeZone on the start/end payload - fixed to
// the org's own zone since this app has no per-user timezone setting.
const EVENT_TIME_ZONE = 'Europe/Berlin';
// Refresh a little before actual expiry so a request never races an
// about-to-expire cached token.
const TOKEN_EXPIRY_BUFFER_MS = 60 * 1000;
function tokenEndpoint(tenantId: string): string {
return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
}
function getErrorMessage(error: unknown): string {
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()
export class GraphClientService {
private cachedToken: CachedToken | null = null;
isConfigured(): boolean {
const issuerUrl = process.env.OIDC_ISSUER_URL;
return Boolean(
issuerUrl &&
tenantIdFromIssuerUrl(issuerUrl) &&
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET,
);
}
private async getAccessToken(): Promise<string> {
if (this.cachedToken && this.cachedToken.expiresAt > Date.now()) {
return this.cachedToken.accessToken;
}
const issuerUrl = process.env.OIDC_ISSUER_URL;
const clientId = process.env.OIDC_CLIENT_ID;
const clientSecret = process.env.OIDC_CLIENT_SECRET;
const tenantId = issuerUrl ? tenantIdFromIssuerUrl(issuerUrl) : null;
if (!tenantId || !clientId || !clientSecret) {
throw new Error(
'M365-Kalender-Synchronisation ist nicht konfiguriert (SSO-Env-Variablen fehlen)',
);
}
let response: Response;
try {
response = await fetch(tokenEndpoint(tenantId), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
scope: 'https://graph.microsoft.com/.default',
}),
});
} catch (error: unknown) {
throw new Error(
`Token-Anfrage an Microsoft fehlgeschlagen: ${getErrorMessage(error)}`,
);
}
if (!response.ok) {
throw new Error(
`Token-Anfrage an Microsoft fehlgeschlagen (${response.status}): ${await response.text()}`,
);
}
const data = (await response.json()) as {
access_token: string;
expires_in: number;
};
this.cachedToken = {
accessToken: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000 - TOKEN_EXPIRY_BUFFER_MS,
};
return this.cachedToken.accessToken;
}
private allDayEventBody(
input: GraphCalendarEventInput,
): Record<string, unknown> {
const day = input.date.toISOString().slice(0, 10);
const nextDay = new Date(input.date);
nextDay.setUTCDate(nextDay.getUTCDate() + 1);
const nextDayString = nextDay.toISOString().slice(0, 10);
return {
subject: input.title,
isAllDay: true,
start: {
dateTime: `${day}T00:00:00.0000000`,
timeZone: EVENT_TIME_ZONE,
},
end: {
dateTime: `${nextDayString}T00:00:00.0000000`,
timeZone: EVENT_TIME_ZONE,
},
};
}
async createEvent(
groupId: string,
input: GraphCalendarEventInput,
): Promise<string> {
const token = await this.getAccessToken();
const response = await fetch(`${GRAPH_BASE_URL}/groups/${groupId}/events`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(this.allDayEventBody(input)),
});
if (!response.ok) {
throw new Error(
`Graph createEvent fehlgeschlagen (${response.status}): ${await response.text()}`,
);
}
const data = (await response.json()) as { id: string };
return data.id;
}
async updateEvent(
groupId: string,
eventId: string,
input: GraphCalendarEventInput,
): Promise<void> {
const token = await this.getAccessToken();
const response = await fetch(
`${GRAPH_BASE_URL}/groups/${groupId}/events/${eventId}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(this.allDayEventBody(input)),
},
);
if (!response.ok) {
throw new Error(
`Graph updateEvent fehlgeschlagen (${response.status}): ${await response.text()}`,
);
}
}
async deleteEvent(groupId: string, eventId: string): Promise<void> {
const token = await this.getAccessToken();
const response = await fetch(
`${GRAPH_BASE_URL}/groups/${groupId}/events/${eventId}`,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
},
);
// A 404 means the event is already gone - treat as success so a retry
// after a partially-applied delete doesn't throw.
if (!response.ok && response.status !== 404) {
throw new Error(
`Graph deleteEvent fehlgeschlagen (${response.status}): ${await response.text()}`,
);
}
}
}