I try to do integrations tests with WebApplicationFactory, but I get error.
Part of Program.cs:
builder.AddNegotiate(options =>
{
var ldapConnectionsFactory = new LdapConnectionsFactory(domainConfiguration, loggerFactory.CreateLogger<LdapConnectionsFactory>());
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && ldapConnectionsFactory.TryCreate(out var ldapConnection))
{
options.EnableLdap(settings =>
{
settings.LdapConnection = ldapConnection;
settings.Domain = domainConfiguration.Domain;
});
}
});
This code add six services to services array. If I don't add builder.AddNegotiate, I don't have problem.
When I try to do test, I get error:
Negotiate authentication requires a server that supports IConnectionItemsFeature like Kestrel.
TestFile:
[TestFixture]
public class AuthControllerTests
{
readonly CustomWebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public AuthControllerTests()
{
_factory = new CustomWebApplicationFactory<Program>();
_client = _factory.CreateClient();
_client.BaseAddress = new Uri("http://localhost:8001/");
}
[Test]
public async Task CheckAdminLogIn_SendRequest_ShouldReturnOk()
{
// Arrange
var credentials = new Credentials() { Login = "admin", Password = "admin" };
var jsonSerializerOptions = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNameCaseInsensitive = true
};
string jsonString = JsonSerializer.Serialize(credentials, jsonSerializerOptions);
StringContent httpContent = new StringContent(jsonString, System.Text.Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("api/Auth/login", httpContent);
// Assert
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
}
}
I try remove services in CustomWebApplicationFactory:
public class CustomWebApplicationFactory<TStartup>
: WebApplicationFactory<TStartup> where TStartup : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Here I am trying to find and delete Negotiate, but it deletes 4 out of 6 services
var negotiateHandler = services.SingleOrDefault(d => d.ServiceType == typeof(NegotiateHandler));
services.Remove(negotiateHandler);
List<ServiceDescriptor> servicesForRemove = services.Where(d => d.ServiceType.FullName.Contains("Negotiate")).ToList();
foreach (var s in servicesForRemove)
{
services.Remove(s);
}
});
}
}
It is not removed:
Is there another way to disable negotiate auth?
I removed all Microsoft.AspNetCore.Authentication, then added JwtBearer. It works for me
public class CustomWebApplicationFactory<TStartup>
: WebApplicationFactory<TStartup> where TStartup : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
List<ServiceDescriptor> servicesForRemove = services.Where(d => d.ServiceType.FullName.Contains("Microsoft.AspNetCore.Authentication")).ToList();
foreach (var s in servicesForRemove)
{
services.Remove(s);
}
var buildServiceProvider = services.BuildServiceProvider();
var loggerFactory = buildServiceProvider.GetService<ILoggerFactory>();
services.AddAuthentication().AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = JwtProvider.CreateTokenValidator(loggerFactory);
});
});
}
}
You can remove a specific scheme with:
internal class CustomWebApplicationFactory : WebApplicationFactory<Startup>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.Configure<AuthenticationOptions>(o =>
{
if (o.Schemes is List<AuthenticationSchemeBuilder> schemes)
{
schemes.RemoveAll(s => s.Name == NegotiateDefaults.AuthenticationScheme);
o.SchemeMap.Remove(NegotiateDefaults.AuthenticationScheme);
}
});
});
}
}
Related
I'm using ASP.NET 6 Core and writing a basic integration test for a controller that uses a mocked DBContext. Trying to base it on the Microsoft docs. But I can't build because I'm getting
CS0051 Inconsistent accessibility: parameter type 'CustomWebApplicationFactory<Program>' is less accessible than method 'ProjectsControllerTests.ProjectsControllerTests(CustomWebApplicationFactory<Program>)' DatabaseManagerService
But they are both public?!
ProjectControllersTests.cs
public class ProjectsControllerTests
{
private readonly CustomWebApplicationFactory<Program> _factory;
private const string s_apiBaseUri = "/api/v1";
public ProjectsControllerTests(CustomWebApplicationFactory<Program> factory)
{
// Arrange
_factory = factory;
}
[Fact]
public async Task Post_Responds201IfAuthenticatedAndRequiredFields_SuccessAsync()
{
// Arrange
HttpClient? client = _factory.CreateClient(new WebApplicationFactoryClientOptions()
{
AllowAutoRedirect = false
});
var project = new Project() { Name = "Test Project" };
var httpContent = new StringContent(JsonSerializer.Serialize(project));
// Act
var response = await client.PostAsync($"{s_apiBaseUri}/projects", httpContent);
// Assert
Assert.Equal(System.Net.HttpStatusCode.Created, response.StatusCode);
}
}
CustomWebApplicationFactory.cs
public class CustomWebApplicationFactory<TStartup>
: WebApplicationFactory<TStartup> where TStartup : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(
d => d.ServiceType ==
typeof(DbContextOptions<myDbContext>));
if (descriptor != null)
services.Remove(descriptor);
services.AddDbContext<myDbContext>(options =>
{
options.UseInMemoryDatabase("InMemoryDbForTesting");
});
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, MockAuthHandler>(
"Test", options => { });
var sp = services.BuildServiceProvider();
using (var scope = sp.CreateScope())
{
var scopedServices = scope.ServiceProvider;
var db = scopedServices.GetRequiredService<myDbContext>();
var logger = scopedServices
.GetRequiredService<ILogger<CustomWebApplicationFactory<Program>>>();
db.Database.EnsureCreated();
try
{
db.EndUsers.Add(new DataAccessLibrary.Models.EndUser() { ObjectIdentifier = "test-oid" });
db.SaveChanges();
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred seeding the " +
"database with test messages. Error: {Message}", ex.Message);
}
}
});
}
}
The Program class is not public in ASP.NET 6 Core. As per https://learn.microsoft.com/en-us/aspnet/core/migration/50-to-60-samples?view=aspnetcore-6.0#twa you can make Program public by appending the following to Program.cs:
public partial class Program { }
It should also be noted that the above test class must be defined as:
public class ProjectsControllerTests : IClassFixture<CustomWebApplicationFactory<Program>>
for DI to work.
I got an error when trying to fetch gRPC API (using C#) to blazor client, at first it worked fine but after adding IdentityServer4 and use CORS for gRPC-Web similar like in the docs. Here's the code relevant to the error.
BackEnd/Startup.cs
namespace BackEnd
{
public class Startup
{
public IWebHostEnvironment Environment { get; }
public IConfiguration Configuration { get; }
private string _clientId = null;
private string _clientSecret = null;
public Startup(IWebHostEnvironment environment, IConfiguration configuration)
{
Environment = environment;
Configuration = configuration;
}
public void ConfigureServices(IServiceCollection services)
{
// Initialize certificate
var cert = new X509Certificate2(Path.Combine(".", "IdsvCertificate.pfx"), "YouShallNotPass123");
var migrationAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;
// The connection strings is in user secret
string connectionString = Configuration["ConnectionStrings:DefaultConnection"];
_clientId = Configuration["OAuth:ClientId"];
_clientSecret = Configuration["OAuth:ClientSecret"];
services.AddControllersWithViews();
services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString));
services.AddIdentity<ApplicationUser, IdentityRole>(options => options.SignIn.RequireConfirmedAccount = true)
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddClaimsPrincipalFactory<ClaimsFactory>()
.AddDefaultTokenProviders();
var builder = services.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
// see https://identityserver4.readthedocs.io/en/latest/topics/resources.html
options.EmitStaticAudienceClaim = true;
options.UserInteraction = new UserInteractionOptions()
{
LoginUrl = "/Account/Login",
LogoutUrl = "/Account/Logout"
};
})
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddInMemoryApiResources(Config.ApiResources)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryClients(Config.Clients)
.AddProfileService<ProfileService>()
.AddAspNetIdentity<ApplicationUser>()
.AddConfigurationStore(options =>
{
options.ConfigureDbContext = b => b.UseNpgsql(connectionString,
sql => sql.MigrationsAssembly(migrationAssembly));
})
.AddOperationalStore(options =>
{
options.ConfigureDbContext = b => b.UseNpgsql(connectionString,
sql => sql.MigrationsAssembly(migrationAssembly));
});
// Add signed certificate to identity server
builder.AddSigningCredential(cert);
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
// Enable CORS for gRPC
services.AddCors(o => o.AddPolicy("AllowAll", builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader()
.WithExposedHeaders("Grpc-Status", "Grpc-Message", "Grpc-Encoding", "Grpc-Accept-Encoding");
}));
// Add profile service
services.AddScoped<IProfileService, ProfileService>();
services.AddAuthentication()
.AddGoogle("Google", options =>
{
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
options.ClientId = _clientId;
options.ClientSecret = _clientSecret;
options.SaveTokens = true;
options.ClaimActions.MapJsonKey("role", "role");
});
services.AddAuthorization();
services.AddGrpc(options =>
{
options.EnableDetailedErrors = true;
});
}
public void Configure(IApplicationBuilder app)
{
InitializeDatabase(app);
if (Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseStaticFiles();
app.UseRouting();
app.UseIdentityServer();
app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true });
app.UseAuthentication();
app.UseCors("AllowAll");
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapGrpcService<UserService>().RequireCors("AllowAll");
endpoints.MapDefaultControllerRoute().RequireAuthorization();
});
}
// Based on IdentityServer4 document
private void InitializeDatabase(IApplicationBuilder app)
{
using (var serviceScope = app.ApplicationServices.GetService<IServiceScopeFactory>().CreateScope())
{
serviceScope.ServiceProvider.GetRequiredService<ApplicationDbContext>().Database.Migrate();
var context = serviceScope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();
context.Database.Migrate();
if (!context.Clients.Any())
{
foreach (var client in Config.Clients)
{
context.Clients.Add(client.ToEntity());
}
context.SaveChanges();
}
if (!context.IdentityResources.Any())
{
foreach (var resource in Config.IdentityResources)
{
context.IdentityResources.Add(resource.ToEntity());
}
context.SaveChanges();
}
if (!context.ApiScopes.Any())
{
foreach (var resource in Config.ApiScopes)
{
context.ApiScopes.Add(resource.ToEntity());
}
context.SaveChanges();
}
}
}
}
}
BackEnd/Services/UserService.cs
namespace BackEnd
{
[Authorize(Roles="User")]
public class UserService : User.UserBase
{
private readonly ILogger<UserService> _logger;
private readonly ApplicationDbContext _dataContext;
public UserService(ILogger<UserService> logger, ApplicationDbContext dataContext)
{
_logger = logger;
_dataContext = dataContext;
}
public override async Task<Empty> GetUser(UserInfo request, ServerCallContext context)
{
var response = new Empty();
var userList = new UserResponse();
if (_dataContext.UserDb.Any(x => x.Sub == request.Sub))
{
var newUser = new UserInfo(){ Id = userList.UserList.Count, Sub = request.Sub, Email = request.Email };
_dataContext.UserDb.Add(newUser);
userList.UserList.Add(newUser);
await _dataContext.SaveChangesAsync();
}
else
{
var user = _dataContext.UserDb.Single(u => u.Sub == request.Sub);
userList.UserList.Add(user);
}
return await Task.FromResult(response);
}
public override async Task<ToDoItemList> GetToDoList(UuidParameter request, ServerCallContext context)
{
var todoList = new ToDoItemList();
var userInfo = new UserInfo();
var getTodo = (from data in _dataContext.ToDoDb
where data.Uuid == userInfo.Sub
select data).ToList();
todoList.ToDoList.Add(getTodo);
return await Task.FromResult(todoList);
}
public override async Task<Empty> AddToDo(ToDoStructure request, ServerCallContext context)
{
var todoList = new ToDoItemList();
var userInfo = new UserInfo();
var newTodo = new ToDoStructure()
{
Id = todoList.ToDoList.Count,
Uuid = request.Uuid,
Description = request.Description,
IsCompleted = false
};
todoList.ToDoList.Add(newTodo);
await _dataContext.ToDoDb.AddAsync(newTodo);
await _dataContext.SaveChangesAsync();
return await Task.FromResult(new Empty());
}
public override async Task<Empty> PutToDo(ToDoStructure request, ServerCallContext context)
{
var response = new Empty();
_dataContext.ToDoDb.Update(request);
await _dataContext.SaveChangesAsync();
return await Task.FromResult(response);
}
public override async Task<Empty> DeleteToDo(DeleteToDoParameter request, ServerCallContext context)
{
var item = (from data in _dataContext.ToDoDb
where data.Id == request.Id
select data).First();
_dataContext.ToDoDb.Remove(item);
var result = await _dataContext.SaveChangesAsync();
return await Task.FromResult(new Empty());
}
}
}
FrontEnd/Program.cs
namespace FrontEnd
{
public class Program
{
public static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.Services.AddScoped(sp => new HttpClient()
{ BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
// Connect server to client
builder.Services.AddScoped(services =>
{
var baseAddressMessageHandler = services.GetRequiredService<AuthorizationMessageHandler>()
.ConfigureHandler(
authorizedUrls: new[] { "https://localhost:5001" },
scopes: new[] { "todoApi" }
);
baseAddressMessageHandler.InnerHandler = new HttpClientHandler();
var httpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler());
var channel = GrpcChannel.ForAddress("https://localhost:5000", new GrpcChannelOptions
{
HttpHandler = httpHandler
});
return new User.UserClient(channel);
});
// Add Open-ID Connect authentication
builder.Services.AddOidcAuthentication(options =>
{
builder.Configuration.Bind("Authentication:Google", options.ProviderOptions);
options.ProviderOptions.DefaultScopes.Add("role");
options.UserOptions.RoleClaim = "role"; // Important to get role claim
}).AddAccountClaimsPrincipalFactory<CustomUserFactory>();
builder.Services.AddOptions();
builder.Services.AddAuthorizationCore();
await builder.Build().RunAsync();
}
}
}
FrontEnd/Pages/ToDoList.razor.cs
namespace FrontEnd.Pages
{
public partial class TodoList
{
[Inject]
private User.UserClient UserClient { get; set; }
[Inject]
private IJSRuntime JSRuntime { get; set; }
[CascadingParameter]
public Task<AuthenticationState> authenticationStateTask { get; set; }
public string Description { get; set; }
public string ToDoDescription { get; set; }
public RepeatedField<ToDoStructure> ServerToDoResponse { get; set; } = new RepeatedField<ToDoStructure>();
protected override async Task OnInitializedAsync()
{
var authState = await authenticationStateTask;
var user = authState.User;
Console.WriteLine($"IsAuthenticated: {user.Identity.IsAuthenticated} | IsUser: {user.IsInRole("User")}");
if (user.Identity.IsAuthenticated && user.IsInRole("User"))
{
await GetUser(); // Error when trying to call this function
}
}
// Fetch usser from server
public async Task GetUser()
{
var authState = await authenticationStateTask;
var user = authState.User;
var userRole = user.IsInRole("User");
var userUuid = user.Claims.FirstOrDefault(c => c.Type == "preferred_username").Value;
var subjectId = user.Claims.FirstOrDefault(c => c.Type == "sub").Value;
var userEmail = user.Claims.FirstOrDefault(c => c.Type == "email").Value;
var request = new UserInfo(){ Sub = subjectId, Email = userEmail };
await UserClient.GetUserAsync(request);
await InvokeAsync(StateHasChanged);
await GetToDoList();
}
// Fetch to-do list from server
private async Task GetToDoList()
{
var authState = await authenticationStateTask;
var user = authState.User;
var userUuid = user.Claims.FirstOrDefault(c => c.Type == "preferred_username").Value;
var request = new UuidParameter(){ Uuid = userUuid };
var response = await UserClient.GetToDoListAsync(request);
ServerToDoResponse = response.ToDoList;
}
// Add to-do list to the server
public async Task AddToDo(KeyboardEventArgs e)
{
var authState = await authenticationStateTask;
var user = authState.User;
var userUuid = user.Claims.FirstOrDefault(c => c.Type == "Sub").Value;
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(Description) ||
e.Key == "NumpadEnter" && !string.IsNullOrWhiteSpace(Description))
{
var request = new ToDoStructure()
{
Uuid = userUuid,
Description = this.Description,
};
await UserClient.AddToDoAsync(request);
await InvokeAsync(StateHasChanged);
await GetToDoList();
}
}
// Update the checkbox state of the to-do list
public async Task PutToDoIsCompleted(int id, string description, bool isCompleted, MouseEventArgs e)
{
if (isCompleted == false && e.Button== 0)
{
isCompleted = true;
}
else if (isCompleted == true && e.Button == 0)
{
isCompleted = false;
}
var request = new ToDoStructure()
{
Id = id,
Description = description,
IsCompleted = isCompleted
};
await UserClient.PutToDoAsync(request);
await GetToDoList();
}
// Edit mode function
private async Task EditToDo(int todoId, string description, bool isCompleted)
{
var authState = await authenticationStateTask;
var user = authState.User;
var userUuid = user.Claims.FirstOrDefault(c => c.Type == "Sub").Value;
// Get the index of the to-do list
int grpcIndex = ServerToDoResponse.IndexOf(new ToDoStructure()
{
Id = todoId,
Uuid = userUuid,
Description = description,
IsCompleted = isCompleted
});
ToDoDescription = ServerToDoResponse[grpcIndex].Description;
// Make text area appear and focus on text area and edit icon dissapear based on the to-do list index
await JSRuntime.InvokeVoidAsync("editMode", "edit-icon", "todo-description", "edit-todo", grpcIndex);
await JSRuntime.InvokeVoidAsync("focusTextArea", todoId.ToString(), ToDoDescription);
}
// Update the to-do description
public async Task PutToDoDescription(int id, string htmlId, string oldDescription, string newDescription, bool isCompleted)
{
var authState = await authenticationStateTask;
var user = authState.User;
var userUuid = user.Claims.FirstOrDefault(c => c.Type == "Sub").Value;
var request = new ToDoStructure()
{
Id = id,
Uuid = userUuid,
Description = newDescription,
};
int grpcIndex = ServerToDoResponse.IndexOf(new ToDoStructure()
{
Id = id,
Description = oldDescription,
IsCompleted = isCompleted
});
// Text area auto resize function
await JSRuntime.InvokeVoidAsync("theRealAutoResize", htmlId);
// Make text area display to none and edit icon appear base on the to-do list index
await JSRuntime.InvokeVoidAsync("initialMode", "edit-icon", "todo-description", "edit-todo", grpcIndex);
await UserClient.PutToDoAsync(request);
await GetToDoList();
}
// Delete to-do
public async Task DeleteToDo(int id)
{
var request = new DeleteToDoParameter(){ Id = id };
await UserClient.DeleteToDoAsync(request);
await GetToDoList();
}
}
}
This is the output of the console
Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
Unhandled exception rendering component: Status(StatusCode="Cancelled", Detail="Bad gRPC response. Invalid content-type value: text/html; charset=utf-8")
Grpc.Core.RpcException: Status(StatusCode="Cancelled", Detail="Bad gRPC response. Invalid content-type value: text/html; charset=utf-8")
at FrontEnd.Pages.TodoList.GetUser() in C:\Users\bryan\source\repos\Productivity_App\frontend\Pages\TodoList.razor.cs:line 50
at FrontEnd.Pages.TodoList.OnInitializedAsync() in C:\Users\bryan\source\repos\Productivity_App\frontend\Pages\TodoList.razor.cs:line 35
at Microsoft.AspNetCore.Components.ComponentBase.RunInitAndSetParametersAsync()
at Microsoft.AspNetCore.Components.RenderTree.Renderer.GetErrorHandledTask(Task taskToHandle)
This is the output in the terminal when trying to authenticate with IdentityServer4 (the authentication and authorization is working fine though)
[21:11:15 Debug] Grpc.AspNetCore.Web.Internal.GrpcWebMiddleware
Detected gRPC-Web request from content-type 'application/grpc-web'.
[21:11:15 Information] Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler
AuthenticationScheme: Identity.Application was challenged.
[21:11:15 Debug] IdentityServer4.Hosting.CorsPolicyProvider
CORS request made for path: /Account/Login from origin: https://localhost:5001 but was ignored because path was not for an allowed IdentityServer CORS endpoint
You can't do OpenID Connect authentication as part of gRPC, the user must have first authenticated on your web site and you should then have received the access token.
Then you can send the access token with gRPC to the API. If you then get a 401 http status back, then you need to refresh(get a new one) the access token.
To make your life easier and to reduce complexity and your sanity, I recommend that you put IdentityServer in its own service, standalone from the client/api. Otherwise its hard to reason about the system and it will be very hard to debug.
My recommendation is that you have this architecture, in three different services:
gRPC is just a transport, similar to HTTP and in the API, you have this basic architecture (slide taken from one of my training classes):
The JwtBearer will examine the access token to verify who you are and after that the authorization module takes over and check if you are allowed in.
I a have a very simple app with one JWT authenticated controller:
[ApiController]
[Authorize]
[Route("[controller]")]
public class JwtController : ControllerBase
{
public JwtController() { }
[HttpGet]
public ActionResult Get() => Ok("Working!");
}
With the authentication configured as:
services.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(x =>
{
x.RequireHttpsMetadata = false;
x.SaveToken = true;
x.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false
};
});
During tests, i want the user to be "authenticated" all the time so that [Authorize] would be skipped.
[Fact]
public async Task JwtIsSkipped()
{
var response = (await _Client.GetAsync("/jwt")).EnsureSuccessStatusCode();
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Equal("Working!", stringResponse);
}
Running the test like this will fail, so following this doc I added this simple auth handler:
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string DefaultScheme = "Test";
public TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[] { new Claim(ClaimTypes.Name, "Test user") };
var identity = new ClaimsIdentity(claims, DefaultScheme);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, DefaultScheme);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
So now my test class looks like this:
public class UnitTest : IClassFixture<WebApplicationFactory<Startup>>
{
private readonly WebApplicationFactory<Startup> _Factory;
private readonly HttpClient _Client;
public UnitTest(WebApplicationFactory<Startup> factory)
{
_Factory = factory;
_Client = _Factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication(TestAuthHandler.DefaultScheme)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.DefaultScheme, options => { });
});
}).CreateClient();
_Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.DefaultScheme);
}
[Fact]
public async Task JwtIsSkipped()
{
var response = (await _Client.GetAsync("/jwt")).EnsureSuccessStatusCode();
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Equal("Working!", stringResponse);
}
}
And it still fails, I have no idea what I'm doing wrong.
I have had a similar situation previously with the Microsoft example and can promise you it can give headaches, it may work on specific Core versions, but I have given up. I have solved this way.
My goal was, is to Authorize the system while testing, instead of using AddAuthentication in our test we create a FakePolicyEvaluator class and add it as a singleton to our test.
So let's go to our FakePolicyEvaluator class:
public class FakePolicyEvaluator : IPolicyEvaluator
{
public virtual async Task<AuthenticateResult> AuthenticateAsync(AuthorizationPolicy policy, HttpContext context)
{
var principal = new ClaimsPrincipal();
principal.AddIdentity(new ClaimsIdentity(new[] {
new Claim("Permission", "CanViewPage"),
new Claim("Manager", "yes"),
new Claim(ClaimTypes.Role, "Administrator"),
new Claim(ClaimTypes.NameIdentifier, "John")
}, "FakeScheme"));
return await Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal,
new AuthenticationProperties(), "FakeScheme")));
}
public virtual async Task<PolicyAuthorizationResult> AuthorizeAsync(AuthorizationPolicy policy,
AuthenticateResult authenticationResult, HttpContext context, object resource)
{
return await Task.FromResult(PolicyAuthorizationResult.Success());
}
}
Then in our ConfigureTestServices we added services.AddSingleton<IPolicyEvaluator, FakePolicyEvaluator>();
So in your test code like this:
private readonly HttpClient _client;
public UnitTest(WebApplicationFactory<Startup> factory)
{
_client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddSingleton<IPolicyEvaluator, FakePolicyEvaluator>();
});
}).CreateClient();
}
[Fact]
public async Task JwtIsSkipped()
{
var response = (await _client.GetAsync("/jwt")).EnsureSuccessStatusCode();
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Equal("Working!", stringResponse);
}
That is it. Now when you test, it will bypass authentication. I have tested it with the provided controller and it works.
It is also possible to place the fake inside the application startup, and it will be both testable for test and working under a development environment. Check the referenced article.
Disclaimer: I have written in more depth article about this on my personal website Reference where you can find and download a source code from GitHub.
You need to set DefaultAuthenticateScheme
builder.ConfigureTestServices(services =>
{
services.AddAuthentication(options =>
{
x.DefaultAuthenticateScheme = TestAuthHandler.DefaultScheme;
x.DefaultScheme = TestAuthHandler.DefaultScheme;
}).AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.DefaultScheme, options => { });
});
its a small change to maysam fahmi answer that HttpContext.User also have values:
public class FakeUserPolicyEvaluator: IPolicyEvaluator
{
private ClaimsIdentity _claimsIdentity;
public virtual async Task<AuthenticateResult> AuthenticateAsync(AuthorizationPolicy policy, HttpContext context)
{
var testScheme = "FakeScheme";
var principal = new ClaimsPrincipal();
_claimsIdentity = new ClaimsIdentity(new[]
{
new Claim("sub", "a5"),
new Claim("client_id", "a6"),
new Claim(ClaimTypes.Role, BackmanConsts.Authorization.ClientPolicy),
}, testScheme);
principal.AddIdentity(_claimsIdentity);
return await Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal,
new AuthenticationProperties(), testScheme)));
}
public virtual async Task<PolicyAuthorizationResult> AuthorizeAsync(AuthorizationPolicy policy,
AuthenticateResult authenticationResult, HttpContext context, object resource)
{
context.User = new ClaimsPrincipal(_claimsIdentity);
return await Task.FromResult(PolicyAuthorizationResult.Success());
}
}
Can I avoid using BuildServiceProvider in ExpireToken function below? In other words...
Is there a way of avoiding BuildServiceProvider() in Startup.cs when using JWTBearer OnAuthenticationFailed event?
(Using Web API aspnet Core 3.0)
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
//some code removed..
//Authentication
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
options.Events = new JwtBearerEvents()
{
OnAuthenticationFailed = context =>
{
if (context.Exception.Message.Contains("The token is expired"))
{
Microsoft.Extensions.Primitives.StringValues token = string.Empty;
var logger = new LoggerManager();
logger.LogError("Token expired"); //confirming that the token expired so authentication failed
var header = context.Request.Headers;
if (!header.TryGetValue("Authorization", out token))
logger.LogError("no token found");
else
token = token.ToString().Substring("Bearer ".Length).Trim();
ExpireToken(services, token);
}
return Task.CompletedTask;
}
};
});
}
public void ExpireToken(IServiceCollection services, string tokenId)
{
var sp = services.BuildServiceProvider();
var jwtManager = sp.GetService<IJWTAuthenticationManager>();
jwtManager.ExpireToken(tokenId);
}
jwtAuthentication Manager will publish this event so that subscribers are notified (c#:event + delegates)
public delegate void TokenExpiredEventHandler(object source, TokenExpiredEventArgs args);
JWTAuthenticationManager.cs
public class JWTAuthenticationManager : IJWTAuthenticationManager
{
private readonly string _tokenKey;
public event TokenExpiredEventHandler TokenExpired;
//some code removed for brevity
public void ExpireToken(string tokenId)
{
OnTokenExpired(tokenId); //notify all subscribers
}
protected virtual void OnTokenExpired(string token) {
TokenExpired?.Invoke(this, new TokenExpiredEventArgs(token));
}
}
public class TokenExpiredEventArgs : EventArgs
{
public string token;
public TokenExpiredEventArgs(string tokenId)
{
token = tokenId;
}
}
Program.cs
public static void Main(string[] args)
{
var host = CreateWebHostBuilder(args).Build();
var jwtMgr = host.Services.GetRequiredService<IJWTAuthenticationManager>();
var cacheMgr = host.Services.GetRequiredService<ICacheManager>();
jwtMgr.TokenExpired += cacheMgr.OnTokenExpired;
host.Run();
}
Why not resolve services from context? Simply change your ExpireToken method to take an IServiceProvider:
public void ExpireToken(IServiceProvider services, string tokenId)
{
var jwtManager = services.GetService<IJWTAuthenticationManager>();
jwtManager.ExpireToken(tokenId);
}
And then pass context.HttpContext.RequestServices:
ExpireToken(context.HttpContext.RequestServices, token);
public Fixture()
{
_server = new TestServer(new WebHostBuilder()
.UseStartup<Startup>()
.ConfigureServices(services =>
{
services.AddScoped<ICarService, CarService>();
}));
Client = _server.CreateClient();
}
from tests I'm using this HttpClient to test my API.
using (var response = await _client.GetAsync($"/api/car/{id}"))
{
//...
}
The thing is that I want to fake the result of the GetAsync(int id) method in CarService class.
So I tried
var myCarObject = ... omitted for clarity
var myCarMockService = new Mock<ICarService>();
myCarMockService.Setup(x => x.GetAsync(It.IsAny<int>())).Returns(Task.FromResult(myCarObject));
I don't know is this right approach, but if it is how can I inject it
into Fixture class so CarService can use it.
public class CarService: ICarService {
private readonly CarDbContext _carDbContext;
public CarService(CarDbContext carDbContext)
{
_carDbContext = carDbContext;
}
public async Task<Car> GetAsync(int id)
{
return await _carDbContext.Cars.FindAsync(id);
}
}
Update:
private readonly ICarService _carService;
public CarController(ICarService carService)
{
_carService = carService;
}
public async Task<IActionResult> Get([FromRoute] int id)
{
var car = await _carService.GetAsync(id);
}
Update 2:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<CarDbContext>(options => { options.UseSqlServer(Configuration.GetConnectionString("Db")); });
services.AddTransient<ICarService, CarService>();
}
}
public class CarService : ICarService
{
private readonly CarDbContext _carDbContext;
public ContactService(CarDbContext carDbContext)
{
_carDbContext= carDbContext;
}
public async Task<Owner> GetAsync(int ownerId)
{
var owner = await _carDbContext.Owners.FindAsync(ownerId);
return owner.Car;
}
}
Update 3:
private readonly TestServer _server;
public Fixture()
{
var dbContextOptions = new DbContextOptionsBuilder<CarDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var mockContext = new Mock<CarDbContext>(dbContextOptions);
var mockOwnerSet = new Mock<DbSet<Owner>>();
var mockCarSet = new Mock<DbSet<Car>>();
mockContext.Setup(m => m.Owners).Returns(mockOwnerSet.Object);
mockContext.Setup(m => m.Cars).Returns(mockCarSet.Object);
var carService = new CarService(mockContext.Object);
_server = new TestServer(new WebHostBuilder()
.ConfigureAppConfiguration((context, conf) =>
{
conf.AddJsonFile(#Directory.GetCurrentDirectory() + "../appsettings.json");
}).UseStartup<Startup>()
.ConfigureServices(services =>
{
services.AddDbContext<CarDbContext>(options => options.UseInMemoryDatabase("Test"));
services.AddScoped<ICarService>(_ => carService);
})
);
Client = _server.CreateClient();
}
Configure the test server to use the mocked service
public Fixture() {
Car myCarObject = //... omitted for brevity
var myCarMockService = new Mock<ICarService>();
myCarMockService
.Setup(x => x.GetAsync(It.IsAny<int>()))
.ReturnsAsync(myCarObject);
_server = new TestServer(new WebHostBuilder()
.UseStartup<Startup>()
.ConfigureTestServices(services => {
var serviceDescriptor = services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(ICarService));
if (serviceDescriptor != null) services.Remove(serviceDescriptor);
services.AddTransient<ICarService>(_ => myCarMockService.Object); // <-- NOTE
})
);
Client = _server.CreateClient();
}
That way when the call is made the mocked service will be injected as expected.