diff --git a/backend/api.test/Security/AuthenticationConfigurationsTests.cs b/backend/api.test/Security/AuthenticationConfigurationsTests.cs
new file mode 100644
index 000000000..6217a7fae
--- /dev/null
+++ b/backend/api.test/Security/AuthenticationConfigurationsTests.cs
@@ -0,0 +1,156 @@
+using System;
+using System.Collections.Generic;
+using Api.Configurations;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using Microsoft.Identity.Abstractions;
+using Xunit;
+
+namespace Api.Test.Security
+{
+ ///
+ /// Guard rails for the integration-test authentication path.
+ ///
+ /// The generic OIDC issuer removes Entra ID from the picture entirely, so these
+ /// tests exist to make sure it stays confined to the IntegrationTest environment
+ /// and cannot be reached from Development, Staging or Production.
+ ///
+ public class AuthenticationConfigurationsTests
+ {
+ private sealed class StubHostEnvironment(string environmentName) : IHostEnvironment
+ {
+ public string EnvironmentName { get; set; } = environmentName;
+ public string ApplicationName { get; set; } = "Api.Test";
+ public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
+ public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
+ }
+
+ private static ServiceProvider BuildProvider(string environmentName)
+ {
+ var settings = new Dictionary
+ {
+ ["AzureAd:Instance"] = "https://login.microsoftonline.com",
+ ["AzureAd:TenantId"] = "00000000-0000-0000-0000-000000000000",
+ ["AzureAd:ClientId"] = "flotilla-test",
+ ["Redis:UseRedis"] = "false",
+ ["Isar:Scopes:0"] = "isar-test/.default",
+ ["SARA:Scopes:0"] = "sara-test/.default",
+ ["Pointilla:Scopes:0"] = "pointilla-test/.default",
+ };
+
+ // Mirrors the appsettings layout: AzureAd:Authority is set only in
+ // appsettings.IntegrationTest.json.
+ if (environmentName == AuthenticationConfigurations.IntegrationTestEnvironment)
+ {
+ settings["AzureAd:Authority"] = "http://oauth-mock:8080";
+ }
+
+ var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
+
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddSingleton(configuration);
+ services.ConfigureAuthentication(
+ configuration,
+ new StubHostEnvironment(environmentName)
+ );
+
+ return services.BuildServiceProvider();
+ }
+
+ [Theory]
+ [InlineData("Development")]
+ [InlineData("Staging")]
+ [InlineData("Production")]
+ [InlineData("Local")]
+ [InlineData("Test")]
+ public void GenericOidcProviderIsNotRegisteredOutsideIntegrationTest(string environmentName)
+ {
+ using var provider = BuildProvider(environmentName);
+
+ var headerProvider = provider.GetService();
+
+ Assert.IsNotType(headerProvider);
+ }
+
+ [Theory]
+ [InlineData("Development")]
+ [InlineData("Staging")]
+ [InlineData("Production")]
+ public void EntraIssuerValidatorIsWiredOutsideIntegrationTest(string environmentName)
+ {
+ using var provider = BuildProvider(environmentName);
+
+ var options = provider
+ .GetRequiredService>()
+ .Get(JwtBearerDefaults.AuthenticationScheme);
+
+ // AddMicrosoftIdentityWebApi installs the Entra-aware issuer validator.
+ // If this is ever null outside IntegrationTest, issuer validation has been
+ // weakened for a real deployment.
+ Assert.NotNull(options.TokenValidationParameters.IssuerValidator);
+ }
+
+ [Fact]
+ public void GenericOidcProviderIsRegisteredInIntegrationTest()
+ {
+ using var provider = BuildProvider(
+ AuthenticationConfigurations.IntegrationTestEnvironment
+ );
+
+ var headerProvider = provider.GetRequiredService();
+
+ Assert.IsType(headerProvider);
+ }
+
+ [Fact]
+ public void IntegrationTestEnvironmentPointsTokenValidationAtTheMockIssuer()
+ {
+ using var provider = BuildProvider(
+ AuthenticationConfigurations.IntegrationTestEnvironment
+ );
+
+ var options = provider
+ .GetRequiredService>()
+ .Get(JwtBearerDefaults.AuthenticationScheme);
+
+ Assert.Equal("http://oauth-mock:8080", options.Authority);
+ Assert.Equal("flotilla-test", options.Audience);
+ Assert.False(options.RequireHttpsMetadata);
+
+ var parameters = options.TokenValidationParameters;
+ Assert.True(parameters.ValidateIssuer);
+ Assert.True(parameters.ValidateAudience);
+ Assert.True(parameters.ValidateLifetime);
+ // The Entra-specific validator must be gone, otherwise the mock's issuer
+ // would be rejected and instance discovery would hit login.microsoftonline.com.
+ Assert.Null(parameters.IssuerValidator);
+ }
+
+ [Fact]
+ public void SignalRQueryStringTokenHookIsAppliedInEveryEnvironment()
+ {
+ foreach (
+ var environmentName in new[]
+ {
+ "Development",
+ "Production",
+ AuthenticationConfigurations.IntegrationTestEnvironment,
+ }
+ )
+ {
+ using var provider = BuildProvider(environmentName);
+
+ var options = provider
+ .GetRequiredService>()
+ .Get(JwtBearerDefaults.AuthenticationScheme);
+
+ Assert.NotNull(options.Events?.OnMessageReceived);
+ }
+ }
+ }
+}
diff --git a/backend/api/Configurations/AuthenticationConfigurations.cs b/backend/api/Configurations/AuthenticationConfigurations.cs
new file mode 100644
index 000000000..5dca125a1
--- /dev/null
+++ b/backend/api/Configurations/AuthenticationConfigurations.cs
@@ -0,0 +1,175 @@
+using Api.Services;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Identity.Abstractions;
+using Microsoft.Identity.Web;
+
+namespace Api.Configurations
+{
+ public static class AuthenticationConfigurations
+ {
+ ///
+ /// The environment in which the backend validates tokens against a generic
+ /// OpenID Connect issuer instead of Microsoft Entra ID.
+ ///
+ /// This exists solely so the armada integration tests can run against a local
+ /// mock issuer, with no Entra app registrations and no client secrets, while
+ /// still exercising authentication for real.
+ ///
+ /// Gating on the environment name rather than on a configuration flag is
+ /// deliberate: it keeps the generic-issuer path unreachable from Development,
+ /// Staging and Production regardless of which environment variables are set.
+ ///
+ public const string IntegrationTestEnvironment = "IntegrationTest";
+
+ public static bool UsesGenericOidc(this IHostEnvironment environment) =>
+ environment.IsEnvironment(IntegrationTestEnvironment);
+
+ ///
+ /// Registers JWT bearer authentication, the MSAL token caches and the
+ /// downstream API clients for ISAR, SARA and Pointilla.
+ ///
+ public static IServiceCollection ConfigureAuthentication(
+ this IServiceCollection services,
+ IConfiguration configuration,
+ IHostEnvironment environment
+ )
+ {
+ bool useRedis = configuration.GetSection("Redis").GetValue("UseRedis");
+ if (useRedis)
+ {
+ services.ConfigureRedisCache(configuration);
+ }
+
+ var authenticationBuilder = services
+ .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddMicrosoftIdentityWebApi(configuration.GetSection("AzureAd"))
+ .EnableTokenAcquisitionToCallDownstreamApi();
+
+ if (useRedis)
+ {
+ authenticationBuilder.AddDistributedTokenCaches();
+ }
+ else
+ {
+ authenticationBuilder.AddInMemoryTokenCaches();
+ }
+
+ authenticationBuilder
+ .AddDownstreamApi(InspectionService.ServiceName, configuration.GetSection("SARA"))
+ .AddDownstreamApi(IsarService.ServiceName, configuration.GetSection("Isar"))
+ .AddDownstreamApi(
+ PointillaService.ServiceName,
+ configuration.GetSection("Pointilla")
+ );
+
+ if (environment.UsesGenericOidc())
+ {
+ ConfigureGenericOidcOverrides(services, configuration);
+ }
+
+ ConfigureSignalRQueryStringToken(services);
+
+ return services;
+ }
+
+ ///
+ /// Redirects both halves of authentication at the mock issuer.
+ ///
+ /// Inbound: AddMicrosoftIdentityWebApi installs an AadIssuerValidator, which
+ /// expects Entra-shaped issuers and performs instance discovery against
+ /// login.microsoftonline.com. The validator is replaced with plain issuer
+ /// validation against the mock's discovery document.
+ ///
+ /// This is deliberately split across the two options phases:
+ ///
+ /// Configure - the authority and RequireHttpsMetadata, because
+ /// JwtBearerPostConfigureOptions throws
+ /// "MetadataAddress or Authority must use HTTPS" for a plain
+ /// HTTP authority, and it runs before any post-configuration
+ /// we could register.
+ /// PostConfigure - clearing the issuer validator, because
+ /// Microsoft.Identity.Web installs it during
+ /// post-configuration and the last registration wins.
+ ///
+ /// Outbound: IDownstreamApi resolves its bearer tokens through
+ /// IAuthorizationHeaderProvider, so replacing that single service redirects
+ /// the ISAR, SARA and Pointilla calls without fighting MSAL's authority
+ /// validation.
+ ///
+ private static void ConfigureGenericOidcOverrides(
+ IServiceCollection services,
+ IConfiguration configuration
+ )
+ {
+ string authority =
+ configuration["AzureAd:Authority"]
+ ?? throw new InvalidOperationException(
+ $"AzureAd:Authority is required in the {IntegrationTestEnvironment} environment"
+ );
+ string audience =
+ configuration["AzureAd:ClientId"]
+ ?? throw new InvalidOperationException(
+ $"AzureAd:ClientId is required in the {IntegrationTestEnvironment} environment"
+ );
+
+ services.Configure(
+ JwtBearerDefaults.AuthenticationScheme,
+ options =>
+ {
+ options.Authority = authority;
+ options.Audience = audience;
+ // The mock issuer is plain HTTP on the test network.
+ options.RequireHttpsMetadata = false;
+ }
+ );
+
+ services.PostConfigure(
+ JwtBearerDefaults.AuthenticationScheme,
+ options =>
+ {
+ var parameters = options.TokenValidationParameters;
+ parameters.ValidateIssuer = true;
+ parameters.ValidateAudience = true;
+ parameters.ValidateLifetime = true;
+ parameters.ValidAudience = audience;
+ parameters.ValidAudiences = [audience];
+ // Drop the Entra-specific issuer validator; the issuer is taken from
+ // the mock's discovery document instead.
+ parameters.IssuerValidator = null;
+ parameters.ValidIssuers = null;
+ }
+ );
+
+ services.AddSingleton<
+ IAuthorizationHeaderProvider,
+ GenericOidcAuthorizationHeaderProvider
+ >();
+ }
+
+ ///
+ /// Browsers cannot set headers on WebSocket connections, so SignalR passes the
+ /// access token in the query string instead.
+ ///
+ private static void ConfigureSignalRQueryStringToken(IServiceCollection services)
+ {
+ services.Configure(
+ JwtBearerDefaults.AuthenticationScheme,
+ options =>
+ {
+ options.Events ??= new JwtBearerEvents();
+ options.Events.OnMessageReceived = context =>
+ {
+ if (
+ context.HttpContext.Request.Path.StartsWithSegments("/hub")
+ && context.Request.Query.TryGetValue("access_token", out var token)
+ )
+ {
+ context.Token = token;
+ }
+ return Task.CompletedTask;
+ };
+ }
+ );
+ }
+ }
+}
diff --git a/backend/api/Configurations/GenericOidcAuthorizationHeaderProvider.cs b/backend/api/Configurations/GenericOidcAuthorizationHeaderProvider.cs
new file mode 100644
index 000000000..2e4b90399
--- /dev/null
+++ b/backend/api/Configurations/GenericOidcAuthorizationHeaderProvider.cs
@@ -0,0 +1,155 @@
+using System.Collections.Concurrent;
+using System.Net.Http.Headers;
+using System.Security.Claims;
+using Microsoft.Identity.Abstractions;
+using Microsoft.Identity.Web.Extensibility;
+
+namespace Api.Configurations
+{
+ ///
+ /// Acquires downstream API tokens from a generic OpenID Connect issuer instead of
+ /// Microsoft Entra ID.
+ ///
+ /// Used only in the
+ /// environment. IDownstreamApi resolves every bearer token through
+ /// , so substituting this one service
+ /// redirects the ISAR, SARA and Pointilla calls in a single place.
+ ///
+ /// Derives from , the extensibility
+ /// point Microsoft.Identity.Web provides for exactly this purpose, so that any
+ /// protocol the overrides do not handle still falls back to the default behaviour.
+ ///
+ /// Every token is an application token: the mock issuer only implements the client
+ /// credentials grant, and the integration tests do not exercise on-behalf-of flows.
+ ///
+ public class GenericOidcAuthorizationHeaderProvider(
+ IServiceProvider serviceProvider,
+ IHttpClientFactory httpClientFactory,
+ IConfiguration configuration,
+ ILogger logger
+ ) : BaseAuthorizationHeaderProvider(serviceProvider)
+ {
+ private const string BearerScheme = "Bearer";
+
+ // Renew a little before expiry so a token cannot lapse mid-request.
+ private static readonly TimeSpan ExpiryMargin = TimeSpan.FromSeconds(60);
+
+ private readonly ConcurrentDictionary _cache = new();
+ private readonly SemaphoreSlim _lock = new(1, 1);
+
+ private string Authority =>
+ configuration["AzureAd:Authority"]
+ ?? throw new InvalidOperationException("AzureAd:Authority is not configured");
+
+ public override Task CreateAuthorizationHeaderForAppAsync(
+ string scopes,
+ AuthorizationHeaderProviderOptions? downstreamApiOptions = null,
+ CancellationToken cancellationToken = default
+ ) => GetAuthorizationHeaderAsync(scopes, cancellationToken);
+
+ public override Task CreateAuthorizationHeaderForUserAsync(
+ IEnumerable scopes,
+ AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null,
+ ClaimsPrincipal? claimsPrincipal = null,
+ CancellationToken cancellationToken = default
+ ) => GetAuthorizationHeaderAsync(string.Join(' ', scopes), cancellationToken);
+
+ public override Task CreateAuthorizationHeaderAsync(
+ IEnumerable scopes,
+ AuthorizationHeaderProviderOptions? options = null,
+ ClaimsPrincipal? claimsPrincipal = null,
+ CancellationToken cancellationToken = default
+ ) => GetAuthorizationHeaderAsync(string.Join(' ', scopes), cancellationToken);
+
+ private async Task GetAuthorizationHeaderAsync(
+ string scopes,
+ CancellationToken cancellationToken
+ )
+ {
+ if (
+ _cache.TryGetValue(scopes, out var cached)
+ && cached.ExpiresAt - ExpiryMargin > DateTimeOffset.UtcNow
+ )
+ {
+ return $"{BearerScheme} {cached.AccessToken}";
+ }
+
+ await _lock.WaitAsync(cancellationToken);
+ try
+ {
+ // Another caller may have refreshed while this one waited.
+ if (
+ _cache.TryGetValue(scopes, out cached)
+ && cached.ExpiresAt - ExpiryMargin > DateTimeOffset.UtcNow
+ )
+ {
+ return $"{BearerScheme} {cached.AccessToken}";
+ }
+
+ var token = await RequestTokenAsync(scopes, cancellationToken);
+ _cache[scopes] = token;
+ return $"{BearerScheme} {token.AccessToken}";
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ private async Task RequestTokenAsync(
+ string scopes,
+ CancellationToken cancellationToken
+ )
+ {
+ logger.LogInformation(
+ "Acquiring token for scope '{Scopes}' from {Authority}",
+ scopes,
+ Authority
+ );
+
+ using var client = httpClientFactory.CreateClient();
+ using var request = new HttpRequestMessage(HttpMethod.Post, $"{Authority}/token")
+ {
+ Content = new FormUrlEncodedContent(
+ new Dictionary
+ {
+ ["grant_type"] = "client_credentials",
+ ["scope"] = scopes,
+ ["client_id"] = configuration["AzureAd:ClientId"] ?? "flotilla",
+ }
+ ),
+ };
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+
+ using var response = await client.SendAsync(request, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var payload = await response.Content.ReadFromJsonAsync(
+ cancellationToken: cancellationToken
+ );
+
+ if (string.IsNullOrEmpty(payload?.AccessToken))
+ {
+ throw new InvalidOperationException(
+ $"Token endpoint at {Authority} returned no access_token for scope '{scopes}'"
+ );
+ }
+
+ return new CachedToken(
+ payload.AccessToken,
+ DateTimeOffset.UtcNow.AddSeconds(payload.ExpiresIn ?? 3600)
+ );
+ }
+
+ private sealed record CachedToken(string AccessToken, DateTimeOffset ExpiresAt);
+
+ private sealed class TokenResponse
+ {
+ [System.Text.Json.Serialization.JsonPropertyName("access_token")]
+ public string? AccessToken { get; set; }
+
+ [System.Text.Json.Serialization.JsonPropertyName("expires_in")]
+ public int? ExpiresIn { get; set; }
+ }
+ }
+}
diff --git a/backend/api/Program.cs b/backend/api/Program.cs
index ffdbae631..c6dbf6971 100644
--- a/backend/api/Program.cs
+++ b/backend/api/Program.cs
@@ -17,10 +17,8 @@
using Azure.Identity;
using DotEnv.Core;
using Hangfire;
-using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.Rewrite;
-using Microsoft.Identity.Web;
var builder = WebApplication.CreateBuilder(args);
new EnvLoader().Load();
@@ -130,55 +128,9 @@
builder.Services.AddEndpointsApiExplorer();
builder.Services.ConfigureSwagger(builder.Configuration);
-// Configure Redis with Microsoft Entra Authentication
-if (builder.Configuration.GetSection("Redis").GetValue("UseRedis"))
-{
- builder.Services.ConfigureRedisCache(builder.Configuration);
- builder
- .Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
- .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"))
- .EnableTokenAcquisitionToCallDownstreamApi()
- .AddDistributedTokenCaches()
- .AddDownstreamApi(InspectionService.ServiceName, builder.Configuration.GetSection("SARA"))
- .AddDownstreamApi(IsarService.ServiceName, builder.Configuration.GetSection("Isar"))
- .AddDownstreamApi(
- PointillaService.ServiceName,
- builder.Configuration.GetSection("Pointilla")
- );
-}
-else
-{
- builder
- .Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
- .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"))
- .EnableTokenAcquisitionToCallDownstreamApi()
- .AddInMemoryTokenCaches()
- .AddDownstreamApi(InspectionService.ServiceName, builder.Configuration.GetSection("SARA"))
- .AddDownstreamApi(IsarService.ServiceName, builder.Configuration.GetSection("Isar"))
- .AddDownstreamApi(
- PointillaService.ServiceName,
- builder.Configuration.GetSection("Pointilla")
- );
-}
-
-builder.Services.Configure(
- JwtBearerDefaults.AuthenticationScheme,
- options =>
- {
- options.Events ??= new JwtBearerEvents();
- options.Events.OnMessageReceived = context =>
- {
- if (
- context.HttpContext.Request.Path.StartsWithSegments("/hub")
- && context.Request.Query.TryGetValue("access_token", out var token)
- )
- {
- context.Token = token;
- }
- return Task.CompletedTask;
- };
- }
-);
+// Configures JWT bearer authentication, the token caches (Redis-backed when
+// Redis:UseRedis is set) and the ISAR / SARA / Pointilla downstream API clients.
+builder.Services.ConfigureAuthentication(builder.Configuration, builder.Environment);
builder
.Services.AddAuthorizationBuilder()
diff --git a/backend/api/appsettings.IntegrationTest.json b/backend/api/appsettings.IntegrationTest.json
new file mode 100644
index 000000000..cae2cfe7a
--- /dev/null
+++ b/backend/api/appsettings.IntegrationTest.json
@@ -0,0 +1,35 @@
+{
+ "AppName": "FlotillaBackendIntegrationTest",
+ "AzureAd": {
+ "ClientId": "flotilla-test",
+ "Authority": "http://oauth-mock:8080"
+ },
+ "KeyVault": {
+ "UseKeyVault": false
+ },
+ "Redis": {
+ "UseRedis": false
+ },
+ "OpenTelemetry": {
+ "Enabled": false
+ },
+ "Isar": {
+ "Scopes": ["isar-test/.default"]
+ },
+ "SARA": {
+ "BaseUrl": "http://sara:8100/",
+ "Scopes": ["sara-test/.default"]
+ },
+ "Pointilla": {
+ "BaseUrl": "http://pointilla:8200/",
+ "Scopes": ["pointilla-test/.default"]
+ },
+ "AllowedHosts": "*",
+ "AllowedOrigins": ["http://localhost:3001", "https://localhost:3001"],
+ "Database": {
+ "UseInMemoryDatabase": false
+ },
+ "TeamsNotification": {
+ "WebhookUrl": ""
+ }
+}