ASP.NET Core 6 MVC Integration Tests - Authorization - c#

Here is my problem. I can't seem to manage to create an integration test that requires an authenticated user. I use Microsoft.AspNetCore.Mvc.Testing for testing. Here is my test:
As seen the client has the role "Patient" and a UserId. Here are my helpers:
public class TestClaimsProvider
{
public IList<Claim> Claims { get; }
public TestClaimsProvider(IList<Claim> claims)
{
Claims = claims;
}
public TestClaimsProvider()
{
Claims = new List<Claim>();
}
public static TestClaimsProvider WithAdminClaims()
{
var provider = new TestClaimsProvider();
provider.Claims.Add(new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()));
provider.Claims.Add(new Claim(ClaimTypes.Name, "Admin user"));
provider.Claims.Add(new Claim(ClaimTypes.Role, "Administrator"));
return provider;
}
public static TestClaimsProvider WithUserClaims()
{
var provider = new TestClaimsProvider();
provider.Claims.Add(new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()));
provider.Claims.Add(new Claim(ClaimTypes.Name, "Patient"));
provider.Claims.Add(new Claim(ClaimTypes.Role, "Patient"));
return provider;
}
}
This also:
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly IList<Claim> _claims;
public TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock, TestClaimsProvider claimsProvider) : base(options, logger, encoder, clock)
{
_claims = claimsProvider.Claims;
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var identity = new ClaimsIdentity(_claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
var result = AuthenticateResult.Success(ticket);
return Task.FromResult(result);
}
}
And:
public static class WebApplicationFactoryExtensions
{
public static WebApplicationFactory<T> WithAuthentication<T>(this WebApplicationFactory<T> factory, TestClaimsProvider claimsProvider) where T : class
{
return factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", op => { });
services.AddScoped<TestClaimsProvider>(_ => claimsProvider);
});
});
}
public static HttpClient CreateClientWithTestAuth<T>(this WebApplicationFactory<T> factory, TestClaimsProvider claimsProvider) where T : class
{
var client = factory.WithAuthentication(claimsProvider).CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test");
return client;
}
These are based on this subject https://gunnarpeipman.com/aspnet-core-integration-tests-users-roles/. Although I am not using the FakeStartup class that he has pointed in previous threads. Also I have tried the authentication from the docs here https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests?view=aspnetcore-6.0#customize-the-client-with-withwebhostbuilder but it is the same.
Here is my action method in the controller:
[Authorize(Roles = PatientRoleName)]
public async Task<IActionResult> MakePatientAppointment()
{
var patient = await this.patientService.GetPatientByUserIdAsync(this.User.GetId());
if (string.IsNullOrWhiteSpace(patient.FirstName) ||
string.IsNullOrWhiteSpace(patient.LastName) ||
string.IsNullOrWhiteSpace(patient.Phone))
{
this.TempData["Message"] = PatientProfileIsNotFinishedMsg;
return RedirectToAction("Finish", "Patient", new { area = "" });
}
var viewModel = new PatientAppointmentCreateModel
{
DoctorId = await this.doctorService.GetDoctorId(),
AppointmentCauses = await this.appointmentCauseService.GetAllCauses()
};
return View(viewModel);
}
From debugging the test the response redirect is to /Identity/Login, so from what I am understanding the user is not logged in. How can I refactor the code to manage to get the user authenticated?

Updated for ASP.net 6
The top-level statements new feature of C# 10, provided the unification of the Startup and Program classes into a single Program class, and because of that (among other things like the new minimal hosting model), the creation of the WebApplicationFactory has changed, starting with the way to change the visibility of the Program class to the TestFixture (since it now doesn't contain a namespace due to the top-level statements feature)...
And the documentation has been updated as such, however, the way we registered the AuthenticationHandler to create an impersonated client for authorization has also changed - but the documentation (at least so far) doesn't address this.
So if we register the AuthenticationHandler as we did until ASP.net 5, now in ASP.net 6, we get a 401 Unauthorized (because it's not working anymore).
After a lot of research I found the solution:
Now, it's necessary to make explicit the AuthenticationOptions settings in the WebApplicationFactory according to the settings you have in your WebApi, in my case:
{
o.DefaultAuthenticateScheme = "Test";
o.DefaultChallengeScheme = "Test";
}

Related

Passing claims into HandleAuthenticateAsync for integration testing

While following Microsoft's ASP.NET Core guide for integration testing authentication, I have the following test built for Authentication:
[Fact]
public async Task Get_SecurePageIsReturnedForAnAuthenticatedUser()
{
// Arrange
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
"Test", options => {});
});
})
.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Test");
//Act
var response = await client.GetAsync("/SecurePage");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
What I want to do, is use the [Theory] option instead of [Fact] to test multiple Authentications so it will look like this:
[Theory]
[InlineData("TestAuth1","12345")]
[InlineData("TestAuth2","23456")]
[InlineData("TestAuth3","34567")]
public async Task Get_SecurePageIsReturnedForAnAuthenticatedUser(string claim, string claimsIdentity)
{
var claim = new Claim(claim, claimsIdentity);
.
.
.
However I'm not sure how to pass claim to TestAuthHandler through AddScheme<AuthenticationSchemeOptions, TestAuthHandler>
Here is the given TestAuthHandler
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
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, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
var result = AuthenticateResult.Success(ticket);
return Task.FromResult(result);
}
}
I would like to replace the claims variable in HandleAuthenticaAsync() with the claim passed into Get_SecurePageIsReturnedForAnAuthenticatedUser(Claim claim)
As a note they do have to be tested individually since my current authentication will pass as long as one correct authentication exists in the HandleAuthenticateAsync claims variable.
Thank you for any help provided.
I had a similar problem and was able to solve it by creating a custom TestAuthenticationSchemeOptions which implements AuthenticationSchemeOptions which would have a Claims property similar to the example in this link below
How to claim role for HttpContext.User.IsInRole method check in integration test?
e.g.
you would have
public class TestAuthenticationSchemeOptions : AuthenticationSchemeOptions
{
public IEnumerable<Claim> Claims { get; set; }
}
In your test
// Arrange
var inputClaims = new List<Claim> { new Claim(ClaimTypes.Name, "Test user") };
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<TestAuthenticationSchemeOptions, TestAuthHandler>(
"Test", options => { options.Claims = inputClaims; });
});
})
then in your AuthHandler you can grab the claims using Options.Claims
public class TestAuthHandler : AuthenticationHandler<TestAuthenticationSchemeOptions>
{
public TestAuthHandler(IOptionsMonitor<TestAuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = Options.Claims;
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
var result = AuthenticateResult.Success(ticket);
return Task.FromResult(result);
}
}

.net 5 how to setup authorization using custom provider

in my .net 5 website i have to read user login from header and the call external webservice to check if is authorized and get permission list.
EDIT 3:
GOALS
Read current user from http header setted by corporate single sign-on
Read user permission and info by calling external web services and
keep them daved to prevent extra-calls for every action
let the user be free to access by any page
authorize by default all controller's actions with custom claims
Actual Problem
context.User.Identity.IsAuthenticated in middleware is always false
Actual code
Startup - ConfigureServices
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
services.AddControllers(options => { options.Filters.Add<AuditAuthorizationFilter>(); });
services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromSeconds(10);
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
});
Startup - Configure
app.UseMiddleware<AuthenticationMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
Middleware
public class AuthenticationMiddleware
{
private readonly RequestDelegate _next;
// Dependency Injection
public AuthenticationMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
if (!context.User.Identity.IsAuthenticated)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, context.Request.Headers["Token"]),
};
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaultsAuthenticationScheme);
var authProperties = new AuthenticationProperties();
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity),
authProperties);
}
await _next(context);
}
}
Filter
public class AuditAuthorizationFilter : IAuthorizationFilter, IOrderedFilter
{
public int Order => -1;
private readonly IHttpContextAccessor _httpContextAccessor;
public AuditAuthorizationFilter(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public void OnAuthorization(AuthorizationFilterContext context)
{
if (context.HttpContext.User.Identity.IsAuthenticated)
{
context.Result = new ForbidResult();
}
else
{
string metodo = $"{context.RouteData.Values["controller"]}/{context.RouteData.Values["action"]}";
if (!context.HttpContext.User.HasClaim("type", metodo))
{
context.Result = new ForbidResult();
}
}
}
}
EDIT 2:
my Startup
public void ConfigureServices(IServiceCollection services)
{
services.AddDevExpressControls();
services.AddTransient<ILoggingService, LoggingService>();
services.AddHttpContextAccessor();
services.AddMvc().SetCompatibilityVersion(Microsoft.AspNetCore.Mvc.CompatibilityVersion.Version_3_0);
services.ConfigureReportingServices(configurator => {
configurator.UseAsyncEngine();
configurator.ConfigureWebDocumentViewer(viewerConfigurator => {
viewerConfigurator.UseCachedReportSourceBuilder();
});
});
services.AddControllersWithViews().AddJsonOptions(options => options.JsonSerializerOptions.PropertyNamingPolicy = null);
services.AddControllersWithViews().AddRazorRuntimeCompilation();
services.AddControllers(options => { options.Filters.Add(new MyAuthenticationAttribute ()); });
services.AddDistributedMemoryCache();
services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromSeconds(10);
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory)
{
app.UseDevExpressControls();
app.UseExceptionHandlerMiddleware(Log.Logger, errorPagePath: "/Error/HandleError" , respondWithJsonErrorDetails: true);
app.UseStatusCodePagesWithReExecute("/Error/HandleError/{0}");
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseSerilogRequestLogging(opts => opts.EnrichDiagnosticContext = LogHelper.EnrichFromRequest);
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseSession();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
EDIT 1:
to adapt original code to .net 5 i made some changes:
if (!context.HttpContext.User.Identity.IsAuthenticated)
{
const string MyHeaderToken = "HTTP_KEY";
string userSSO = null;
if (string.IsNullOrWhiteSpace(context.HttpContext.Request.Headers[MyHeaderToken]))
{
userSSO = context.HttpContext.Request.Headers[MyHeaderToken];
}
if (string.IsNullOrWhiteSpace(userSSO))
{
//filterContext.Result = new unh();
}
else
{
// Create GenericPrincipal
GenericIdentity webIdentity = new GenericIdentity(userSSO, "My");
//string[] methods = new string[0]; // GetMethods(userSSO);
GenericPrincipal principal = new GenericPrincipal(webIdentity, null);
IdentityUser user = new (userSSO);
Thread.CurrentPrincipal = principal;
}
}
but context.HttpContext.User.Identity.IsAuthenticated is false everytimes, even if the previous action set principal
ORIGINAL:
I'using custom attribute to manage this scenario in this way:
public class MyAuthenticationAttribute : ActionFilterAttribute, IAuthenticationFilter{
public string[] Roles { get; set; }
public void OnAuthentication(AuthenticationContext filterContext)
{
string MyHeaderToken = “SM_USER”;
string userSSO = null;
if (HttpContext.Current.Request.Headers[MyHeaderToken] != null)
{
userSSO = HttpContext.Current.Request.Headers[MyHeaderToken];
Trace.WriteLine(string.Format(“got MyToken: {0}”, userSSO));
}
if (string.IsNullOrWhiteSpace(userSSO))
{
Trace.WriteLine(“access denied, no token found”);
}
else
{
// Create GenericPrincipal
GenericIdentity webIdentity = new GenericIdentity(userSSO, “My”);
string[] methods= GetMethods(userSSO);
GenericPrincipal principal = new GenericPrincipal(webIdentity, methods);
filterContext.HttpContext.User = principal;
}
}
public void OnAuthenticationChallenge(AuthenticationChallengeContext filterContext)
{
//check authorizations
}
}
but external webservice returns list of controller/action authorized for users, so i have to test all actions executions to simply check if names is contained in the list.
is there a way to do this without have to write attribute on every actions or every controllers in this way:
[MyAuthentication(Roles = “Admin”)]
pubic class AdminController: Controller
{
}
i know i can use
services.AddMvc(o =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
o.Filters.Add(new AuthorizeFilter(policy));
});
but no idea of how to use this with my custom authorization
i'am also not sure if string[] methods= GetMethods(userSSO) is cached by .net core filterContext.HttpContext.User avoiding multiple calls to external webservice.
Thanks
If you want to apply your custom IAuthenticationFilter globally then you can do the ff:
services.AddControllers(options =>
{
options.Filters.Add(new MyAuthenticationFilter());
});
With this approach, you no longer need to inherit from ActionFilterAttribute and no need to add the [MyAuthentication(Roles = “Admin”)] attributes.
Just ensure that you are allowing anonymous requests to actions that doesn't need authentication and/or authorization.
EDIT 2:
For your updated setup, make sure you do the ff:
Add cookie authentication
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie();
Order of middlewares
app.UseRouting();
app.UseAuthentication();
app.UseMiddleware<AuthenticationMiddleware>();
app.UseAuthorization();
EDIT 1:
i'am also not sure if string[] methods= GetMethods(userSSO) is cached by .net core filterContext.HttpContext.User avoiding multiple calls to external webservice.
The lifetime of the filter depends on how you implemented it, usually it is singleton but you can make it transient by following the approach below:
public class MyAuthorizationFilter : IAuthorizationFilter, IOrderedFilter
{
public int Order => -1; // Ensures that it runs first before basic Authorize filter
public void OnAuthorization(AuthorizationFilterContext context)
{
if (!context.HttpContext.User.Identity.IsAuthenticated)
{
if (context.HttpContext.Session.IsAvailable
&& context.HttpContext.Session.TryGetValue("_SessionUser", out byte[] _user))
{
SessionUser su = (SessionUser)this.ByteArrayToObject(_user);
GenericPrincipal principal = this.CreateGenericPrincipal(su.IdentityName, su.Type, su.Roles);
context.HttpContext.User = principal;
}
else
{
const string MyHeaderToken = "HTTP_KEY";
string userSSO = null;
if (!string.IsNullOrWhiteSpace(context.HttpContext.Request.Headers[MyHeaderToken]))
{
userSSO = context.HttpContext.Request.Headers[MyHeaderToken];
}
userSSO = "TestUser";
if (string.IsNullOrWhiteSpace(userSSO))
{
//filterContext.Result = new unh();
}
else
{
string identityType = "My";
string[] methods = new string[0]; // GetMethods(userSSO);
// Create GenericPrincipal
GenericPrincipal principal = this.CreateGenericPrincipal(userSSO, identityType, methods);
context.HttpContext.User = principal;
if (context.HttpContext.Session.IsAvailable)
{
SessionUser su = new SessionUser()
{
IdentityName = principal.Identity.Name,
Type = principal.Identity.AuthenticationType,
Roles = methods
};
byte[] _sessionUser = this.ObjectToByteArray(su);
context.HttpContext.Session.Set("_SessionUser", _sessionUser);
}
}
}
}
}
private GenericPrincipal CreateGenericPrincipal(string name, string type, string[] roles)
{
GenericIdentity webIdentity = new GenericIdentity(name, type);
GenericPrincipal principal = new GenericPrincipal(webIdentity, roles);
return principal;
}
// Convert an object to a byte array
private byte[] ObjectToByteArray(Object obj)
{
BinaryFormatter bf = new BinaryFormatter();
using (var ms = new MemoryStream())
{
bf.Serialize(ms, obj);
return ms.ToArray();
}
}
// Convert a byte array to an Object
private Object ByteArrayToObject(byte[] arrBytes)
{
using (var memStream = new MemoryStream())
{
var binForm = new BinaryFormatter();
memStream.Write(arrBytes, 0, arrBytes.Length);
memStream.Seek(0, SeekOrigin.Begin);
var obj = binForm.Deserialize(memStream);
return obj;
}
}
[Serializable]
private class SessionUser
{
public string IdentityName { get; set; }
public string Type { get; set; }
public string[] Roles { get; set; }
}
}
public class MyAuthorizationAttribute : TypeFilterAttribute
{
public MyAuthorizationAttribute()
: base(typeof(MyAuthorizationFilter))
{
}
}
On Startup.cs > Configure call app.UseSession(); immediately after app.UseRouting() so that session will be available during authorization.
The code above will set the current HTTP Context's user and save it on session. Subsequent requests will attempt to use the user stored on the session. This will also make the DI container manage the lifetime of the filter. Read more about it in Filters in ASP.NET Core.
I do not recommend you follow this approach. Please do either cookie or token-based authentication by taking advantage of the authentication middleware in .NET Core.
Once the request reaches the action execution, context.HttpContext.User.Identity.IsAuthenticated will now be true.

Skip JWT Auth during Tests ASP.Net Core 3.1 Web Api

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());
}
}

Asp .net Core 3.1 transforming ClaimsIdentity with multiple AuthenticationScheme

I've faced a problem while implementing a couple of authorization schemes in ASP .Net Core application. Lets say they are declared like this in Startup.cs
public void ConfigureServices(IServiceCollection services)
{
AuthenticationBuilder builder = services
.AddAuthentication()
.AddBasicAuthentication(o => { o.Realm = "MyRealm"; })
.AddApiKeyAuthentication()
.AddBearerToken(opt => { });
}
Each of these schemes provide its own implementation of AuthenticationHandler returning ClaimsIdentity if succeeded. But in each case the structure of claims are incosistent, i.e. ApiKeyAuthentication may return ClaimsIdentity with business-sensitive data stored in claim "api_service" while BearerTokenScheme will store it in a claim "sub", and I dont have control over this. So if I would like to use this information in a controller to associate some process with a service which have called my api method, I have to implement some complicated logic that would analyze current ClaimsIdentity, its auth scheme and set of claims.
Instead I would like to implement some sort of tranformation of ClaimsIdentity into MyServiceClaimsIdentity which would expose claims in a handy way so I can utilize them easily in my Controllers code:
public class MyServiceClaimsIdentity: IIdentity
{
private readonly ClaimsIdentity innerIdentity;
public Guid? UserId {get; }
public string UserName {get; }
public string ServiceName {get; }
public MyServiceClaimsIdentity(ClaimsIdentity identity)
{
this.innerIdentity = identity;
TransformClaimsIntoProperties();
}
private void TransformClaimsIntoProperties()
{
......
}
}
I've tried to implement some sort of "transformative" AuthenticationHandler which would produce MyServiceClaimsIdentity after all other handlers would produce their ClaimsIdentity.
public class FinalAuthenticationHandler : AuthenticationHandler<FinalAuthenticationOptions>
{
public FinalAuthenticationHandler(
IOptionsMonitor<FinalAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!this.Context.User.Identity.IsAuthenticated)
{
return null;
}
var identity = new MyServiceClaimsIdentity(this.Context.User.Identity);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, this.Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}
Too bad at this point this.Context.User.Identity doesnt have any information of an user, so I'm confused where to put this tranformation logic or how would I get current ClaimsIdentity provided by other Handler in my FinalAuthenticationHandler. Any help would be appreciated.
Implementing IClaimsTransformation and registering it as a Singleton did the job just fine
internal sealed class ClaimsTransformation : IClaimsTransformation
{
private readonly IDictionary<string, IClaimsHandler> handlersMap;
public ClaimsTransformation(IEnumerable<IClaimsHandler> handlers)
{
if (handlers == null)
{
throw new ArgumentNullException(nameof(handlers));
}
this.handlersMap = handlers.ToDictionary(t => t.SchemeName);
}
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (!(principal.Identity is ClaimsIdentity claimsIdentity))
{
throw new InvalidOperationException($"Principal.Identity is of type {principal.Identity.GetType()}, expected ClaimsIdentity");
}
if (!this.handlersMap.TryGetValue(principal.Identity.AuthenticationType, out var handler))
{
throw new AuthenticationException($"Scheme of type {principal.Identity.AuthenticationType} is not supported");
}
var result = new ClaimsPrincipal(handler.Handle(claimsIdentity));
return Task.FromResult(result);
}
}

Using Azure Active Directory OAuth with Identity Model in ASP.NET Core 2.0

The problem statement
We are developing a new enterprise level application and want to utilize Azure Active Directory for signing into the application so that we do not have to create another set of user credentials. However, our permissions model for this application is more complex than what can be handled via groups inside of AAD.
The thought
The thought was that we could use Azure Active Directory OAuth 2.0 in addition to the ASP.NET Core Identity framework to force users to authenticate through Azure Active Directory and then use the identity framework to handle authorization/permissions.
The Issues
You can create projects out of the box using Azure OpenId authentication and then you can easily add Microsoft account authentication (Not AAD) to any project using Identity framework. But there was nothing built in to add OAuth for AAD to the identity model.
After trying to hack those methods to get them to work like I needed I finally went through trying to home-brew my own solution building off of the OAuthHandler and OAuthOptions classes.
I ran into a lot of issues going down this route but managed to work through most of them. Now I am to a point where I am getting a token back from the endpoint but my ClaimsIdentity doesn't appear to be valid. Then when redirecting to the ExternalLoginCallback my SigninManager is unable to get the external login information.
There almost certainly must be something simple that I am missing but I can't seem to determine what it is.
The Code
Startup.cs
services.AddAuthentication()
.AddAzureAd(options =>
{
options.ClientId = Configuration["AzureAd:ClientId"];
options.AuthorizationEndpoint = $"{Configuration["AzureAd:Instance"]}{Configuration["AzureAd:TenantId"]}/oauth2/authorize";
options.TokenEndpoint = $"{Configuration["AzureAd:Instance"]}{Configuration["AzureAd:TenantId"]}/oauth2/token";
options.UserInformationEndpoint = $"{Configuration["AzureAd:Instance"]}{Configuration["AzureAd:TenantId"]}/openid/userinfo";
options.Resource = Configuration["AzureAd:ClientId"];
options.ClientSecret = Configuration["AzureAd:ClientSecret"];
options.CallbackPath = Configuration["AzureAd:CallbackPath"];
});
AzureADExtensions
namespace Microsoft.AspNetCore.Authentication.AzureAD
{
public static class AzureAdExtensions
{
public static AuthenticationBuilder AddAzureAd(this AuthenticationBuilder builder)
=> builder.AddAzureAd(_ => { });
public static AuthenticationBuilder AddAzureAd(this AuthenticationBuilder builder, Action<AzureAdOptions> configureOptions)
{
return builder.AddOAuth<AzureAdOptions, AzureAdHandler>(AzureAdDefaults.AuthenticationScheme, AzureAdDefaults.DisplayName, configureOptions);
}
public static ChallengeResult ChallengeAzureAD(this ControllerBase controllerBase, SignInManager<ApplicationUser> signInManager, string redirectUrl)
{
return controllerBase.Challenge(signInManager.ConfigureExternalAuthenticationProperties(AzureAdDefaults.AuthenticationScheme, redirectUrl), AzureAdDefaults.AuthenticationScheme);
}
}
}
AzureADOptions & Defaults
public class AzureAdOptions : OAuthOptions
{
public string Instance { get; set; }
public string Resource { get; set; }
public string TenantId { get; set; }
public AzureAdOptions()
{
CallbackPath = new PathString("/signin-azureAd");
AuthorizationEndpoint = AzureAdDefaults.AuthorizationEndpoint;
TokenEndpoint = AzureAdDefaults.TokenEndpoint;
UserInformationEndpoint = AzureAdDefaults.UserInformationEndpoint;
Scope.Add("https://graph.windows.net/user.read");
ClaimActions.MapJsonKey("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "unique_name");
ClaimActions.MapJsonKey("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", "given_name");
ClaimActions.MapJsonKey("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", "family_name");
ClaimActions.MapJsonKey("http://schemas.microsoft.com/ws/2008/06/identity/claims/groups", "groups");
ClaimActions.MapJsonKey("http://schemas.microsoft.com/identity/claims/objectidentifier", "oid");
ClaimActions.MapJsonKey("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", "roles");
}
}
public static class AzureAdDefaults
{
public static readonly string DisplayName = "AzureAD";
public static readonly string AuthorizationEndpoint = "https://login.microsoftonline.com/common/oauth2/authorize";
public static readonly string TokenEndpoint = "https://login.microsoftonline.com/common/oauth2/token";
public static readonly string UserInformationEndpoint = "https://login.microsoftonline.com/common/openid/userinfo"; // "https://graph.windows.net/v1.0/me";
public const string AuthenticationScheme = "AzureAD";
}
AzureADHandler
internal class AzureAdHandler : OAuthHandler<AzureAdOptions>
{
public AzureAdHandler(IOptionsMonitor<AzureAdOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override async Task<AuthenticationTicket> CreateTicketAsync(ClaimsIdentity identity, AuthenticationProperties properties, OAuthTokenResponse tokens)
{
HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint);
httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
HttpResponseMessage httpResponseMessage = await Backchannel.SendAsync(httpRequestMessage, Context.RequestAborted);
if (!httpResponseMessage.IsSuccessStatusCode)
throw new HttpRequestException(message: $"Failed to retrived Azure AD user information ({httpResponseMessage.StatusCode}) Please check if the authentication information is correct and the corresponding Microsoft Account API is enabled.");
JObject user = JObject.Parse(await httpResponseMessage.Content.ReadAsStringAsync());
OAuthCreatingTicketContext context = new OAuthCreatingTicketContext(new ClaimsPrincipal(identity), properties, Context, Scheme, Options, Backchannel, tokens, user);
context.RunClaimActions();
await Events.CreatingTicket(context);
return new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name);
}
protected override async Task<OAuthTokenResponse> ExchangeCodeAsync(string code, string redirectUri)
{
Dictionary<string, string> dictionary = new Dictionary<string, string>();
dictionary.Add("grant_type", "authorization_code");
dictionary.Add("client_id", Options.ClientId);
dictionary.Add("redirect_uri", redirectUri);
dictionary.Add("client_secret", Options.ClientSecret);
dictionary.Add(nameof(code), code);
dictionary.Add("resource", Options.Resource);
HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, Options.TokenEndpoint);
httpRequestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
httpRequestMessage.Content = new FormUrlEncodedContent(dictionary);
HttpResponseMessage response = await Backchannel.SendAsync(httpRequestMessage, Context.RequestAborted);
if (response.IsSuccessStatusCode)
return OAuthTokenResponse.Success(JObject.Parse(await response.Content.ReadAsStringAsync()));
return OAuthTokenResponse.Failed(new Exception(string.Concat("OAuth token endpoint failure: ", await Display(response))));
}
protected override string BuildChallengeUrl(AuthenticationProperties properties, string redirectUri)
{
Dictionary<string, string> dictionary = new Dictionary<string, string>();
dictionary.Add("client_id", Options.ClientId);
dictionary.Add("scope", FormatScope());
dictionary.Add("response_type", "code");
dictionary.Add("redirect_uri", redirectUri);
dictionary.Add("state", Options.StateDataFormat.Protect(properties));
dictionary.Add("resource", Options.Resource);
return QueryHelpers.AddQueryString(Options.AuthorizationEndpoint, dictionary);
}
private static async Task<string> Display(HttpResponseMessage response)
{
StringBuilder output = new StringBuilder();
output.Append($"Status: { response.StatusCode };");
output.Append($"Headers: { response.Headers.ToString() };");
output.Append($"Body: { await response.Content.ReadAsStringAsync() };");
return output.ToString();
}
}
AccountController.cs
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> SignIn()
{
var redirectUrl = Url.Action(nameof(ExternalLoginCallback), "Account");
return this.ChallengeAzureAD(_signInManager, redirectUrl);
}
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> ExternalLoginCallback(string returnUrl = null, string remoteError = null)
{
if (remoteError != null)
{
_logger.LogInformation($"Error from external provider: {remoteError}");
return RedirectToAction(nameof(SignedOut));
}
var info = await _signInManager.GetExternalLoginInfoAsync();
if (info == null) //This always ends up true!
{
return RedirectToAction(nameof(SignedOut));
}
}
There you have it!
This is the code I have, and I'm almost sure that at this point there is something simple I am missing but am unsure of what it is. I know that my CreateTicketAsync method is problematic as well since I'm not hitting the correct user information endpoint (or hitting it correctly) but that's another problem all together as from what I understand the claims I care about should come back as part of the token.
Any assistance would be greatly appreciated!
I ended up resolving my own problem as it ended up being several issues. I was passing the wrong value in for the resource field, hadn't set my NameIdentifer mapping correctly and then had the wrong endpoint for pulling down user information. The user information piece being the biggest as that is the token I found out that the external login piece was looking for.
Updated Code
Startup.cs
services.AddAuthentication()
.AddAzureAd(options =>
{
options.ClientId = Configuration["AzureAd:ClientId"];
options.AuthorizationEndpoint = $"{Configuration["AzureAd:Instance"]}{Configuration["AzureAd:TenantId"]}/oauth2/authorize";
options.TokenEndpoint = $"{Configuration["AzureAd:Instance"]}{Configuration["AzureAd:TenantId"]}/oauth2/token";
options.ClientSecret = Configuration["AzureAd:ClientSecret"];
options.CallbackPath = Configuration["AzureAd:CallbackPath"];
});
AzureADOptions & Defaults
public class AzureAdOptions : OAuthOptions
{
public string Instance { get; set; }
public string Resource { get; set; }
public string TenantId { get; set; }
public AzureAdOptions()
{
CallbackPath = new PathString("/signin-azureAd");
AuthorizationEndpoint = AzureAdDefaults.AuthorizationEndpoint;
TokenEndpoint = AzureAdDefaults.TokenEndpoint;
UserInformationEndpoint = AzureAdDefaults.UserInformationEndpoint;
Resource = AzureAdDefaults.Resource;
Scope.Add("user.read");
ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
ClaimActions.MapJsonKey(ClaimTypes.Name, "displayName");
ClaimActions.MapJsonKey(ClaimTypes.GivenName, "givenName");
ClaimActions.MapJsonKey(ClaimTypes.Surname, "surname");
ClaimActions.MapJsonKey(ClaimTypes.MobilePhone, "mobilePhone");
ClaimActions.MapCustomJson(ClaimTypes.Email, user => user.Value<string>("mail") ?? user.Value<string>("userPrincipalName"));
}
}
public static class AzureAdDefaults
{
public static readonly string DisplayName = "AzureAD";
public static readonly string AuthorizationEndpoint = "https://login.microsoftonline.com/common/oauth2/authorize";
public static readonly string TokenEndpoint = "https://login.microsoftonline.com/common/oauth2/token";
public static readonly string Resource = "https://graph.microsoft.com";
public static readonly string UserInformationEndpoint = "https://graph.microsoft.com/v1.0/me";
public const string AuthenticationScheme = "AzureAD";
}

Categories

Resources