Asp.NET Identity 2 giving "Invalid Token" error - c#
I'm using Asp.Net-Identity-2 and I'm trying to verify email verification code using the below method. But I am getting an "Invalid Token" error message.
My Application's User Manager is like this:
public class AppUserManager : UserManager<AppUser>
{
public AppUserManager(IUserStore<AppUser> store) : base(store) { }
public static AppUserManager Create(IdentityFactoryOptions<AppUserManager> options, IOwinContext context)
{
AppIdentityDbContext db = context.Get<AppIdentityDbContext>();
AppUserManager manager = new AppUserManager(new UserStore<AppUser>(db));
manager.PasswordValidator = new PasswordValidator {
RequiredLength = 6,
RequireNonLetterOrDigit = false,
RequireDigit = false,
RequireLowercase = true,
RequireUppercase = true
};
manager.UserValidator = new UserValidator<AppUser>(manager)
{
AllowOnlyAlphanumericUserNames = true,
RequireUniqueEmail = true
};
var dataProtectionProvider = options.DataProtectionProvider;
//token life span is 3 hours
if (dataProtectionProvider != null)
{
manager.UserTokenProvider =
new DataProtectorTokenProvider<AppUser>
(dataProtectionProvider.Create("ConfirmationToken"))
{
TokenLifespan = TimeSpan.FromHours(3)
};
}
manager.EmailService = new EmailService();
return manager;
} //Create
} //class
} //namespace
My Action to generate the token is (and even if I check the token here, I get "Invalid token" message):
[AllowAnonymous]
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult ForgotPassword(string email)
{
if (ModelState.IsValid)
{
AppUser user = UserManager.FindByEmail(email);
if (user == null || !(UserManager.IsEmailConfirmed(user.Id)))
{
// Returning without warning anything wrong...
return View("../Home/Index");
} //if
string code = UserManager.GeneratePasswordResetToken(user.Id);
string callbackUrl = Url.Action("ResetPassword", "Admin", new { Id = user.Id, code = HttpUtility.UrlEncode(code) }, protocol: Request.Url.Scheme);
UserManager.SendEmail(user.Id, "Reset password Link", "Use the following link to reset your password: link");
//This 2 lines I use tho debugger propose. The result is: "Invalid token" (???)
IdentityResult result;
result = UserManager.ConfirmEmail(user.Id, code);
}
// If we got this far, something failed, redisplay form
return View();
} //ForgotPassword
My Action to check the token is (here, I always get "Invalid Token" when I check the result):
[AllowAnonymous]
public async Task<ActionResult> ResetPassword(string id, string code)
{
if (id == null || code == null)
{
return View("Error", new string[] { "Invalid params to reset password." });
}
IdentityResult result;
try
{
result = await UserManager.ConfirmEmailAsync(id, code);
}
catch (InvalidOperationException ioe)
{
// ConfirmEmailAsync throws when the id is not found.
return View("Error", new string[] { "Error to reset password:<br/><br/><li>" + ioe.Message + "</li>" });
}
if (result.Succeeded)
{
AppUser objUser = await UserManager.FindByIdAsync(id);
ResetPasswordModel model = new ResetPasswordModel();
model.Id = objUser.Id;
model.Name = objUser.UserName;
model.Email = objUser.Email;
return View(model);
}
// If we got this far, something failed.
string strErrorMsg = "";
foreach(string strError in result.Errors)
{
strErrorMsg += "<li>" + strError + "</li>";
} //foreach
return View("Error", new string[] { strErrorMsg });
} //ForgotPasswordConfirmation
I don't know what could be missing or what's wrong...
I encountered this problem and resolved it. There are several possible reasons.
1. URL-Encoding issues (if problem occurring "randomly")
If this happens randomly, you might be running into url-encoding problems.
For unknown reasons, the token is not designed for url-safe, which means it might contain invalid characters when being passed through a url (for example, if sent via an e-mail).
In this case, HttpUtility.UrlEncode(token) and HttpUtility.UrlDecode(token) should be used.
As oão Pereira said in his comments, UrlDecode is not (or sometimes not?) required. Try both please. Thanks.
2. Non-matching methods (email vs password tokens)
For example:
var code = await userManager.GenerateEmailConfirmationTokenAsync(user.Id);
and
var result = await userManager.ResetPasswordAsync(user.Id, code, newPassword);
The token generated by the email-token-provide cannot be confirmed by the reset-password-token-provider.
But we will see the root cause of why this happens.
3. Different instances of token providers
Even if you are using:
var token = await _userManager.GeneratePasswordResetTokenAsync(user.Id);
along with
var result = await _userManager.ResetPasswordAsync(user.Id, HttpUtility.UrlDecode(token), newPassword);
the error still could happen.
My old code shows why:
public class AccountController : Controller
{
private readonly UserManager _userManager = UserManager.CreateUserManager();
[AllowAnonymous]
[HttpPost]
public async Task<ActionResult> ForgotPassword(FormCollection collection)
{
var token = await _userManager.GeneratePasswordResetTokenAsync(user.Id);
var callbackUrl = Url.Action("ResetPassword", "Account", new { area = "", UserId = user.Id, token = HttpUtility.UrlEncode(token) }, Request.Url.Scheme);
Mail.Send(...);
}
and:
public class UserManager : UserManager<IdentityUser>
{
private static readonly UserStore<IdentityUser> UserStore = new UserStore<IdentityUser>();
private static readonly UserManager Instance = new UserManager();
private UserManager()
: base(UserStore)
{
}
public static UserManager CreateUserManager()
{
var dataProtectionProvider = new DpapiDataProtectionProvider();
Instance.UserTokenProvider = new DataProtectorTokenProvider<IdentityUser>(dataProtectionProvider.Create());
return Instance;
}
Pay attention that in this code, every time when a UserManager is created (or new-ed), a new dataProtectionProvider is generated as well. So when a user receives the email and clicks the link:
public class AccountController : Controller
{
private readonly UserManager _userManager = UserManager.CreateUserManager();
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<ActionResult> ResetPassword(string userId, string token, FormCollection collection)
{
var result = await _userManager.ResetPasswordAsync(user.Id, HttpUtility.UrlDecode(token), newPassword);
if (result != IdentityResult.Success)
return Content(result.Errors.Aggregate("", (current, error) => current + error + "\r\n"));
return RedirectToAction("Login");
}
The AccountController is no longer the old one, and neither are the _userManager and its token provider. So the new token provider will fail because it has no that token in it's memory.
Thus we need to use a single instance for the token provider. Here is my new code and it works fine:
public class UserManager : UserManager<IdentityUser>
{
private static readonly UserStore<IdentityUser> UserStore = new UserStore<IdentityUser>();
private static readonly UserManager Instance = new UserManager();
private UserManager()
: base(UserStore)
{
}
public static UserManager CreateUserManager()
{
//...
Instance.UserTokenProvider = TokenProvider.Provider;
return Instance;
}
and:
public static class TokenProvider
{
[UsedImplicitly] private static DataProtectorTokenProvider<IdentityUser> _tokenProvider;
public static DataProtectorTokenProvider<IdentityUser> Provider
{
get
{
if (_tokenProvider != null)
return _tokenProvider;
var dataProtectionProvider = new DpapiDataProtectionProvider();
_tokenProvider = new DataProtectorTokenProvider<IdentityUser>(dataProtectionProvider.Create());
return _tokenProvider;
}
}
}
It could not be called an elegant solution, but it hit the root and solved my problem.
Because you are generating token for password reset here:
string code = UserManager.GeneratePasswordResetToken(user.Id);
But actually trying to validate token for email:
result = await UserManager.ConfirmEmailAsync(id, code);
These are 2 different tokens.
In your question you say that you are trying to verify email, but your code is for password reset. Which one are you doing?
If you need email confirmation, then generate token via
var emailConfirmationCode = await UserManager.GenerateEmailConfirmationTokenAsync(user.Id);
and confirm it via
var confirmResult = await UserManager.ConfirmEmailAsync(userId, code);
If you need password reset, generate token like this:
var code = await UserManager.GeneratePasswordResetTokenAsync(user.Id);
and confirm it like this:
var resetResult = await userManager.ResetPasswordAsync(user.Id, code, newPassword);
I was getting the "Invalid Token" error even with code like this:
var emailCode = UserManager.GenerateEmailConfirmationToken(id);
var result = UserManager.ConfirmEmail(id, emailCode);
In my case the problem turned out to be that I was creating the user manually and adding him to the database without using the UserManager.Create(...) method. The user existed in the database but without a security stamp.
It's interesting that the GenerateEmailConfirmationToken returned a token without complaining about the lack of security stamp, but that token could never be validated.
Other than that, I've seen the code itself fail if it's not encoded.
I've recently started encoding mine in the following fashion:
string code = manager.GeneratePasswordResetToken(user.Id);
code = HttpUtility.UrlEncode(code);
And then when I'm ready to read it back:
string code = IdentityHelper.GetCodeFromRequest(Request);
code = HttpUtility.UrlDecode(code);
To be quite honest, I'm surprised that it isn't being properly encoded in the first place.
In my case, our AngularJS app converted all plus signs (+) to empty spaces (" ") so the token was indeed invalid when it was passed back.
To resolve the issue, in our ResetPassword method in the AccountController, I simply added a replace prior to updating the password:
code = code.Replace(" ", "+");
IdentityResult result = await AppUserManager.ResetPasswordAsync(user.Id, code, newPassword);
I hope this helps anyone else working with Identity in a Web API and AngularJS.
tl;dr: Register custom token provider in aspnet core 2.2 to use AES encryption instead of MachineKey protection, gist: https://gist.github.com/cyptus/dd9b2f90c190aaed4e807177c45c3c8b
i ran into the same issue with aspnet core 2.2, as cheny pointed out the instances of the token provider needs to be the same.
this does not work for me because
i got different API-projects which does generate the token and
receive the token to reset password
the APIs may run on different instances of virtual machines, so the machine key would not be the
same
the API may restart and the token would be invalid because it is
not the same instance any more
i could use
services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo("path"))
to save the token to the file system and avoid restart and multiple instance sharing issues, but could not get around the issue with multiple projects, as each project generates a own file.
the solution for me is to replace the MachineKey data protection logic with an own logic which does use AES then HMAC to symmetric encrypt the token with a key from my own settings which i can share across machines, instances and projects. I took the encryption logic from
Encrypt and decrypt a string in C#?
(Gist: https://gist.github.com/jbtule/4336842#file-aesthenhmac-cs)
and implemented a custom TokenProvider:
public class AesDataProtectorTokenProvider<TUser> : DataProtectorTokenProvider<TUser> where TUser : class
{
public AesDataProtectorTokenProvider(IOptions<DataProtectionTokenProviderOptions> options, ISettingSupplier settingSupplier)
: base(new AesProtectionProvider(settingSupplier.Supply()), options)
{
var settingsLifetime = settingSupplier.Supply().Encryption.PasswordResetLifetime;
if (settingsLifetime.TotalSeconds > 1)
{
Options.TokenLifespan = settingsLifetime;
}
}
}
public class AesProtectionProvider : IDataProtectionProvider
{
private readonly SystemSettings _settings;
public AesProtectionProvider(SystemSettings settings)
{
_settings = settings;
if(string.IsNullOrEmpty(_settings.Encryption.AESPasswordResetKey))
throw new ArgumentNullException("AESPasswordResetKey must be set");
}
public IDataProtector CreateProtector(string purpose)
{
return new AesDataProtector(purpose, _settings.Encryption.AESPasswordResetKey);
}
}
public class AesDataProtector : IDataProtector
{
private readonly string _purpose;
private readonly SymmetricSecurityKey _key;
private readonly Encoding _encoding = Encoding.UTF8;
public AesDataProtector(string purpose, string key)
{
_purpose = purpose;
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
}
public byte[] Protect(byte[] userData)
{
return AESThenHMAC.SimpleEncryptWithPassword(userData, _encoding.GetString(_key.Key));
}
public byte[] Unprotect(byte[] protectedData)
{
return AESThenHMAC.SimpleDecryptWithPassword(protectedData, _encoding.GetString(_key.Key));
}
public IDataProtector CreateProtector(string purpose)
{
throw new NotSupportedException();
}
}
and the SettingsSupplier i use in my project to supply my settings
public interface ISettingSupplier
{
SystemSettings Supply();
}
public class SettingSupplier : ISettingSupplier
{
private IConfiguration Configuration { get; }
public SettingSupplier(IConfiguration configuration)
{
Configuration = configuration;
}
public SystemSettings Supply()
{
var settings = new SystemSettings();
Configuration.Bind("SystemSettings", settings);
return settings;
}
}
public class SystemSettings
{
public EncryptionSettings Encryption { get; set; } = new EncryptionSettings();
}
public class EncryptionSettings
{
public string AESPasswordResetKey { get; set; }
public TimeSpan PasswordResetLifetime { get; set; } = new TimeSpan(3, 0, 0, 0);
}
finally register the provider in Startup:
services
.AddIdentity<AppUser, AppRole>()
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders()
.AddTokenProvider<AesDataProtectorTokenProvider<AppUser>>(TokenOptions.DefaultProvider);
services.AddScoped(typeof(ISettingSupplier), typeof(SettingSupplier));
//AESThenHMAC.cs: See https://gist.github.com/jbtule/4336842#file-aesthenhmac-cs
string code = _userManager.GeneratePasswordResetToken(user.Id);
code = HttpUtility.UrlEncode(code);
//send rest email
do not decode the code
var result = await _userManager.ResetPasswordAsync(user.Id, model.Code, model.Password);
Here is what I did: Decode Token after encoding it for URL (in short)
First I had to Encode the User GenerateEmailConfirmationToken that was generated. (Standard above advice)
var token = await userManager.GenerateEmailConfirmationTokenAsync(user);
var encodedToken = HttpUtility.UrlEncode(token);
and in your controller's "Confirm" Action I had to decode the Token before I validated it.
var decodedCode = HttpUtility.UrlDecode(mViewModel.Token);
var result = await userManager.ConfirmEmailAsync(user,decodedCode);
Hit this issue with asp.net core and after a lot of digging I realised I'd turned this option on in Startup:
services.Configure<RouteOptions>(options =>
{
options.LowercaseQueryStrings = true;
});
This of course invalidated the token that was in the query string.
Here I've the same problem but after a lot of time I found that in my case the invalid token error was raised by the fact that my custom Account class has the Id property re-declared and overridden.
Like that:
public class Account : IdentityUser
{
[ScaffoldColumn(false)]
public override string Id { get; set; }
//Other properties ....
}
So to fix it I've just removed that property and generated again the database schema just to be sure.
Removing this solves the problem.
The following solution helped me in WebApi:
Registration
var result = await _userManager.CreateAsync(user, model.Password);
if (result.Succeeded) {
EmailService emailService = new EmailService();
var url = _configuration["ServiceName"];
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var encodedToken = HttpUtility.UrlEncode(token);
// .Net Core 2.1, Url.Action return null
// Url.Action("confirm", "account", new { userId = user.Id, code = token }, protocol: HttpContext.Request.Scheme);
var callbackUrl = _configuration["ServiceAddress"] + $"/account/confirm?userId={user.Id}&code={encodedToken}";
var message = emailService.GetRegisterMailTemplate(callbackUrl, url);
await emailService.SendEmailAsync( model.Email, $"please confirm your registration {url}", message );
}
Confirm
[Route("account/confirm")]
[AllowAnonymous]
[HttpGet]
public async Task<IActionResult> ConfirmEmail(string userId, string code) {
if (userId == null || code == null) {
return Content(JsonConvert.SerializeObject( new { result = "false", message = "data is incorrect" }), "application/json");
}
var user = await _userManager.FindByIdAsync(userId);
if (user == null) {
return Content(JsonConvert.SerializeObject(new { result = "false", message = "user not found" }), "application/json");
}
//var decodedCode = HttpUtility.UrlDecode(code);
//var result = await _userManager.ConfirmEmailAsync(user, decodedCode);
var result = await _userManager.ConfirmEmailAsync(user, code);
if (result.Succeeded)
return Content(JsonConvert.SerializeObject(new { result = "true", message = "ок", token = code }), "application/json");
else
return Content(JsonConvert.SerializeObject(new { result = "false", message = "confirm error" }), "application/json");
}
Insipired by the soluion #3 posted by #cheny, I realized that if you use the same UserManager instance the generated code is accepted. But in a real scenario, the validation code happens in a second API call after the user clicks on the email link.
It means that a new instance of the UserManager is created and it is unable to verify the code generated by the first instance of the first call. The only way to make it work is to be sure to have the SecurityStamp column in the database user table.
Registering the class that's using the UserManager as singleton throws an exception at the application startup because the UserManager class is automatically registered with a Scoped lifetime
Make sure when generate, you use:
GeneratePasswordResetTokenAsync(user.Id)
And confirm you use:
ResetPasswordAsync(user.Id, model.Code, model.Password)
If you make sure you are using the matching methods, but it still doesn't work, please verify that user.Id is the same in both methods. (Sometimes your logic may not be correct because you allow using same email for registry, etc.)
Maybe this is an old thread but, just for the case, I've been scratching my head with the random occurrence of this error. I've been checking all threads about and verifying each suggestion but -randomly seemed- some of the codes where returned as "invalid token".
After some queries to the user database I've finally found that those "invalid token" errors where directly related with spaces or other non alphanumerical characters in user names.
Solution was easy to find then. Just configure the UserManager to allow those characters in user's names.
This can be done just after the user manager create event, adding a new UserValidator setting to false the corresponding property this way:
public static UserManager<User> Create(IdentityFactoryOptions<UserManager<User>> options, IOwinContext context)
{
var userManager = new UserManager<User>(new UserStore());
// this is the key
userManager.UserValidator = new UserValidator<User>(userManager) { AllowOnlyAlphanumericUserNames = false };
// other settings here
userManager.UserLockoutEnabledByDefault = true;
userManager.MaxFailedAccessAttemptsBeforeLockout = 5;
userManager.DefaultAccountLockoutTimeSpan = TimeSpan.FromDays(1);
var dataProtectionProvider = options.DataProtectionProvider;
if (dataProtectionProvider != null)
{
userManager.UserTokenProvider = new DataProtectorTokenProvider<User>(dataProtectionProvider.Create("ASP.NET Identity"))
{
TokenLifespan = TimeSpan.FromDays(5)
};
}
return userManager;
}
Hope this could help "late arrivals" like me!
Make sure that the token that you generate doesn't expire rapidly - I had changed it to 10 seconds for testing and it would always return the error.
if (dataProtectionProvider != null) {
manager.UserTokenProvider =
new DataProtectorTokenProvider<AppUser>
(dataProtectionProvider.Create("ConfirmationToken")) {
TokenLifespan = TimeSpan.FromHours(3)
//TokenLifespan = TimeSpan.FromSeconds(10);
};
}
We have run into this situation with a set of users where it was all working fine. We have isolated it down to Symantec's email protection system which replaces links in our emails to users with safe links that go to their site for validation and then redirects the user to the original link we sent.
The problem is that they are introducing a decode... they appear to do a URL Encode on the generated link to embed our link as a query parameter to their site but then when the user clicks and clicksafe.symantec.com decodes the url it decodes the first part they needed to encode but also the content of our query string and then the URL that the browser gets redirected to has been decoded and we are back in the state where the special characters mess up the query string handling in the code behind.
In my case, I just need to do HttpUtility.UrlEncode before sending an email. No HttpUtility.UrlDecode during reset.
Related to chenny's 3. Different instances of token providers .
In my case I was passing IDataProtectionProvider.Create a new guid every time it got called, which prevented existing codes from being recognized in subsequent web api calls (each request creates its own user manager).
Making the string static solved it for me.
private static string m_tokenProviderId = "MyApp_" + Guid.NewGuid().ToString();
...
manager.UserTokenProvider =
new DataProtectorTokenProvider<User>(
dataProtectionProvider.Create(new string[1] { m_tokenProviderId } ))
{
TokenLifespan = TimeSpan.FromMinutes(accessTokenLifespan)
};
In case anyone runs into this, it turns out that the token was not URL-friendly, and so I had to wrap it in a HttpUtility.UrlEncode() like so:
var callback = Url.Content($"{this.Request.Scheme}://{this.Request.Host}{this.Request.PathBase}/reset-password?token={HttpUtility.UrlEncode(token)}&email={user.Email}");
I have solved "Invalid Token" issue most of described hints. Here is my solution for blazor project. The core is in StringExtensions class.
Generating email when user is registering his/her email:
user = new IdentityUser { UserName = email, Email = email };
var createUser = await _userManager.CreateAsync(user, password);
if (createUser.Succeeded)
{
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var baseUri = NavMgr.BaseUri;
var setNewPasswordUri = baseUri + "confirm-password";
var urlWithParams = StringExtensions.GenerateUrl(token, emailTo, url);
await SendAsync( urlWithParams ); // use your own Email solution send the email
}
Email confirmation (user clicks on the link in the mail)
#page "/confirm-email"
<h3>Confirm email</h3>
#Error
[Inject]
UserManager<IdentityUser> UserMgr { get; set; }
[Inject]
NavigationManager NavMgr { get; set; }
protected override Task OnInitializedAsync()
{
var url = NavMgr.Uri;
Token = StringExtensions.GetParamFromUrl(url, "token");
Email = StringExtensions.GetParamFromUrl(url, "email");
log.Trace($"Initialised with email={Email} , token={Token}");
return ActivateEmailAsync();
}
private async Task ActivateEmailAsync()
{
isProcessing = true;
Error = null;
log.Trace($"ActivateEmailAsync started for {Email}");
isProcessing = true;
Error = null;
try
{
var user = await UserMgr.FindByEmailAsync(Email);
if (user != null)
{
if (!string.IsNullOrEmpty(Token))
{
var result = await UserMgr.ConfirmEmailAsync(user, Token);
if (result.Succeeded)
{
// Show user , that account is activated
}
else
{
foreach (var error in result.Errors)
{
Error += error.Description;
}
log.Error($"Setting new password failed for {Email} due to the: {Error}");
}
}
else
{
log.Error("This should not happen. Token is null or empty");
}
}
}
catch (Exception exc)
{
Error = $"Activation failed";
}
isProcessing = false;
}
public static class StringExtensions
{
/// <summary>
/// Encode string to be safe to use it in the URL param
/// </summary>
/// <param name="toBeEncoded"></param>
/// <returns></returns>
public static string Encode(string toBeEncoded)
{
var result = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(toBeEncoded));
return result;
}
/// <summary>
/// Decode from the url safe string the original value
/// </summary>
/// <param name="toBeDecoded"></param>
/// <returns></returns>
public static string Decode(string toBeDecoded)
{
var decodedBytes = WebEncoders.Base64UrlDecode(toBeDecoded);
var result = Encoding.UTF8.GetString(decodedBytes);
return result;
}
public static string GenerateUrl(string token, string emailTo, string baseUri, string tokenParamName = "token", string emailParamName = "email")
{
var tokenEncoded = StringExtensions.Encode(token);
var emailEncoded = StringExtensions.Encode(emailTo);
var queryParams = new Dictionary<string, string>();
queryParams.Add(tokenParamName, tokenEncoded);
queryParams.Add(emailParamName, emailEncoded);
var urlWithParams = QueryHelpers.AddQueryString(baseUri, queryParams);
return urlWithParams;
}
public static string GetParamFromUrl(string uriWithParams, string paramName)
{
var uri = new Uri(uriWithParams, UriKind.Absolute);
var result = string.Empty;
if (QueryHelpers.ParseQuery(uri.Query).TryGetValue(paramName, out var paramToken))
{
var queryToken = paramToken.First();
result = StringExtensions.Decode(queryToken);
}
return result;
}
I have experienced Invalid token in Reset password scenario. The root cause was, that I was generating reset token for for incorrect IndentityUser. It can be spotted easily in simplified code, but it it took me some to fix it time in more complex code.
I should have used the code:
var user = await UserMgr.FindByEmailAsync(Model.Email);
string resetToken = await _userManager.GeneratePasswordResetTokenAsync(user);
But I was wrongly ( creating another IndentityUser).
// This is example "How it should not be done"
var user = await UserMgr.FindByEmailAsync(Model.Email);
user = new IdentityUser { UserName = email, Email = email }; // This must not be her !!!! We need to use user found by UserMgr.FindByEmailAsync(Model.Email);
string resetToken = await _userManager.GeneratePasswordResetTokenAsync(user);
Complete simplified code is here:
private async Task GenerateResetToken()
{
var user = await UserMgr.FindByEmailAsync(Model.Email);
if (user == null)
{
Model.Error = "Not registered";
}
else
{
try
{
var _userManager = SignInMgr.UserManager;
UserMgr.FindByEmailAsync(Model.Email);
string resetToken = await _userManager.GeneratePasswordResetTokenAsync(user);
if (resetToken == null)
{
log.Error("Cannot get token from GeneratePasswordResetTokenAsync");
}
else
{
// Reset token generated. Send email to user
}
}
catch (Exception exc)
{
log.Error(exc, $"Password reset failed due to the {exc.Message}");
}
}
}
My problem was that there was a typo in the email containing the ConfirmationToken:
<p>Please confirm your account by <a href=#ViewBag.CallbackUrl'>clicking here</a>.</p>
This meant the extra apostrophe was appended to the end of the ConfirmationToken.
D'oh!
My issue was that I was missing a <input asp-for="Input.Code" type="hidden" /> control in my Reset Password form
<form role="form" method="post">
<div asp-validation-summary="All" class="text-danger"></div>
<input asp-for="Input.Code" type="hidden" />
Related
How to create a 'CallBack' action after getting a Code from Authentication Server in OAuth2 in Web Application Controller
I'm Providing an 'OAuthHandler' for Walmart and overriding some OAuthHandler methods to communicate between User Agent (Client) and Remote Authenticate Server. Below is my controller: [AllowAnonymous] public class WalmartLoginController : Controller { public async Task<IActionResult> Login([FromForm] string provider) { if (string.IsNullOrWhiteSpace(provider)) { return BadRequest(); } if (!await HttpContext.IsProviderSupportedAsync(provider)) { return BadRequest(); } return Challenge(new AuthenticationProperties { RedirectUri = "/" }, provider); } [HttpGet("~/signout")] [HttpPost("~/signout")] public IActionResult SignOutCurrentUser() { return SignOut(new AuthenticationProperties { RedirectUri = "/" }, CookieAuthenticationDefaults.AuthenticationScheme); } } And I added some classes to handle my requests: [here is the importance of those that overrode from the OAuthHandler class] public partial class WalmartAuthenticationHandler : OAuthHandler<WalmartAuthenticationOptions> { public WalmartAuthenticationHandler( [NotNull] IOptionsMonitor<WalmartAuthenticationOptions> options, [NotNull] ILoggerFactory logger, [NotNull] UrlEncoder encoder, [NotNull] ISystemClock clock) : base(options, logger, encoder, clock) {} // STEP 1: CREATE CHALLENGE URL protected override string BuildChallengeUrl([NotNull] AuthenticationProperties properties, [NotNull] string redirectUri) { var scopeParameter = properties.GetParameter<ICollection<string>>(OAuthChallengeProperties.ScopeKey); var scope = scopeParameter != null ? FormatScope(scopeParameter) : FormatScope(); var parameters = new Dictionary<string, string?> { ["client_id"] = Options.ClientId, ["response_type"] = "code", ["scope"] = scope }; if (Options.UsePkce) { var bytes = BitConverter.GetBytes(256 / 8); var codeVerifier = WebEncoders.Base64UrlEncode(bytes); // Store this for use during the code redemption. properties.Items.Add(OAuthConstants.CodeVerifierKey, codeVerifier); var challengeBytes = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier)); var codeChallenge = WebEncoders.Base64UrlEncode(challengeBytes); parameters[OAuthConstants.CodeChallengeKey] = codeChallenge; parameters[OAuthConstants.CodeChallengeMethodKey] = OAuthConstants.CodeChallengeMethodS256; } var state = Options.StateDataFormat.Protect(properties); parameters["redirect_uri"] = QueryHelpers.AddQueryString(redirectUri, "state", state); return QueryHelpers.AddQueryString(Options.AuthorizationEndpoint, parameters); } // STEP 2 : CHANGE CODE WITH ACCESS_TOKEN protected override async Task<OAuthTokenResponse> ExchangeCodeAsync([NotNull] OAuthCodeExchangeContext context) { var tokenRequestParameters = new Dictionary<string, string?>() { ["client_id"] = Options.ClientId, ["client_secret"] = Options.ClientSecret, ["redirect_uri"] = context.RedirectUri, ["code"] = context.Code, ["grant_type"] = "authorization_code" }; // Add CodeVerify to tokenRequestParameters if (context.Properties.Items.TryGetValue(OAuthConstants.CodeVerifierKey, out var codeVerifier)) { tokenRequestParameters.Add(OAuthConstants.CodeVerifierKey, codeVerifier); context.Properties.Items.Remove(OAuthConstants.CodeVerifierKey); } var endpoint = QueryHelpers.AddQueryString(Options.TokenEndpoint, tokenRequestParameters); using var request = new HttpRequestMessage(HttpMethod.Post, Options.TokenEndpoint); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/x-www-form-urlencoded")); request.Content = new FormUrlEncodedContent(tokenRequestParameters); using var response = await Backchannel.SendAsync(request, Context.RequestAborted); if (!response.IsSuccessStatusCode) { // An error occurred while retrieving an OAuth token. } var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted)); return OAuthTokenResponse.Success(payload); } // STEP 3: access to UserInformation with Access Token protected override async Task<AuthenticationTicket> CreateTicketAsync( [NotNull] ClaimsIdentity identity, [NotNull] AuthenticationProperties properties, [NotNull] OAuthTokenResponse tokens) { using var request = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); using var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); if (!response.IsSuccessStatusCode) { // An error occurred while retrieving the user profile. } using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted)); var principal = new ClaimsPrincipal(identity); var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, payload.RootElement.GetProperty("data")); context.RunClaimActions(); await Events.CreatingTicket(context); return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name); } } As you know, after running https:\\Development-Domain.com\signin by post method (second action), a Challenge is started and will redirect to BuildChallengeUrl() and return a Url with Code and State. The next action would be ExchangeCodeAsync() to change the Code sent by the remote server with an AccessToken. The question is, which action or method was missed in this process? Will my ExchangeCodeAsync() call automatically after the BuildChallengeUrl() handler, or do I need to put some action to continue to authorize the user? Update #1: I added a callback action to get info from 'QueryString' in controller to call next method of handler class: [HttpPost("~/signin-oidc")] public async Task<IActionResult> Callback([FromForm] string provider) { var code = Request.Query["code"]; var state = Request.Query["state"]; if (string.IsNullOrWhiteSpace(code)) { return BadRequest(); } if (!await HttpContext.IsProviderSupportedAsync(provider)) { return BadRequest(); } return {?}; } What code must I write in {?} to request for Access Token from TokenEndpoint?
Except callback action other code are in-line with Authorisation code flow. In PKCE Authorization code flow, you should match with flow mentioned here, that will help pass security validation / testing post production. How it works, Authorization Flow Answer to question, CallBack action an endpoint action method of callback url should trigger and make post request to token endpoint of authorisation server with code received as query string in CallBack Url with Code Verifier created with Code Challenge and on successful post it will return access-token, refresh-token, id-token, .... based on your configuration. You will need to specify callback url also with client_id and client_secret in configuration. re-arranging code based on this How it works, Authorization Flow will help.
After figuring out and researching Microsoft .Net Core OAuth libraries, I got the answer and I would like to share it with you: As you know, we no need to write any code in callback action for running the next method in the WalmartAuthenticationHandler class because all the process is automatic and event-based and all of them are provided in the Microsoft OAuthHandler class. You need to write some code inside the Callback action just for registering User after the authentication process. Even you can use Microsoft ExternalLogin Identity pages for the registration of an external User. Also, I decided to create a project on GitHub and I'd like to share it here OktaProvider, maybe everyone needs to add external authentication for non-famous companies.
Google Sign-in with existing ASP.NET MVC Application
I'm trying to handle google-signin to my existing ASP.NET MVC application. It has a forms authentication developped customely and it works fine for username and password. I want to add Google-sign-in to this project. I currently added the button, created the app in google developper console got my id and secret and set up the urls. I can see the button in login page, click, i see my account and picture, select account. At this point google posts me some data. I received in an Action method an string array object called "credential" which has a string in first position. But I don't know what to do from here on... Can somebody help me with this? Which document I should use? I'm reading this: https://developers.google.com/identity/gsi/web till now but i'm stuck. What I dont want is this: https://learn.microsoft.com/en-us/aspnet/mvc/overview/security/create-an-aspnet-mvc-5-app-with-facebook-and-google-oauth2-and-openid-sign-on I want to handle the requests needed myself (with cookies, database checks and keeping track of tokens) and get the user information google provides by myself in my controller's action methods. Here is a part of the razor view code <div id="g_id_onload" data-client_id="my id is here" data-login_uri="#Url.Action("LoginWithGoogle","Login")" data-auto_prompt="false"> </div> <div class="g_id_signin" data-type="standard" data-size="large" data-theme="outline" data-text="sign_in_with" data-shape="rectangular" data-logo_alignment="left" data-auto_prompt="true" > </div> I added this script: <script src="https://accounts.google.com/gsi/client" async defer></script> This Action method can catch the string: [HttpPost] public ActionResult LoginWithGoogle(string[] credential) { ViewBag.Data = credential; ///I will do necessary stuff here. return View(); } Notes: -Identity is not installed and will not be used (if unless impossible without using it). -My .Net Framework version: 4.7.2 Thanks for the help.
I don't use "full" default auth code too, here is how I handle Google/Yandex/Discord/OAuth response: [HttpPost] [AllowAnonymous] public IActionResult ExternalLogin(string provider, string? returnUrl = null) { var redirectUrl = Url.Action(nameof(ExternalLoginCallback), null, new { returnUrl }); var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); return new ChallengeResult(provider, properties); } [HttpGet] [AllowAnonymous] public async Task<IActionResult> ExternalLoginCallback(string? returnUrl = null) { var info = await signInManager.GetExternalLoginInfoAsync(); if (info == null) { return this.Redirect(LoginPath); } var email = info.Principal.FindFirstValue(ClaimTypes.Email); var name = info.Principal.FindFirstValue(ClaimTypes.Name) ?? info.Principal.Identity.Name; // your own actions & checks with email, name etc This still required some "default" preparations in Startup: services.AddIdentity<User, UserStoreService.UserRole>() .AddUserStore<UserStoreService>() .AddRoleStore<UserStoreService>() .AddDefaultTokenProviders(); services.AddAuthentication().AddGoogle(...) But here User is my own class, UserRole is own (end empty) class, and UserStoreService is my own implementation of IDisposable, IUserStore<User>, IUserEmailStore<User>, IUserClaimStore<User>, IUserSecurityStampStore<User>, IRoleStore<UserStoreService.UserRole> (you may modify this list according to your needs)
This is example how to process external authentication using owin. First of all, you have to setup your startup.cs and update configuration with something like this. Of course you have to import required packages into your project. public void Configuration(IAppBuilder app) { .... app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie); app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions() { ClientId = "YourGoogleClientId", ClientSecret = "YourGoogleClientSecret", }); } Then in your account controller (example name) create method that will handle your login with google button. For example: public ActionResult ExternalLogin(string provider) { var returnUrl = Request.Url.GetLeftPart(UriPartial.Authority) + "/Account/ExternalLoginCallback"; return new ChallengeResult(provider, returnUrl); } Create your ChallengeResult method: internal class ChallengeResult : HttpUnauthorizedResult { public ChallengeResult(string provider, string redirectUrl) { LoginProvider = provider; RedirectUri = redirectUrl; } public string LoginProvider { get; set; } public string RedirectUri { get; set; } public override void ExecuteResult(ControllerContext context) { var properties = new AuthenticationProperties { RedirectUri = RedirectUri }; var owin = context.HttpContext.GetOwinContext(); owin.Authentication.Challenge(properties, LoginProvider); } } Add your new authentication method in your account controller. For example: public async Task<ActionResult> ExternalLoginCallback() { try { var loginInfo = await AuthenticationManager.GetExternalLoginInfoAsync(); if (loginInfo == null || string.IsNullOrEmpty(loginInfo.Email)) { return RedirectToAction("ExternalLoginFailed", "WebAccount"); } else { // handle external login, means process user login in your system } } catch (Exception ex) { // handle possible exceptions } return RedirectToAction("ExternalLoginFailed", "WebAccount"); } private IAuthenticationManager AuthenticationManager { get { return HttpContext.GetOwinContext().Authentication; } }
Invalid Token using GenerateEmailConfirmationTokenAsync Outside Controller in MVC
I've been stuck on this for days. I am using GenerateEmailConfirmationTokenAsync to create a token outside the Controller (it's working fine), but somehow my token is longer than the ones created within the Controller using the GenerateEmailConfirmationTokenAsync and therefore the ConfirmEmail action rejects the token. (Error: Invalid Token). I have tried Machinekey on web.config, HttpUtility.UrlEncode, but I am still stuck. How to sort out the Invalid Token error on Controller ConfirmEmail? Here is my Code: RegisterUser (outside Controller) public async Task RegisterUserAsync() { var store = new UserStore<ApplicationUser>(db); var UserManager = new ApplicationUserManager(store); var query = from c in db.Customer where !(from o in db.Users select o.customer_pk) .Contains(c.customer_pk) select c; var model = query.ToList(); if (query != null) { foreach (var item in model) { var user = new ApplicationUser { UserName = item.email, Email = item.email, customerId = item.customerId}; var result = await UserManager.CreateAsync(user); if (result.Succeeded) { string callbackUrl = await SendEmailConfirmationTokenAsync(user.Id); SmtpClient client = new SmtpClient(); MailMessage message = new MailMessage { IsBodyHtml = true }; message.Subject = "Confirm Email"; message.To.Add(item.email1); message.Body = "Please confirm your account by clicking here"; client.SendAsync(message, "userToken"); //Assign Role User Here await UserManager.AddToRoleAsync(user.Id, "Client"); } } } } SendEmailConfirmation method (outside Controller) public async Task<string> SendEmailConfirmationTokenAsync(string userID) { var store = new UserStore<ApplicationUser>(db); var UserManager = new ApplicationUserManager(store); var url = new UrlHelper(); var provider = new DpapiDataProtectionProvider("MyApp"); UserManager.UserTokenProvider = new DataProtectorTokenProvider<ApplicationUser>( provider.Create("EmailConfirmation")); string code = await UserManager.GenerateEmailConfirmationTokenAsync(userID); string encodedCode = HttpUtility.UrlEncode(code); string callbackUrl = "http://localhost/Accounts/ConfirmEmail?userId=" + userID + "&code=" + encodedCode; return callbackUrl; } where db is ApplicationdDbContext db = new ApplicationdDbContext(); ConfirmEmail within the Identity Controller (Accounts Controller) - I've created Accounts instead of Account controller but it's working fine. // // GET: /Account/ConfirmEmail [AllowAnonymous] public async Task<ActionResult> ConfirmEmail(string userId, string code) { if (userId == null || code == null) { return View("Error"); } var confirmed = await UserManager.IsEmailConfirmedAsync(userId); if (confirmed) { return RedirectToLocal(userId); } var result = await UserManager.ConfirmEmailAsync(userId, code); //Here I get the error (Token Invlaid, despite the token and userId being displayed) if (result.Succeeded) { ViewBag.userId = userId; ViewBag.code = code; } return View(result.Succeeded ? "ConfirmEmail" : "Error"); } [HttpPost] [ValidateAntiForgeryToken] [AllowAnonymous] public async Task<ActionResult> ConfirmEmail(SetPasswordViewModel model, string userId, string code) { if (userId == null || code == null) { return View("Error"); } if (!ModelState.IsValid) { return View(model); } var result = await UserManager.AddPasswordAsync(userId, model.NewPassword); if (result.Succeeded) { var user = await UserManager.FindByIdAsync(userId); if (user != null) { await SignInManager.SignInAsync(user, isPersistent: false, rememberBrowser: false); } return RedirectToLocal(userId); } ViewBag.userId = userId; ViewBag.code = code; AddErrors(result); return View(model); } I have worked for hours in this code but until now I can't sort it out. Thanks for any comments or solution. The reason for this approach is that I have to use task scheduler (I'm using fluentscheduler, which is working fine).
Your problem is in this line: var provider = new DpapiDataProtectionProvider("MyApp"); UserManager.UserTokenProvider = new DataProtectorTokenProvider<ApplicationUser>( provider.Create("EmailConfirmation")); DpapiDataProtectionProvider here is just not the same as what Identity uses while running under IIS. As far as I remember instead of "MyApp" it uses internal name for IIS web-site. Also it does some magic with registering it through delegates and as a singleton. You can try to save a static reference to data protection provider and use it in your scheduler code. In Startup.Auth.cs class do this: public partial class Startup { internal static IDataProtectionProvider DataProtectionProvider { get; private set; } public void ConfigureAuth(IAppBuilder app) { DataProtectionProvider = app.GetDataProtectionProvider(); // other stuff. } } Then in your UserManager access for that reference like this: public class UserManager : UserManager<ApplicationUser> { public UserManager() : base(new UserStore<ApplicationUser>(new MyDbContext())) { var dataProtectionProvider = Startup.DataProtectionProvider; this.UserTokenProvider = new DataProtectorTokenProvider<ApplicationUser>(dataProtectionProvider.Create("ASP.NET Identity")); // do other configuration } } However I'm not familiar wiht details of FluentScheduler and it might not let you access this static variable if it starts processes in separate AppDomain. But give it a try and see how it works.
Asp.net Core Identity unit test controller actions
I'm having a problem working out how and what to test. I have a controller that injects UserManager and calls the CreateAsync method to create a new user. I don't want to test the Identity user manager as this has clearly been thoroughly tested already. What I would like to do is test that the controller runs through the correct paths (in my case, there are 3 paths, sending responses back with either model state errors, identity response errors or a simple string) Should I be trying to create a mock of the user manager in order to create my test (I'm not sure how to set up user manager as a mock dependency) Second, how can I set conditions to verify that the controller has taken a given path. I am using xUnit and Moq. [Route("api/[controller]")] public class MembershipController : BaseApiController { private UserManager<ApplicationUser> _userManager; public MembershipController(UserManager<ApplicationUser> userManager) { _userManager = userManager; } [HttpGet("RegisterNewUser")] public HttpResponseMessage RegisterNewUser([FromBody] NewUserRegistration user) { if (ModelState.IsValid) { ApplicationUser newUser = new ApplicationUser(); newUser.UserName = user.username; newUser.Email = user.password; IdentityResult result = _userManager.CreateAsync(newUser, user.password).Result; if (result.Errors.Count() > 0) { var errors = new IdentityResultErrorResponse().returnResponseErrors(result.Errors); return this.WebApiResponse(errors, HttpStatusCode.BadRequest); } } else { var errors = new ViewModelResultErrorResponse().returnResponseErrors(ModelState); return this.WebApiResponse(errors, HttpStatusCode.BadRequest); } return this.WebApiResponse( "We have sent a valifation email to you, please click on the verify email account link.", HttpStatusCode.OK); } } In My unit test I have the following to test a happy path scenario [Fact] public void RegisterNewUser_ReturnsHttpStatusOK_WhenValidModelPosted() { var mockStore = new Mock<IUserStore<ApplicationUser>>(); var mockUserManager = new Mock<UserManager<ApplicationUser>>(mockStore.Object, null, null, null, null, null, null, null, null); ApplicationUser testUser = new ApplicationUser { UserName = "user#test.com" }; mockStore.Setup(x => x.CreateAsync(testUser, It.IsAny<CancellationToken>())) .Returns(Task.FromResult(IdentityResult.Success)); mockStore.Setup(x => x.FindByNameAsync(testUser.UserName, It.IsAny<CancellationToken>())) .Returns(Task.FromResult(testUser)); mockUserManager.Setup(x => x.CreateAsync(testUser).Result).Returns(new IdentityResult()); MembershipController sut = new MembershipController(mockUserManager.Object); var input = new NewUserInputBuilder().Build(); sut.RegisterNewUser(input); } Where "input" in sut.RegisterNewUser(input); refers to a helper class which constructs the viewmodel which the controller action requires: public class NewUserInputBuilder { private string username { get; set; } private string password { get; set; } private string passwordConfirmation { get; set; } private string firstname { get; set; } private string lastname { get; set; } internal NewUserInputBuilder() { this.username = "user#test.com"; this.password = "password"; this.passwordConfirmation = "password"; this.firstname = "user"; this.lastname = "name"; } internal NewUserInputBuilder WithNoUsername() { this.username = ""; return this; } internal NewUserInputBuilder WithMisMatchedPasswordConfirmation() { this.passwordConfirmation = "MismatchedPassword"; return this; } internal NewUserRegistration Build() { return new NewUserRegistration { username = this.username, password = this.password, passwordConfirmation = this.passwordConfirmation, firstname = this.firstname, lastname = this.lastname }; } } My aim here is to force 3 conditions via tests: Create a valid viewmodel and return a success message Create a valid viewmodel but returns a IdentityResponse error (eg. user exists) which gets converted to Create an invalid viewmodel and returns Modelstate errors The errors are handled using a abstract class which returns a json object The base class for the controller simply constructs a HttpResponseMessage for return. Basically I want to check that the correct error response class is called by forcing the test down the modelstate error path, the identityresult.errors path and that the happy path can be achieved. Then my plan is to test the error response classes in isolation. Hopefully that is enough detail.
Mehod under test should be made async and not use blocking calls ie .Result [HttpGet("RegisterNewUser")] public async Task<HttpResponseMessage> RegisterNewUser([FromBody] NewUserRegistration user) { if (ModelState.IsValid) { var newUser = new ApplicationUser() { UserName = user.username, Email = user.password }; var result = await _userManager.CreateAsync(newUser, user.password); if (result.Errors.Count() > 0) { var errors = new IdentityResultErrorResponse().returnResponseErrors(result.Errors); return this.WebApiResponse(errors, HttpStatusCode.BadRequest); } } else { var errors = new ViewModelResultErrorResponse().returnResponseErrors(ModelState); return this.WebApiResponse(errors, HttpStatusCode.BadRequest); } return this.WebApiResponse( "We have sent a valifation email to you, please click on the verify email account link.", HttpStatusCode.OK); } Review of Happy path scenario and method under test shows that there is no need to setup the UserStore as test will be overriding the user manager virtual members directly. Note the test has also been made async as well. Create a valid viewmodel and return a success message [Fact] public async Task RegisterNewUser_ReturnsHttpStatusOK_WhenValidModelPosted() { //Arrange var mockStore = Mock.Of<IUserStore<ApplicationUser>>(); var mockUserManager = new Mock<UserManager<ApplicationUser>>(mockStore, null, null, null, null, null, null, null, null); mockUserManager .Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>())) .ReturnsAsync(IdentityResult.Success); var sut = new MembershipController(mockUserManager.Object); var input = new NewUserInputBuilder().Build(); //Act var actual = await sut.RegisterNewUser(input); //Assert actual .Should().NotBeNull() .And.Match<HttpResponseMessage>(_ => _.IsSuccessStatusCode == true); } Create a valid viewmodel but returns a IdentityResponse error (eg. user exists) which gets converted For this you just need to setup the mock to return a result with errors. [Fact] public async Task RegisterNewUser_ReturnsHttpStatusBadRequest_WhenViewModelPosted() { //Arrange //...code removed for brevity mockUserManager .Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>())) .ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "test"})); //...code removed for brevity //Assert actual .Should().NotBeNull() .And.Match<HttpResponseMessage>(_ => _.StatusCode == HttpStatusCode.BadRequest); } And for Create an invalid viewmodel and returns Modelstate errors You just need to set the model state of the controller so that it is invalid. [Fact] public async Task RegisterNewUser_ReturnsHttpStatusBadRequest_WhenInvalidModelState() { //Arrange var mockStore = Mock.Of<IUserStore<ApplicationUser>>(); var mockUserManager = new Mock<UserManager<ApplicationUser>>(mockStore, null, null, null, null, null, null, null, null); var sut = new MembershipController(mockUserManager.Object); sut.ModelState.AddModelError("", "invalid data"); var input = new NewUserInputBuilder().Build(); //Act var actual = await sut.RegisterNewUser(input); //Assert actual .Should().NotBeNull() .And.Match<HttpResponseMessage>(_ => _.StatusCode == HttpStatusCode.BadRequest); } FluentAssertions were used to do all the assertions. You could just as easily used Assert.* API. This should be enough to get you on your way with the above question.
Here's a simple way using NUnit (you could do something similar with xUnit), if you don't want to test the user manager. (I've also shown how the DbContext can be passed to the same controller, using an in-memory database that can be used for setting up mock data) private DbContextOptions<MyContextName> options; [OneTimeSetUp] public void SetUp() { options = new DbContextOptionsBuilder<MyContextName>() .UseInMemoryDatabase(databaseName: "MyDatabase") .Options; // Insert seed data into the in-memory mock database using one instance of the context using (var context = new MyContextName(options)) { var testWibble = new Wibble { MyProperty = 1, MyOtherProperty = 2 ... }; context.wibbles.Add(testWibble); context.SaveChanges(); } } [Test] public void Some_TestMethod() { // Use a clean instance of the context to run the test using (var context = new MyDbContext(options)) { var store = new UserStore<MyUserType>(context); var userManager = new UserManager<MyUserType>(store, null, null, null, null, null, null, null, null); MyController MyController = new MyController(userManager, context); ... test the controller } }
Authorize By Group in Azure Active Directory B2C
I am trying to figure out how to authorize using groups in Azure Active Directory B2C. I can Authorize via User, for example: [Authorize(Users="Bill")] However, this is not very effective and I see very few use-cases for this. An alternate solution would be Authorizing via Role. However for some reason that does not seem to work. It does not work if I give a user the Role "Global Admin" for example, and try: [Authorize(Roles="Global Admin")] Is there a way to authorize via Groups or Roles?
Obtaining group memberships for a user from Azure AD requires quite a bit more than just "a couple lines of code", so I thought I'd share what finally worked for me to save others a few days worth of hair-pulling and head-banging. Let's begin by adding the following dependencies to project.json: "dependencies": { ... "Microsoft.IdentityModel.Clients.ActiveDirectory": "3.13.8", "Microsoft.Azure.ActiveDirectory.GraphClient": "2.0.2" } The first one is necessary as we need to authenticate our application in order for it to be able to access AAD Graph API. The second one is the Graph API client library we'll be using to query user memberships. It goes without saying that the versions are only valid as of the time of this writing and may change in the future. Next, in the Configure() method of the Startup class, perhaps just before we configure OpenID Connect authentication, we create the Graph API client as follows: var authContext = new AuthenticationContext("https://login.microsoftonline.com/<your_directory_name>.onmicrosoft.com"); var clientCredential = new ClientCredential("<your_b2c_app_id>", "<your_b2c_secret_app_key>"); const string AAD_GRAPH_URI = "https://graph.windows.net"; var graphUri = new Uri(AAD_GRAPH_URI); var serviceRoot = new Uri(graphUri, "<your_directory_name>.onmicrosoft.com"); this.aadClient = new ActiveDirectoryClient(serviceRoot, async () => await AcquireGraphAPIAccessToken(AAD_GRAPH_URI, authContext, clientCredential)); WARNING: DO NOT hard-code your secret app key but instead keep it in a secure place. Well, you already knew that, right? :) The asynchronous AcquireGraphAPIAccessToken() method that we handed to the AD client constructor will be called as necessary when the client needs to obtain authentication token. Here's what the method looks like: private async Task<string> AcquireGraphAPIAccessToken(string graphAPIUrl, AuthenticationContext authContext, ClientCredential clientCredential) { AuthenticationResult result = null; var retryCount = 0; var retry = false; do { retry = false; try { // ADAL includes an in-memory cache, so this will only send a request if the cached token has expired result = await authContext.AcquireTokenAsync(graphAPIUrl, clientCredential); } catch (AdalException ex) { if (ex.ErrorCode == "temporarily_unavailable") { retry = true; retryCount++; await Task.Delay(3000); } } } while (retry && (retryCount < 3)); if (result != null) { return result.AccessToken; } return null; } Note that it has a built-in retry mechanism for handling transient conditions, which you may want to tailor to your application's needs. Now that we have taken care of application authentication and AD client setup, we can go ahead and tap into OpenIdConnect events to finally make use of it. Back in the Configure() method where we'd typically call app.UseOpenIdConnectAuthentication() and create an instance of OpenIdConnectOptions, we add an event handler for the OnTokenValidated event: new OpenIdConnectOptions() { ... Events = new OpenIdConnectEvents() { ... OnTokenValidated = SecurityTokenValidated }, }; The event is fired when access token for the signing-in user has been obtained, validated and user identity established. (Not to be confused with the application's own access token required to call AAD Graph API!) It looks like a good place for querying Graph API for user's group memberships and adding those groups onto the identity, in the form of additional claims: private Task SecurityTokenValidated(TokenValidatedContext context) { return Task.Run(async () => { var oidClaim = context.SecurityToken.Claims.FirstOrDefault(c => c.Type == "oid"); if (!string.IsNullOrWhiteSpace(oidClaim?.Value)) { var pagedCollection = await this.aadClient.Users.GetByObjectId(oidClaim.Value).MemberOf.ExecuteAsync(); do { var directoryObjects = pagedCollection.CurrentPage.ToList(); foreach (var directoryObject in directoryObjects) { var group = directoryObject as Group; if (group != null) { ((ClaimsIdentity)context.Ticket.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role, group.DisplayName, ClaimValueTypes.String)); } } pagedCollection = pagedCollection.MorePagesAvailable ? await pagedCollection.GetNextPageAsync() : null; } while (pagedCollection != null); } }); } Used here is the Role claim type, however you could use a custom one. Having done the above, if you're using ClaimType.Role, all you need to do is decorate your controller class or method like so: [Authorize(Role = "Administrators")] That is, of course, provided you have a designated group configured in B2C with a display name of "Administrators". If, however, you chose to use a custom claim type, you'd need to define an authorization policy based on the claim type by adding something like this in the ConfigureServices() method, e.g.: services.AddAuthorization(options => options.AddPolicy("ADMIN_ONLY", policy => policy.RequireClaim("<your_custom_claim_type>", "Administrators"))); and then decorate a privileged controller class or method as follows: [Authorize(Policy = "ADMIN_ONLY")] Ok, are we done yet? - Well, not exactly. If you ran your application and tried signing in, you'd get an exception from Graph API claiming "Insufficient privileges to complete the operation". It may not be obvious, but while your application authenticates successfully with AD using its app_id and app_key, it doesn't have the privileges required to read the details of users from your AD. In order to grant the application such access, I chose to use the Azure Active Directory Module for PowerShell The following script did the trick for me: $tenantGuid = "<your_tenant_GUID>" $appID = "<your_app_id>" $userVal = "<admin_user>#<your_AD>.onmicrosoft.com" $pass = "<admin password in clear text>" $Creds = New-Object System.Management.Automation.PsCredential($userVal, (ConvertTo-SecureString $pass -AsPlainText -Force)) Connect-MSOLSERVICE -Credential $Creds $msSP = Get-MsolServicePrincipal -AppPrincipalId $appID -TenantID $tenantGuid $objectId = $msSP.ObjectId Add-MsolRoleMember -RoleName "Company Administrator" -RoleMemberType ServicePrincipal -RoleMemberObjectId $objectId And now we're finally done! How's that for "a couple lines of code"? :)
This will work, however you have to write a couple of lines of code in your authentication logic in order to achieve what you're looking for. First of all, you have to distinguish between Roles and Groups in Azure AD (B2C). User Role is very specific and only valid within Azure AD (B2C) itself. The Role defines what permissions a user does have inside Azure AD . Group (or Security Group) defines user group membership, which can be exposed to the external applications. The external applications can model Role based access control on top of Security Groups. Yes, I know it may sound a bit confusing, but that's what it is. So, your first step is to model your Groups in Azure AD B2C - you have to create the groups and manually assign users to those groups. You can do that in the Azure Portal (https://portal.azure.com/): Then, back to your application, you will have to code a bit and ask the Azure AD B2C Graph API for users memberships once the user is successfully authenticated. You can use this sample to get inspired on how to get users group memberships. It is best to execute this code in one of the OpenID Notifications (i.e. SecurityTokenValidated) and add users role to the ClaimsPrincipal. Once you change the ClaimsPrincipal to have Azure AD Security Groups and "Role Claim" values, you will be able to use the Authrize attribute with Roles feature. This is really 5-6 lines of code. Finally, you can give your vote for the feature here in order to get group membership claim without having to query Graph API for that.
i implmented this as written , but as of May 2017 the line ((ClaimsIdentity)context.Ticket.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role, group.DisplayName, ClaimValueTypes.String)); needs to be changed to ((ClaimsIdentity)context.Ticket.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role, group.DisplayName)); To make it work with latest libs Great work to the author Also if your having a problem with Connect-MsolService giving bad username and password update to latest lib
Alex's answer is essential to figure out a working solution, thanks for pointing to the right direction. However it uses app.UseOpenIdConnectAuthentication() which was long time depreciated already in Core 2 and completely removed in Core 3 (Migrate authentication and Identity to ASP.NET Core 2.0) The fundamental task we must implement is attach an event handler to OnTokenValidated using OpenIdConnectOptions which is used by ADB2C Authentication under the hood. We must do this without interfering any other configuration of ADB2C. Here is my take: // My (and probably everyone's) existing code in Startup: services.AddAuthentication(AzureADB2CDefaults.AuthenticationScheme) .AddAzureADB2C(options => Configuration.Bind("AzureAdB2C", options)); // This adds the custom event handler, without interfering any existing functionality: services.Configure<OpenIdConnectOptions>(AzureADB2CDefaults.OpenIdScheme, options => { options.Events.OnTokenValidated = new AzureADB2CHelper(options.Events.OnTokenValidated).OnTokenValidated; }); All implementation is encapsulated in a helper class to keep Startup class clean. The original event handler is saved and called in case if it is not null (it is not btw) public class AzureADB2CHelper { private readonly ActiveDirectoryClient _activeDirectoryClient; private readonly Func<TokenValidatedContext, Task> _onTokenValidated; private const string AadGraphUri = "https://graph.windows.net"; public AzureADB2CHelper(Func<TokenValidatedContext, Task> onTokenValidated) { _onTokenValidated = onTokenValidated; _activeDirectoryClient = CreateActiveDirectoryClient(); } private ActiveDirectoryClient CreateActiveDirectoryClient() { // TODO: Refactor secrets to settings var authContext = new AuthenticationContext("https://login.microsoftonline.com/<yourdomain, like xxx.onmicrosoft.com>"); var clientCredential = new ClientCredential("<yourclientcredential>", #"<yourappsecret>"); var graphUri = new Uri(AadGraphUri); var serviceRoot = new Uri(graphUri, "<yourdomain, like xxx.onmicrosoft.com>"); return new ActiveDirectoryClient(serviceRoot, async () => await AcquireGraphAPIAccessToken(AadGraphUri, authContext, clientCredential)); } private async Task<string> AcquireGraphAPIAccessToken(string graphAPIUrl, AuthenticationContext authContext, ClientCredential clientCredential) { AuthenticationResult result = null; var retryCount = 0; var retry = false; do { retry = false; try { // ADAL includes an in-memory cache, so this will only send a request if the cached token has expired result = await authContext.AcquireTokenAsync(graphAPIUrl, clientCredential); } catch (AdalException ex) { if (ex.ErrorCode != "temporarily_unavailable") { continue; } retry = true; retryCount++; await Task.Delay(3000); } } while (retry && retryCount < 3); return result?.AccessToken; } public Task OnTokenValidated(TokenValidatedContext context) { _onTokenValidated?.Invoke(context); return Task.Run(async () => { try { var oidClaim = context.SecurityToken.Claims.FirstOrDefault(c => c.Type == "oid"); if (!string.IsNullOrWhiteSpace(oidClaim?.Value)) { var pagedCollection = await _activeDirectoryClient.Users.GetByObjectId(oidClaim.Value).MemberOf .ExecuteAsync(); do { var directoryObjects = pagedCollection.CurrentPage.ToList(); foreach (var directoryObject in directoryObjects) { if (directoryObject is Group group) { ((ClaimsIdentity) context.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role, group.DisplayName, ClaimValueTypes.String)); } } pagedCollection = pagedCollection.MorePagesAvailable ? await pagedCollection.GetNextPageAsync() : null; } while (pagedCollection != null); } } catch (Exception e) { Debug.WriteLine(e); } }); } } You will need the appropriate packages I am using the following ones: <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="3.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.0" /> <PackageReference Include="Microsoft.Azure.ActiveDirectory.GraphClient" Version="2.1.1" /> <PackageReference Include="Microsoft.IdentityModel.Clients.ActiveDirectory" Version="5.2.3" /> Catch: You must give your application permission to read AD. As of Oct 2019 this application must be a 'legacy' app and not the newest B2C application. Here is a very good guide: Azure AD B2C: Use the Azure AD Graph API
There is an official sample: Azure AD B2C: Role-Based Access Control available here from the Azure AD team. But yes, the only solution seems to be a custom implementation by reading user groups with the help of MS Graph.
Based on all the amazing answers here, getting user groups using the new Microsoft Graph API IConfidentialClientApplication confidentialClientApplication = ConfidentialClientApplicationBuilder .Create("application-id") .WithTenantId("tenant-id") .WithClientSecret("xxxxxxxxx") .Build(); ClientCredentialProvider authProvider = new ClientCredentialProvider(confidentialClientApplication); GraphServiceClient graphClient = new GraphServiceClient(authProvider); var groups = await graphClient.Users[oid].MemberOf.Request().GetAsync();
I really like the answer from #AlexLobakov but I wanted an updated answer for .NET 6 and also something that was testable but still implemented the caching features. I also wanted the roles to be sent to my front end, be compatible with any SPA like React and use standard Azure AD B2C User flows for Role-based access control (RBAC) in my application. I also missed a start to finish guide, so many variables that can go wrong and you end up with an application not working. Start with creating a new ASP.NET Core Web API in Visual Studio 2022 with the following settings: You should get a dialogue like this after creation: If you don't see this then right click on the project in Visual Studio and click on Overview and then Connected services. Create a new App registration in your Azure AD B2C or use an existing. I registered a new one for this demo purpose. After creating the App registration Visual Studio got stuck on Dependency configuration progress so the rest will be configured manually: Log on to https://portal.azure.com/, Switch directory to your AD B2C, select your new App registration and then click on Authentication. Then click on Add a platform and select Web. Add a Redirect URI and Front-channel logout URL for localhost. Example: https://localhost:7166/signin-oidc https://localhost:7166/logout If you choose Single-page application instead it will look nearly the same. However you then need to add a code_challenge as described below. A full example for this will not be shown. Is Active Directory not supporting Authorization Code Flow with PKCE? Authentication should look something like this: Click on Certificates & secrets and create a new Client secret. Click on Expose an API and then edit Application ID URI. Default value should look something like this api://11111111-1111-1111-1111-111111111111. Edit it to be https://youradb2c.onmicrosoft.com/11111111-1111-1111-1111-111111111111. There should be a scope named access_as_user. Create if it is not there. Now click on API permissions: Four Microsoft Graph permissions are needed. Two Application: GroupMember.Read.All User.Read.All Two Delegated: offline_access openid You also need your access_as_user permission from My APIs. When this is done click on Grant admin consent for .... Should look like this: If you don't have a User Flow already then create either a Sign up and sign in or a Sign in and select Recommended. My user flow is default B2C_1_signin. Verify that your AD B2C user is a member of the group you want to authenticate against: Now you can go back to your application and verify that you can get a code to login. Use this sample and it should redirect with a code: https://<tenant-name>.b2clogin.com/tfp/<tenant-name>.onmicrosoft.com/<user-flow-name>/oauth2/v2.0/authorize? client_id=<application-ID> &nonce=anyRandomValue &redirect_uri=https://localhost:7166/signin-oidc &scope=https://<tenant-name>.onmicrosoft.com/11111111-1111-1111-1111-111111111111/access_as_user &response_type=code If it works you should be redirected to something like this after login: https://localhost:7166/signin-oidc?code= If you get an error that says: AADB2C99059: The supplied request must present a code_challenge Then you have probably selected platform Single-page application and needs to add a code_challenge to the request like: &code_challenge=123. This is not enough because you also need to validate the challenge later otherwise you will get the error below when running my code. AADB2C90183: The supplied code_verifier is invalid Now open your application and appsettings.json. Default should look something like this: "AzureAd": { "Instance": "https://login.microsoftonline.com/", "Domain": "qualified.domain.name", "TenantId": "22222222-2222-2222-2222-222222222222", "ClientId": "11111111-1111-1111-11111111111111111", "Scopes": "access_as_user", "CallbackPath": "/signin-oidc" }, We need a few more values so it should look like this in the end: "AzureAd": { "Instance": "https://<tenant-name>.b2clogin.com/", "Domain": "<tenant-name>.onmicrosoft.com", "TenantId": "22222222-2222-2222-2222-222222222222", "ClientId": "11111111-1111-1111-11111111111111111", "SignUpSignInPolicyId": "B2C_1_signin", "ClientSecret": "--SECRET--", "ApiScope": "https://<tenant-name>.onmicrosoft.com/11111111-1111-1111-11111111111111111/access_as_user", "TokenUrl": "https://<tenant-name>.b2clogin.com/<tenant-name>.onmicrosoft.com/B2C_1_signin/oauth2/v2.0/token", "Scopes": "access_as_user", "CallbackPath": "/signin-oidc" }, I store ClientSecret in Secret Manager. https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows#manage-user-secrets-with-visual-studio Now create these new classes: AppSettings: namespace AzureADB2CWebAPIGroupTest { public class AppSettings { public AzureAdSettings AzureAd { get; set; } = new AzureAdSettings(); } public class AzureAdSettings { public string Instance { get; set; } public string Domain { get; set; } public string TenantId { get; set; } public string ClientId { get; set; } public string IssuerSigningKey { get; set; } public string ValidIssuer { get; set; } public string ClientSecret { get; set; } public string ApiScope { get; set; } public string TokenUrl { get; set; } } } Adb2cTokenResponse: namespace AzureADB2CWebAPIGroupTest { public class Adb2cTokenResponse { public string access_token { get; set; } public string id_token { get; set; } public string token_type { get; set; } public int not_before { get; set; } public int expires_in { get; set; } public int ext_expires_in { get; set; } public int expires_on { get; set; } public string resource { get; set; } public int id_token_expires_in { get; set; } public string profile_info { get; set; } public string scope { get; set; } public string refresh_token { get; set; } public int refresh_token_expires_in { get; set; } } } CacheKeys: namespace AzureADB2CWebAPIGroupTest { public static class CacheKeys { public const string GraphApiAccessToken = "_GraphApiAccessToken"; } } GraphApiService: using Microsoft.Extensions.Caching.Memory; using Microsoft.Graph; using System.Text.Json; namespace AzureADB2CWebAPIGroupTest { public class GraphApiService { private readonly IHttpClientFactory _clientFactory; private readonly IMemoryCache _memoryCache; private readonly AppSettings _settings; private readonly string _accessToken; public GraphApiService(IHttpClientFactory clientFactory, IMemoryCache memoryCache, AppSettings settings) { _clientFactory = clientFactory; _memoryCache = memoryCache; _settings = settings; string graphApiAccessTokenCacheEntry; // Look for cache key. if (!_memoryCache.TryGetValue(CacheKeys.GraphApiAccessToken, out graphApiAccessTokenCacheEntry)) { // Key not in cache, so get data. var adb2cTokenResponse = GetAccessTokenAsync().GetAwaiter().GetResult(); graphApiAccessTokenCacheEntry = adb2cTokenResponse.access_token; // Set cache options. var cacheEntryOptions = new MemoryCacheEntryOptions() .SetAbsoluteExpiration(TimeSpan.FromSeconds(adb2cTokenResponse.expires_in)); // Save data in cache. _memoryCache.Set(CacheKeys.GraphApiAccessToken, graphApiAccessTokenCacheEntry, cacheEntryOptions); } _accessToken = graphApiAccessTokenCacheEntry; } public async Task<List<string>> GetUserGroupsAsync(string oid) { var authProvider = new AuthenticationProvider(_accessToken); GraphServiceClient graphClient = new GraphServiceClient(authProvider, new HttpClientHttpProvider(_clientFactory.CreateClient())); //Requires GroupMember.Read.All and User.Read.All to get everything we want var groups = await graphClient.Users[oid].MemberOf.Request().GetAsync(); if (groups == null) { return null; } var graphGroup = groups.Cast<Microsoft.Graph.Group>().ToList(); return graphGroup.Select(x => x.DisplayName).ToList(); } private async Task<Adb2cTokenResponse> GetAccessTokenAsync() { var client = _clientFactory.CreateClient(); var kvpList = new List<KeyValuePair<string, string>>(); kvpList.Add(new KeyValuePair<string, string>("grant_type", "client_credentials")); kvpList.Add(new KeyValuePair<string, string>("client_id", _settings.AzureAd.ClientId)); kvpList.Add(new KeyValuePair<string, string>("scope", "https://graph.microsoft.com/.default")); kvpList.Add(new KeyValuePair<string, string>("client_secret", _settings.AzureAd.ClientSecret)); #pragma warning disable SecurityIntelliSenseCS // MS Security rules violation var req = new HttpRequestMessage(HttpMethod.Post, $"https://login.microsoftonline.com/{_settings.AzureAd.Domain}/oauth2/v2.0/token") { Content = new FormUrlEncodedContent(kvpList) }; #pragma warning restore SecurityIntelliSenseCS // MS Security rules violation using var httpResponse = await client.SendAsync(req); var response = await httpResponse.Content.ReadAsStringAsync(); httpResponse.EnsureSuccessStatusCode(); var adb2cTokenResponse = JsonSerializer.Deserialize<Adb2cTokenResponse>(response); return adb2cTokenResponse; } } public class AuthenticationProvider : IAuthenticationProvider { private readonly string _accessToken; public AuthenticationProvider(string accessToken) { _accessToken = accessToken; } public Task AuthenticateRequestAsync(HttpRequestMessage request) { request.Headers.Add("Authorization", $"Bearer {_accessToken}"); return Task.CompletedTask; } } public class HttpClientHttpProvider : IHttpProvider { private readonly HttpClient http; public HttpClientHttpProvider(HttpClient http) { this.http = http; } public ISerializer Serializer { get; } = new Serializer(); public TimeSpan OverallTimeout { get; set; } = TimeSpan.FromSeconds(300); public void Dispose() { } public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request) { return http.SendAsync(request); } public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken) { return http.SendAsync(request, completionOption, cancellationToken); } } } At the moment only accessToken for GraphServiceClient is stored in memorycache but if the application requires better performance a users groups could also be cached. Add a new class: Adb2cUser: namespace AzureADB2CWebAPIGroupTest { public class Adb2cUser { public Guid Id { get; set; } public string GivenName { get; set; } public string FamilyName { get; set; } public string Email { get; set; } public List<string> Roles { get; set; } public Adb2cTokenResponse Adb2cTokenResponse { get; set; } } } and struct: namespace AzureADB2CWebAPIGroupTest { public struct ADB2CJwtRegisteredClaimNames { public const string Emails = "emails"; public const string Name = "name"; } } And now add a new API Controller LoginController: using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.IdentityModel.Tokens.Jwt; using System.Text.Json; namespace AzureADB2CWebAPIGroupTest.Controllers { [Route("api/[controller]")] [ApiController] [Authorize] public class LoginController : ControllerBase { private readonly ILogger<LoginController> _logger; private readonly IHttpClientFactory _clientFactory; private readonly AppSettings _settings; private readonly GraphApiService _graphApiService; public LoginController(ILogger<LoginController> logger, IHttpClientFactory clientFactory, AppSettings settings, GraphApiService graphApiService) { _logger = logger; _clientFactory = clientFactory; _settings = settings; _graphApiService=graphApiService; } [HttpPost] [AllowAnonymous] public async Task<ActionResult<Adb2cUser>> Post([FromBody] string code) { var redirectUri = ""; if (HttpContext != null) { redirectUri = HttpContext.Request.Scheme + "://" + HttpContext.Request.Host + "/signin-oidc"; } var kvpList = new List<KeyValuePair<string, string>>(); kvpList.Add(new KeyValuePair<string, string>("grant_type", "authorization_code")); kvpList.Add(new KeyValuePair<string, string>("client_id", _settings.AzureAd.ClientId)); kvpList.Add(new KeyValuePair<string, string>("scope", "openid offline_access " + _settings.AzureAd.ApiScope)); kvpList.Add(new KeyValuePair<string, string>("code", code)); kvpList.Add(new KeyValuePair<string, string>("redirect_uri", redirectUri)); kvpList.Add(new KeyValuePair<string, string>("client_secret", _settings.AzureAd.ClientSecret)); return await UserLoginAndRefresh(kvpList); } [HttpPost("refresh")] [AllowAnonymous] public async Task<ActionResult<Adb2cUser>> Refresh([FromBody] string token) { var redirectUri = ""; if (HttpContext != null) { redirectUri = HttpContext.Request.Scheme + "://" + HttpContext.Request.Host; } var kvpList = new List<KeyValuePair<string, string>>(); kvpList.Add(new KeyValuePair<string, string>("grant_type", "refresh_token")); kvpList.Add(new KeyValuePair<string, string>("client_id", _settings.AzureAd.ClientId)); kvpList.Add(new KeyValuePair<string, string>("scope", "openid offline_access " + _settings.AzureAd.ApiScope)); kvpList.Add(new KeyValuePair<string, string>("refresh_token", token)); kvpList.Add(new KeyValuePair<string, string>("redirect_uri", redirectUri)); kvpList.Add(new KeyValuePair<string, string>("client_secret", _settings.AzureAd.ClientSecret)); return await UserLoginAndRefresh(kvpList); } private async Task<ActionResult<Adb2cUser>> UserLoginAndRefresh(List<KeyValuePair<string, string>> kvpList) { var user = await TokenRequest(kvpList); if (user == null) { return Unauthorized(); } //Return access token and user information return Ok(user); } private async Task<Adb2cUser> TokenRequest(List<KeyValuePair<string, string>> keyValuePairs) { var client = _clientFactory.CreateClient(); #pragma warning disable SecurityIntelliSenseCS // MS Security rules violation var req = new HttpRequestMessage(HttpMethod.Post, _settings.AzureAd.TokenUrl) { Content = new FormUrlEncodedContent(keyValuePairs) }; #pragma warning restore SecurityIntelliSenseCS // MS Security rules violation using var httpResponse = await client.SendAsync(req); var response = await httpResponse.Content.ReadAsStringAsync(); httpResponse.EnsureSuccessStatusCode(); var adb2cTokenResponse = JsonSerializer.Deserialize<Adb2cTokenResponse>(response); var handler = new JwtSecurityTokenHandler(); var jwtSecurityToken = handler.ReadJwtToken(adb2cTokenResponse.access_token); var id = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.Sub).Value; var groups = await _graphApiService.GetUserGroupsAsync(id); var givenName = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.GivenName).Value; var familyName = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.FamilyName).Value; //Unless Alternate email have been added in Azure AD there will only be one email here. //TODO Handle multiple emails var emails = jwtSecurityToken.Claims.First(claim => claim.Type == ADB2CJwtRegisteredClaimNames.Emails).Value; var user = new Adb2cUser() { Id = Guid.Parse(id), GivenName = givenName, FamilyName = familyName, Email = emails, Roles = groups, Adb2cTokenResponse = adb2cTokenResponse }; return user; } } } Now it is time to edit Program.cs. Should look something like this for the new minimal hosting model in ASP.NET Core 6.0: var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")); Notice that ASP.NET Core 6.0 are using JwtBearerDefaults.AuthenticationScheme and not AzureADB2CDefaults.AuthenticationScheme or AzureADB2CDefaults.OpenIdScheme. Edit so Program.cs looks like this: using AzureADB2CWebAPIGroupTest; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Caching.Memory; using Microsoft.Identity.Web; using System.Security.Claims; var builder = WebApplication.CreateBuilder(args); //Used for debugging //IdentityModelEventSource.ShowPII = true; var settings = new AppSettings(); builder.Configuration.Bind(settings); builder.Services.AddSingleton(settings); var services = new ServiceCollection(); services.AddMemoryCache(); services.AddHttpClient(); var serviceProvider = services.BuildServiceProvider(); var memoryCache = serviceProvider.GetService<IMemoryCache>(); var httpClientFactory = serviceProvider.GetService<IHttpClientFactory>(); var graphApiService = new GraphApiService(httpClientFactory, memoryCache, settings); // Add services to the container. builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddMicrosoftIdentityWebApi(options => { builder.Configuration.Bind("AzureAd", options); options.TokenValidationParameters.NameClaimType = "name"; options.TokenValidationParameters.ValidateIssuerSigningKey = true; options.TokenValidationParameters.ValidateLifetime = true; options.TokenValidationParameters.ValidateIssuer = true; options.TokenValidationParameters.ValidateLifetime = true; options.TokenValidationParameters.ValidateTokenReplay = true; options.Audience = settings.AzureAd.ClientId; options.Events = new JwtBearerEvents() { OnTokenValidated = async ctx => { //Runs on every request, cache a users groups if needed var oidClaim = ((System.IdentityModel.Tokens.Jwt.JwtSecurityToken)ctx.SecurityToken).Claims.FirstOrDefault(c => c.Type == "oid"); if (!string.IsNullOrWhiteSpace(oidClaim?.Value)) { var groups = await graphApiService.GetUserGroupsAsync(oidClaim.Value); foreach (var group in groups) { ((ClaimsIdentity)ctx.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role.ToString(), group)); } } } }; }, options => { builder.Configuration.Bind("AzureAd", options); }); builder.Services.AddTransient<GraphApiService>(); builder.Services.AddHttpClient(); builder.Services.AddMemoryCache(); builder.Services.AddControllers(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run(); Now you can run your application and use the code from earlier in a request like this: POST /api/login/ HTTP/1.1 Host: localhost:7166 Content-Type: application/json "code" You will then receieve a response like this with an access_token: { "id": "31111111-1111-1111-1111-111111111111", "givenName": "Oscar", "familyName": "Andersson", "email": "oscar.andersson#example.com", "roles": [ "Administrator", ], "adb2cTokenResponse": { } } Adding [Authorize(Roles = "Administrator")] to WeatherForecastController.cs we can now verify that only a user with the correct role is allowed to access this resource using the access_token we got earlier: If we change to [Authorize(Roles = "Administrator2")] we get a HTTP 403 with the same user: LoginController can handle refresh tokens as well. With NuGets Microsoft.NET.Test.Sdk, xunit, xunit.runner.visualstudio and Moq we can also test LoginController and in turn also GraphApiService used for ClaimsIdentity in Program.cs. Unfortunately due body being limited to 30000 charcters the entire test can not be shown. It basically looks like this: LoginControllerTest: using AzureADB2CWebAPIGroupTest.Controllers; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Moq; using Moq.Protected; using System.Net; using Xunit; namespace AzureADB2CWebAPIGroupTest { public class LoginControllerTest { [Theory] [MemberData(nameof(PostData))] public async Task Post(string code, string response, string expectedEmail, string expectedFamilyName, string expectedGivenName) { var controller = GetLoginController(response); var result = await controller.Post(code); var actionResult = Assert.IsType<ActionResult<Adb2cUser>>(result); var okResult = Assert.IsType<OkObjectResult>(result.Result); var returnValue = Assert.IsType<Adb2cUser>(okResult.Value); Assert.Equal(returnValue.Email, expectedEmail); Assert.Equal(returnValue.Roles[1], GraphApiServiceMock.DummyGroup2Name); } [Theory] [MemberData(nameof(RefreshData))] public async Task Refresh(string code, string response, string expectedEmail, string expectedFamilyName, string expectedGivenName) { var controller = GetLoginController(response); var result = await controller.Refresh(code); var actionResult = Assert.IsType<ActionResult<Adb2cUser>>(result); var okResult = Assert.IsType<OkObjectResult>(result.Result); var returnValue = Assert.IsType<Adb2cUser>(okResult.Value); Assert.Equal(returnValue.Email, expectedEmail); Assert.Equal(returnValue.Roles[1], GraphApiServiceMock.DummyGroup2Name); } //PostData and RefreshData removed for space private LoginController GetLoginController(string expectedResponse) { var mockFactory = new Mock<IHttpClientFactory>(); var settings = new AppSettings(); settings.AzureAd.TokenUrl = "https://example.com"; var mockMessageHandler = new Mock<HttpMessageHandler>(); GraphApiServiceMock.MockHttpRequests(mockMessageHandler); mockMessageHandler.Protected() .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.Is<HttpRequestMessage>(x => x.RequestUri.AbsoluteUri.Contains(settings.AzureAd.TokenUrl)), ItExpr.IsAny<CancellationToken>()) .ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(expectedResponse) }); var httpClient = new HttpClient(mockMessageHandler.Object); mockFactory.Setup(_ => _.CreateClient(It.IsAny<string>())).Returns(httpClient); var logger = Mock.Of<ILogger<LoginController>>(); var services = new ServiceCollection(); services.AddMemoryCache(); var serviceProvider = services.BuildServiceProvider(); var memoryCache = serviceProvider.GetService<IMemoryCache>(); var graphService = new GraphApiService(mockFactory.Object, memoryCache, settings); var controller = new LoginController(logger, mockFactory.Object, settings, graphService); return controller; } } } A GraphApiServiceMock.cs is also needed but it just adds more values like the example with mockMessageHandler.Protected() and static values like public static string DummyUserExternalId = "11111111-1111-1111-1111-111111111111";. There are other ways to do this but they usually depend on Custom Policies: https://learn.microsoft.com/en-us/answers/questions/469509/can-we-get-and-edit-azure-ad-b2c-roles-using-ad-b2.html https://devblogs.microsoft.com/premier-developer/using-groups-in-azure-ad-b2c/ https://learn.microsoft.com/en-us/azure/active-directory-b2c/user-flow-overview
First of all, thank you all for the previous responses. I've spent the entire day to put this to work. I'm using ASPNET Core 3.1 and I was getting the following error when using the solution from previous response: secure binary serialization is not supported on this platform I've replaces to REST API queries and I was able to get the groups: public Task OnTokenValidated(TokenValidatedContext context) { _onTokenValidated?.Invoke(context); return Task.Run(async () => { try { var oidClaim = context.SecurityToken.Claims.FirstOrDefault(c => c.Type == "oid"); if (!string.IsNullOrWhiteSpace(oidClaim?.Value)) { HttpClient http = new HttpClient(); var domainName = _azureADSettings.Domain; var authContext = new AuthenticationContext($"https://login.microsoftonline.com/{domainName}"); var clientCredential = new ClientCredential(_azureADSettings.ApplicationClientId, _azureADSettings.ApplicationSecret); var accessToken = AcquireGraphAPIAccessToken(AadGraphUri, authContext, clientCredential).Result; var url = $"https://graph.windows.net/{domainName}/users/" + oidClaim?.Value + "/$links/memberOf?api-version=1.6"; HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); HttpResponseMessage response = await http.SendAsync(request); dynamic json = JsonConvert.DeserializeObject<dynamic>(await response.Content.ReadAsStringAsync()); foreach(var group in json.value) { dynamic x = group.url.ToString(); request = new HttpRequestMessage(HttpMethod.Get, x + "?api-version=1.6"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); response = await http.SendAsync(request); dynamic json2 = JsonConvert.DeserializeObject<dynamic>(await response.Content.ReadAsStringAsync()); ((ClaimsIdentity)((ClaimsIdentity)context.Principal.Identity)).AddClaim(new Claim(ClaimTypes.Role.ToString(), json2.displayName.ToString())); } } } catch (Exception e) { Debug.WriteLine(e); } }); }