using System.IdentityModel.Tokens.Jwt; using FluentAssertions; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using SatelliteProvider.Api.Authentication; using SatelliteProvider.TestSupport; using AuthExtensions = SatelliteProvider.Api.Authentication.AuthenticationServiceCollectionExtensions; namespace SatelliteProvider.Tests.Authentication; public class AuthenticationServiceCollectionExtensionsTests : IDisposable { private const string ValidSecret = "test-secret-that-is-definitely-longer-than-32-bytes"; private const string ValidIssuer = "https://test-issuer.example/"; private const string ValidAudience = "satellite-provider-tests"; private readonly string? _originalSecret; private readonly string? _originalIssuer; private readonly string? _originalAudience; public AuthenticationServiceCollectionExtensionsTests() { _originalSecret = Environment.GetEnvironmentVariable(AuthExtensions.JwtSecretEnvVar); _originalIssuer = Environment.GetEnvironmentVariable(AuthExtensions.JwtIssuerEnvVar); _originalAudience = Environment.GetEnvironmentVariable(AuthExtensions.JwtAudienceEnvVar); Environment.SetEnvironmentVariable(AuthExtensions.JwtSecretEnvVar, null); Environment.SetEnvironmentVariable(AuthExtensions.JwtIssuerEnvVar, null); Environment.SetEnvironmentVariable(AuthExtensions.JwtAudienceEnvVar, null); } public void Dispose() { Environment.SetEnvironmentVariable(AuthExtensions.JwtSecretEnvVar, _originalSecret); Environment.SetEnvironmentVariable(AuthExtensions.JwtIssuerEnvVar, _originalIssuer); Environment.SetEnvironmentVariable(AuthExtensions.JwtAudienceEnvVar, _originalAudience); GC.SuppressFinalize(this); } [Fact] public void AddSatelliteJwt_RegistersJwtBearerScheme() { // Arrange var services = new ServiceCollection(); services.AddLogging(); var configuration = BuildValidConfiguration(); // Act services.AddSatelliteJwt(configuration); var provider = services.BuildServiceProvider(); var schemeProvider = provider.GetRequiredService(); var scheme = schemeProvider.GetSchemeAsync(JwtBearerDefaults.AuthenticationScheme).GetAwaiter().GetResult(); // Assert scheme.Should().NotBeNull("JwtBearer scheme should be registered"); scheme!.HandlerType.Should().Be(typeof(JwtBearerHandler)); } [Fact] public void AddSatelliteJwt_ConfiguresTokenValidationParameters_AsPerContract() { // Arrange var services = new ServiceCollection(); services.AddLogging(); var configuration = BuildValidConfiguration(); // Act services.AddSatelliteJwt(configuration); var provider = services.BuildServiceProvider(); var options = provider.GetRequiredService>().Get(JwtBearerDefaults.AuthenticationScheme); // Assert var p = options.TokenValidationParameters; p.ValidateIssuerSigningKey.Should().BeTrue(); p.ValidateLifetime.Should().BeTrue(); p.ValidateIssuer.Should().BeTrue("AZ-494 flipped issuer validation on"); p.ValidIssuer.Should().Be(ValidIssuer); p.ValidateAudience.Should().BeTrue("AZ-494 flipped audience validation on"); p.ValidAudience.Should().Be(ValidAudience); p.RequireSignedTokens.Should().BeTrue(); p.RequireExpirationTime.Should().BeTrue(); p.ClockSkew.Should().Be(TimeSpan.FromSeconds(30)); p.IssuerSigningKey.Should().BeOfType(); } [Fact] public void AddSatelliteJwt_ThrowsOnMissingSecret() { // Arrange var services = new ServiceCollection(); var configuration = BuildConfiguration( ("Jwt:Issuer", ValidIssuer), ("Jwt:Audience", ValidAudience)); // Act var act = () => services.AddSatelliteJwt(configuration); // Assert act.Should().Throw() .WithMessage("*JWT secret is not configured*"); } [Fact] public void AddSatelliteJwt_ThrowsOnEmptySecret() { // Arrange var services = new ServiceCollection(); var configuration = BuildConfiguration( ("Jwt:Secret", ""), ("Jwt:Issuer", ValidIssuer), ("Jwt:Audience", ValidAudience)); // Act var act = () => services.AddSatelliteJwt(configuration); // Assert act.Should().Throw() .WithMessage("*JWT secret is not configured*"); } [Fact] public void AddSatelliteJwt_ThrowsOnShortSecret() { // Arrange var services = new ServiceCollection(); var configuration = BuildConfiguration( ("Jwt:Secret", "too-short-secret"), ("Jwt:Issuer", ValidIssuer), ("Jwt:Audience", ValidAudience)); // Act var act = () => services.AddSatelliteJwt(configuration); // Assert act.Should().Throw() .WithMessage("*at least 32 bytes*"); } [Fact] public void AddSatelliteJwt_ThrowsOnMissingIssuer() { // Arrange — AZ-494 AC-4: fail-fast on missing issuer. var services = new ServiceCollection(); var configuration = BuildConfiguration( ("Jwt:Secret", ValidSecret), ("Jwt:Audience", ValidAudience)); // Act var act = () => services.AddSatelliteJwt(configuration); // Assert act.Should().Throw() .WithMessage("*JWT issuer is not configured*") .Where(ex => ex.Message.Contains(AuthExtensions.JwtIssuerEnvVar)); } [Fact] public void AddSatelliteJwt_ThrowsOnEmptyIssuer() { // Arrange var services = new ServiceCollection(); var configuration = BuildConfiguration( ("Jwt:Secret", ValidSecret), ("Jwt:Issuer", " "), ("Jwt:Audience", ValidAudience)); // Act var act = () => services.AddSatelliteJwt(configuration); // Assert act.Should().Throw() .WithMessage("*JWT issuer is not configured*"); } [Fact] public void AddSatelliteJwt_ThrowsOnMissingAudience() { // Arrange — AZ-494 AC-4: fail-fast on missing audience. var services = new ServiceCollection(); var configuration = BuildConfiguration( ("Jwt:Secret", ValidSecret), ("Jwt:Issuer", ValidIssuer)); // Act var act = () => services.AddSatelliteJwt(configuration); // Assert act.Should().Throw() .WithMessage("*JWT audience is not configured*") .Where(ex => ex.Message.Contains(AuthExtensions.JwtAudienceEnvVar)); } [Fact] public void AddSatelliteJwt_ThrowsOnEmptyAudience() { // Arrange var services = new ServiceCollection(); var configuration = BuildConfiguration( ("Jwt:Secret", ValidSecret), ("Jwt:Issuer", ValidIssuer), ("Jwt:Audience", "")); // Act var act = () => services.AddSatelliteJwt(configuration); // Assert act.Should().Throw() .WithMessage("*JWT audience is not configured*"); } [Fact] public void AddSatelliteJwt_PrefersEnvironmentVariableOverConfiguration() { // Arrange const string envSecret = "env-secret-also-longer-than-thirty-two-bytes-for-hmac"; const string envIssuer = "https://env-issuer.example/"; const string envAudience = "env-audience"; Environment.SetEnvironmentVariable(AuthExtensions.JwtSecretEnvVar, envSecret); Environment.SetEnvironmentVariable(AuthExtensions.JwtIssuerEnvVar, envIssuer); Environment.SetEnvironmentVariable(AuthExtensions.JwtAudienceEnvVar, envAudience); var services = new ServiceCollection(); services.AddLogging(); var configuration = BuildConfiguration( ("Jwt:Secret", "config-secret-also-32-bytes-long-aaaaaaaaaa"), ("Jwt:Issuer", "https://config-issuer.example/"), ("Jwt:Audience", "config-audience")); // Act services.AddSatelliteJwt(configuration); var provider = services.BuildServiceProvider(); var options = provider.GetRequiredService>().Get(JwtBearerDefaults.AuthenticationScheme); var token = JwtTokenFactory.Create(envSecret, issuer: envIssuer, audience: envAudience); var handler = new JwtSecurityTokenHandler(); var act = () => handler.ValidateToken(token, options.TokenValidationParameters, out _); // Assert act.Should().NotThrow("token signed with env secret + minted with env iss/aud must validate when env precedence applies"); options.TokenValidationParameters.ValidIssuer.Should().Be(envIssuer); options.TokenValidationParameters.ValidAudience.Should().Be(envAudience); } private static IConfiguration BuildValidConfiguration() => BuildConfiguration( ("Jwt:Secret", ValidSecret), ("Jwt:Issuer", ValidIssuer), ("Jwt:Audience", ValidAudience)); private static IConfiguration BuildConfiguration(params (string Key, string Value)[] pairs) { var builder = new ConfigurationBuilder(); if (pairs.Length > 0) { builder.AddInMemoryCollection(pairs.Select(p => new KeyValuePair(p.Key, p.Value))); } return builder.Build(); } }