Skip to content

Commit 6e713e0

Browse files
committed
Allow openid for integration tests
1 parent de78a36 commit 6e713e0

5 files changed

Lines changed: 524 additions & 51 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Api.Configurations;
4+
using Microsoft.AspNetCore.Authentication.JwtBearer;
5+
using Microsoft.Extensions.Configuration;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using Microsoft.Extensions.FileProviders;
8+
using Microsoft.Extensions.Hosting;
9+
using Microsoft.Extensions.Options;
10+
using Microsoft.Identity.Abstractions;
11+
using Xunit;
12+
13+
namespace Api.Test.Security
14+
{
15+
/// <summary>
16+
/// Guard rails for the integration-test authentication path.
17+
///
18+
/// The generic OIDC issuer removes Entra ID from the picture entirely, so these
19+
/// tests exist to make sure it stays confined to the IntegrationTest environment
20+
/// and cannot be reached from Development, Staging or Production.
21+
/// </summary>
22+
public class AuthenticationConfigurationsTests
23+
{
24+
private sealed class StubHostEnvironment(string environmentName) : IHostEnvironment
25+
{
26+
public string EnvironmentName { get; set; } = environmentName;
27+
public string ApplicationName { get; set; } = "Api.Test";
28+
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
29+
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
30+
}
31+
32+
private static ServiceProvider BuildProvider(string environmentName)
33+
{
34+
var settings = new Dictionary<string, string?>
35+
{
36+
["AzureAd:Instance"] = "https://login.microsoftonline.com",
37+
["AzureAd:TenantId"] = "00000000-0000-0000-0000-000000000000",
38+
["AzureAd:ClientId"] = "flotilla-test",
39+
["Redis:UseRedis"] = "false",
40+
["Isar:Scopes:0"] = "isar-test/.default",
41+
["SARA:Scopes:0"] = "sara-test/.default",
42+
["Pointilla:Scopes:0"] = "pointilla-test/.default",
43+
};
44+
45+
// Mirrors the appsettings layout: AzureAd:Authority is set only in
46+
// appsettings.IntegrationTest.json.
47+
if (environmentName == AuthenticationConfigurations.IntegrationTestEnvironment)
48+
{
49+
settings["AzureAd:Authority"] = "http://oauth-mock:8080";
50+
}
51+
52+
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
53+
54+
var services = new ServiceCollection();
55+
services.AddLogging();
56+
services.AddSingleton<IConfiguration>(configuration);
57+
services.ConfigureAuthentication(
58+
configuration,
59+
new StubHostEnvironment(environmentName)
60+
);
61+
62+
return services.BuildServiceProvider();
63+
}
64+
65+
[Theory]
66+
[InlineData("Development")]
67+
[InlineData("Staging")]
68+
[InlineData("Production")]
69+
[InlineData("Local")]
70+
[InlineData("Test")]
71+
public void GenericOidcProviderIsNotRegisteredOutsideIntegrationTest(string environmentName)
72+
{
73+
using var provider = BuildProvider(environmentName);
74+
75+
var headerProvider = provider.GetService<IAuthorizationHeaderProvider>();
76+
77+
Assert.IsNotType<GenericOidcAuthorizationHeaderProvider>(headerProvider);
78+
}
79+
80+
[Theory]
81+
[InlineData("Development")]
82+
[InlineData("Staging")]
83+
[InlineData("Production")]
84+
public void EntraIssuerValidatorIsWiredOutsideIntegrationTest(string environmentName)
85+
{
86+
using var provider = BuildProvider(environmentName);
87+
88+
var options = provider
89+
.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>()
90+
.Get(JwtBearerDefaults.AuthenticationScheme);
91+
92+
// AddMicrosoftIdentityWebApi installs the Entra-aware issuer validator.
93+
// If this is ever null outside IntegrationTest, issuer validation has been
94+
// weakened for a real deployment.
95+
Assert.NotNull(options.TokenValidationParameters.IssuerValidator);
96+
}
97+
98+
[Fact]
99+
public void GenericOidcProviderIsRegisteredInIntegrationTest()
100+
{
101+
using var provider = BuildProvider(
102+
AuthenticationConfigurations.IntegrationTestEnvironment
103+
);
104+
105+
var headerProvider = provider.GetRequiredService<IAuthorizationHeaderProvider>();
106+
107+
Assert.IsType<GenericOidcAuthorizationHeaderProvider>(headerProvider);
108+
}
109+
110+
[Fact]
111+
public void IntegrationTestEnvironmentPointsTokenValidationAtTheMockIssuer()
112+
{
113+
using var provider = BuildProvider(
114+
AuthenticationConfigurations.IntegrationTestEnvironment
115+
);
116+
117+
var options = provider
118+
.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>()
119+
.Get(JwtBearerDefaults.AuthenticationScheme);
120+
121+
Assert.Equal("http://oauth-mock:8080", options.Authority);
122+
Assert.Equal("flotilla-test", options.Audience);
123+
Assert.False(options.RequireHttpsMetadata);
124+
125+
var parameters = options.TokenValidationParameters;
126+
Assert.True(parameters.ValidateIssuer);
127+
Assert.True(parameters.ValidateAudience);
128+
Assert.True(parameters.ValidateLifetime);
129+
// The Entra-specific validator must be gone, otherwise the mock's issuer
130+
// would be rejected and instance discovery would hit login.microsoftonline.com.
131+
Assert.Null(parameters.IssuerValidator);
132+
}
133+
134+
[Fact]
135+
public void SignalRQueryStringTokenHookIsAppliedInEveryEnvironment()
136+
{
137+
foreach (
138+
var environmentName in new[]
139+
{
140+
"Development",
141+
"Production",
142+
AuthenticationConfigurations.IntegrationTestEnvironment,
143+
}
144+
)
145+
{
146+
using var provider = BuildProvider(environmentName);
147+
148+
var options = provider
149+
.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>()
150+
.Get(JwtBearerDefaults.AuthenticationScheme);
151+
152+
Assert.NotNull(options.Events?.OnMessageReceived);
153+
}
154+
}
155+
}
156+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
using Api.Services;
2+
using Microsoft.AspNetCore.Authentication.JwtBearer;
3+
using Microsoft.Identity.Abstractions;
4+
using Microsoft.Identity.Web;
5+
6+
namespace Api.Configurations
7+
{
8+
public static class AuthenticationConfigurations
9+
{
10+
/// <summary>
11+
/// The environment in which the backend validates tokens against a generic
12+
/// OpenID Connect issuer instead of Microsoft Entra ID.
13+
///
14+
/// This exists solely so the armada integration tests can run against a local
15+
/// mock issuer, with no Entra app registrations and no client secrets, while
16+
/// still exercising authentication for real.
17+
///
18+
/// Gating on the environment name rather than on a configuration flag is
19+
/// deliberate: it keeps the generic-issuer path unreachable from Development,
20+
/// Staging and Production regardless of which environment variables are set.
21+
/// </summary>
22+
public const string IntegrationTestEnvironment = "IntegrationTest";
23+
24+
public static bool UsesGenericOidc(this IHostEnvironment environment) =>
25+
environment.IsEnvironment(IntegrationTestEnvironment);
26+
27+
/// <summary>
28+
/// Registers JWT bearer authentication, the MSAL token caches and the
29+
/// downstream API clients for ISAR, SARA and Pointilla.
30+
/// </summary>
31+
public static IServiceCollection ConfigureAuthentication(
32+
this IServiceCollection services,
33+
IConfiguration configuration,
34+
IHostEnvironment environment
35+
)
36+
{
37+
bool useRedis = configuration.GetSection("Redis").GetValue<bool>("UseRedis");
38+
if (useRedis)
39+
{
40+
services.ConfigureRedisCache(configuration);
41+
}
42+
43+
var authenticationBuilder = services
44+
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
45+
.AddMicrosoftIdentityWebApi(configuration.GetSection("AzureAd"))
46+
.EnableTokenAcquisitionToCallDownstreamApi();
47+
48+
if (useRedis)
49+
{
50+
authenticationBuilder.AddDistributedTokenCaches();
51+
}
52+
else
53+
{
54+
authenticationBuilder.AddInMemoryTokenCaches();
55+
}
56+
57+
authenticationBuilder
58+
.AddDownstreamApi(InspectionService.ServiceName, configuration.GetSection("SARA"))
59+
.AddDownstreamApi(IsarService.ServiceName, configuration.GetSection("Isar"))
60+
.AddDownstreamApi(
61+
PointillaService.ServiceName,
62+
configuration.GetSection("Pointilla")
63+
);
64+
65+
if (environment.UsesGenericOidc())
66+
{
67+
ConfigureGenericOidcOverrides(services, configuration);
68+
}
69+
70+
ConfigureSignalRQueryStringToken(services);
71+
72+
return services;
73+
}
74+
75+
/// <summary>
76+
/// Redirects both halves of authentication at the mock issuer.
77+
///
78+
/// Inbound: AddMicrosoftIdentityWebApi installs an AadIssuerValidator, which
79+
/// expects Entra-shaped issuers and performs instance discovery against
80+
/// login.microsoftonline.com. The validator is replaced with plain issuer
81+
/// validation against the mock's discovery document.
82+
///
83+
/// This is deliberately split across the two options phases:
84+
///
85+
/// Configure - the authority and RequireHttpsMetadata, because
86+
/// JwtBearerPostConfigureOptions throws
87+
/// "MetadataAddress or Authority must use HTTPS" for a plain
88+
/// HTTP authority, and it runs before any post-configuration
89+
/// we could register.
90+
/// PostConfigure - clearing the issuer validator, because
91+
/// Microsoft.Identity.Web installs it during
92+
/// post-configuration and the last registration wins.
93+
///
94+
/// Outbound: IDownstreamApi resolves its bearer tokens through
95+
/// IAuthorizationHeaderProvider, so replacing that single service redirects
96+
/// the ISAR, SARA and Pointilla calls without fighting MSAL's authority
97+
/// validation.
98+
/// </summary>
99+
private static void ConfigureGenericOidcOverrides(
100+
IServiceCollection services,
101+
IConfiguration configuration
102+
)
103+
{
104+
string authority =
105+
configuration["AzureAd:Authority"]
106+
?? throw new InvalidOperationException(
107+
$"AzureAd:Authority is required in the {IntegrationTestEnvironment} environment"
108+
);
109+
string audience =
110+
configuration["AzureAd:ClientId"]
111+
?? throw new InvalidOperationException(
112+
$"AzureAd:ClientId is required in the {IntegrationTestEnvironment} environment"
113+
);
114+
115+
services.Configure<JwtBearerOptions>(
116+
JwtBearerDefaults.AuthenticationScheme,
117+
options =>
118+
{
119+
options.Authority = authority;
120+
options.Audience = audience;
121+
// The mock issuer is plain HTTP on the test network.
122+
options.RequireHttpsMetadata = false;
123+
}
124+
);
125+
126+
services.PostConfigure<JwtBearerOptions>(
127+
JwtBearerDefaults.AuthenticationScheme,
128+
options =>
129+
{
130+
var parameters = options.TokenValidationParameters;
131+
parameters.ValidateIssuer = true;
132+
parameters.ValidateAudience = true;
133+
parameters.ValidateLifetime = true;
134+
parameters.ValidAudience = audience;
135+
parameters.ValidAudiences = [audience];
136+
// Drop the Entra-specific issuer validator; the issuer is taken from
137+
// the mock's discovery document instead.
138+
parameters.IssuerValidator = null;
139+
parameters.ValidIssuers = null;
140+
}
141+
);
142+
143+
services.AddSingleton<
144+
IAuthorizationHeaderProvider,
145+
GenericOidcAuthorizationHeaderProvider
146+
>();
147+
}
148+
149+
/// <summary>
150+
/// Browsers cannot set headers on WebSocket connections, so SignalR passes the
151+
/// access token in the query string instead.
152+
/// </summary>
153+
private static void ConfigureSignalRQueryStringToken(IServiceCollection services)
154+
{
155+
services.Configure<JwtBearerOptions>(
156+
JwtBearerDefaults.AuthenticationScheme,
157+
options =>
158+
{
159+
options.Events ??= new JwtBearerEvents();
160+
options.Events.OnMessageReceived = context =>
161+
{
162+
if (
163+
context.HttpContext.Request.Path.StartsWithSegments("/hub")
164+
&& context.Request.Query.TryGetValue("access_token", out var token)
165+
)
166+
{
167+
context.Token = token;
168+
}
169+
return Task.CompletedTask;
170+
};
171+
}
172+
);
173+
}
174+
}
175+
}

0 commit comments

Comments
 (0)