asp.net-coreauthenticationasp.net-core-mvc.net-5challenge-response

Add Additional Authentication Provider but keep current session data


I have a project in .NET5 MVC that had implemented Twitch authentication using AspNet.Security.OAuth.Twitch. I configured everything and it is working fine, but I want to add the option to link an additional account with other providers, like Twitter. I tried to add Twitter authentication using Microsoft.AspNetCore.Authentication.Twitter. Also configured everything.

But when I login using Twitter, my current session is lost and all the Claims from Twitch were removed and replaced by Twitter Claims. I suppose that's the expected behaviour, but I don't know if I can keep those claims or only recover the Twitter Claims without storing in the User Identity (e.g. store in database). My main goal is to use Twitch authentication as the only way to login in the application but have to option to link accounts from other providers.

I have in my Startup.cs both providers added (and eventually maybe others added sometime in the future)

public void ConfigureServices(IServiceCollection services)
{
    // more stuff ...
    
    services.AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    })
    .AddTwitch(options =>
    {
        options.ClientId = Configuration["Twitch-ClientId"];
        options.ClientSecret = Configuration["Twitch-ClientSecret"];
    })
    .AddTwitter(options =>
    {
        options.ConsumerKey = Configuration["Twitter-ConsumerKey"];
        options.ConsumerSecret = Configuration["Twitter-ConsumerSecret"];
    });
}

In my AuthController.cs I have the corresponding methods with the Challenge.

// Default Login using Twitch
[HttpGet("~/signin")]
public IActionResult Login() => RedirectToAction("Login", "Auth", new { provider = "Twitch" });

[HttpPost("~/signin")]
public IActionResult Login([FromForm] string provider)
{
     string redirect_uri = Url.Action("Index", "Home");

     return Challenge(new AuthenticationProperties() { RedirectUri = redirect_uri }, provider);
}

I don't know if Challenge can be modified or configured to allow this behaviour. I don't see any property in AuthenticationProperties class that can be used. I initially tried to create another Controller/Action for the additional providers but the results were the same.

Any help will be appreciated.


Solution

  • As long as the user's session cookies are valid, you can authenticate it with multiple auth schemes and access those claims anytime.

    But when I login using Twitter, my current session is lost and all the Claims from Twitch were removed and replaced by Twitter Claims.

    This happens because you're trying to use Cookie scheme to hold the session cookie for both Twitter & Twitch. When you log in with one, it overwrites the other.

    To solve this, you need to add separate cookies for each individual login option.

    services.AddAuthentication()
        .AddCookie("GoogleSession")
        .AddCookie("GithubSession")
        .AddGoogle(
            options => {
                // set the app credentials
                Configuration.GetSection("Google").Bind(options);
                // save session to this cookie
                options.SignInScheme = "GoogleSession";
            })
        .AddGitHub(
            options => {
                // set the app credentials
                Configuration.GetSection("Github").Bind(options);
                // save session to this cookie
                options.SignInScheme = "GithubSession";
            });
    

    Then issue a challenge to force the user to login:

    [AllowAnonymous]
    [HttpGet("login-google")]
    public ActionResult LoginGoogle()
    {
        return Challenge(
            new AuthenticationProperties
            {
                RedirectUri = Url.Action("WhoAmI"),
            }, GoogleDefaults.AuthenticationScheme
        );
    }
    
    [AllowAnonymous]
    [HttpGet("login-github")]
    public ActionResult LoginGithub()
    {
        return Challenge(
            new AuthenticationProperties
            {
                RedirectUri = Url.Action("WhoAmI"),
            }, GitHubAuthenticationDefaults.AuthenticationScheme
        );
    }
    

    Then at anytime, you can authenticate the user to read & parse the cookie to access the claims:

    [AllowAnonymous]
    [HttpGet("me")]
    public async Task<ActionResult> WhoAmI()
    {
        var googleResult = await HttpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme);
        if (googleResult.Succeeded)
        {
            var googlePrincipal = googleResult.Principal;
            // ... use google claims
            User.AddIdentity((ClaimsIdentity)googlePrincipal.Identity);
        }
    
        var githubResult = await HttpContext.AuthenticateAsync(GitHubAuthenticationDefaults.AuthenticationScheme);
        if (githubResult.Succeeded)
        {
            var githubPrincipal = githubResult.Principal;
            // ... use google claims
            User.AddIdentity((ClaimsIdentity)githubPrincipal.Identity);
        }
    
        return Ok(
            User.Identities.Select(
                    id => new
                    {
                        id.AuthenticationType, 
                        Claims = id.Claims.Select(c => new { c.Type, c.Value })
                    }
                )
                .ToList()
        );
    

    Now when I visit /me, I get a list of all the claims from all the session:

    [
      {
        "authenticationType": null,
        "claims": []
      },
      {
        "authenticationType": "Google",
        "claims": [
          {
            "type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
            "value": "123131231231312123123123"
          },
          {
            "type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
            "value": "My Fullname"
          },
          {
            "type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
            "value": "MyName"
          },
          {
            "type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
            "value": "MyLastname"
          },
          {
            "type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
            "value": "my@gmail.com"
          }
        ]
      },
      {
        "authenticationType": "GitHub",
        "claims": [
          {
            "type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
            "value": "1313123123"
          },
          {
            "type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
            "value": "abdusco"
          },
          {
            "type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
            "value": "my@email.com"
          },
          {
            "type": "urn:github:name",
            "value": "my name"
          },
          {
            "type": "urn:github:url",
            "value": "https://api.github.com/users/abdusco"
          }
        ]
      }
    ]
    

    It's a bit tedious to manually authenticate the user with multiple authentication schemes. We can let ASP.NET Core do it for us.

    Define an authorization policy that accepts multiple auth schemes.

    services.AddAuthorization(
        options => options.DefaultPolicy = new AuthorizationPolicyBuilder(
                GoogleDefaults.AuthenticationScheme,
                GitHubAuthenticationDefaults.AuthenticationScheme
            ).RequireAuthenticatedUser()
            .Build()
    );
    

    Now when you decorate an action with [Authorize] (and specify the policy name, if needed), HttpContext.User will contain both identities and claims from all sessions.

    [Authorize]
    [HttpGet("me")]
    public async Task<ActionResult> WhoAmI()
    {
        return Ok(
            // user has claims from all sessions
            User.Identities.Select(
                    id => new
                    {
                        id.AuthenticationType,
                        Claims = id.Claims.Select(c => new { c.Type, c.Value })
                    }
                )
                .ToList()
        );
    }
    

    This has the same output as before, but without the boilerplate.