Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions backend/api.test/Security/AuthenticationConfigurationsTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<string, string?>
{
["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<IConfiguration>(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<IAuthorizationHeaderProvider>();

Assert.IsNotType<GenericOidcAuthorizationHeaderProvider>(headerProvider);
}

[Theory]
[InlineData("Development")]
[InlineData("Staging")]
[InlineData("Production")]
public void EntraIssuerValidatorIsWiredOutsideIntegrationTest(string environmentName)
{
using var provider = BuildProvider(environmentName);

var options = provider
.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>()
.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<IAuthorizationHeaderProvider>();

Assert.IsType<GenericOidcAuthorizationHeaderProvider>(headerProvider);
}

[Fact]
public void IntegrationTestEnvironmentPointsTokenValidationAtTheMockIssuer()
{
using var provider = BuildProvider(
AuthenticationConfigurations.IntegrationTestEnvironment
);

var options = provider
.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>()
.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<IOptionsMonitor<JwtBearerOptions>>()
.Get(JwtBearerDefaults.AuthenticationScheme);

Assert.NotNull(options.Events?.OnMessageReceived);
}
}
}
}
175 changes: 175 additions & 0 deletions backend/api/Configurations/AuthenticationConfigurations.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public const string IntegrationTestEnvironment = "IntegrationTest";

public static bool UsesGenericOidc(this IHostEnvironment environment) =>
Comment on lines +10 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I agree that generic oidc should be gated on environment. I want to use this for local development as well. Having environment variables on environments directly is also not recommended according to the 12 factor app principles:

https://12factor.net/config

Another aspect of config management is grouping. Sometimes apps batch config into named groups (often called “environments”) named after specific deploys, such as the development, test, and production environments in Rails. This method does not scale cleanly: as more deploys of the app are created, new environment names are necessary, such as staging or qa. As the project grows further, developers may add their own special environments like joes-staging, resulting in a combinatorial explosion of config which makes managing deploys of the app very brittle.

In a twelve-factor app, env vars are granular controls, each fully orthogonal to other env vars. They are never grouped together as “environments”, but instead are independently managed for each deploy. This is a model that scales up smoothly as the app naturally expands into more deploys over its lifetime.

Since it is an open source repo it might be useful for others to use this in their dev/staging/prod

environment.IsEnvironment(IntegrationTestEnvironment);

/// <summary>
/// Registers JWT bearer authentication, the MSAL token caches and the
/// downstream API clients for ISAR, SARA and Pointilla.
/// </summary>
public static IServiceCollection ConfigureAuthentication(
this IServiceCollection services,
IConfiguration configuration,
IHostEnvironment environment
)
{
bool useRedis = configuration.GetSection("Redis").GetValue<bool>("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;
}

/// <summary>
/// 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.
/// </summary>
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<JwtBearerOptions>(
JwtBearerDefaults.AuthenticationScheme,
options =>
{
options.Authority = authority;
options.Audience = audience;
// The mock issuer is plain HTTP on the test network.
options.RequireHttpsMetadata = false;

@olaals olaals Aug 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-generated review comment: This observation was produced with AI assistance and should be validated and discussed by the team before deciding on an implementation.

RequireHttpsMetadata is disabled for every generic provider. That is useful for a local HTTP test server, but it couples provider selection to transport-security relaxation. If generic OIDC becomes configuration-driven, a mistaken production configuration could fetch discovery metadata and signing keys over HTTP, allowing key substitution and forged-token validation.

I recommend keeping HTTPS metadata required by default and introducing a separate explicit setting such as AllowInsecureHttpMetadata, defaulting to false. It can be enabled only in local/integration deployment configuration, ideally with an additional startup guard that rejects it outside an explicitly permitted context. Tests should verify that an HTTP authority fails by default, succeeds only with the opt-in, and that HTTPS providers retain metadata enforcement.

Scores

  • Overall importance: 8/10
  • Correctness-related: 7/10
  • Security-related: 10/10
  • Interoperability-related: 5/10
  • Flexibility-related: 6/10
  • Confidence: 9/10

}
);

services.PostConfigure<JwtBearerOptions>(
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
>();
}

/// <summary>
/// Browsers cannot set headers on WebSocket connections, so SignalR passes the
/// access token in the query string instead.
/// </summary>
private static void ConfigureSignalRQueryStringToken(IServiceCollection services)
{
services.Configure<JwtBearerOptions>(
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;
};
}
);
}
}
}
Loading
Loading