I'm trying to figure out how to setup a login via Discord Oauth2 while using Dapper as my ORM.
Microsoft has a guide here that I have followed to setup all of my stores. I infact can call CreateAsync() method and a user gets created in my database, so I believe that side of things is completely setup.
My issues lie within external login. Below you will find what I have tried.
Program.cs:
//omitted code that binds interfaces and classes - this code works and is fully tested. it is not related to problem at hand.
builder.Services.AddIdentity<User, Role>()
.AddDefaultTokenProviders();
builder.Services.AddAuthentication()
.AddCookie(options =>
{
options.LoginPath = "/signin";
options.LogoutPath = "/signout";
})
.AddDiscord(options =>
{
options.ClientId = "some id";
options.ClientSecret = "some secret";
options.ClaimActions.MapCustomJson("urn:discord:avatar:url", user =>
string.Format(
CultureInfo.InvariantCulture,
"https://cdn.discordapp.com/avatars/{0}/{1}.{2}",
user.GetString("id"),
user.GetString("avatar"),
user.GetString("avatar")!.StartsWith("a_") ? "gif" : "png"));
});
builder.Services.AddRazorPages();
var app = builder.Build();
app.UseDeveloperExceptionPage();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");
app.Run();
Here is the Account Controller Code:
public class AccountController : Controller
{
private readonly ISignInService _signInService;
private readonly IUserService _userService;
public AccountController(ISignInService signInService, IUserService userService)
{
_signInService = signInService;
_userService = userService;
}
[HttpGet("~/signin")]
public async Task<IActionResult> SignIn() => View("SignIn", await HttpContext.GetExternalProvidersAsync());
[HttpPost("~/signin")]
public async Task<IActionResult> SignIn([FromForm] string provider, string returnUrl)
{
if (string.IsNullOrWhiteSpace(provider))
{
return BadRequest();
}
if (!await HttpContext.IsProviderSupportedAsync(provider))
{
return BadRequest();
}
var redirectUrl = Url.Action(nameof(LoginCallback), "Account", new { returnUrl });
var properties = _signInService.ConfigureExternalAuthenticationProperties(provider, redirectUrl, null);
properties.Items.Add("XsrfKey", "Test");
return Challenge(properties, provider);
}
[HttpGet("~/signout")]
[HttpPost("~/signout")]
public IActionResult SignOutCurrentUser()
{
return SignOut(new AuthenticationProperties {RedirectUri = "/"},
CookieAuthenticationDefaults.AuthenticationScheme);
}
//[HttpGet("~/Account/LoginCallback")]
[HttpGet]
public async Task<IActionResult> LoginCallback(string returnUrl = null, string remoteError = null)
{
if (remoteError != null)
{
return RedirectToAction("Index", "Home");
}
var info = await _signInService.GetExternalLoginInfoAsync("Test");
if (info == null)
{
return RedirectToAction("Index", "Home");
}
var result = await _signInService.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
if (result.Succeeded)
{
return RedirectToLocal(returnUrl);
}
if (result.IsLockedOut)
{
return RedirectToAction("Index", "Home");
}
else
{
// If the user does not have an account, then ask the user to create an account.
ViewData["ReturnUrl"] = returnUrl;
ViewData["LoginProvider"] = info.LoginProvider;
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
return RedirectToAction("Index", "Home");
}
}
private IActionResult RedirectToLocal(string returnUrl)
{
if (Url.IsLocalUrl(returnUrl))
{
return Redirect(returnUrl);
}
else
{
return RedirectToAction(nameof(HomeController.Index), "Home");
}
}
}
Here is what happens:
var info = await _signInService.GetExternalLoginInfoAsync("Test"); that line is always null.I've been struggling to figure out what I have overlooked in my setup as I don't have any errors about anything.
Firstly... We need to take a look at the implementation of the internal method GetExternalLoginInfoAsync inside SignInManager.cs and take note of all the conditions that could possibly lead to null being returned.
I will provide my answer as comments within the code below:
/// <summary>
/// Gets the external login information for the current login, as an asynchronous operation.
/// </summary>
/// <param name="expectedXsrf">Flag indication whether a Cross Site Request Forgery token was expected in the current request.</param>
/// <returns>The task object representing the asynchronous operation containing the <see name="ExternalLoginInfo"/>
/// for the sign-in attempt.</returns>
public virtual async Task<ExternalLoginInfo> GetExternalLoginInfoAsync(string expectedXsrf = null)
{
var auth = await Context.AuthenticateAsync(IdentityConstants.ExternalScheme);
var items = auth?.Properties?.Items;
if (auth?.Principal == null || items == null || !items.ContainsKey(LoginProviderKey))
{
// What cases can lead us here?
// * The authentication was unsuccessful maybe due to
// - Login cancellation
// - Project not running on a secured environment (https)
// - SignInScheme property of auth options not
// equal to IdentityConstants.ExternalScheme
return null;
}
if (expectedXsrf != null)
{
// It is important to note that XsrfKey is a constant
// declared above in this class whose value is "XsrfId".
if (!items.ContainsKey(XsrfKey))
{
// What cases can lead us here?
// * You passed an argument for expectedXsrf but
// the initialized key-value pairs does not contain
// any key for XsrfKey ("XsrfId").
// In your case the below is wrong:
// properties.Items.Add("XsrfKey", "Test"); <= remove
// Pass the value as 3rd parameter in
// "ConfigureExternalAuthenticationProperties" method call instead
// _signInService.ConfigureExternalAuthenticationProperties(provider, redirectUrl, "Test")
return null;
}
var userId = items[XsrfKey] as string;
if (userId != expectedXsrf)
{
// What cases can lead us here?
// * The argument passed for expectedXsrf does not
// match the value of initialized key-value pair
// for XsrfKey ("XsrfId").
// Ensure "Test" should go with "XsrfId" as key
// by passing the value as 3rd parameter in
// "ConfigureExternalAuthenticationProperties" method call instead.
return null;
}
}
var providerKey = auth.Principal.FindFirstValue(ClaimTypes.NameIdentifier);
var provider = items[LoginProviderKey] as string;
if (providerKey == null || provider == null)
{
return null;
}
var providerDisplayName = (await GetExternalAuthenticationSchemesAsync()).FirstOrDefault(p => p.Name == provider)?.DisplayName
?? provider;
return new ExternalLoginInfo(auth.Principal, provider, providerKey, providerDisplayName)
{
AuthenticationTokens = auth.Properties.GetTokens()
};
}
So from the code review these are some possible causes for null:
The authentication was unsuccessful maybe due to
Login cancellation
Project not running on a secured environment (https)
SignInScheme property of auth options under StartUp.cs or appsettings.json not equal to IdentityConstants.ExternalScheme
You passed an argument for expectedXsrf but the initialized key-value pair does not contain any key for XsrfKey ("XsrfId").
In your case the below is wrong:
properties.Items.Add("XsrfKey", "Test"); <= remove this line as "XsrfKey" is unknown.
Instead you pass the value as 3rd parameter in "ConfigureExternalAuthenticationProperties" method call:
_signInService.ConfigureExternalAuthenticationProperties(provider, redirectUrl, "Test");