-
Notifications
You must be signed in to change notification settings - Fork 45
Allow openid for integration tests #2856
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| } | ||
| } | ||
| } | ||
| } |
| 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) => | ||
| 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
I recommend keeping HTTPS metadata required by default and introducing a separate explicit setting such as Scores
|
||
| } | ||
| ); | ||
|
|
||
| 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; | ||
| }; | ||
| } | ||
| ); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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
Since it is an open source repo it might be useful for others to use this in their dev/staging/prod