I am new to IdentityServer and I have been struggling with this issue all day. So much so that I'm almost about to give up on this. I know this question has been asked over and over again and I have tried many different solutions but none seem to work. Hopefully you can help me push me in the right direction with this.
First I installed the IdentityServer4 templates by running dotnet new -i identityserver4.templates and created a new project with the is4aspid template by running dotnet new is4aspid -o IdentityServer.
After that i created a new IdentityServer database and ran the migrations. By that time I had a the default Identity database structure.
In Config.cs I changed MVC client to the following:
new Client
{
ClientId = "mvc",
ClientName = "MVC Client",
AllowedGrantTypes = GrantTypes.Implicit,
ClientSecrets = { new Secret("47C2A9E1-6A76-3A19-F3C0-S37763QB36D9".Sha256()) },
RedirectUris = { "https://localhost:44307/signin-oidc" },
FrontChannelLogoutUri = "https://localhost:44307/signout-oidc",
PostLogoutRedirectUris = { "https://localhost:44307/signout-callback-oidc" },
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "api1", JwtClaimTypes.Role }
},
And changed the GetApis method to this:
public static IEnumerable<ApiResource> GetApis()
{
return new ApiResource[]
{
new ApiResource("api1", "My API #1", new List<string>() { "role" })
};
}
There where of course no users in the database yet so i added a registration form and registered two dummy users, one with the username admin#example.com and one with the username subscriber#example.com.
To assign the roles to these user I created the following method in Startup.cs.
private async Task CreateUserRoles(IServiceProvider serviceProvider) {
var RoleManager = serviceProvider.GetRequiredService<RoleManager<IdentityRole>>();
var UserManager = serviceProvider.GetRequiredService<UserManager<ApplicationUser>>();
IdentityResult adminRoleResult;
IdentityResult subscriberRoleResult;
bool adminRoleExists = await RoleManager.RoleExistsAsync("Admin");
bool subscriberRoleExists = await RoleManager.RoleExistsAsync("Subscriber");
if (!adminRoleExists) {
adminRoleResult = await RoleManager.CreateAsync(new IdentityRole("Admin"));
}
if(!subscriberRoleExists) {
subscriberRoleResult = await RoleManager.CreateAsync(new IdentityRole("Subscriber"));
}
ApplicationUser userToMakeAdmin = await UserManager.FindByNameAsync("admin#example.com");
await UserManager.AddToRoleAsync(userToMakeAdmin, "Admin");
ApplicationUser userToMakeSubscriber = await UserManager.FindByNameAsync("subscriber#example.com");
await UserManager.AddToRoleAsync(userToMakeSubscriber, "Subscriber");
}
In the Configure method of the same class I add the the parameter IServiceProvider services and called the above method like so: CreateUserRoles(services).Wait();. By this time my database did have two roles in it.
Next I created a new solution (within the same project) and in the Startup.cs file of that solution I added the following in the ConfigureServices method.
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options => {
options.SaveTokens = true;
options.ClientId = "mvc";
options.ClientSecret = "32D7A7W0-0ALN-2Q44-A1H4-A37990NN83BP";
options.RequireHttpsMetadata = false;
options.Authority = "http://localhost:5000/";
options.ClaimActions.MapJsonKey("role", "role");
});
After that I added app.UseAuthentication(); in the Configure method of the same class.
Then I created a new page with the following if statements.
if(User.Identity.IsAuthenticated) {
<div>Yes, user is authenticated</div>
}
if(User.IsInRole("ADMIN")) {
<div>Yes, user is admin</div>
}
I logged in with admin#example.com but the second if statement returns False. I inspected all the claims by looping over them like so.
#foreach (var claim in User.Claims) {
<dt>#claim.Type</dt>
<dd>#claim.Value</dd>
}
But there was no role claim to be found, only sid, sub, idp, preferred_username and name.
I tried to get the role in there so that the second if statement returns True but after trying and trying I have not yet been able to make it work. Can someone see what I have to do in order to make this work? I am an absolute beginner in IdentityServer4 and trying my best to understand it. Any help will be appreciated. Thanks in advance!
EDIT 1:
Thanks to this question and this question I got the feeling that I'm on the right track. I have made some modifications but I still can not get it to work. I just tried the following.
Created a new ProfileService class in my IdentityServer project with the following content.
public class MyProfileService : IProfileService {
public MyProfileService() { }
public Task GetProfileDataAsync(ProfileDataRequestContext context) {
var roleClaims = context.Subject.FindAll(JwtClaimTypes.Role);
List<string> list = context.RequestedClaimTypes.ToList();
context.IssuedClaims.AddRange(roleClaims);
return Task.CompletedTask;
}
public Task IsActiveAsync(IsActiveContext context) {
return Task.CompletedTask;
}
}
Next I registered this class in the ConfigureServices method by adding the line services.AddTransient<IProfileService, MyProfileService>();. After that I added a new a new line to the GetIdentityResources method, which looks like this now.
public static IEnumerable<IdentityResource> GetIdentityResources()
{
return new IdentityResource[]
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
new IdentityResource("roles", new[] { "role" })
};
}
I also added the roles to my Mvc client like so: AllowedScopes = { "openid", "profile", "api1", "roles" }.
Next I switched over to the other project and added the following lines in the .AddOpenIdConnect oidc.
options.ClaimActions.MapJsonKey("role", "role", "role");
options.TokenValidationParameters.RoleClaimType = "role";
But still, I cannot get it to work like I want it to. Anyone knows what I am missing?
Two things you need to do to make sure you will get users roles in the claims:
1- In IdentityServer4 project: you need to have implementation for IProfileService
http://docs.identityserver.io/en/latest/reference/profileservice.html
don't forget to add the class in startup.cs file like this
services.AddIdentityServer()
// I just removed some other configurations for clarity
**.AddProfileService<IdentityProfileService>();**
2- In Web Client project's startup.cs file: when configuring the openId, you have to mention this :
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
{
options.SignInScheme = "Cookies";
options.Authority = "Identity URL ";
options.RequireHttpsMetadata = true;
options.ClientId = "saas_crm_webclient";
options.ClientSecret = "49C1A7E1-0C79-4A89-A3D6-A37998FB86B0";
options.ResponseType = "code id_token";
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = false;
options.Scope.Add("test.api");
options.Scope.Add("identity.api");
options.Scope.Add("offline_access");
**options.ClaimActions.Add(new JsonKeyClaimAction("role", null, "role"));**
**options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "role"
};**
});
Slightly different question, absolutely matching answer.
With the Edit 1, IdP configuration looks enough to supply both identity and access tokens with roles when requested. The only thing left is to configure the client to request the access token (.Net client doesn't do that by default), or just request the roles scope within the identity token.
To get the roles with id_token, the client side config must include options.Scope.Add("roles");
To get the roles with bearer token, that token must be requested by specifying options.ResponseType = "id_token token"; in client side config.
I did it like this in .NET 5:
Add JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); before services.AddAuthentication in Startup.cs.
https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/issues/1349
I also added
services.AddScoped<IProfileService, ProfileService>();
and ProfileService.cs that looks like this to map roles to claims:
public sealed class ProfileService : IProfileService
{
private readonly IUserClaimsPrincipalFactory<ApplicationUser> _userClaimsPrincipalFactory;
private readonly UserManager<ApplicationUser> _userMgr;
private readonly RoleManager<IdentityRole> _roleMgr;
public ProfileService(
UserManager<ApplicationUser> userMgr,
RoleManager<IdentityRole> roleMgr,
IUserClaimsPrincipalFactory<ApplicationUser> userClaimsPrincipalFactory)
{
_userMgr = userMgr;
_roleMgr = roleMgr;
_userClaimsPrincipalFactory = userClaimsPrincipalFactory;
}
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
string sub = context.Subject.GetSubjectId();
ApplicationUser user = await _userMgr.FindByIdAsync(sub);
ClaimsPrincipal userClaims = await _userClaimsPrincipalFactory.CreateAsync(user);
List<Claim> claims = userClaims.Claims.ToList();
claims = claims.Where(claim => context.RequestedClaimTypes.Contains(claim.Type)).ToList();
if (_userMgr.SupportsUserRole)
{
IList<string> roles = await _userMgr.GetRolesAsync(user);
foreach (var roleName in roles)
{
claims.Add(new Claim(JwtClaimTypes.Role, roleName));
if (_roleMgr.SupportsRoleClaims)
{
IdentityRole role = await _roleMgr.FindByNameAsync(roleName);
if (role != null)
{
claims.AddRange(await _roleMgr.GetClaimsAsync(role));
}
}
}
}
context.IssuedClaims = claims;
}
public async Task IsActiveAsync(IsActiveContext context)
{
string sub = context.Subject.GetSubjectId();
ApplicationUser user = await _userMgr.FindByIdAsync(sub);
context.IsActive = user != null;
}
}
Source:
https://ffimnsr.medium.com/adding-identity-roles-to-identity-server-4-in-net-core-3-1-d42b64ff6675
Related
I am using aspnet core 5.0 webapi with CQRS in my project and already have jwt implementation. Not using role management from aspnet core but manually added for aspnet users table role field and it is using everywhere. In internet I can't find any article to implement keycloak for existing authentication and authorization. My point is for now users login with their email+password, idea is not for all but for some users which they already stored in keycloak, or for some users we will store there, give option login to our app using keycloak as well.
Scenario 1:
I have admin#gmail.com in both in my db and in keycloak and both are they in admin role, I need give access for both to login my app, first scenario already working needs implement 2nd scenarion beside first.
Found only this article which implements securing app (as we have already and not trying to replace but extend)
Medium keycloak
My jwt configuration looks like:
public static IServiceCollection AddCustomAuthentication(this IServiceCollection services,
IConfiguration configuration)
{
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(configuration.GetSection("AppSettings:Token").Value));
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ValidateAudience = false,
ValidateIssuer = false,
ClockSkew = TimeSpan.Zero
};
opt.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
{
context.Response.Headers.Add("Token-Expired", "true");
}
return Task.CompletedTask;
}
};
});
return services;
}
My jwt service looks like:
public JwtGenerator(IConfiguration config)
{
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config.GetSection("AppSettings:Token").Value));
}
public string CreateToken(User user)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id),
new(ClaimTypes.Email, user.Email),
new(ClaimTypes.Name, user.UserName),
new(ClaimTypes.Role, user.Role.ToString("G").ToLower())
};
var creds = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddMinutes(15),
SigningCredentials = creds
};
var tokenHandler = new JwtSecurityTokenHandler();
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
My login method looks like:
public async Task<GetToken> Handle(LoginCommand request, CancellationToken cancellationToken)
{
var user = await _userManager.FindByEmailAsync(request.Email);
if (user == null)
throw new BadRequestException("User not found");
UserManagement.ForbiddenForLoginUser(user);
var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password, false);
if (result.Succeeded)
{
user.IsRoleChanged = false;
RefreshToken refreshToken = new RefreshToken
{
Name = _jwtGenerator.GenerateRefreshToken(),
DeviceName = $"{user.UserName}---{_jwtGenerator.GenerateRefreshToken()}",
User = user,
Expiration = DateTime.UtcNow.AddHours(4)
};
await _context.RefreshTokens.AddAsync(refreshToken, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return new GetToken(_jwtGenerator.CreateToken(user),refreshToken.Name);
}
throw new BadRequestException("Bad credentials");
}
My authorization handler:
public static IServiceCollection AddCustomMvc(this IServiceCollection services)
{
services.AddMvc(opt =>
{
var policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
opt.Filters.Add(new AuthorizeFilter(policy));
// Build the intermediate service provider
opt.Filters.Add<CustomAuthorizationAttribute>();
}).AddFluentValidation(cfg => cfg.RegisterValidatorsFromAssemblyContaining<CreateProjectCommand>());
return services;
}
What is best practise to implement keycloak authentiaction+authorization beside my current approach and give users to login with two scenarios, normal and keycloak login.
P.S. Ui is different and we are using angular this one just webapi for backend.
Since your login method returns a jwt, you could configure multiple bearer tokens by chaining .AddJwtBearer(), one for your normal login and one for keycloak.
Here is a link to a question that might solve your problem: Use multiple jwt bearer authentication.
Keycloak configuration:
Go to Roles -> Realm Roles and create a corresponding role.
Go to Clients -> Your client -> Mappers.
Create a new role mapper and select "User Realm Role" for Mapper Type, "roles" for Token Claim Name and "String" for Claim JSON Type. Without the mapping the role configured before would be nested somewhere else in the jwt.
You can use the debugger at jwt.io to check if your token is correct. The result should look like this:
{
"exp": 1627565901,
"iat": 1627564101,
"jti": "a99ccef1-afa9-4a62-965b-15e8d33de7de",
// [...]
// roles nested in realm_access :(
"realm_access": {
"roles": [
"offline_access",
"uma_authorization",
"Admin"
]
},
// [...]
// your mapped roles in your custom claim
"roles": [
"offline_access",
"uma_authorization",
"Admin"
]
// [...]
}
I have configured my OpenIdConnect within KeyCloak and now I am trying to connect to it using .NET 5 MVC application.
How to sign-in correctly?
This is what I have so far
When I try to access protected resource I get redirected to KeyCloak for a correct "relm" to sign in.
The user can sign in and the application flows through the OpenIDConnect to the method OnTokenValidated.
In this event, I can see that the user has successfully logged in and while debugging see the authentication details
string firstName = idToken.Claims.SingleOrDefault(c => c.Type == JwtRegisteredClaimNames.GivenName)?.Value;
where firstName gets populated to the correct user.
Redirect issue
The redirection to my application keeps going into OnTokenValidated as a loop but the application does not register that the user is signed in
My code looks like thus:
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
const string clientId = "demoClient";
const string clientSecret = "4342abf9-CC85-4cf2-ba83-316c56a523b9"; // representative
const string authority = "http://localhost:8080/auth/realms/demo"; // name of authority
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services
.AddAuthentication(options =>
{
options.DefaultScheme = OpenIdConnectDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = "oidc";
options.DefaultSignInScheme = "Cookies";
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.CallbackPath = "/home/index";
options.Authority = authority;
options.ClientId = clientId;
options.ClientSecret = clientSecret;
options.SaveTokens = true;
options.ResponseType = OpenIdConnectResponseType.IdTokenToken;
options.RequireHttpsMetadata = false; // dev only
options.GetClaimsFromUserInfoEndpoint = true;
//options.Scope.Add("openid"); // TODO: not sure how to configure
options.Scope.Add("profile");
options.Scope.Add("email");
options.SaveTokens = true;
options.Events = new OpenIdConnectEvents
{
OnAuthorizationCodeReceived = context =>
{
// short lived code used to authorise the application on back channel
return Task.CompletedTask;
},
OnRedirectToIdentityProvider = async n =>
{
//save url to state
// n.ProtocolMessage.State = n.HttpContext.Request.Path.Value.ToString();
},
OnTokenValidated = context =>
{
return Task.CompletedTask;
},
OnTicketReceived = context =>
{
return Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
context.Response.Redirect("/Home/Error?errormessage = " + context.Exception.Message);
// context.HandleResponse(); // Suppress the exception
return Task.CompletedTask;
},
OnRemoteFailure = context =>
{
context.Response.Redirect("/Home/Error");
context.HandleResponse();
return Task.FromResult(0);
},
};
});
To configure an ASP.NET MVC web application client to authenticate with Keycloak use the following configuration:
Add your client in the realm and set the access type as "confidential"
Make sure that the authorization is enabled
If it is a strongly trusted client, you could also enable the Direct Access Grants, but this is optional.
In the field "Valid Redirect URI" set "https://your.app.uri/signin-oidc" and "https://your.app.uri/signout-callback-oidc"
In the field "Web Origins" set "*"
In the field "Backchannel Logout URL" you may set "https://your.app.uri/signin-oidc"
Then configure your client-Id, Authority and Client Secret as you did in the code that you provided in the question.
I am making a POC of a small website that uses Keycloak as an OIDC provider, for now I am just using the "standard" scaffolded website that .NET Core generates. The Privacy page has an authorize attribute so that it can only be accessed if the user is authenticated.
My StartUp.cs looks like this:
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(i => new CookieAuthenticationOptions
{
Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = context =>
{
return OnValidatePrincipal(context);
}
}
})
.AddOpenIdConnect(options =>
{
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.Authority = "http://localhost:8080/auth/realms/WatchList/";
options.RequireHttpsMetadata = false;
options.ClientId = "ClientID";
options.ClientSecret = "20ea1950-af47-4251-85e9-7c4f33189c77";
options.ResponseType = OpenIdConnectResponseType.Code;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("openid");
options.Scope.Add("profile");
options.SaveTokens = true;
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "groups",
ValidateIssuer = true
};
});
services.AddAuthorization();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
private async Task OnValidatePrincipal(CookieValidatePrincipalContext context)
{
const string accessTokenName = "access_token";
const string refreshTokenName = "refresh_token";
const string expirationTokenName = "expires_at";
if (context.Principal.Identity.IsAuthenticated)
{
var exp = context.Properties.GetTokenValue(expirationTokenName);
if (exp != null)
{
var expires = DateTime.Parse(exp, CultureInfo.InvariantCulture).ToUniversalTime();
if (expires < DateTime.UtcNow)
{
// If we don't have the refresh token, then check if this client has set the
// "AllowOfflineAccess" property set in Identity Server and if we have requested
// the "OpenIdConnectScope.OfflineAccess" scope when requesting an access token.
var refreshToken = context.Properties.GetTokenValue(refreshTokenName);
if (refreshToken == null)
{
context.RejectPrincipal();
return;
}
var cancellationToken = context.HttpContext.RequestAborted;
// Obtain the OpenIdConnect options that have been registered with the
// "AddOpenIdConnect" call. Make sure we get the same scheme that has
// been passed to the "AddOpenIdConnect" call.
//
// TODO: Cache the token client options
// The OpenId Connect configuration will not change, unless there has
// been a change to the client's settings. In that case, it is a good
// idea not to refresh and make sure the user does re-authenticate.
var serviceProvider = context.HttpContext.RequestServices;
var openIdConnectOptions = serviceProvider.GetRequiredService<IOptionsSnapshot<OpenIdConnectOptions>>().Get("Cookies");
var configuration = openIdConnectOptions.Configuration ?? await openIdConnectOptions.ConfigurationManager.GetConfigurationAsync(cancellationToken).ConfigureAwait(false);
// Set the proper token client options
var tokenClientOptions = new TokenClientOptions
{
Address = configuration.TokenEndpoint,
ClientId = openIdConnectOptions.ClientId,
ClientSecret = openIdConnectOptions.ClientSecret
};
var httpClientFactory = serviceProvider.GetService<IHttpClientFactory>();
using var httpClient = httpClientFactory.CreateClient();
var tokenClient = new TokenClient(httpClient, tokenClientOptions);
var tokenResponse = await tokenClient.RequestRefreshTokenAsync(refreshToken, cancellationToken: cancellationToken).ConfigureAwait(false);
if (tokenResponse.IsError)
{
context.RejectPrincipal();
return;
}
// Update the tokens
var expirationValue = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn).ToString("o", CultureInfo.InvariantCulture);
context.Properties.StoreTokens(new[]
{
new AuthenticationToken { Name = refreshTokenName, Value = tokenResponse.RefreshToken },
new AuthenticationToken { Name = accessTokenName, Value = tokenResponse.AccessToken },
new AuthenticationToken { Name = expirationTokenName, Value = expirationValue }
});
// Update the cookie with the new tokens
context.ShouldRenew = true;
}
}
}
}
}
This all works as I expect it would: i can access the Home page without having to authenticate but if I want access to the Privacy page I am redirected to the Keycloak login page and after successfully logging in I have access to the Privacy Page as well.
However I want to access the Acces Token and Refresh Token (because I want to use the access token to access an api) as well, this in itself isn't a problem either:
[Authorize]
public IActionResult Privacy()
{
var accessToken = HttpContext.GetTokenAsync("access_token").Result;
Debug.WriteLine(accessToken);
var refreshToken = HttpContext.GetTokenAsync("refresh_token").Result;
Debug.WriteLine(refreshToken);
return View();
}
The problem is that these might be expired and I want to retrieve a new access token, but this should happen automatically.
I've tried the solution that was proposed here, and like you can see in my StartUp.cs, I "catch" the OnValidatePrincipalEvent and execute the code below.
But here is the problem: for some reason this event is never called. I would expect it to be called everytime I access the Privacy page. However this isn't the case, the event seems not be thrown ever.
Things I've tried:
I've followed this comment: https://stackoverflow.com/a/61396951/9784279, that seems to do exactly what I want, except for the fact that the event isn't thrown.
Checked if other events work like expected: I have confirmed that OnTokenValidated and OnTokenResponseReceived are called like expected, however these are OpenIdConnectEvents and not CookieAuthenticationEvents.
Tried playing with the StartUp.cs file, mainly the order/place of app.UseAuthentication() and app.UseAuthorization(). But this didn't change anything.
I thought maybe I could create an extension method GetOrUpdateTokenAsync on HttpContext that retrieves the access token and if it is expired it will retrieve a new one. The problem is that I don't think I have access to StoreTokens in
CookieValidatePrincipalContext
[1]: https://stackoverflow.com/a/61396951/9784279
I'm following Identity Server quickstart template, and trying to setup the following
Identity server aspnet core app
Mvc client, that authenticates to is4 and also calls webapi client which is a protected api resource.
The ApplicationUser has an extra column which I add into claims from ProfileService like this:
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var sub = context.Subject.GetSubjectId();
var user = await _userManager.FindByIdAsync(sub);
if (user == null)
return;
var principal = await _claimsFactory.CreateAsync(user);
if (principal == null)
return;
var claims = principal.Claims.ToList();
claims.Add(new Claim(type: "clientidentifier", user.ClientId ?? string.Empty));
// ... add roles and so on
context.IssuedClaims = claims;
}
And finally here's the configuration in Mvc Client app ConfigureServices method:
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
}).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "http://localhost:5000";
options.RequireHttpsMetadata = false;
options.ClientId = "mvc";
options.ClientSecret = "mvc-secret";
options.ResponseType = "code";
options.SaveTokens = true;
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("offline_access");
options.Scope.Add("api1");
options.GetClaimsFromUserInfoEndpoint = true;
options.ClaimActions.MapUniqueJsonKey("clientidentifier", "clientidentifier");
});
With GetClaimsFromUserInfoEndpoint set to true I can access the custom claim in User.Identity, but this results in 2 calls for ProfileService.
If I remove or set to false then this claim is still part of access_token, but not part of id_token, and then I can't access this specific claim from context User.
Is there a better way I can access this claim from User principal without resulting in 2 calls (as it's now)? or perhaps reading access_token from context and updating user claims once the token is retrieved?
thanks :)
Turns out that Client object in identity server has this property that does the job:
//
// Summary:
// When requesting both an id token and access token, should the user claims always
// be added to the id token instead of requring the client to use the userinfo endpoint.
// Defaults to false.
public bool AlwaysIncludeUserClaimsInIdToken { get; set; }
As explained in the lib metadata setting this to true for a client, then it's not necessary for the client to go and re-get the claims from endpoint
thanks everybody :)
If you want to access custom claims in client side over those added in identity server just follow these steps, it worked for me. I imagine you implement both client and identity server as separated projects in asp.net core and they are ready, you now want to play with claims or maybe want to authorize by role-claim and so on, alright let's go
create a class that inherits from "IClaimsTransformation" like this:
public class MyClaimsTransformation : IClaimsTransformation
{
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var userName = principal.Identity.Name;
var clone = principal.Clone();
var newIdentity = (ClaimsIdentity)clone.Identity;
var user = config.GetTestUsers().Where(p => p.Username == userName).First();
if (user != null)
{
var lstUserClaims = user.Claims.Where(p => p.Type == JwtClaimTypes.Role).ToList();
foreach (var item in lstUserClaims)
if (!newIdentity.Claims.Where(p => p.ValueType == item.ValueType && p.Value == item.Value).Select(p => true).FirstOrDefault())
newIdentity.AddClaim(item);
}
return Task.FromResult(principal);
}
}
But be aware this class will call multiple times over user authentication so i added a simple code to prevent multiple duplicate claim. also you have user name of authenticated user too.
Next create another class like this:
public class ProfileService : IProfileService
{
//private readonly UserManager<ApplicationUser> userManager;
public ProfileService(/*UserManager<ApplicationUser> userManager*/ /*, SignInManager<ApplicationUser> signInManager*/)
{
//this.userManager = userManager;
}
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
context.AddRequestedClaims(context.Subject.Claims);
var collection = context.Subject.Claims.Where(p => p.Type == JwtClaimTypes.Role).ToList();
foreach (var item in collection)
{
var lst = context.IssuedClaims.Where(p => p.Value == item.Value).ToList();
if (lst.Count == 0)
context.IssuedClaims.Add(item);
}
await Task.CompletedTask;
}
public async Task IsActiveAsync(IsActiveContext context)
{
//context.IsActive = true;
await Task.FromResult(0); /*Task.CompletedTask;*/
}
}
This class will call by several context but it's okay cause we added our custom claim(s) at part #1 at this code
foreach (var item in lstUserClaims)
if (!newIdentity.Claims.Where(p => p.ValueType == item.ValueType && p.Value == item.Value).Select(p => true).FirstOrDefault())
newIdentity.AddClaim(item);
This is your basic startup.cs at identity server side:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(options => options.EnableEndpointRouting = false);
services.AddTransient<Microsoft.AspNetCore.Authentication.IClaimsTransformation, MyClaimsTransformation>();
services.AddIdentityServer().AddDeveloperSigningCredential()
.AddInMemoryApiResources(config.GetApiResources())
.AddInMemoryIdentityResources(config.GetIdentityResources())
.AddInMemoryClients(config.GetClients())
.AddTestUsers(config.GetTestUsers())
.AddInMemoryApiScopes(config.GetApiScope())
.AddProfileService<ProfileService>();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseIdentityServer();
app.UseStaticFiles();
app.UseMvcWithDefaultRoute();
}
}
Pay attention to .AddProfileService<ProfileService>(); and services.AddTransient<Microsoft.AspNetCore.Authentication.IClaimsTransformation, MyClaimsTransformation>();
Now at client side go to startup.cs and do as follows:
.AddOpenIdConnect("oidc", options =>
{
//other code
options.GetClaimsFromUserInfoEndpoint = true;
options.ClaimActions.Add(new JsonKeyClaimAction(JwtClaimTypes.Role, null, JwtClaimTypes.Role));
})
for my sample i tried to use "Role" and authorize users by my custom roles.
Next at your controller class do like this:
[Authorize(Roles = "myCustomClaimValue")] or you can create a class for custom authorization filter.
Note that you define test user in config file in your identity server project and the user has a custom claim like this new claim(JwtClaimTypes.Role, "myCustomClaimValue") and this will be back at lstUserClaims variable.
I am assuming you are passing Authorization header with Bearer JWT token while calling the API. You can read access_token from HttpContext in your API Controller.
var accessToken = await this.HttpContext.GetTokenAsync("access_token");
var handler = new JwtSecurityTokenHandler();
if (handler.ReadToken(accessToken) is JwtSecurityToken jt && (jsonToken.Claims.FirstOrDefault(claim => claim.Type == "sub") != null))
{
var subID = jt.Claims.FirstOrDefault(claim => claim.Type == "sub").Value;
}
NOTE : GetClaimsFromUserInfoEndpoint no need to set explicitly.
Here is a bit of extra info on the subject. By default, IdentityServer doesn't include identity claims in the identity token. It is allowed by setting the AlwaysIncludeUserClaimsInIdToken setting on the client configuration to true. But it is not recommended. The initial identity token is returned from the authorization endpoint via front‑channel communication either through a form post or through the URI. If it's returned via the URI and the token becomes too big, you might hit URI length restrictions, which are still dependent on the browser. Most modern browsers don't have issues with long URIs, but older browsers like Internet Explorer might. This may or may not be of concern to you. Looks like my project is similar to yours. Good luck.
I'm using ASP.Net Core 2.1 with IdentityCore Service, the application is a pure API, no views at all. For authentication I'm purely using Steam authentication (No User/Pass login) provided by, https://github.com/aspnet-contrib/AspNet.Security.OpenId.Providers
This API has been created to fit a very specific authentication workflow (User's can only login to the API with Steam) the Angular SPA sitting as the frontend handles the workflow just fine.
The problem is that when I add a Role to a user (I've already got roles seeded and I've added my own steam account to the Admin Role), the role type claims are not being added on login, therefore when an admin user attempts to access an API route protected by [Authorize(Roles = "Admin") I'm being returned an Unauthorized Redirect.
Below I have added all code snippets I think is required (feel free to request more).
If I use (I am currently using this as a temporary solution, but it is not ideal for future development);
services.AddIdentity<User, Role>()
.AddEntityFrameworkStores<RSContext>()
.AddSignInManager<SignInManager<User>>()
.AddRoleManager<RoleManager<Role>>()
.AddDefaultTokenProviders();
The application correctly adds the role claims on user sign in (and the Authorize attributes work), using all existing code from AuthController.cs, yet using IdentityCore it fails. I feel I am missing a single line that is responsible for this but after trolling MSDN docs for days I am finally outwitted.
NOTE: The API will correctly authenticate and set the users cookies on sign in, but does not add the users roles to the users identity claims. Therefore, Authentication is Working, Authorization is not. If I utilise the [Authorize] attribute without specifying a Role it works flawlessly and only allows Authenticated users to access the route whilst denying unAuthenticated users. This can be seen in the Testing Screenshot at the end, identities[0].isAuthenticated = True, but the admin role is not being added to the Identity's Claims. As noted above, if I do not use AddIdentityCore and use AddIdentity, the roles are added to the user's claims correctly and the [Authorize(Role = "Admin")] attribute will work as expected, only allowing users that are apart of the Admin role to access it.
Startup.cs (Omitted irrelevant parts, eg. Database Connection)
public void ConfigureServices(IServiceCollection services)
{
IdentityBuilder builder = services.AddIdentityCore<User>(opt =>
{
opt.Password.RequireDigit = true;
opt.Password.RequiredLength = 6;
opt.Password.RequireNonAlphanumeric = true;
opt.Password.RequireUppercase = true;
opt.User.AllowedUserNameCharacters += ":/";
});
builder = new IdentityBuilder(builder.UserType, typeof(Role), builder.Services);
builder.AddEntityFrameworkStores<RSContext>();
builder.AddSignInManager<SignInManager<User>>();
builder.AddRoleValidator<RoleValidator<Role>>();
builder.AddRoles<Role>();
builder.AddRoleManager<RoleManager<Role>>();
builder.AddClaimsPrincipalFactory<UserClaimsPrincipalFactory<User>>();
builder.AddDefaultTokenProviders();
services.AddAuthentication(options =>
{
options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignOutScheme = IdentityConstants.ApplicationScheme;
options.DefaultForbidScheme = IdentityConstants.ApplicationScheme;
})
.AddSteam(options =>
{
options.ApplicationKey = Configuration.GetSection("Authentication:Steam:Key").Value;
options.CallbackPath = "/api/auth/steam/callback";
options.Events.OnAuthenticated = OnClientAuthenticated;
})
.AddIdentityCookies(options =>
{
options.ApplicationCookie.Configure(appCookie =>
{
appCookie.Cookie.Name = "RaidSimulator";
appCookie.LoginPath = "/api/auth/login";
appCookie.LogoutPath = "/api/auth/logout";
appCookie.Cookie.HttpOnly = true;
appCookie.Cookie.SameSite = SameSiteMode.Lax;
appCookie.Cookie.IsEssential = true;
appCookie.SlidingExpiration = true;
appCookie.Cookie.Expiration = TimeSpan.FromMinutes(1);
appCookie.Cookie.MaxAge = TimeSpan.FromDays(7);
});
options.ExternalCookie.Configure(extCookie =>
{
extCookie.Cookie.Name = "ExternalLogin";
extCookie.LoginPath = "/api/auth/login";
extCookie.LogoutPath = "/api/auth/logout";
extCookie.Cookie.HttpOnly = true;
extCookie.Cookie.SameSite = SameSiteMode.Lax;
extCookie.Cookie.IsEssential = true;
extCookie.Cookie.Expiration = TimeSpan.FromMinutes(10);
});
});
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, RoleManager<Role> roleManager)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
RolesSeed.Seed(roleManager).Wait();
app.UseCors();
app.UseAuthentication();
app.UseMvc();
}
// Responsible for storing/updating steam profile in database
private async Task OnClientAuthenticated(OpenIdAuthenticatedContext context)
{
var rsContext = context.HttpContext.RequestServices.GetRequiredService<RSContext>();
var userManager = context.HttpContext.RequestServices.GetRequiredService<UserManager<User>>();
var profile = context.User?.Value<JObject>(SteamAuthenticationConstants.Parameters.Response)
?.Value<JArray>(SteamAuthenticationConstants.Parameters.Players)?[0]?.ToObject<SteamProfile>();
// TODO: Handle this better, Redir user to an informative error page or something
if (profile == null)
return;
var dbProfile = await rsContext.SteamProfiles.FindAsync(profile.SteamId);
if (dbProfile != null)
{
rsContext.Update(dbProfile);
dbProfile.UpdateProfile(profile);
await rsContext.SaveChangesAsync();
}
else
{
await rsContext.SteamProfiles.AddAsync(profile);
await rsContext.SaveChangesAsync();
}
}
AuthController.cs => The only code responsible for authenticating against the Identity.Application scheme
[HttpGet("callback")]
[Authorize(AuthenticationSchemes = "Steam")]
public async Task<IActionResult> Callback([FromQuery]string ReturnUrl)
{
ReturnUrl = ReturnUrl?.Contains("api/") == true ? "/" : ReturnUrl;
if (HttpContext.User.Claims.Count() > 0)
{
var provider = HttpContext.User.Identity.AuthenticationType;
var nameIdentifier = HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
var name = HttpContext.User.FindFirstValue(ClaimTypes.Name);
var loginResult = await signInManager.ExternalLoginSignInAsync(provider, nameIdentifier, false);
if (loginResult.Succeeded)
{
return Redirect(ReturnUrl ?? "/api/auth/claims");
}
var result = await userManager.CreateAsync(new User { UserName = nameIdentifier, SteamId = nameIdentifier.Split("/").Last() });
if (result.Succeeded)
{
var user = await userManager.FindByNameAsync(nameIdentifier);
var identity = await userManager.AddLoginAsync(user, new UserLoginInfo(provider, nameIdentifier, name));
if (identity.Succeeded)
{
await signInManager.ExternalLoginSignInAsync(provider, nameIdentifier, false);
return Redirect(ReturnUrl ?? "/api/auth/claims");
}
}
}
return BadRequest(new { success = false });
}
[HttpGet("claims")]
[Authorize]
public async Task<IActionResult> GetClaims()
{
var user = await userManager.GetUserAsync(User);
var claims =
User.Claims.Select(c => new
{
c.Type,
c.Value
});
var inAdmin = new string[] {
"User.IsInRole(\"Admin\") = " + User.IsInRole("Admin"),
"User.IsInRole(\"ADMIN\") = " + User.IsInRole("ADMIN"),
"User.IsInRole(\"admin\") = " + User.IsInRole("admin"),
"userManager.IsInRoleAsync(user, \"admin\") = " + await userManager.IsInRoleAsync(user, "admin")
};
return Ok(new { success = true, data = new { claims, inAdmin, User.Identities } });
}
RoleSeeder.cs
public static async Task Seed(RoleManager<Role> roleManager)
{
// Developer Role
if(!await roleManager.RoleExistsAsync("Developer"))
{
var role = new Role("Developer");
await roleManager.CreateAsync(role);
}
// Community Manager Role
if (!await roleManager.RoleExistsAsync("Community Manager"))
{
var role = new Role("Community Manager");
await roleManager.CreateAsync(role);
}
// Admin Role
if (!await roleManager.RoleExistsAsync("Admin"))
{
var role = new Role("Admin");
await roleManager.CreateAsync(role);
}
// Moderator Role
if (!await roleManager.RoleExistsAsync("Moderator"))
{
var role = new Role("Moderator");
await roleManager.CreateAsync(role);
}
}
Testing Screenshot:
claims/identities/roletest API Response
Posted this issue to ASP.Net Identity GitHub repo, it is a known bug and has been resolved in ASP.Net Core 2.2
Link: https://github.com/aspnet/Identity/issues/1997
You have tow way for this issue.
When you send a request to any WebService, If you set, Authorization be run Now:
Before login If you want to send a request to your WebService, and want to ignore Authorization you have to use of Allowanonymus Attribute like:
[Allowanonymous]
Public void Login()
{
// here
}
With this attribute, Authorization will ignore the request.
Now! If you want to send a request after login, you should to create you cookie In login time, and send a response to client, and also you have save that cookie in your localStorage in client, for Identifying in client. After this you have to set that cookie in
header of every request. With this way, you authorization will Don!
Now If you want, I can create a sample for Authorization in best practice.