From 88884b49f9304088573a0527659573d041437324 Mon Sep 17 00:00:00 2001 From: oysand Date: Fri, 31 Jul 2026 10:05:56 +0200 Subject: [PATCH] Allow openid for integration tests --- .../AuthenticationConfigurationsTests.cs | 156 ++++++++++++++++ .../AuthenticationConfigurations.cs | 175 ++++++++++++++++++ .../GenericOidcAuthorizationHeaderProvider.cs | 155 ++++++++++++++++ backend/api/Program.cs | 54 +----- backend/api/appsettings.IntegrationTest.json | 35 ++++ 5 files changed, 524 insertions(+), 51 deletions(-) create mode 100644 backend/api.test/Security/AuthenticationConfigurationsTests.cs create mode 100644 backend/api/Configurations/AuthenticationConfigurations.cs create mode 100644 backend/api/Configurations/GenericOidcAuthorizationHeaderProvider.cs create mode 100644 backend/api/appsettings.IntegrationTest.json 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": "" + } +}