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:
1 parent
e7b157ec35
commit
e133a677ab
6 files changed
+70
-44
No files matched your search
@@ -17,30 +17,36 @@ describe('GraphClientService', () => {
|
||||
service = new GraphClientService();
|
||||
fetchMock = jest.fn();
|
||||
global.fetch = fetchMock;
|
||||
process.env.M365_CALENDAR_TENANT_ID = 'tenant-1';
|
||||
process.env.M365_CALENDAR_CLIENT_ID = 'client-1';
|
||||
process.env.M365_CALENDAR_CLIENT_SECRET = 'secret-1';
|
||||
process.env.OIDC_ISSUER_URL =
|
||||
'https://login.microsoftonline.com/tenant-1/v2.0';
|
||||
process.env.OIDC_CLIENT_ID = 'client-1';
|
||||
process.env.OIDC_CLIENT_SECRET = 'secret-1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.M365_CALENDAR_TENANT_ID;
|
||||
delete process.env.M365_CALENDAR_CLIENT_ID;
|
||||
delete process.env.M365_CALENDAR_CLIENT_SECRET;
|
||||
delete process.env.OIDC_ISSUER_URL;
|
||||
delete process.env.OIDC_CLIENT_ID;
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
||||
@@ -54,10 +60,15 @@ describe('GraphClientService', () => {
|
||||
|
||||
expect(result).toBe('graph-event-1');
|
||||
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(
|
||||
'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 [
|
||||
string,
|
||||
{ headers: Record<string, string>; body: string },
|
||||
|
||||
@@ -27,15 +27,34 @@ 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(
|
||||
process.env.M365_CALENDAR_TENANT_ID &&
|
||||
process.env.M365_CALENDAR_CLIENT_ID &&
|
||||
process.env.M365_CALENDAR_CLIENT_SECRET,
|
||||
issuerUrl &&
|
||||
tenantIdFromIssuerUrl(issuerUrl) &&
|
||||
process.env.OIDC_CLIENT_ID &&
|
||||
process.env.OIDC_CLIENT_SECRET,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,12 +63,13 @@ export class GraphClientService {
|
||||
return this.cachedToken.accessToken;
|
||||
}
|
||||
|
||||
const tenantId = process.env.M365_CALENDAR_TENANT_ID;
|
||||
const clientId = process.env.M365_CALENDAR_CLIENT_ID;
|
||||
const clientSecret = process.env.M365_CALENDAR_CLIENT_SECRET;
|
||||
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 (Env-Variablen fehlen)',
|
||||
'M365-Kalender-Synchronisation ist nicht konfiguriert (SSO-Env-Variablen fehlen)',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user