IdentityServer4 losing original returnUrl when using External Login server - asp.net-core

Unfortunately, given the size of the project, I can’t easily share a reproducible version. However, hopefully what I have below will shed some light on my issue and you’ll see where I made a mistake.
I have two sites, an ASP.Net Core MVC application and a Login server, also ASP.Net Core MVC. Let’s call them http://mvc.mysite.com and http://login.mysite.com. Neither are significantly different from the IdentityServer4 Quickstart #6. The only real difference is that I have implemented an external login provider for AzureAd. My code for that is below.
Scenario 1
Given an internal login flow, where the user uses an internal login page at http://login.mysite.com everything works fine.
User visits http://mvc.mysite.com/clients/client-page-1
User is redirected to http://login.mysite.com/Account/Login
User logs in with correct username/password
User is redirected to http://mvc.mysite.com/clients/client-page-1
Scenario 2
However, if the login server’s AccountController::Login() method determines there is a single ExternalLoginProvider and executes the line “return await ExternalLogin(vm.ExternalLoginScheme, returnUrl);” then the original redirectUrl is lost.
User visits http://mvc.mysite.com/clients/client-page-1
User is redirected to http://login.mysite.com/Account/Login (receiving the output of AccountController::ExternalLogin)
User is redirected to AzureAd External OIDC Provider
User logs in with correct username/password
User is redirected to http://login.mysite.com/Account/ExternalLoginCallback
User is redirected to http://mvc.mysite.com (Notice that the user is redirected to the root of the MVC site instead of /clients/client-page-1)
For Scenario 1:
Given the MVC site
When using the debugger to inspect the Context provided to the OpenIdConnectEvents (e.g. OnMessageReceived, OnUserInformationReceived, etc.)
Then all Contexts have a Properties object that contains a RedirectUri == “http://mvc.mysite.com/clients/client-page-1”
For Scenario 2:
Given the MVC site
When using the debugger to inspect the Context provided to the OpenIdConnectEvents (e.g. OnMessageReceived, OnUserInformationReceived, etc.)
Then all Contexts have a Properties object that contains a RedirectUri == “http://mvc.mysite.com” (missing the /client.client-page-1)
In my login server’s Startup.cs I have added this to ConfigureServices:
services.AddAuthentication()
.AddAzureAd(options =>
{
Configuration.Bind("AzureAd", options);
AzureAdOptions.Settings = options;
});
The implementation of AddAzureAd is as follows: (You’ll see options objects handed around, I have replaced all uses of options with constant values except for ClientId and ClientSecret).
public static class AzureAdAuthenticationBuilderExtensions
{
public static AuthenticationBuilder AddAzureAd(this AuthenticationBuilder builder, Action<AzureAdOptions> configureOptions)
{
builder.AddOpenIdConnect("AzureAd", "Azure AD", options =>
{
var opts = new AzureAdOptions();
configureOptions(opts);
var config = new ConfigureAzureOptions(opts);
config.Configure(options);
});
return builder;
}
private class ConfigureAzureOptions : IConfigureNamedOptions<OpenIdConnectOptions>
{
private readonly AzureAdOptions _azureOptions;
public ConfigureAzureOptions(AzureAdOptions azureOptions)
{
_azureOptions = azureOptions;
}
public ConfigureAzureOptions(IOptions<AzureAdOptions> azureOptions) : this(azureOptions.Value) {}
public void Configure(string name, OpenIdConnectOptions options)
{
Configure(options);
}
public void Configure(OpenIdConnectOptions options)
{
options.ClientId = _azureOptions.ClientId;
options.Authority = "https://login.microsoftonline.com/common"; //_azureOptions.Authority;
options.UseTokenLifetime = true;
options.CallbackPath = "/signin-oidc"; // _azureOptions.CallbackPath;
options.RequireHttpsMetadata = false; // true in production // _azureOptions.RequireHttps;
options.ClientSecret = _azureOptions.ClientSecret;
// Add code for hybridflow
options.ResponseType = "id_token code";
options.TokenValidationParameters = new IdentityModel.Tokens.TokenValidationParameters
{
// instead of using the default validation (validating against a single issuer value, as we do in line of business apps),
// we inject our own multitenant validation logic
ValidateIssuer = false,
};
// Subscribing to the OIDC events
options.Events.OnAuthorizationCodeReceived = OnAuthorizationCodeReceived;
options.Events.OnAuthenticationFailed = OnAuthenticationFailed;
}
/// <summary>
/// Redeems the authorization code by calling AcquireTokenByAuthorizationCodeAsync in order to ensure
/// that the cache has a token for the signed-in user.
/// </summary>
private async Task OnAuthorizationCodeReceived(AuthorizationCodeReceivedContext context)
{
string userObjectId = (context.Principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier"))?.Value;
var authContext = new AuthenticationContext(context.Options.Authority, new NaiveSessionCache(userObjectId, context.HttpContext.Session));
var credential = new ClientCredential(context.Options.ClientId, context.Options.ClientSecret);
var authResult = await authContext.AcquireTokenByAuthorizationCodeAsync(context.TokenEndpointRequest.Code,
new Uri(context.TokenEndpointRequest.RedirectUri, UriKind.RelativeOrAbsolute), credential, context.Options.Resource);
// Notify the OIDC middleware that we already took care of code redemption.
context.HandleCodeRedemption(authResult.AccessToken, context.ProtocolMessage.IdToken);
}
private Task OnAuthenticationFailed(AuthenticationFailedContext context)
{
throw context.Exception;
}
}
}
public class NaiveSessionCache : TokenCache
{
private static readonly object FileLock = new object();
string UserObjectId = string.Empty;
string CacheId = string.Empty;
ISession Session = null;
public NaiveSessionCache(string userId, ISession session)
{
UserObjectId = userId;
CacheId = UserObjectId + "_TokenCache";
Session = session;
this.AfterAccess = AfterAccessNotification;
this.BeforeAccess = BeforeAccessNotification;
Load();
}
public void Load()
{
lock (FileLock)
this.Deserialize(Session.Get(CacheId));
}
public void Persist()
{
lock (FileLock)
{
// reflect changes in the persistent store
Session.Set(CacheId, this.Serialize());
// once the write operation took place, restore the HasStateChanged bit to false
this.HasStateChanged = false;
}
}
// Empties the persistent store.
public override void Clear()
{
base.Clear();
Session.Remove(CacheId);
}
public override void DeleteItem(TokenCacheItem item)
{
base.DeleteItem(item);
Persist();
}
// Triggered right before ADAL needs to access the cache.
// Reload the cache from the persistent store in case it changed since the last access.
void BeforeAccessNotification(TokenCacheNotificationArgs args)
{
Load();
}
// Triggered right after ADAL accessed the cache.
void AfterAccessNotification(TokenCacheNotificationArgs args)
{
// if the access operation resulted in a cache update
if (this.HasStateChanged)
Persist();
}
}

Related

ASP.NET 6 CORS problems after adding IMiddleware for authorizing users

Recently, in a project I'm working on, I added a Role creation system. There are many actions in the system and we decided to let the user create their own roles. I added an implementation of IMiddleware where I intercept every single request from the client and see whether or not that api url is included in the actions the user's role has included. The Middleware includes calls to our DB to fetch the user and to validate. I branch either into calling await next.Invoke() or throwing an UnauthorizedAccessException. We also have Fluent Validation in the mix. It frequently uses the same DB when validating and we had no problems, so I don't think the problem lies in the db. I'm uncertain how the Middleware could have interfered with CORS. I scanned the internet, even asked ChatGPT, but to no luck.
Here is the IMiddleware implementation:
public class AuthorizationMiddleware : IMiddleware
{
readonly IQueryById Query;
public AuthorizationMiddleware(IQueryById query)
{
Query = query;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
string relativePath = context.Request.Path.Value.Substring(1);
if (GetWhiteListApiUrls().Contains(relativePath))
{
await next.Invoke(context).ConfigureAwait(false);
return;
}
string requestType = context.Request.Method;
string protocol = $"{requestType}_{relativePath}";
var userId = GetUserIdFromHttpContext(context);
var user = await Query.GetById<User>(userId);
var roles = await Query.GetByIds<Role>(user.Roles.Select(x => x.Id));
var actions = roles.SelectMany(x => x.Actions);
if (!actions.Select(x => x.RelativePathProtocol).Contains(protocol))
throw new UnauthorizedAccessException();
await next.Invoke(context).ConfigureAwait(false);
}
static string GetUserIdFromHttpContext(HttpContext context)
{
string jwt = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
var handler = new JwtSecurityTokenHandler();
var token = handler.ReadToken(jwt) as JwtSecurityToken;
string userId = token.Subject.ToString();
return Common.IdUtils.GetUserId(userId);
}
}
I figured it out! We are using ServiceStack. The issue was in ordering of app.UseMiddleware();
The correct ordering is shown below:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
Licensing.RegisterLicense(Configuration["ServiceStack:Licence"]);
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseCors("CorsPolicy");
app.UseServiceStack(new AppHost(Configuration)
{
AppSettings = new NetCoreAppSettings(Configuration)
});
app.UseMiddleware<AuthorizationMiddleware>();
}

Blazor WASM Authentication and Authorization on Components and Controllers

I am developing a Blazor WASM with authentication and authorization. The idea is that the user need to login in order to be able to view the Components of the Client Project but also to be able to consume data of Controllers from Server Project which are behind the /api.
Currently I have implemented the restriction on Client components:
<AuthorizeView>
<NotAuthorized>
<div class="row">
<div class="col-md-4">
<p>Please sign in to use the Platform...</p>
</div>
</div>
</NotAuthorized>
<Authorized>
#Body
</Authorized>
</AuthorizeView>
I have also a Login and a Logout Page which are storing a Cookie for later use and perform a custom AuthenticationStateProvider
await LocalStorage.SetItemAsync<int>($"{Parameters.application}_{Parameters.enviroment}_userid", authentication.user_id);
await LocalStorage.SetItemAsync<string>($"{Parameters.application}_{Parameters.enviroment}_username", authentication.user_name);
await AuthStateProvider.GetAuthenticationStateAsync();
The AuthenticationStateProvider code is the following:
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var state = new AuthenticationState(new ClaimsPrincipal());
string authcookie_name = $"{Parameters.application}_{Parameters.enviroment}_username";
string authcookie_value = await _localStorage.GetItemAsStringAsync(authcookie_name);
if (!string.IsNullOrEmpty(authcookie_value))
{
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Authentication, authcookie_value)
}, "Login");
state = new AuthenticationState(new ClaimsPrincipal(identity));
}
NotifyAuthenticationStateChanged(Task.FromResult(state));
return state;
}
The authentication controller is the following:
[HttpPost, Route("/api/auth/login")]
public IActionResult AuthLogin(Authentication authentication)
{
try
{
int auth = _IAuth.AuthLogin(authentication);
if (auth != -1)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.Authentication, authentication.user_name)
};
var claimsIdentity = new ClaimsIdentity(claims, "Login");
var properties = new AuthenticationProperties()
{
IsPersistent = true
};
HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity), properties);
}
return Ok(auth);
}
catch { throw; }
}
Everything is working as excepted and the user need to login in order to see the content of the pages, but he is able to see the data of each page if he perform an http call http://domain.ext/api/model/view
In order to resolve this problem I added the Authorize attribute on each controller of Server project like this:
[Authorize]
[Route("/api/model")]
[ApiController]
public class Controller_Model : ControllerBase
{
}
And also added this code on the Program.cs of Server project in order to be able to make controller to work
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options =>
{
options.ExpireTimeSpan = TimeSpan.FromMinutes(30);
options.SlidingExpiration = true;
options.LoginPath = new PathString("/auth/login");
options.LogoutPath = new PathString("/auth/logout");
options.Cookie = new CookieBuilder();
options.Cookie.MaxAge = options.ExpireTimeSpan;
options.AccessDeniedPath = "/";
options.EventsType = typeof(CustomCookieAuthenticationEvents);
});
Now the user is not able to see the content of a page even he is making a request to the /api.
The problem is that after some time, even I see the User is still logged in the Authorize attribute of controllers is consider the user not authorized and it returns an error because controller is not returning the supposed object list.
I have no clue why and when this is happening. Then if user Logout and Login again it works for a while again.
===============UPDATE 1===============
After lot of investigation, seems that the client side is authenticated and then every time it sees the localstorage item it continues to be in authenticated state. On the other side the server state is based on a cookie which expires after 30mins.
So the Client and the Server states are operated differently and that's why the Client seems authenticated while Server is not while denying access on controllers.
I think the solution is to change the CustomAuthenticationStateProvider in order to check if the cookie exists and if it's valid. So the event order be as follow:
User SingIn via Client Page -> Server Controller creates the cookie -> Client Page is authenticated via Authentication State Provider which reads the cookie.
Any ideas?
Seems that is possible to read and write cookies from Client Project only via Javascript. What needs to be done is the following:
A custom javascript file "cookie.js", under wwwroot/js:
export function get() {
return document.cookie;
}
export function set(key, value) {
document.cookie = `${key}=${value}`;
}
A C# class file "CookieStorageAccessor.cs", under /Classes:
public class CookieStorageAccessor
{
private Lazy<IJSObjectReference> _accessorJsRef = new();
private readonly IJSRuntime _jsRuntime;
public CookieStorageAccessor(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
private async Task WaitForReference()
{
if (_accessorJsRef.IsValueCreated is false)
{
_accessorJsRef = new(await _jsRuntime.InvokeAsync<IJSObjectReference>("import", "/js/cookie.js"));
}
}
public async ValueTask DisposeAsync()
{
if (_accessorJsRef.IsValueCreated)
{
await _accessorJsRef.Value.DisposeAsync();
}
}
public async Task<T> GetValueAsync<T>(string key)
{
await WaitForReference();
var result = await _accessorJsRef.Value.InvokeAsync<T>("get", key);
return result;
}
public async Task SetValueAsync<T>(string key, T value)
{
await WaitForReference();
await _accessorJsRef.Value.InvokeVoidAsync("set", key, value);
}
}
The C# class can be used injecting javascript and reading the cookie on
CustomAuthStateProvider:
//CREATE INSTANCE OF COOKIE ACCESSOR
CookieStorageAccessor cookieStorageAccessor = new CookieStorageAccessor(_jSRuntime);
//CHECK IF COOKIE IS EXISTS FROM COOKIE ACCESSOR
string auth_cookie = await cookieStorageAccessor.GetValueAsync<string>("authentication");
if (!string.IsNullOrEmpty(auth_cookie))
{ }
else
{ }

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

Return "ui_locale" back to client

So I know how to make IdentityServer4 app to use culture that the challenging client has. By defining
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = context =>
{
context.ProtocolMessage.UiLocales = "pl-PL";
return Task.CompletedTask;
},
}
I can make IdentityServer4 to also show me login page in "pl-PL". The trick is however, that I allow users to change the language on the login screen. How can I inform the client that culture info was changed during login?
Currently my client does not even show any page, goes directly to Login screen (thus from client app browser is redirected immediately to IdentityServer4 app, where a user can change his/her language).
It seems that this is not a functionality that IdentityServer4 offers (any contradictory comments welcome). So I ended up with using claims to pass the culture information back to my client.
So I created a class inheriting from IProfileService so I can load additional claim JwtClaimTypes.Locale to the idToken. However it seems that when it is running, it is in a different context then the user it runs for, so CultureInfo.CurrentCulture is set to a different locale than what I was expecting (for example the UI was set pl-PL but inside profile service, it was set to en-US). So I ended up with creating a InMemoryUserInfo class that is basically a wrapped ConcurrentDictionary that contains my user id and an object that contains user's selected locale. I create entry/update that dictionary, whenever user changes the preferred language or when a user language is delivered from the database. Anyway, that InMemoryUserInfo is then injected into my profile service where it is added as another claim:
public class IdentityWithAdditionalClaimsProfileService : IProfileService
{
private readonly IUserClaimsPrincipalFactory<ApplicationUser> _claimsFactory;
private readonly UserManager<ApplicationUser> _userManager;
/// <summary>
/// This services is running in a different thread then UI, so
/// when trying to obtain CultureInfo.CurrentUICulture, it not necessarily
/// is going to be correct. So whenever culture is changed,
/// it is stored in InMemoryUserInfo. Current user's culture will
/// be included in a claim.
/// </summary>
private readonly InMemoryUserInfo _userInfo;
public IdentityWithAdditionalClaimsProfileService(
IUserClaimsPrincipalFactory<ApplicationUser> claimsFactory,
UserManager<ApplicationUser> userManager,
InMemoryUserInfo userInfo)
{
_claimsFactory = claimsFactory;
_userManager = userManager;
_userInfo = userInfo;
}
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var sub = context.Subject.GetSubjectId();
var user = await _userManager.FindByIdAsync(sub);
var principal = await _claimsFactory.CreateAsync(user);
var claims = principal.Claims.ToList();
claims = claims.Where(claim => context.RequestedClaimTypes.Contains(claim.Type)).ToList();
claims.Add(new Claim(JwtClaimTypes.Locale, _userInfo.Get(user.Id).Culture ?? throw new ArgumentNullException()));
context.IssuedClaims = claims;
}
public async Task IsActiveAsync(IsActiveContext context)
{
var sub = context.Subject.GetSubjectId();
var user = await _userManager.FindByIdAsync(sub);
context.IsActive = user != null;
}
}
Remember to register IProfileService with DI
services.AddTransient<IProfileService, IdentityWithAdditionalClaimsProfileService>();
Afterwards, in my client's startup, I analyse the claims in OpenIdConnectEvents and set the cookie to culture received from IdentityServer:
.AddOpenIdConnect("oidc", options =>
{
options.Events = new OpenIdConnectEvents
{
OnTicketReceived = context =>
{
//Goes through returned claims from authentication endpoint and looks for
//localization info. If found and different, then new CultureInfo is set.
string? culture = context.Principal?.FindFirstValue(JwtClaimTypes.Locale);
if (culture != null && CultureInfo.CurrentUICulture.Name != culture)
{
context.HttpContext.Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(
new RequestCulture(culture, culture)),
new CookieOptions
{ Expires = DateTimeOffset.UtcNow.AddYears(1) }
);
}
return Task.CompletedTask;
};
}
});

ASP.NET Core Identity 2: User.IsInRole always returns false

The question: I call RoleManager.CreateAsync() and RoleManager.AddClaimAsync() to create roles and associated role claims. Then I call UserManager.AddToRoleAsync() to add users to those roles. But when the user logs in, neither the roles nor the associated claims show up in the ClaimsPrincipal (i.e. the Controller's User object). The upshot of this is that User.IsInRole() always returns false, and the collection of Claims returned by User.Claims doesn't contain the role claims, and the [Authorize(policy: xxx)] annotations don't work.
I should also add that one solution is to revert from using the new services.AddDefaultIdentity() (which is provided by the templated code) back to calling services.AddIdentity().AddSomething().AddSomethingElse(). I don't want to go there, because I've seen too many conflicting stories online about what I need to do to configure AddIdentity for various use cases. AddDefaultIdentity seems to do most things correctly without a lot of added fluent configuration.
BTW, I'm asking this question with the intention of answering it... unless someone else gives me a better answer than the one I'm prepared to post. I'm also asking this question because after several weeks of searching I have yet to find a good end-to-end example of creating and using Roles and Claims in ASP.NET Core Identity 2. Hopefully, the code example in this question might help someone else who stumbles upon it...
The setup:
I created a new ASP.NET Core Web Application, select Web Application (Model-View-Controller), and change the Authentication to Individual User Accounts. In the resultant project, I do the following:
In Package Manager Console, update the database to match the scaffolded migration:
update-database
Add an ApplicationUser class that extends IdentityUser. This involves adding the class, adding a line of code to the ApplicationDbContext and replacing every instance of <IdentityUser> with <ApplicationUser> everywhere in the project.
The new ApplicationUser class:
public class ApplicationUser : IdentityUser
{
public string FullName { get; set; }
}
The updated ApplicationDbContext class:
public class ApplicationDbContext : IdentityDbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{ }
// Add this line of code
public DbSet<ApplicationUser> ApplicationUsers { get; set; }
}
In Package Manager Console, create a new migration and update the database to incorporate the ApplicationUsers entity.
add-migration m_001
update-database
Add the following line of code in Startup.cs to enable RoleManager
services.AddDefaultIdentity<ApplicationUser>()
.AddRoles<IdentityRole>() // <-- Add this line
.AddEntityFrameworkStores<ApplicationDbContext>();
Add some code to seed roles, claims, and users. The basic concept for this sample code is that I have two claims: can_report allows the holder to create reports, and can_test allows the holder to run tests. I have two Roles, Admin and Tester. The Tester role can run tests, but can't create reports. The Admin role can do both. So, I add the claims to the roles, and create one Admin test user and one Tester test user.
First, I add a class whose sole purpose in life is to contain constants used elsewhere in this example:
// Contains constant strings used throughout this example
public class MyApp
{
// Claims
public const string CanTestClaim = "can_test";
public const string CanReportClaim = "can_report";
// Role names
public const string AdminRole = "admin";
public const string TesterRole = "tester";
// Authorization policy names
public const string CanTestPolicy = "can_test";
public const string CanReportPolicy = "can_report";
}
Next, I seed my roles, claims, and users. I put this code in the main landing page controller just for expedience; it really belongs in the "startup" Configure method, but that's an extra half-dozen lines of code...
public class HomeController : Controller
{
const string Password = "QwertyA1?";
const string AdminEmail = "admin#example.com";
const string TesterEmail = "tester#example.com";
private readonly RoleManager<IdentityRole> _roleManager;
private readonly UserManager<ApplicationUser> _userManager;
// Constructor (DI claptrap)
public HomeController(RoleManager<IdentityRole> roleManager, UserManager<ApplicationUser> userManager)
{
_roleManager = roleManager;
_userManager = userManager;
}
public async Task<IActionResult> Index()
{
// Initialize roles
if (!await _roleManager.RoleExistsAsync(MyApp.AdminRole)) {
var role = new IdentityRole(MyApp.AdminRole);
await _roleManager.CreateAsync(role);
await _roleManager.AddClaimAsync(role, new Claim(MyApp.CanTestClaim, ""));
await _roleManager.AddClaimAsync(role, new Claim(MyApp.CanReportClaim, ""));
}
if (!await _roleManager.RoleExistsAsync(MyApp.TesterRole)) {
var role = new IdentityRole(MyApp.TesterRole);
await _roleManager.CreateAsync(role);
await _roleManager.AddClaimAsync(role, new Claim(MyApp.CanTestClaim, ""));
}
// Initialize users
var qry = _userManager.Users;
IdentityResult result;
if (await qry.Where(x => x.UserName == AdminEmail).FirstOrDefaultAsync() == null) {
var user = new ApplicationUser {
UserName = AdminEmail,
Email = AdminEmail,
FullName = "Administrator"
};
result = await _userManager.CreateAsync(user, Password);
if (!result.Succeeded) throw new InvalidOperationException(string.Join(" | ", result.Errors.Select(x => x.Description)));
result = await _userManager.AddToRoleAsync(user, MyApp.AdminRole);
if (!result.Succeeded) throw new InvalidOperationException(string.Join(" | ", result.Errors.Select(x => x.Description)));
}
if (await qry.Where(x => x.UserName == TesterEmail).FirstOrDefaultAsync() == null) {
var user = new ApplicationUser {
UserName = TesterEmail,
Email = TesterEmail,
FullName = "Tester"
};
result = await _userManager.CreateAsync(user, Password);
if (!result.Succeeded) throw new InvalidOperationException(string.Join(" | ", result.Errors.Select(x => x.Description)));
result = await _userManager.AddToRoleAsync(user, MyApp.TesterRole);
if (!result.Succeeded) throw new InvalidOperationException(string.Join(" | ", result.Errors.Select(x => x.Description)));
}
// Roles and Claims are in a cookie. Don't expect to see them in
// the same request that creates them (i.e., the request that
// executes the above code to create them). You need to refresh
// the page to create a round-trip that includes the cookie.
var admin = User.IsInRole(MyApp.AdminRole);
var claims = User.Claims.ToList();
return View();
}
[Authorize(policy: MyApp.CanTestPolicy)]
public IActionResult Test()
{
return View();
}
[Authorize(policy: MyApp.CanReportPolicy)]
public IActionResult Report()
{
return View();
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
}
and I register my authentication policies in the "Startup" ConfigureServices routine, just after the call to services.AddMvc
// Register authorization policies
services.AddAuthorization(options => {
options.AddPolicy(MyApp.CanTestPolicy, policy => policy.RequireClaim(MyApp.CanTestClaim));
options.AddPolicy(MyApp.CanReportPolicy, policy => policy.RequireClaim(MyApp.CanReportClaim));
});
Whew. Now, (assuming I've noted all of the applicable code I've added to the project, above), when I run the app, I notice that neither of my "built-in" test users can access either the /home/Test or /home/Report page. Moreover, if I set a breakpoint in the Index method, I see that my roles and claims do not exist in the User object. But I can look at the database and see all of the roles and claims are there.
So, to recap, the question asks why the code provided by the ASP.NET Core Web Application template doesn't load roles or role claims into the cookie when a user logs in.
After much Googling and experimenting, there appear to be two modifications that must be made to the templated code in order to get Roles and Role Claims to work:
First, you must add the following line of code in Startup.cs to enable RoleManager. (This bit of magic was mentioned in the OP.)
services.AddDefaultIdentity<ApplicationUser>()
.AddRoles<IdentityRole>() // <-- Add this line
.AddEntityFrameworkStores<ApplicationDbContext>();
But wait, there's more! According to this discussion on GitHub, getting the roles and claims to show up in the cookie involves either reverting to the service.AddIdentity initialization code, or sticking with service.AddDefaultIdentity and adding this line of code to ConfigureServices:
// Add Role claims to the User object
// See: https://github.com/aspnet/Identity/issues/1813#issuecomment-420066501
services.AddScoped<IUserClaimsPrincipalFactory<ApplicationUser>, UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>>();
If you read the discussion referenced above, you'll see that Roles and Role Claims are apparently kind-of-deprecated, or at least not eagerly supported. Personally, I find it really useful to assign claims to roles, assign roles to users, and then make authorization decisions based on the claims (which are granted to the users based on their roles). This gives me an easy, declarative way to allow, for example, one function to be accessed by multiple roles (i.e. all of the roles that contain the claim used to enable that function).
But you DO want to pay attention to the amount of role and claim data being carried in the auth cookie. More data means more bytes sent to the server with each request, and I have no clue what happens when you bump up against some sort of limit to the cookie size.
Ahh, there are some changes from ASP.NET Core version 2.0 to 2.1. AddDefaultIdentity is the one.
I don't know where to start from your code, so, I will provide an example to create and get user role(s).
Let's create UserRoles first:
public enum UserRoles
{
[Display(Name = "Quản trị viên")]
Administrator = 0,
[Display(Name = "Kiểm soát viên")]
Moderator = 1,
[Display(Name = "Thành viên")]
Member = 2
}
Note: You can remove the attribute Display.
Then, we create RolesExtensions class:
public static class RolesExtensions
{
public static async Task InitializeAsync(RoleManager<IdentityRole> roleManager)
{
foreach (string roleName in Enum.GetNames(typeof(UserRoles)))
{
if (!await roleManager.RoleExistsAsync(roleName))
{
await roleManager.CreateAsync(new IdentityRole(roleName));
}
}
}
}
Next, in the Startup.cs class, we run it:
public void Configure(
IApplicationBuilder app,
IHostingEnvironment env,
RoleManager<IdentityRole> roleManager)
{
// other settings...
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
var task = RolesExtensions.InitializeAsync(roleManager);
task.Wait();
}
Note: Configure requires a returned type void, so we need to create a task to initialize the user roles and we call Wait method.
Do not change the returned type like this:
public async void Configure(...)
{
await RolesExtensions.InitializeAsync(roleManager);
}
Source: Async/Await - Best Practices in Asynchronous Programming
In the ConfigureServices method, these configurations would NOT work (we cannot use User.IsInRole correctly):
services.AddDefaultIdentity<ApplicationUser>()
//.AddRoles<IdentityRole>()
//.AddRoleManager<RoleManager<IdentityRole>>()
.AddEntityFrameworkStores<ApplicationDbContext>();
I don't know why but AddRoles and AddRoleManager don't support to check role for a user (User.IsInRole).
In this case, we need to register service like this:
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
By using this way, we create 3 user roles in the databse:
When register new user, we just need to call:
await _userManager.AddToRoleAsync(user, nameof(UserRoles.Administrator));
Finally, we can use [Authorize(Roles = "Administrator")] and:
if (User.IsInRole("Administrator"))
{
// authorized
}
// or
if (User.IsInRole(nameof(UserRoles.Administrator)))
{
// authorized
}
// but
if (User.IsInRole("ADMINISTRATOR"))
{
// authorized
}
P/S: There are a lot things which need to be implement to achieve this goal. So maybe I missed something in this example.
Also you can try to fix Authentication like this
services.AddDefaultIdentity<ApplicationUser>()
.AddRoles<IdentityRole>()
.AddRoleManager<RoleManager<IdentityRole>>()
.AddEntityFrameworkStores<ApplicationDbContext>();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
});
If I use “Roles” instead of ClaimTypes.Role in .net6 blazor wasm , #attribute [Authorize(Roles = "admin")] not work and get this error in browser console :
RolesAuthorizationRequirement:User.IsInRole must be true for one of the following roles: (admin)”
By using of ClaimTypes.Role the problem resolved :
private async Task<List<Claim>> GetClaimsAsync(User user)
{
var claims = new List<Claim>()
{
new Claim("UserName", user.Email),
new Claim("FullName", user.FirstName+" "+user.LastName),
};
var roles = await _userManager.GetRolesAsync(user);
foreach (var role in roles)
claims.Add(new Claim(ClaimTypes.Role, role)); // this line
return claims;
}
https://github.com/mammadkoma/Attendance/blob/master/Attendance/Server/Controllers/AccountsController.cs