fix(ms365): direkte OAuth2 URL-Konstruktion statt MSAL für Integration-Flow

MSAL-node v5 erzeugt bei getAuthCodeUrl mit reinen Graph-API-Scopes
(ohne openid) einen fehlerhaften Authorize-URL → AADSTS900561.

getIntegrationAuthUrl und handleIntegrationCallback verwenden jetzt
direkte fetch-Aufrufe (analog zu refreshIntegrationToken) ohne MSAL,
was den Fehler umgeht und denselben Standard-OAuth2-Flow garantiert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-12 23:17:38 +01:00
parent 254d00c106
commit 1f6e59d362

View file

@ -332,65 +332,101 @@ export class EntraIdService implements OnModuleInit {
/**
* Authorization-URL fuer MS365 Integration generieren.
* Verwendet erweiterte Scopes (Mail, Calendar, Tasks).
* Direkte URL-Konstruktion (kein MSAL) umgeht MSAL v5 Kompatibilitaetsprobleme
* mit reinen Graph-API-Scopes (ohne openid).
* @param state CSRF-Token
* @param redirectUri Optionaler Override (fuer dynamischen Host-basierten URI)
*/
async getIntegrationAuthUrl(state: string, redirectUri?: string): Promise<string> {
if (!this.msalClient) {
const ssoConfig = await this.loadConfigFromRedis();
const tenantId =
ssoConfig?.tenantId ?? this.config.get<string>('AZURE_TENANT_ID') ?? '';
const clientId =
ssoConfig?.clientId ?? this.config.get<string>('AZURE_CLIENT_ID') ?? '';
if (!tenantId || !clientId) {
throw new ServiceUnavailableException(
'Microsoft SSO ist nicht konfiguriert',
);
}
const authUrlRequest: AuthorizationUrlRequest = {
scopes: this.integrationScopes,
redirectUri: redirectUri || this.integrationRedirectUri,
const params = new URLSearchParams({
client_id: clientId,
response_type: 'code',
redirect_uri: redirectUri || this.integrationRedirectUri,
scope: this.integrationScopes.join(' '),
state,
prompt: 'consent', // Immer Zustimmung anfordern fuer neue Scopes
};
prompt: 'consent',
response_mode: 'query',
});
return this.msalClient.getAuthCodeUrl(authUrlRequest);
const authUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?${params.toString()}`;
this.logger.log(`M365 Integration Auth URL generiert (tenant: ${tenantId})`);
return authUrl;
}
/**
* Authorization Code gegen M365-Tokens tauschen.
* Gibt Access-Token, Refresh-Token und Ablaufzeit zurueck.
* Authorization Code gegen M365-Tokens tauschen (Standard OAuth2 POST).
* Direkte fetch-Implementierung analog zu refreshIntegrationToken
* umgeht MSAL v5 Probleme mit acquireTokenByCode fuer Graph-Scopes.
* @param code Authorization Code von Microsoft
* @param redirectUri Muss exakt mit dem URI aus dem Auth-Request uebereinstimmen
*/
async handleIntegrationCallback(code: string, redirectUri?: string): Promise<M365TokenResult> {
if (!this.msalClient) {
const ssoConfig = await this.loadConfigFromRedis();
const tenantId =
ssoConfig?.tenantId ?? this.config.get<string>('AZURE_TENANT_ID') ?? '';
const clientId =
ssoConfig?.clientId ?? this.config.get<string>('AZURE_CLIENT_ID') ?? '';
const clientSecret =
ssoConfig?.clientSecret ??
this.config.get<string>('AZURE_CLIENT_SECRET') ??
'';
if (!tenantId || !clientId || !clientSecret) {
throw new ServiceUnavailableException(
'Microsoft SSO ist nicht konfiguriert',
'Azure-Konfiguration fehlt fuer Token-Exchange',
);
}
const tokenRequest: AuthorizationCodeRequest = {
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
scopes: this.integrationScopes,
redirectUri: redirectUri || this.integrationRedirectUri,
};
redirect_uri: redirectUri || this.integrationRedirectUri,
grant_type: 'authorization_code',
scope: this.integrationScopes.join(' '),
});
const response: AuthenticationResult =
await this.msalClient.acquireTokenByCode(tokenRequest);
const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const resp = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!resp.ok) {
const err = (await resp.json()) as {
error_description?: string;
error?: string;
};
throw new Error(
`Token-Exchange fehlgeschlagen: ${err.error_description ?? err.error ?? resp.statusText}`,
);
}
const data = (await resp.json()) as {
access_token: string;
refresh_token?: string;
expires_in: number;
};
this.logger.log('M365 Integration Token erhalten');
// Refresh-Token aus MSAL-Antwort (erfordert offline_access Scope)
// In msal-node >= 2.x ist refreshToken in der Antwort verfuegbar
const rawResponse = response as AuthenticationResult & {
refreshToken?: string;
};
const refreshToken = rawResponse.refreshToken ?? '';
const tenantId =
(response.account?.tenantId) ?? '';
return {
accessToken: response.accessToken,
refreshToken,
expiresAt: response.expiresOn ?? new Date(Date.now() + 3600 * 1000),
accessToken: data.access_token,
refreshToken: data.refresh_token ?? '',
expiresAt: new Date(Date.now() + data.expires_in * 1000),
scopes: this.integrationScopes,
msTenantId: tenantId,
};