How to do Azure AD groups based authorization? - asp.net-core

net core web api application. I have configured swagger for my web api app. I am doing authentication and authorization from swagger and I do not have webapp or SPA. Now I want to do authorization based on groups. When I saw JWT token I saw hasgroups: true rather than group ids. This is changed If more than 5 groups are associated with user. Please correct me If my understanding is wrong. So I have now hasgroups: true. So to get groups I need to call graph api. Once I get groups from graph API I need to create policies. This is my understanding and please correct me If I am on wrong track. Now I have my below web api app.
Startup.cs
public Startup(IConfiguration configuration)
{
Configuration = configuration;
azureActiveDirectoryOptions = Configuration.GetSection("AzureAd").Get<AzureActiveDirectoryOptions>();
swaggerUIOptions = Configuration.GetSection("Swagger").Get<SwaggerUIOptions>();
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services
.AddAuthentication(o =>
{
o.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(o =>
{
o.Authority = azureActiveDirectoryOptions.Authority;
o.TokenValidationParameters = new TokenValidationParameters
{
ValidAudiences = new List<string>
{
azureActiveDirectoryOptions.AppIdUri,
azureActiveDirectoryOptions.ClientId
},
};
});
services.AddMvc(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
})
.SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new Info { Title = "My API", Version = "v1" });
c.AddSecurityDefinition("oauth2", new OAuth2Scheme
{
Type = "oauth2",
Flow = "implicit",
AuthorizationUrl = swaggerUIOptions.AuthorizationUrl,
TokenUrl = swaggerUIOptions.TokenUrl
});
c.AddSecurityRequirement(new Dictionary<string, IEnumerable<string>>
{
{ "oauth2", new[] { "readAccess", "writeAccess" } }
});
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.OAuthClientId(swaggerUIOptions.ClientId);
c.OAuthClientSecret(swaggerUIOptions.ClientSecret);
c.OAuthRealm(azureActiveDirectoryOptions.ClientId);
c.OAuthAppName("Swagger");
c.OAuthAdditionalQueryStringParams(new { resource = azureActiveDirectoryOptions.ClientId });
c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1");
});
app.UseAuthentication();
app.UseMvc();
}
}
I have API as below.
[Authorize]
[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
private IHttpContextAccessor _httpContextAccessor;
public ValuesController(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
// GET api/values
[HttpGet]
public ActionResult<string> Get()
{
string owner = (User.FindFirst(ClaimTypes.Name))?.Value;
var accessToken = _httpContextAccessor.HttpContext.Request.Headers["Authorization"];
return owner;
}
}
Now After log in I can hit to API. Now I want to have something like Authorize(admin/user) based on the groups I want to control authorization. Now I am having trouble, where I should call graph api and get groups. Can some one help me to understand this? Any help would be appreciated. Thanks

Which protol and which flow you are using. ?

Yes , implict flow has the limit for groups claim . To use Microsoft Graph to get current user's groups , you can try below ways :
Use the on-behalf-of grant to acquire an access token that allows the API to call MS Graph as the user , here is code sample .
Use client credentials flow to acquire Microsoft Graph's access token in web api, this flow uses application's permission with no user context . Code sample here is for Azure AD V1.0 using ADAL . And here is code sample for Azure AD V2.0 using MSAL .

Related

Getting 401 from custom Api when using Microsoft.Identity.platform to protect api

I am following the tutorial from Microsfot.document for how to protect api using Azure AD (Microsoft Identity).
The steps I took are following: Sorry I tried to put information that might be helpful but too much to get to the issue most of the time contributors ask for screenshot or the code.
I followed several documents and video tutorials but here is the link for one of them: https://learn.microsoft.com/en-us/learn/modules/identity-secure-custom-api/2-secure-api-microsoft-identity
WebApi.
Created a webapi using core 5. Register it in Azure AD.
Created single scope 'check' and allowed permission to user and admin.
Webapp
Created webapp using .net(classic) Note that webapi is core 5.
Created a webapp and register it in Azure AD.
Setup the authentication and created a OnAuthorizationCodeReceived to get the access token to call the api.
Configuration:
1.From Azure AD->Registration for Webapi-> selected application(web app created above) and give permission to the scope.
2. For Azure AD->Registration for webapp-> Access permission->delegate->selected the scope.
Test:
1.Run the test. At this point; I do not have [Authorization] on the api method which I am calling.
2. Webapp successfully able to get the string which is returned by the api. Somewhat I get the idea that plumbing was right.
Added [Authorize] filter on the app method.
Result 401 unauthorized.
I have checked multiple times and looked at multiple tutorial and rewrote my code, watched several videos and updated my code but I am always getting 401 error.
Below is the code;
Api controller:
namespace Utility.Controllers
{
[Authorize]
[Route("api/[controller]")]
[ApiController]
public class AzAdUtility : ControllerBase
{
// GET: api/<AzAdUtility>
[HttpGet]
public string Get()
{
//HttpContext.VerifyUserHasAnyAcceptedScope(new string[] {"check"});
var name = "Vaqas";
return name;
}
}
}
Api startup :
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(Configuration.GetSection("AzureAd"));
services.AddControllers();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "GlobalNetApiUtility", Version = "v1" });
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Utility v1"));
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
Api Appsettings:
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "myorg.onmicrosoft.com",
"ClientId": "abcd.............................",
"TenantId": "dabcd.............................."
},
Webapp startup:
Only adding startup page because at first all I am doing getting some data for testing purpose in the OnAuthorizationCodeReceived.
public class Startup
{
// The Client ID is used by the application to uniquely identify itself to Azure AD.
static string clientId = System.Configuration.ConfigurationManager.AppSettings["ClientId"];
// RedirectUri is the URL where the user will be redirected to after they sign in.
string redirectUri = System.Configuration.ConfigurationManager.AppSettings["RedirectUri"];
// Tenant is the tenant ID (e.g. contoso.onmicrosoft.com, or 'common' for multi-tenant)
static string tenant = System.Configuration.ConfigurationManager.AppSettings["Tenant"];
// Authority is the URL for authority, composed by Microsoft identity platform endpoint and the tenant name (e.g. https://login.microsoftonline.com/contoso.onmicrosoft.com/v2.0)
string authority = String.Format(System.Globalization.CultureInfo.InvariantCulture, System.Configuration.ConfigurationManager.AppSettings["Authority"], tenant);
//string authority = "https://login.microsoftonline.com/" + tenant + "/adminconsent?client_id=" + clientId;
string clientSecret = System.Configuration.ConfigurationManager.AppSettings["ClientSecret"];
/// <summary>
/// Configure OWIN to use OpenIdConnect
/// </summary>
/// <param name="app"></param>
public void Configuration(IAppBuilder app)
{
app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
app.UseCookieAuthentication(new CookieAuthenticationOptions());
app.UseOpenIdConnectAuthentication(
new OpenIdConnectAuthenticationOptions
{
// Sets the ClientId, authority, RedirectUri as obtained from web.config
ClientId = clientId,
Authority = authority,
RedirectUri = redirectUri,
// PostLogoutRedirectUri is the page that users will be redirected to after sign-out. In this case, it is using the home page
PostLogoutRedirectUri = redirectUri,
Scope = OpenIdConnectScope.OpenIdProfile,
// ResponseType is set to request the code id_token - which contains basic information about the signed-in user
//ResponseType = OpenIdConnectResponseType.CodeIdToken,
ResponseType = OpenIdConnectResponseType.CodeIdToken,
// OpenIdConnectAuthenticationNotifications configures OWIN to send notification of failed authentications to OnAuthenticationFailed method
Notifications = new OpenIdConnectAuthenticationNotifications
{
AuthorizationCodeReceived = OnAuthorizationCodeReceived,
AuthenticationFailed = OnAuthenticationFailed
}
}
);
}
private async Task OnAuthorizationCodeReceived(AuthorizationCodeReceivedNotification notification)
{
notification.HandleCodeRedemption();
var idClient = ConfidentialClientApplicationBuilder.Create(clientId)
.WithRedirectUri(redirectUri)
.WithClientSecret(clientSecret)
.WithAuthority(authority)
.Build();
try
{
var apiScope = "api://28......................../check2 api://28................/check api://28...........................1d/AzAdUtility.Get";
string[] scopes = apiScope.Split(' ');
//gettig the token no issues.
var result = await idClient.AcquireTokenByAuthorizationCode(
scopes, notification.Code).ExecuteAsync();
var myurl = "https://localhost:99356/api/AzAdUtility";
var client = new HttpClient();
// var accessToken = await tokenAcquisition.GetAccessTokenForUserAsync(Constants.ProductCatalogAPI.SCOPES);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", result.AccessToken); //accessToken
var json = await client.GetStringAsync(myurl);
var serializerOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
//getting 401 response
var checkResponse = JsonSerializer.Deserialize(json, typeof(string), serializerOptions) as string;
}
catch (Exception ex)
{
string message = "AcquireTokenByAuthorizationCodeAsync threw an exception";
notification.HandleResponse();
notification.Response.Redirect($"/Home/Error?message={message}&debug={ex.Message}");
}
}
/// <summary>
/// Handle failed authentication requests by redirecting the user to the home page with an error in the query string
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
private Task OnAuthenticationFailed(AuthenticationFailedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> context)
{
context.HandleResponse();
context.Response.Redirect("Error/AccessDenied/?errormessage=" + context.Exception.Message);
return Task.FromResult(0);
}
}
In Api startup class I was missing app.UseAuthentication().
I never really thought that would be an issue. Once I added this code. I got the expected response rather than 401 Unauthorized error

How do I authorise the Hangfire Dashboard via Microsoft Single Sign-On with Angular 12 and ASP.Net Core 5

My application is an Angular 12 application running on ASP.Net Core 5.
I am currently trying to lock down Hangfire so that it will only work for people with the Admin role.
It uses Microsoft Identity to log in - specifically Single Sign-on, set up in Azure.
public void ConfigureServices(IServiceCollection services)
{
...
services.AddHangfire(x =>
{
x.UseSqlServerStorage(sqlServerConnectionString);
});
...
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(Configuration);
...
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
...
app.UseAuthentication();
app.UseRouting();
app.UseAuthorization();
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = new[] {
new HangfireAuthorisationFilter()
},
AppPath = "/"
});
...
app.UseEndpoints(endpoints => {
...
});
app.UseSpa(spa=>{
...
});
}
This works in my dot net core controllers.
All I need to do to get it to work is add the Authorize attribute:
namespace MyAppName.Controllers
{
[Produces("application/json")]
[Route("api/MyRoute")]
[Authorize(Roles="Role1,Role2,Administrator")]
public class MyControllerController: MyBaseApiController
{
...
}
}
But when I want to Authorise in Hangfire, the User object is missing a whole lot of its properties.
Here is the HangfireAuthorisationFilter:
public class HangfireAuthorisationFilter : IDashboardAuthorizationFilter
{
public HangfireAuthorisationFilter()
{
}
public bool Authorize(DashboardContext context)
{
var httpContext = context.GetHttpContext();
// the next line always fails. The User object is set. The Identity object is set
// but there are no claims and the User.Name is null. There are also no roles set.
return httpContext.User.Identity.IsAuthenticated;
}
}
There is, however, cookie information, containing the msal cookie:
How can I pass authentication information into the Hangfire Authorize method? How can I access the role information so that I can lock it down to just the Admin role? Is there a way I can decode the msal cookie server-side?
Assuming you have an AzureAd configuration block that looks like below:
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "[Enter the domain of your tenant, e.g. contoso.onmicrosoft.com]",
"TenantId": "[Enter 'common', or 'organizations' or the Tenant Id (Obtained from the Azure portal. Select 'Endpoints' from the 'App registrations' blade and use the GUID in any of the URLs), e.g. da41245a5-11b3-996c-00a8-4d99re19f292]",
"ClientId": "[Enter the Client Id (Application ID obtained from the Azure portal), e.g. ba74781c2-53c2-442a-97c2-3d60re42f403]"
}
I think a better approach to avoid manual validation of the token is to change your code to the following:
public void ConfigureServices(IServiceCollection services)
{
services.AddHangfire(x =>
{
x.UseSqlServerStorage(sqlServerConnectionString);
});
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(Configuration);
services.
.AddAuthentication(AzureADDefaults.AuthenticationScheme)
.AddAzureAD(options => Configuration.Bind("AzureAd", options));
services.AddAuthorization(options =>
{
options.AddPolicy("Hangfire", builder =>
{
builder
.AddAuthenticationSchemes(AzureADDefaults.AuthenticationScheme)
.RequireRole("Admin")
.RequireAuthenticatedUser();
});
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseAuthentication();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapHangfireDashboard("/hangfire", new DashboardOptions()
{
Authorization = Enumerable.Empty<IDashboardAuthorizationFilter>()
})
.RequireAuthorization("Hangfire");
});
}
To break this down, the following changes have been made:
Add authentication for AzureADDefaults.AuthenticationScheme so we can create a policy requiring the "Admin" role.
Add a policy named "Hangfire" that requires the "Admin" role against a user. See the AddAuthorization call.
Instead of calling UseHangfireDashboard we call MapHangfireDashboard inside UseEndpoints and protect the hangfire dashboard endpoint using our "Hangfire" policy through the call to RequireAuthorization("Hangfire")
Removal off the HangfireAuthorisationFilter which is not needed and instead we pass an empty collection of filters in the MapHangfireDashboard call.
The key takeaway is that we are now relying on the security provided by the middleware rather than the implementation of IDashboardAuthorizationFilter which comes with huge risk around the token being invalid and/or a mistake is made in the logic.
Ok I have figured out how to decode the msal cookie to get my list of claims and roles, and authorise successfully with Hangfire
using Hangfire.Dashboard;
using System.IdentityModel.Tokens.Jwt;
namespace MyApp.Filters
{
public class HangfireAuthorisationFilter : IDashboardAuthorizationFilter
{
public HangfireAuthorisationFilter()
{
}
public bool Authorize(DashboardContext context)
{
var httpContext = context.GetHttpContext();
var cookies = httpContext.Request.Cookies;
var msalIdToken = cookies["msal.{your app client id goes here}.idtoken"];
var token = new JwtSecurityTokenHandler().ReadJwtToken(msalIdToken);
foreach(var claim in token.Claims)
{
if (claim.Type=="roles" && claim.Value == "Admin")
{
return true;
}
}
return false;
}
}
}

Create Role-Based Authorization with Windows Authentication

I have an ASP.NET Core application where I want to add role-based authentication. I'm using Windows Authentication because it's an intranet app. I already have a custom database that contains the users/roles that frankly doesn't map to the fields in the IdentityFramework. I can easily get the logged-in user's name via the Context.User.Identity.Name. I then want to look up the user in the custom user/roles table in order to get the available roles for that user. Then I want to use an annotation-based authentication filter decorated at the Controller or Action method level. For example, [Authorize(roles="admin")].
I was able to get this working by turning off Windows Authentication and using Forms Authentication with Cookies. In the AccountController I ran code like this:
using(LDAPConnection connection = new LDAPConnection(loginModel.UserName,loginModel.Password))
{
List<Claim> claims = new List<Claim> {
new Claim(ClaimTypes.Name, loginModel.UserName),
new Claim(ClaimTypes.Role, "admin")
};
ClaimsIdentity userIdentity = new ClaimsIdentity(claims,"login");
ClaimsPrincipal principal = new ClaimsPrincipal(userIdentity);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(principal),
new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTime.Now.AddDays(200)
});
return Redirect("/");
}
I would then store the claims in a cookie. Then when I decorate the Controller with [Authorize(roles="admin")], I'm able to retrieve the View without issues. The authorization works. I would like to replicate this same functionality for WindowsAuthentication without logging the user in. I have tried using a ClaimsTransformer and implementing Policy-based authorization, which works. But if I decorate it with [Authorize(roles="admin")] it bombs when I navigate to the action method. Here is the ClaimsTransformer:
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var identity = (ClaimsIdentity)principal.Identity;
List<Claim> claims = new List<Claim> {
new Claim(ClaimTypes.Name, identity.Name),
new Claim(ClaimTypes.Role, "admin")
};
identity.AddClaims(claims);
return Task.FromResult(principal);
}
What piece am I missing in order to use the [Authorize(Roles="admin")] working? BTW, I'm currently using ASP.NET Core 2.2.
You could write a custom Policy Authorization handlers in which you get all User's Roles and check if they contains your desired role name.
Refer to following steps:
1.Create CheckUserRoleRequirement(accept a parameter)
public class CheckUserRoleRequirement: IAuthorizationRequirement
{
public string RoleName { get; private set; }
public CheckUserRoleRequirement(string roleName)
{
RoleName = roleName;
}
}
2.Create CheckUserRoleHandler
public class CheckUserRoleHandler : AuthorizationHandler<CheckUserRoleRequirement>
{
private readonly IServiceProvider _serviceProvider;
public CheckUserRoleHandler(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
CheckUserRoleRequirement requirement)
{
var name = context.User.Identity.Name;
using (var scope = _serviceProvider.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<YourDbContext>();
//your logic to look up the user in the custom user/roles table in order to get the available roles for that user
List<string> roles = dbContext.UserRoles.Where(...;
if (roles != null && roles.Contains(requirement.RoleName))
{
context.Succeed(requirement);
}
}
return Task.CompletedTask;
}
}
3.Register Handler in ConfigureServices
services.AddAuthorization(options =>
{
options.AddPolicy("AdminRole", policy =>
policy.Requirements.Add(new CheckUserRoleRequirement("Admin")));
});
services.AddSingleton<IAuthorizationHandler, CheckUserRoleHandler>();
4.Usage
[Authorize(Policy = "AdminRole")]
I know this is a bit of a late answer, but I've been troubleshooting the same issue today and none of the answers I've seen on similar posts have fixed my issue.
Here are the steps I took to be able to use [Authorize(Roles = "Admin")] on my controller with Windows authentication.
Double check that UseAuthentication() comes before UseAuthorization() in the Configure() method of Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication(); // <--- this needs to be before
app.UseAuthorization(); // <----this
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Hccc}/{action=Index}/");
});
}
Have a claims transformer to handle the necessary roles. For example,
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var ci = (ClaimsIdentity)principal.Identity;
var user = UserAuth.GetUserRole(ci.Name); // gets my user from db with role
// handle your roles however you need.
foreach(var role in user.Roles)
{
var roleClaim = new Claim(ci.RoleClaimType, role.RoleName);
ci.AddClaim(roleClaim);
}
return Task.FromResult(principal);
}
Set up the ConfigureServices() method in Startup.cs to handle authorization
services.AddSingleton<IClaimsTransformation, ClaimsTransformer>();
// Implement a policy called "AdminOnly" that uses "Windows" authentication
// The policy requires Role "Admin"
services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
{
policy.AddAuthenticationSchemes("Windows");
policy.RequireRole("Admin");
});
});
services.AddMvc();
services.AddControllersWithViews();
Use the [Authorize] tag to implement the policy. For my case, I wanted to block access to a controller unless the user was an "Admin".
[Authorize(Policy = "AdminOnly")]
public class UsersController : Controller
{
}

Version problem using MSAL to access Azure AD and get back an auth id token

Asp.Net Core v2.2.0
Microsoft.AspNetCore.Authentication.AzureAD.UI v2.2.0
Microsoft.Identity.Client v4.2.1
I'm receiving the following error when logging into Azure AD and then requesting an auth id token:
While searching for a solution, the closest thing I've found is that there's an issue with using two different versions of the auth api. V2 uses login.microsoftonline.com and V1 uses sts.windows.net. The question I have is how to get everything in the MSAL library to use V2.
Here's my Startup class. It's based (largely copied) from the doc: Web app that calls web APIs - code configuration
public class Startup
{
private const string AzureAdConfigSectionName = "AzureAd";
private ConfidentialClientApplicationOptions applicationOptions;
private AzureADOptions azureAdOptions;
private MsalPerUserSessionTokenCacheProvider userTokenCacheProvider;
private MsalAppSessionTokenCacheProvider appTokenCacheProvider;
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
applicationOptions = new ConfidentialClientApplicationOptions();
Configuration.Bind(AzureAdConfigSectionName, applicationOptions);
azureAdOptions = new AzureADOptions();
Configuration.Bind(AzureAdConfigSectionName, azureAdOptions);
//services.AddOptions<AzureADOptions>();
var adOptionsMonitor = services.BuildServiceProvider().GetService<IOptionsMonitor<AzureADOptions>>();
userTokenCacheProvider = new MsalPerUserSessionTokenCacheProvider();
appTokenCacheProvider = new MsalAppSessionTokenCacheProvider(adOptionsMonitor);
services.Configure<CookiePolicyOptions>(options =>
{
// This lambda determines whether user consent for non-essential cookies is needed for a given request.
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
});
services.AddAuthentication(AzureADDefaults.AuthenticationScheme)
.AddAzureAD(options => Configuration.Bind(AzureAdConfigSectionName, options));
ConfigureSession(services);
ConfigureTokenHandling(services);
services.AddMvc(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
})
.SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}
private void ConfigureSession(IServiceCollection services)
{
services.AddDistributedMemoryCache();
services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(30);
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
});
}
private void ConfigureTokenHandling(IServiceCollection services)
{
services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, options =>
{
// Response type. We ask ASP.NET to request an Auth Code, and an IDToken
options.ResponseType = OpenIdConnectResponseType.CodeIdToken;
// This "offline_access" scope is needed to get a refresh token when users sign in with
// their Microsoft personal accounts
// (it's required by MSAL.NET and automatically provided by Azure AD when users
// sign in with work or school accounts, but not with their Microsoft personal accounts)
options.Scope.Add("offline_access");
options.Scope.Add("user.read"); // for instance
// Handling the auth redemption by MSAL.NET so that a token is available in the token cache
// where it will be usable from Controllers later (through the TokenAcquisition service)
var handler = options.Events.OnAuthorizationCodeReceived;
options.Events.OnAuthorizationCodeReceived = async context =>
{
// As AcquireTokenByAuthorizationCode is asynchronous we want to tell ASP.NET core
// that we are handing the code even if it's not done yet, so that it does
// not concurrently call the Token endpoint.
context.HandleCodeRedemption();
// Call MSAL.NET AcquireTokenByAuthorizationCode
var application = BuildConfidentialClientApplication(context.HttpContext,
context.Principal);
var scopes = new [] { "user.read" };
var scopesRequestedByMsalNet = new[] { "openid", "profile", "offline_access" };
var result = await application
.AcquireTokenByAuthorizationCode(scopes.Except(scopesRequestedByMsalNet),
context.ProtocolMessage.Code)
.ExecuteAsync();
// Do not share the access token with ASP.NET Core otherwise ASP.NET will cache it
// and will not send the OAuth 2.0 request in case a further call to
// AcquireTokenByAuthorizationCodeAsync in the future for incremental consent
// (getting a code requesting more scopes)
// Share the ID Token so that the identity of the user is known in the application (in
// HttpContext.User)
context.HandleCodeRedemption(null, result.IdToken);
// Call the previous handler if any
await handler(context);
};
});
}
/// <summary>
/// Creates an MSAL Confidential client application
/// </summary>
/// <param name="httpContext">HttpContext associated with the OIDC response</param>
/// <param name="claimsPrincipal">Identity for the signed-in user</param>
/// <returns></returns>
private IConfidentialClientApplication BuildConfidentialClientApplication(HttpContext httpContext,
ClaimsPrincipal claimsPrincipal)
{
var request = httpContext.Request;
// Find the URI of the application)
var currentUri = UriHelper.BuildAbsolute(request.Scheme,
request.Host,
request.PathBase,
azureAdOptions.CallbackPath ?? String.Empty);
// Updates the authority from the instance (including national clouds) and the tenant
var authority = $"{azureAdOptions.Instance}{azureAdOptions.TenantId}/";
// Instantiates the application based on the application options (including the client secret)
var app = ConfidentialClientApplicationBuilder.CreateWithApplicationOptions(applicationOptions)
.WithRedirectUri(currentUri)
.WithAuthority(authority)
.Build();
// Initialize token cache providers. In the case of Web applications, there must be one
// token cache per user (here the key of the token cache is in the claimsPrincipal which
// contains the identity of the signed-in user)
userTokenCacheProvider?.Initialize(app.UserTokenCache, httpContext, claimsPrincipal);
appTokenCacheProvider?.Initialize(app.AppTokenCache, httpContext);
return app;
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
IdentityModelEventSource.ShowPII = true;
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios,
// see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseCookiePolicy();
app.UseSession();
app.UseAuthentication();
app.UseMvc();
}
}
The context received by the OnAuthorizationCodeReceived event, has the following:
JwtSecurityToken.Issuer = https://sts.windows.net
Not sure why, but that's where the issue is coming from.
appsettings.json
{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "{domain}",
"TenantId": "{tenant id}",
"ClientId": "{client id}",
"CallbackPath": "/signin-oidc",
"ClientSecret": "{client secret}"
},
"Logging": {
"LogLevel": {
"Default": "Warning"
}
},
"AllowedHosts": "*"
}
The problem turned out to be I was using
AzureADDefaults.OpenIdScheme
instead of
AzureADDefaults.AuthenticationScheme (Default Azure AD scheme)
Which makes perfect sense, considering the problem.

Is ASP.NET Core Identity needed for Intranet app using Windows Authentication

Using Windows Authentication in an Intranet web application I want to achieve the following:
Gather additional attributes from AD (name, employee number)
Gather additional attributes from a database table (working hours, pay)
Authorize based on application roles (not AD groups)
Authorize based on an AD attribute (has direct reports)
User not provide a username/password
In my search for an answer it is suggested that I need to add ClaimsTransformation to my application:
How do I use Windows Authentication with users in database
Populate custom claim from SQL with Windows Authenticated app in .Net Core
Caching Claims in .net core 2.0
Though I don't fully understand the solution and why ClaimsTransformation happens on every request so I'm looking for answers to the following:
Is ASP.NET Core Identity required for ClaimsTransformation to work?
Does ClaimsTransformation happen on every request with just Windows Authentication or also with form based authentication?
Does this have to happen on every request?
Caching claims like GivenName, Surname seem simple but what about roles? What steps need to be taken to ensure the database isn't hit every time but roles do get updated when there are changes.
Is there a simpler alternative for what I'm trying to do?
This article gave me some ideas and here is a possible solution.
Controllers would inherit from a base controller which has a policy that requires the Authenticated claim. When this isn't present it goes to the AccessDeniedPath and silently performs the login adding the Authenticated claim along with any other claims, if this is already present then the Access Denied message would appear.
When creating the new ClaimsIdentity I've had to strip most of the Claims in the original identity as I was getting a HTTP 400 - Bad Request (Request Header too long) error message.
Are there any obvious issues with this approach?
Startup.cs
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/Home/Login";
options.AccessDeniedPath = "/Home/AccessDenied";
});
services.AddAuthorization(options =>
{
options.AddPolicy("Authenticated",
policy => policy.RequireClaim("Authenticated"));
options.AddPolicy("Admin",
policy => policy.RequireClaim("Admin"));
});
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseBrowserLink();
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
app.UseStaticFiles();
app.UseAuthentication();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
}
}
Controller
[Authorize(Policy = "Authenticated")]
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
[Authorize(Policy = "Admin")]
public IActionResult About()
{
return View();
}
[AllowAnonymous]
public async Task<IActionResult> Login(string returnUrl)
{
var identity = ((ClaimsIdentity)HttpContext.User.Identity);
var claims = new List<Claim>
{
new Claim("Authenticated", "True"),
new Claim(ClaimTypes.Name,
identity.FindFirst(c => c.Type == ClaimTypes.Name).Value),
new Claim(ClaimTypes.PrimarySid,
identity.FindFirst(c => c.Type == ClaimTypes.PrimarySid).Value)
};
var claimsIdentity = new ClaimsIdentity(
claims,
identity.AuthenticationType,
identity.NameClaimType,
identity.RoleClaimType);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity),
new AuthenticationProperties());
return Redirect(returnUrl);
}
[AllowAnonymous]
public IActionResult AccessDenied(string returnUrl)
{
if (User.FindFirst("Authenticated") == null)
return RedirectToAction("Login", new { returnUrl });
return View();
}
}
Here is an alternative which does use IClaimsTransformation (using .NET 6)
A few notes:
In the ClaimsTransformer class it's essential to clone the existing ClaimsPrincipal and add your Claims to that, rather than trying to modify the existing one. It must then be registered as a singleton in ConfigureServices().
The technique used in mheptinstall's answer to set the AccessDeniedPath won't work here, instead I had to use the UseStatusCodePages() method in order to redirect to a custom page for 403 errors.
The new claim must be created with type newIdentity.RoleClaimType, NOT System.Security.Claims.ClaimTypes.Role, otherwise the AuthorizeAttribute (e.g. [Authorize(Roles = "Admin")]) will not work
Obviously the application will be set up to use Windows Authentication.
ClaimsTransformer.cs
public class ClaimsTransformer : IClaimsTransformation
{
// Can consume services from DI as needed, including scoped DbContexts
public ClaimsTransformer(IHttpContextAccessor httpAccessor) { }
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
// Clone current identity
var clone = principal.Clone();
var newIdentity = (ClaimsIdentity)clone.Identity;
// Get the username
var username = principal.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier || c.Type == ClaimTypes.Name).Value;
if (username == null)
{
return principal;
}
// Get the user roles from the database using the username we've just obtained
// Ideally these would be cached where possible
// ...
// Add role claims to cloned identity
foreach (var roleName in roleNamesFromDatabase)
{
var claim = new Claim(newIdentity.RoleClaimType, roleName);
newIdentity.AddClaim(claim);
}
return clone;
}
}
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(IISDefaults.AuthenticationScheme);
services.AddAuthorization();
services.AddSingleton<IClaimsTransformation, ClaimsTransformer>();
services.AddMvc().AddRazorRuntimeCompilation();
// ...
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseStatusCodePages(async context => {
if (context.HttpContext.Response.StatusCode == 403)
{
context.HttpContext.Response.Redirect("/Home/AccessDenied");
}
});
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
Example HomeController.cs
[Authorize]
public class HomeController : Controller
{
public HomeController()
{ }
public IActionResult Index()
{
return View();
}
[Authorize(Roles = "Admin")]
public IActionResult AdminOnly()
{
return View();
}
[AllowAnonymous]
public IActionResult AccessDenied()
{
return View();
}
}