How to setup custom Oauth 2 in .Net Core

Viewed 1701

Please consider the following code in program.cs:

var builder = WebApplication.CreateBuilder(args);

//omit some builder settings

builder.Services.AddAuthentication(options =>
{
    options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
    options.LoginPath = "/signin";
    options.LogoutPath = "/signout";
})
.AddOAuth("Discord", options =>
{
    options.AuthorizationEndpoint = "https://discord.com/api/oauth2/authorize";
    options.Scope.Add("identify");
    options.Scope.Add("email");

    options.CallbackPath = new PathString("/Account/LoginCallback");

    options.ClientId = "some id";
    options.ClientSecret = "some secret";

    options.TokenEndpoint = "https://discord.com/api/oauth2/token";
    options.UserInformationEndpoint = "https://discord.com/api/users/@me";

    options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
    options.ClaimActions.MapJsonKey(ClaimTypes.Name, "username");
    options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
    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"));

    options.AccessDeniedPath = "/Account/LoginFailed";

    options.Events = new OAuthEvents
    {
        OnCreatingTicket = async context =>
        {
            var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
            request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);

            var response = await context.Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, context.HttpContext.RequestAborted);

            var user = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;

            context.RunClaimActions(user);
        }
    };
});

The following is inside my account controller:

[HttpGet("~/signin")]
public async Task<IActionResult> SignIn() => View("SignIn", await HttpContext.GetExternalProvidersAsync());

[HttpPost("~/signin")]
public async Task<IActionResult> SignIn([FromForm] string provider)
{
    if (string.IsNullOrWhiteSpace(provider))
    {
        return BadRequest();
    }

    if (!await HttpContext.IsProviderSupportedAsync(provider))
    {
        return BadRequest();
    }

    return Challenge(new AuthenticationProperties {RedirectUri = "/Account/LoginCallback" }, provider);
}

[HttpGet("~/signout")]
[HttpPost("~/signout")]
public IActionResult SignOutCurrentUser()
{
    return SignOut(new AuthenticationProperties {RedirectUri = "/"},
        CookieAuthenticationDefaults.AuthenticationScheme);
}

[HttpGet]
public IActionResult LoginCallback()
{
    return RedirectToAction("Index", "Home");
}

The above code works. What happens is the following:

  1. I click on LoginViaDiscord button
  2. SignIn post method is called
  3. I am redirected to discord
  4. I Login through discord
  5. I get redirected back to LoginCallback

When I look at the website, I am all fully logged in. The problem is that I have skipped that whole database step where user gets recorded and an account is created.

When I did similar authentication with MVC 5 I would have a callback action method and that callback method would do all the setup. Create the user, do logic around the user and then sign them in. However, with this code I am already fully logged in and there was no database step involved.

My question is how to properly accomplish same as above but not be logged in when the challenge returns to LoginCallback and how to retrieve the information from discord inside LoginCallback action? I would like to perform some logic there on the user before allowing for them to be logged in and I also want to ensure their account is created in the database.

1 Answers

My first answer was wrong.

You should use the options.Events property inside of AddOAuth call.

Property Description
OnAccessDenied Invoked when an access denied error was returned by the remote server.
OnCreatingTicket Gets or sets the function that is invoked when the CreatingTicket method is invoked.
OnRedirectToAuthorizationEndpoint Gets or sets the delegate that is invoked when the RedirectToAuthorizationEndpoint method is invoked.
OnRemoteFailure Invoked when there is a remote failure.
OnTicketReceived Invoked after the remote ticket has been received.

The most interesting is OnTicketReceived.

Here you can check user and disallow login, etc...

OnTicketReceived = async context =>
{
    ClaimsIdentity? identity = context.Principal!.Identity as ClaimsIdentity;
    string? userId = identity!.Claims.FirstOrDefault(x => x.Type == ClaimTypes.NameIdentifier)?.Value;
    //TODO make some logic here. For exapmle:
    string[] allowedUsers = new[] { "931350938345169620", "93123250938345169620", "896089335026528327" };
    if (allowedUsers.Contains(userId))
    {
        context.Success();
    }
    else
    {
        //Anonimously accessible page
        context.Response.Redirect("/Home/AccessDenied");
        context.Fail("You are not allowed");
        context.HandleResponse();
    }
},

UPD1, UPD2:

If you want to move logic into separate class supporting the Dependency Injection, then you can do something like this:


public interface ITicketManager
{
    Task HandleTicket(TicketReceivedContext context);
}

public class TicketManager : ITicketManager
{
    public TicketManager(/*Inject here anything you want*/)
    {
        
    }
    
    public async Task HandleTicket(TicketReceivedContext context)
    {
        ClaimsIdentity? identity = context.Principal!.Identity as ClaimsIdentity;
        string? userId = identity!.Claims.FirstOrDefault(x => x.Type == ClaimTypes.NameIdentifier)?.Value;
        //TODO make some logic here. For exapmle:
        string[] disallowedUsers = new[] { "9313509382345169620", "931232509383451696220", "8960892335026528327" };
        if (!disallowedUsers.Contains(userId))
        {
            context.Success();
        }
        else
        {
            //Anonimously accessible page
            context.Response.Redirect("/Home/AccessDenied");
            context.Fail("You are not allowed");
            context.HandleResponse();
        }
    }
}

Then set it up in DI, and call it from options.Events

builder.Services.AddScoped<ITicketManager, TicketManager>();

...

builder.Services.AddAuthentication(options =>
    {
        ...
    })
    .AddCookie(options =>
    {
        ...
    })
    .AddOAuth("Discord", options =>
    {
        ...
       
        options.Events = new OAuthEvents
        {
            ...
            
            OnTicketReceived = async context =>
            {
                ITicketManager manager = 
                    context.HttpContext.RequestServices
                        .GetRequiredService<ITicketManager>();
                await manager.HandleTicket(context);
            },
        };
    });
Related