From 95c9b9f03adb065cf01a4c71f7e2b8c51b110199 Mon Sep 17 00:00:00 2001 From: Ola Alstad Date: Wed, 29 Jul 2026 19:14:45 +0200 Subject: [PATCH] Warm up MSAL token pipeline at startup Microsoft.Identity.Web builds its MergedOptions and the underlying MSAL confidential-client application lazily on the first token request, and that first initialization is not safe against a second concurrent caller: a concurrent ISAR call can observe a half-initialized configuration and fail with "No ClientId was specified.". Add a MsalWarmupHostedService that acquires a single app token for the ISAR downstream API before the server accepts requests, so the options and confidential client are fully built once. A failed warm-up (no network or invalid secret in local dev) is logged and never blocks startup. --- .../HostedServices/MsalWarmupHostedService.cs | 72 +++++++++++++++++++ backend/api/Program.cs | 1 + 2 files changed, 73 insertions(+) create mode 100644 backend/api/HostedServices/MsalWarmupHostedService.cs diff --git a/backend/api/HostedServices/MsalWarmupHostedService.cs b/backend/api/HostedServices/MsalWarmupHostedService.cs new file mode 100644 index 000000000..e51f0c3d0 --- /dev/null +++ b/backend/api/HostedServices/MsalWarmupHostedService.cs @@ -0,0 +1,72 @@ +using Microsoft.Identity.Web; + +namespace Api.HostedServices +{ + /// + /// Warms up the Microsoft.Identity.Web token-acquisition pipeline at startup by + /// acquiring a single application (client-credentials) access token for the ISAR + /// downstream API. + /// + /// Microsoft.Identity.Web builds its MergedOptions and the underlying MSAL + /// confidential-client application lazily on the first token request. That first + /// initialization is not safe against a second concurrent caller: while one thread + /// is populating the merged options, another concurrent ISAR call can observe a + /// half-initialized configuration and fail with "No ClientId was specified.". + /// + /// Performing one token acquisition here — before the web server starts accepting + /// requests — ensures the options and confidential client are fully built once, so + /// the first real mission-scheduling requests never race that initialization. + /// + public class MsalWarmupHostedService( + ILogger logger, + IServiceScopeFactory scopeFactory, + IConfiguration configuration + ) : IHostedService + { + private static readonly TimeSpan WarmupTimeout = TimeSpan.FromSeconds(30); + + public async Task StartAsync(CancellationToken cancellationToken) + { + var scope = configuration["Isar:Scopes:0"]; + if (string.IsNullOrWhiteSpace(scope)) + { + logger.LogWarning( + "Skipping MSAL warm-up: no ISAR scope configured under 'Isar:Scopes'." + ); + return; + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken + ); + timeoutCts.CancelAfter(WarmupTimeout); + + try + { + using var serviceScope = scopeFactory.CreateScope(); + var tokenAcquisition = + serviceScope.ServiceProvider.GetRequiredService(); + + // Triggers the one-time build of MergedOptions and the MSAL confidential + // client used for all subsequent app-token calls to ISAR. + await tokenAcquisition.GetAccessTokenForAppAsync(scope); + + logger.LogInformation( + "MSAL token-acquisition pipeline warmed up for ISAR downstream API." + ); + } + catch (Exception e) + { + // A failed warm-up (e.g. no network or invalid secret in local dev) is not + // fatal: the merged options are still initialized as a side effect, and the + // token will be acquired lazily on first real use. Never block startup. + logger.LogWarning( + e, + "MSAL warm-up did not complete successfully. Continuing startup; the token will be acquired on first use." + ); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/backend/api/Program.cs b/backend/api/Program.cs index ffdbae631..bb54bf15b 100644 --- a/backend/api/Program.cs +++ b/backend/api/Program.cs @@ -107,6 +107,7 @@ builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService();