diff --git a/src/DfE.CoreLibs.Security/Authorization/ApiOboTokenService.cs b/src/DfE.CoreLibs.Security/Authorization/ApiOboTokenService.cs index 8bc9cfc..36b80ca 100644 --- a/src/DfE.CoreLibs.Security/Authorization/ApiOboTokenService.cs +++ b/src/DfE.CoreLibs.Security/Authorization/ApiOboTokenService.cs @@ -40,7 +40,9 @@ public async Task GetApiOboTokenAsync(string? authenticationScheme = nul // Retrieve user roles var userRoles = user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray(); +#pragma warning disable S2589 if (userRoles == null || !userRoles.Any()) +#pragma warning restore S2589 { throw new UnauthorizedAccessException("User does not have any roles assigned."); } diff --git a/src/DfE.CoreLibs.Security/Authorization/Events/RejectSessionCookieWhenAccountNotInCacheEvents.cs b/src/DfE.CoreLibs.Security/Authorization/Events/RejectSessionCookieWhenAccountNotInCacheEvents.cs index 0b4f7d0..c1116f6 100644 --- a/src/DfE.CoreLibs.Security/Authorization/Events/RejectSessionCookieWhenAccountNotInCacheEvents.cs +++ b/src/DfE.CoreLibs.Security/Authorization/Events/RejectSessionCookieWhenAccountNotInCacheEvents.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Authentication.Cookies; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Identity.Client; @@ -10,6 +11,7 @@ namespace DfE.CoreLibs.Security.Authorization.Events /// Custom cookie authentication events that reject the session cookie when the user's account is not found in the token cache. /// This ensures that the user is signed out if their authentication session is no longer valid. /// + [ExcludeFromCodeCoverage] public class RejectSessionCookieWhenAccountNotInCacheEvents : CookieAuthenticationEvents { /// @@ -51,13 +53,6 @@ await tokenAcquisition.GetAccessTokenForUserAsync( // Reject the principal to sign out the user context.RejectPrincipal(); } - catch (Exception ex) - { - var logger = context.HttpContext.RequestServices.GetRequiredService>(); - logger.LogError(ex, "An unexpected error occurred in ValidatePrincipal."); - - throw; - } } /// diff --git a/src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj b/src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj index 1da3d1c..b70d940 100644 --- a/src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj +++ b/src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj @@ -16,9 +16,9 @@ - - - + + + diff --git a/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/ApiOboTokenServiceTests.cs b/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/ApiOboTokenServiceTests.cs new file mode 100644 index 0000000..858a315 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/ApiOboTokenServiceTests.cs @@ -0,0 +1,423 @@ +using DfE.CoreLibs.Security.Authorization; +using DfE.CoreLibs.Security.Configurations; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Web; +using Moq; +using NSubstitute; +using System.Security.Claims; +using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; + +namespace DfE.CoreLibs.Security.Tests.AuthorizationTests +{ + public class ApiOboTokenServiceTests + { + private readonly ITokenAcquisition _tokenAcquisitionMock; + private readonly IHttpContextAccessor _httpContextAccessorMock; + private readonly IConfiguration _configuration; + private readonly IMemoryCache _memoryCacheMock; + private readonly IOptions _tokenSettingsMock; + private readonly ClaimsPrincipal _testUser; + + public ApiOboTokenServiceTests() + { + _tokenAcquisitionMock = Substitute.For(); + _httpContextAccessorMock = Substitute.For(); + _memoryCacheMock = Substitute.For(); + + _configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) // Ensure the path to the test directory + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .Build(); + + // Configure TokenSettings from appsettings + var tokenSettings = _configuration.GetSection("Authorization:TokenSettings").Get(); + _tokenSettingsMock = Substitute.For>(); + _tokenSettingsMock.Value.Returns(tokenSettings); + + // Setup test user + _testUser = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "test-user-id"), + new Claim(ClaimTypes.Role, "Admin"), + }, "TestAuth")); + } + + [Fact] + public async Task GetApiOboTokenAsync_UserNotAuthenticated_ThrowsUnauthorizedAccessException() + { + // Arrange + _httpContextAccessorMock.HttpContext.Returns((HttpContext)null!); + + var service = new ApiOboTokenService( + _tokenAcquisitionMock, + _httpContextAccessorMock, + _configuration, + _memoryCacheMock, + _tokenSettingsMock); + + // Act & Assert + await Assert.ThrowsAsync(() => + service.GetApiOboTokenAsync()); + } + + [Fact] + public async Task GetApiOboTokenAsync_UserHasNoRoles_ThrowsUnauthorizedAccessException() + { + // Arrange + _httpContextAccessorMock.HttpContext.Returns(new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "test-user-id") + }, "TestAuth")) + }); + + var service = new ApiOboTokenService( + _tokenAcquisitionMock, + _httpContextAccessorMock, + _configuration, + _memoryCacheMock, + _tokenSettingsMock); + + // Act & Assert + await Assert.ThrowsAsync(() => + service.GetApiOboTokenAsync()); + } + + [Fact] + public async Task GetApiOboTokenAsync_ApiClientIdMissing_ThrowsInvalidOperationException() + { + // Arrange + IConfiguration _configurationMock = Substitute.For(); + + _httpContextAccessorMock.HttpContext.Returns(new DefaultHttpContext { User = _testUser }); + _configurationMock["Authorization:ApiSettings:ApiClientId"].Returns((string)null!); + + var service = new ApiOboTokenService( + _tokenAcquisitionMock, + _httpContextAccessorMock, + _configurationMock, + _memoryCacheMock, + _tokenSettingsMock); + + // Act & Assert + await Assert.ThrowsAsync(() => + service.GetApiOboTokenAsync()); + } + + [Fact] + public async Task GetApiOboTokenAsync_TokenCached_ReturnsCachedToken() + { + _httpContextAccessorMock.HttpContext.Returns(new DefaultHttpContext { User = _testUser }); + + var formattedScopes = new[] { "api://test-api-client-id/Scope1" }; + + var cacheKey = "ApiOboToken_test-user-id,Scope1"; + + // Simulate retrieving a cached token + _memoryCacheMock.TryGetValue(cacheKey, out Arg.Any()!) + .Returns(call => + { + call[1] = "cached-token"; + return true; + }); + + _tokenAcquisitionMock + .GetAccessTokenForUserAsync( + Arg.Is>(scopes => scopes.SequenceEqual(formattedScopes)), + Arg.Is(scheme => scheme == null), + Arg.Is(_ => true), + Arg.Is(_ => true), + Arg.Is(user => user == _testUser), + Arg.Is(_ => true)) + .Returns("mock-token"); + + var service = new ApiOboTokenService( + _tokenAcquisitionMock, + _httpContextAccessorMock, + _configuration, + _memoryCacheMock, + _tokenSettingsMock); + + // Act + var token = await service.GetApiOboTokenAsync(); + + // Assert + Assert.Equal("mock-token", token); + } + + + [Fact] + public async Task GetApiOboTokenAsync_AcquiresNewToken_AndCachesIt_WithInMemoryConfig() + { + // Arrange + var memoryCacheMock = new Mock(); + var cacheKey = "ApiOboToken_test-user-id_Scope1"; + + // Setup TryGetValue to simulate a cache miss + object cachedValue = null!; + memoryCacheMock + .Setup(mc => mc.TryGetValue(It.Is(key => key == cacheKey), out cachedValue!)) + .Returns(false); + + // Mock CreateEntry for caching behavior + var cacheEntryMock = new Mock(); + memoryCacheMock + .Setup(mc => mc.CreateEntry(It.Is(key => key == cacheKey))) + .Returns(cacheEntryMock.Object); + + var tokenAcquisitionMock = new Mock(); + tokenAcquisitionMock + .Setup(ta => ta.GetAccessTokenForUserAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync("mock-token"); + + var httpContextAccessorMock = new Mock(); + httpContextAccessorMock.Setup(hca => hca.HttpContext) + .Returns(new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "test-user-id"), + new Claim(ClaimTypes.Role, "Admin") + })) + }); + + // Use In-Memory Configuration + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Authorization:ApiSettings:ApiClientId", "test-api-client-id" }, + { "Authorization:ScopeMappings:Admin:0", "Scope1" } + }!) + .Build(); + + var tokenSettingsMock = new Mock>(); + tokenSettingsMock + .Setup(ts => ts.Value) + .Returns(new TokenSettings + { + TokenLifetimeMinutes = 10, + BufferInSeconds = 60 + }); + + var service = new ApiOboTokenService( + tokenAcquisitionMock.Object, + httpContextAccessorMock.Object, + configuration, + memoryCacheMock.Object, + tokenSettingsMock.Object); + + // Act + var token = await service.GetApiOboTokenAsync(); + + // Assert + Assert.Equal("mock-token", token); + + // Verify CreateEntry was called with correct arguments + memoryCacheMock.Verify(mc => mc.CreateEntry(It.Is(key => key == cacheKey)), Times.Once); + + // Verify the cache entry value + cacheEntryMock.VerifySet(ce => ce.Value = "mock-token"); + } + + [Fact] + public async Task GetApiOboTokenAsync_ThrowsException_WhenUserIdIsMissing() + { + // Arrange + _httpContextAccessorMock.HttpContext + .Returns(new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity()) + }); + + var service = new ApiOboTokenService( + _tokenAcquisitionMock, + _httpContextAccessorMock, + _configuration, + _memoryCacheMock, + _tokenSettingsMock); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => service.GetApiOboTokenAsync()); + Assert.Equal("User ID is missing.", exception.Message); + } + + [Fact] + public async Task GetApiOboTokenAsync_ThrowsException_WhenScopeMappingsMissing() + { + // Arrange + var configurationMock = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Authorization:ApiSettings:ApiClientId", "test-api-client-id" } + }!) + .Build(); + + var service = new ApiOboTokenService( + _tokenAcquisitionMock, + _httpContextAccessorMock, + configurationMock, + _memoryCacheMock, + _tokenSettingsMock); + + _httpContextAccessorMock.HttpContext.Returns(new DefaultHttpContext + { + User = _testUser + }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => service.GetApiOboTokenAsync()); + Assert.Equal("ScopeMappings section is missing from configuration.", exception.Message); + } + + [Fact] + public async Task GetApiOboTokenAsync_UsesDefaultScope_WhenNoApiScopes() + { + // Arrange + var configurationMock = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Authorization:ApiSettings:ApiClientId", "test-api-client-id" }, + { "Authorization:ApiSettings:DefaultScope", "default-scope" }, + { "Authorization:ScopeMappings:Admin:0", "Scope1" } + }!) + .Build(); + + var service = new ApiOboTokenService( + _tokenAcquisitionMock, + _httpContextAccessorMock, + configurationMock, + _memoryCacheMock, + _tokenSettingsMock); + + _httpContextAccessorMock.HttpContext.Returns(new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "test-user-id"), + new Claim(ClaimTypes.Role, "UnknownRole") // Role not mapped + })) + }); + + var defaultScopes = new[] { "api://test-api-client-id/default-scope" }; + + _tokenAcquisitionMock + .GetAccessTokenForUserAsync( + Arg.Is>(scopes => scopes.SequenceEqual(defaultScopes)), + Arg.Is(scheme => scheme == null), + Arg.Is(_ => true), + Arg.Is(_ => true), + Arg.Is(user => true), + Arg.Is(_ => true)) + .Returns("mock-token"); + + // Act + var token = await service.GetApiOboTokenAsync(); + + // Assert + Assert.Equal("mock-token", token); + await _tokenAcquisitionMock.Received(1).GetAccessTokenForUserAsync( + Arg.Is>(scopes => scopes.SequenceEqual(defaultScopes)), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + + [Fact] + public async Task GetApiOboTokenAsync_AcquiresNewToken_AndCachesIt() + { + // Arrange + _httpContextAccessorMock.HttpContext.Returns(new DefaultHttpContext { User = _testUser }); + + var formattedScopes = new[] { "api://test-api-client-id/Scope1" }; + + // Simulate no cached token + _memoryCacheMock.TryGetValue(Arg.Any(), out Arg.Any()!) + .Returns(callInfo => + { + callInfo[1] = null; + return false; + }); + + // Simulate token acquisition + _tokenAcquisitionMock + .GetAccessTokenForUserAsync( + Arg.Is>(scopes => scopes.SequenceEqual(formattedScopes)), + Arg.Is(scheme => scheme == null), + Arg.Is(_ => true), + Arg.Is(_ => true), + Arg.Is(user => user == _testUser), + Arg.Is(_ => true)) + .Returns("mock-token"); + + + var service = new ApiOboTokenService( + _tokenAcquisitionMock, + _httpContextAccessorMock, + _configuration, + _memoryCacheMock, + _tokenSettingsMock); + + // Act + var token = await service.GetApiOboTokenAsync(); + + // Assert the token is returned correctly + Assert.Equal("mock-token", token); + } + + + + [Fact] + public async Task GetApiOboTokenAsync_ReturnsCachedToken_WhenTokenExistsInCache() + { + // Arrange + _httpContextAccessorMock.HttpContext.Returns(new DefaultHttpContext + { + User = _testUser + }); + + var cacheKey = "ApiOboToken_test-user-id_Scope1"; + + _memoryCacheMock.TryGetValue(Arg.Any(), out Arg.Any()!) + .Returns(callInfo => + { + // Access the cache key + var key = callInfo.Arg(); + + // Simulate returning a cached value for a specific key + if (key == cacheKey) + { + callInfo[1] = "cached-token"; + return true; + } + callInfo[1] = null; + return false; + }); + + var service = new ApiOboTokenService( + _tokenAcquisitionMock, + _httpContextAccessorMock, + _configuration, + _memoryCacheMock, + _tokenSettingsMock); + + // Act + var token = await service.GetApiOboTokenAsync(); + + // Assert + Assert.Equal("cached-token", token); + } + } +} diff --git a/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/AuthorizationExtensionsTests.cs b/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/AuthorizationExtensionsTests.cs index 33feafc..12b4fb5 100644 --- a/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/AuthorizationExtensionsTests.cs +++ b/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/AuthorizationExtensionsTests.cs @@ -1,58 +1,40 @@ -using DfE.CoreLibs.Security.Authorization; +using System.Diagnostics.CodeAnalysis; +using DfE.CoreLibs.Security.Authorization; +using DfE.CoreLibs.Security.Interfaces; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using System.Security.Claims; namespace DfE.CoreLibs.Security.Tests.AuthorizationTests { - /// - /// Custom authorization requirement for testing purposes. - /// - public class CustomRequirement : IAuthorizationRequirement { } - - /// - /// Unit tests for the AddApplicationAuthorization extension method. - /// public class AuthorizationExtensionsTests { private readonly IConfiguration _configuration; private readonly ServiceProvider _serviceProvider; - /// - /// Initializes a new instance of the class. - /// Sets up the configuration and service provider for testing. - /// public AuthorizationExtensionsTests() { - // Build configuration from the test project's appsettings.json _configuration = new ConfigurationBuilder() .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .Build(); - // Set up the service collection var services = new ServiceCollection(); - // Register logging services services.AddLogging(); - // Add application authorization services.AddApplicationAuthorization(_configuration); - // Add Authorization services services.AddAuthorization(); - // Register a dummy IClaimsTransformation if necessary services.AddTransient(); - // Register the AuthorizationPolicyProvider services.AddSingleton(); - // Register the IAuthorizationService services.AddSingleton(); - // Build the service provider _serviceProvider = services.BuildServiceProvider(); } @@ -65,9 +47,6 @@ public async Task AddApplicationAuthorization_ShouldLoadPoliciesFromConfiguratio // Arrange var authorizationService = _serviceProvider.GetRequiredService(); - // Define a test user with required scopes and roles - // Note: As per your logic, a user should have either scopes or roles, not both. - // This test ensures that policies are correctly loaded, not the logic itself. var userWithScopes = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] { new Claim("http://schemas.microsoft.com/identity/claims/scope", "SCOPE.API.Read SCOPE.API.Write") @@ -80,28 +59,22 @@ public async Task AddApplicationAuthorization_ShouldLoadPoliciesFromConfiguratio }, "TestAuthentication")); // Act & Assert - - // Test "CanRead" policy with user having scopes var canReadResultScopes = await authorizationService.AuthorizeAsync(userWithScopes, null, "CanRead"); Assert.True(canReadResultScopes.Succeeded, "User with scopes should be authorized for CanRead policy."); - // Test "CanRead" policy with user having roles var canReadResultRoles = await authorizationService.AuthorizeAsync(userWithRoles, null, "CanRead"); Assert.True(canReadResultRoles.Succeeded, "User with roles should be authorized for CanRead policy."); - // Test "CanReadWrite" policy with user having scopes var canReadWriteResultScopes = await authorizationService.AuthorizeAsync(userWithScopes, null, "CanReadWrite"); Assert.True(canReadWriteResultScopes.Succeeded, "User with scopes should be authorized for CanReadWrite policy."); - // Test "CanReadWrite" policy with user having roles var canReadWriteResultRoles = await authorizationService.AuthorizeAsync(userWithRoles, null, "CanReadWrite"); Assert.True(canReadWriteResultRoles.Succeeded, "User with roles should be authorized for CanReadWrite policy."); - // Test "CanReadWritePlus" policy with user having scopes and required claims var userWithScopesAndClaims = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] { new Claim("http://schemas.microsoft.com/identity/claims/scope", "SCOPE.API.Read SCOPE.API.Write"), @@ -113,7 +86,6 @@ public async Task AddApplicationAuthorization_ShouldLoadPoliciesFromConfiguratio Assert.True(canReadWritePlusResultScopes.Succeeded, "User with scopes and required claims should be authorized for CanReadWritePlus policy."); - // Test "CanReadWritePlus" policy with user having roles and required claims var userWithRolesAndClaims = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Role, "API.Read"), @@ -127,16 +99,12 @@ public async Task AddApplicationAuthorization_ShouldLoadPoliciesFromConfiguratio "User with roles and required claims should be authorized for CanReadWritePlus policy."); } - /// - /// Tests that authorization succeeds when the user has all required scopes, even if some roles are missing. - /// [Fact] public async Task AddApplicationAuthorization_ShouldAuthorizeWhenUserHasAllScopesEvenIfMissingSomeRoles() { // Arrange var authorizationService = _serviceProvider.GetRequiredService(); - // Define a test user with all required scopes but missing some roles var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] { new Claim("http://schemas.microsoft.com/identity/claims/scope", "SCOPE.API.Read SCOPE.API.Write") @@ -151,16 +119,12 @@ public async Task AddApplicationAuthorization_ShouldAuthorizeWhenUserHasAllScope "User should be authorized for CanReadWrite policy because they have all required scopes."); } - /// - /// Tests that authorization succeeds when the user has all required roles, even if some scopes are missing. - /// [Fact] public async Task AddApplicationAuthorization_ShouldAuthorizeWhenUserHasAllRolesEvenIfMissingSomeScopes() { // Arrange var authorizationService = _serviceProvider.GetRequiredService(); - // Define a test user with all required roles but missing some scopes var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Role, "API.Read"), @@ -176,16 +140,12 @@ public async Task AddApplicationAuthorization_ShouldAuthorizeWhenUserHasAllRoles "User should be authorized for CanReadWrite policy because they have all required roles."); } - /// - /// Tests that authorization fails when the user lacks both required scopes and roles. - /// [Fact] public async Task AddApplicationAuthorization_ShouldFailWhenUserLacksRequiredScopesAndRoles() { // Arrange var authorizationService = _serviceProvider.GetRequiredService(); - // Define a test user missing all required scopes and roles var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] { // No scope claims @@ -200,20 +160,16 @@ public async Task AddApplicationAuthorization_ShouldFailWhenUserLacksRequiredSco "User should not be authorized for CanReadWrite policy when missing required scopes and roles."); } - /// - /// Tests that authorization fails when the user lacks required scopes. - /// [Fact] public async Task AddApplicationAuthorization_ShouldFailWhenUserLacksRequiredScopes() { // Arrange var authorizationService = _serviceProvider.GetRequiredService(); - // Define a test user with some scopes but missing one required scope var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] { new Claim("http://schemas.microsoft.com/identity/claims/scope", - "SCOPE.API.Read") // Missing "SCOPE.API.Write" + "SCOPE.API.Read") // No role claims }, "TestAuthentication")); @@ -225,21 +181,16 @@ public async Task AddApplicationAuthorization_ShouldFailWhenUserLacksRequiredSco "User should not be authorized for CanReadWrite policy when missing required scopes."); } - /// - /// Tests that authorization fails when the user lacks required roles. - /// [Fact] public async Task AddApplicationAuthorization_ShouldFailWhenUserLacksRequiredRoles() { // Arrange var authorizationService = _serviceProvider.GetRequiredService(); - // Define a test user with some roles but missing one required role - var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] - { + var user = new ClaimsPrincipal(new ClaimsIdentity([ new Claim(ClaimTypes.Role, "API.Read") // Missing "API.Write" // No scope claims - }, "TestAuthentication")); + ], "TestAuthentication")); // Act var canReadWriteResult = await authorizationService.AuthorizeAsync(user, null, "CanReadWrite"); @@ -248,11 +199,177 @@ public async Task AddApplicationAuthorization_ShouldFailWhenUserLacksRequiredRol Assert.False(canReadWriteResult.Succeeded, "User should not be authorized for CanReadWrite policy when missing required roles."); } + + [Fact] + public void AddCustomClaimProvider_ShouldRegisterClaimProvider() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddCustomClaimProvider(); + var provider = services.BuildServiceProvider(); + var registeredProvider = provider.GetService(); + + // Assert + Assert.NotNull(registeredProvider); + Assert.IsType(registeredProvider); + } + + [Fact] + public async Task AddApplicationAuthorization_ShouldApplyPolicyCustomizations() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Authorization:Policies:0:Name", "CustomPolicy" }, + }!) + .Build(); + + var customizations = new Dictionary> + { + { + "CustomPolicy", builder => + { + builder.RequireClaim("CustomClaim", "CustomValue"); + } + } + }; + + // Act + services.AddApplicationAuthorization(configuration, customizations); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService(); + + // Assert + var policy = await options.GetPolicyAsync("CustomPolicy"); + Assert.NotNull(policy); + Assert.Contains(policy.Requirements, r => r is ClaimsAuthorizationRequirement claimReq && + claimReq.ClaimType == "CustomClaim" && + claimReq.AllowedValues!.Contains("CustomValue")); + } + + [Fact] + public async Task AddApplicationAuthorization_ShouldAuthorizeWhenPolicyOperatorIsAnd() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Authorization:Policies:0:Name", "AndPolicy" }, + { "Authorization:Policies:0:Operator", "AND" }, + { "Authorization:Policies:0:Scopes:0", "Scope1" }, + }!) + .Build(); + + services.AddApplicationAuthorization(configuration); + var provider = services.BuildServiceProvider(); + var authorizationService = provider.GetRequiredService(); + + // User with both scope and role + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("http://schemas.microsoft.com/identity/claims/scope", "Scope1"), + }, "TestAuthentication")); + + // Act + var result = await authorizationService.AuthorizeAsync(user, null, "AndPolicy"); + + // Assert + Assert.True(result.Succeeded); + } + + [Fact] + public async Task AddApplicationAuthorization_ShouldAuthorizeWhenPolicyOperatorIsOr() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Authorization:Policies:0:Name", "OrPolicy" }, + { "Authorization:Policies:0:Operator", "OR" }, + { "Authorization:Policies:0:Roles:0", "Role1" }, + { "Authorization:Policies:0:Roles:1", "Role2" } + + }!) + .Build(); + + services.AddApplicationAuthorization(configuration); + var provider = services.BuildServiceProvider(); + var authorizationService = provider.GetRequiredService(); + + // User with only scope + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.Role, "Role2") + }, "TestAuthentication")); + + // Act + var result = await authorizationService.AuthorizeAsync(user, null, "OrPolicy"); + + // Assert + Assert.True(result.Succeeded); + } + + + [Fact] + public async Task AddApplicationAuthorization_ShouldWorkWithoutPolicyCustomizations() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Authorization:Policies:0:Name", "PolicyWithoutCustomizations" } + }!) + .Build(); + + // Act + services.AddApplicationAuthorization(configuration, policyCustomizations: null); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService(); + + // Assert + var policy = await options.GetPolicyAsync("PolicyWithoutCustomizations"); + Assert.NotNull(policy); + } + + [Fact] + public void AddApplicationAuthorization_ShouldHandleNullPolicies() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()!) + .Build(); // No policies in config + + // Act + services.AddApplicationAuthorization(configuration); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService(); + + // Assert + Assert.ThrowsAsync(async () => await options.GetPolicyAsync("NonExistentPolicy")); + } + } /// /// A dummy implementation of IClaimsTransformation for testing purposes. /// + [ExcludeFromCodeCoverage] public class DummyClaimsTransformation : IClaimsTransformation { public Task TransformAsync(ClaimsPrincipal principal) @@ -261,4 +378,16 @@ public Task TransformAsync(ClaimsPrincipal principal) return Task.FromResult(principal); } } + + /// + /// Dummy implementation of ICustomClaimProvider for testing purposes. + /// + [ExcludeFromCodeCoverage] + public class DummyCustomClaimProvider : ICustomClaimProvider + { + public Task> GetClaimsAsync(ClaimsPrincipal principal) + { + return Task.FromResult>(new[] { new Claim("DummyClaim", "DummyValue") }); + } + } } diff --git a/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/ServiceCollectionExtensionsTests.cs b/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..d4004f9 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,123 @@ +using System.Text; +using DfE.CoreLibs.Security.Authorization; +using DfE.CoreLibs.Security.Configurations; +using DfE.CoreLibs.Security.Interfaces; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Web; +using Microsoft.IdentityModel.Tokens; +using Moq; +using NSubstitute; + +namespace DfE.CoreLibs.Security.Tests.AuthorizationTests +{ + public class ServiceCollectionExtensionsTests + { + private readonly IConfiguration _configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .Build(); + private readonly IServiceCollection _services = new ServiceCollection(); + + [Fact] + public void AddUserTokenService_ShouldRegisterUserTokenService() + { + // Act + _services.AddMemoryCache(); + _services.AddLogging(); + _services.AddUserTokenService(_configuration); + var provider = _services.BuildServiceProvider(); + + // Assert + var tokenSettings = provider.GetService>(); + var userTokenService = provider.GetService(); + + Assert.NotNull(tokenSettings); + Assert.NotNull(userTokenService); + Assert.IsType(userTokenService); + } + + [Fact] + public void AddApiOboTokenService_ShouldRegisterApiOboTokenService() + { + // Act + _services.AddMemoryCache(); + _services.AddLogging(); + + _services.AddSingleton(_configuration); + + var tokenAcquisitionMock = Substitute.For(); + _services.AddSingleton(tokenAcquisitionMock); + + _services.AddApiOboTokenService(_configuration); + var provider = _services.BuildServiceProvider(); + + // Assert + var tokenSettings = provider.GetService>(); + var apiOboTokenService = provider.GetService(); + + Assert.NotNull(tokenSettings); + Assert.NotNull(apiOboTokenService); + Assert.IsType(apiOboTokenService); + } + + [Fact] + public void AddCustomJwtAuthentication_ShouldConfigureJwtBearerAuthentication() + { + // Arrange + var authenticationScheme = "CustomJwt"; + var jwtBearerEvents = new Mock().Object; + + _services.AddAuthentication() + .AddJwtBearer(authenticationScheme, options => + { + options.Events = jwtBearerEvents; + }); + + _services.AddLogging(); + _services.AddSingleton(_configuration); + + // Act + _services.AddCustomJwtAuthentication(_configuration, authenticationScheme, new AuthenticationBuilder(_services), jwtBearerEvents); + var provider = _services.BuildServiceProvider(); + + // Assert + var tokenSettings = _configuration.GetSection("Authorization:TokenSettings").Get(); + var symmetricKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(tokenSettings?.SecretKey!)); + + var jwtOptionsMonitor = provider.GetService>(); + Assert.NotNull(jwtOptionsMonitor); + + var jwtOptions = jwtOptionsMonitor.Get(authenticationScheme); + + Assert.Equal(tokenSettings?.Issuer, jwtOptions.TokenValidationParameters.ValidIssuer); + Assert.Equal(tokenSettings?.Audience, jwtOptions.TokenValidationParameters.ValidAudience); + Assert.Equal( + symmetricKey.Key, + ((SymmetricSecurityKey)jwtOptions.TokenValidationParameters.IssuerSigningKey).Key + ); + } + + + [Fact] + public void AddCustomJwtAuthentication_ShouldThrowException_WhenTokenSettingsMissing() + { + // Arrange + _services.AddMemoryCache(); + _services.AddLogging(); + + var invalidConfiguration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()!) + .Build(); + + var authenticationBuilder = new AuthenticationBuilder(_services); + var authenticationScheme = "CustomJwt"; + + // Act & Assert + Assert.Throws(() => + _services.AddCustomJwtAuthentication(invalidConfiguration, authenticationScheme, authenticationBuilder)); + } + } +} diff --git a/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/UserTokenServiceTests.cs b/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/UserTokenServiceTests.cs new file mode 100644 index 0000000..5bb9d69 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/UserTokenServiceTests.cs @@ -0,0 +1,145 @@ +using DfE.CoreLibs.Security.Authorization; +using DfE.CoreLibs.Security.Configurations; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace DfE.CoreLibs.Security.Tests.AuthorizationTests +{ + public class UserTokenServiceTests + { + private readonly IMemoryCache _memoryCacheMock; + private readonly ILogger _loggerMock; + private readonly IOptions _tokenSettingsMock; + private readonly TokenSettings? _tokenSettings; + private readonly ClaimsPrincipal _testUser; + + public UserTokenServiceTests() + { + _memoryCacheMock = Substitute.For(); + _loggerMock = Substitute.For>(); + + IConfiguration configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .Build(); + + _tokenSettings = configuration.GetSection("Authorization:TokenSettings").Get(); + _tokenSettingsMock = Substitute.For>(); + _tokenSettingsMock.Value.Returns(_tokenSettings); + + // Setup test user + _testUser = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "test-user-id"), + new Claim(ClaimTypes.Name, "TestUser"), + new Claim(ClaimTypes.Role, "Admin") + }, "TestAuth")); + } + + [Fact] + public async Task GetUserTokenAsync_ReturnsCachedToken_WhenTokenExists() + { + // Arrange + var cacheKey = "UserToken_test-user-id"; + _memoryCacheMock.TryGetValue(cacheKey, out Arg.Any()!) + .Returns(call => + { + call[1] = "cached-token"; + return true; + }); + + var service = new UserTokenService(_tokenSettingsMock, _memoryCacheMock, _loggerMock); + + // Act + var token = await service.GetUserTokenAsync(_testUser); + + // Assert + Assert.Equal("cached-token", token); + _loggerMock.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString() == "Token retrieved from cache for user: test-user-id"), + Arg.Any(), + Arg.Any>() + ); + } + + [Fact] + public async Task GetUserTokenAsync_GeneratesAndCachesToken_WhenTokenNotInCache() + { + // Arrange + var cacheKey = "UserToken_test-user-id"; + _memoryCacheMock.TryGetValue(cacheKey, out Arg.Any()!) + .Returns(false); + + var cacheEntryMock = Substitute.For(); + _memoryCacheMock.CreateEntry(cacheKey).Returns(cacheEntryMock); + + var service = new UserTokenService(_tokenSettingsMock, _memoryCacheMock, _loggerMock); + + // Act + var token = await service.GetUserTokenAsync(_testUser); + + // Assert + Assert.NotNull(token); + _memoryCacheMock.Received(1).CreateEntry(cacheKey); + cacheEntryMock.Received(1).Value = token; + _loggerMock.Received(0).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString() == "Token retrieved from cache for user: test-user-id"), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task GetUserTokenAsync_ThrowsException_WhenUserIsNull() + { + // Arrange + var service = new UserTokenService(_tokenSettingsMock, _memoryCacheMock, _loggerMock); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => service.GetUserTokenAsync(null!)); + Assert.Equal("user", exception.ParamName); + } + + [Fact] + public async Task GetUserTokenAsync_ThrowsException_WhenUserIdIsInvalid() + { + // Arrange + var invalidUser = new ClaimsPrincipal(new ClaimsIdentity()); // No claims + var service = new UserTokenService(_tokenSettingsMock, _memoryCacheMock, _loggerMock); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => service.GetUserTokenAsync(invalidUser)); + Assert.Equal("User does not have a valid identifier.", exception.Message); + } + + [Fact] + public void GenerateToken_CreatesValidJwtToken() + { + // Arrange + var user = _testUser; + var service = new UserTokenService(_tokenSettingsMock, _memoryCacheMock, _loggerMock); + + // Act + var token = service.GetType() + .GetMethod("GenerateToken", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + ?.Invoke(service, new object[] { user }) as string; + + // Assert + Assert.NotNull(token); + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(token); + Assert.Equal(_tokenSettings?.Issuer, jwtToken.Issuer); + Assert.Equal(_tokenSettings?.Audience, jwtToken.Audiences.First()); + Assert.Contains(jwtToken.Claims, c => c.Type == ClaimTypes.Name && c.Value == "TestUser"); + Assert.Contains(jwtToken.Claims, c => c.Type == ClaimTypes.Role && c.Value == "Admin"); + } + } +} diff --git a/src/Tests/DfE.CoreLibs.Security.Tests/DfE.CoreLibs.Security.Tests.csproj b/src/Tests/DfE.CoreLibs.Security.Tests/DfE.CoreLibs.Security.Tests.csproj index b545dc5..f7e6784 100644 --- a/src/Tests/DfE.CoreLibs.Security.Tests/DfE.CoreLibs.Security.Tests.csproj +++ b/src/Tests/DfE.CoreLibs.Security.Tests/DfE.CoreLibs.Security.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Tests/DfE.CoreLibs.Security.Tests/appsettings.json b/src/Tests/DfE.CoreLibs.Security.Tests/appsettings.json index c2a64a1..bd80a0e 100644 --- a/src/Tests/DfE.CoreLibs.Security.Tests/appsettings.json +++ b/src/Tests/DfE.CoreLibs.Security.Tests/appsettings.json @@ -25,6 +25,20 @@ } ] } - ] + ], + "ApiSettings": { + "ApiClientId": "test-api-client-id" + }, + "ScopeMappings": { + "Admin": [ "Scope1" ], + "User": [ "Scope2" ] + }, + "TokenSettings": { + "SecretKey": "OurLongLongLongLongLongLongSecretKey", + "Issuer": "test-issuer", + "Audience": "test-audience", + "TokenLifetimeMinutes": 30, + "BufferInSeconds": 60 + } } }