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. * 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 state CSRF-Token
* @param redirectUri Optionaler Override (fuer dynamischen Host-basierten URI) * @param redirectUri Optionaler Override (fuer dynamischen Host-basierten URI)
*/ */
async getIntegrationAuthUrl(state: string, redirectUri?: string): Promise<string> { 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( throw new ServiceUnavailableException(
'Microsoft SSO ist nicht konfiguriert', 'Microsoft SSO ist nicht konfiguriert',
); );
} }
const authUrlRequest: AuthorizationUrlRequest = { const params = new URLSearchParams({
scopes: this.integrationScopes, client_id: clientId,
redirectUri: redirectUri || this.integrationRedirectUri, response_type: 'code',
redirect_uri: redirectUri || this.integrationRedirectUri,
scope: this.integrationScopes.join(' '),
state, 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. * Authorization Code gegen M365-Tokens tauschen (Standard OAuth2 POST).
* Gibt Access-Token, Refresh-Token und Ablaufzeit zurueck. * Direkte fetch-Implementierung analog zu refreshIntegrationToken
* umgeht MSAL v5 Probleme mit acquireTokenByCode fuer Graph-Scopes.
* @param code Authorization Code von Microsoft * @param code Authorization Code von Microsoft
* @param redirectUri Muss exakt mit dem URI aus dem Auth-Request uebereinstimmen * @param redirectUri Muss exakt mit dem URI aus dem Auth-Request uebereinstimmen
*/ */
async handleIntegrationCallback(code: string, redirectUri?: string): Promise<M365TokenResult> { 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( 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, code,
scopes: this.integrationScopes, redirect_uri: redirectUri || this.integrationRedirectUri,
redirectUri: redirectUri || this.integrationRedirectUri, grant_type: 'authorization_code',
}; scope: this.integrationScopes.join(' '),
});
const response: AuthenticationResult = const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
await this.msalClient.acquireTokenByCode(tokenRequest); 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'); 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 { return {
accessToken: response.accessToken, accessToken: data.access_token,
refreshToken, refreshToken: data.refresh_token ?? '',
expiresAt: response.expiresOn ?? new Date(Date.now() + 3600 * 1000), expiresAt: new Date(Date.now() + data.expires_in * 1000),
scopes: this.integrationScopes, scopes: this.integrationScopes,
msTenantId: tenantId, msTenantId: tenantId,
}; };