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]>
195 lines
6.0 KiB
TypeScript
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()}`,
|
|
);
|
|
}
|
|
}
|
|
}
|