asp.netidentityserver4abp-framework

Adding External OAuth Authentication via AddOauth


Our application current using a traditional username/password login to authenticate against our AbpUsers table internally.

We are currently on ABP Framework 5.3.5

I am trying to add a new external oauth authentication, and then attempting to link the external user to an internal user.

context.Services.AddAuthentication()
    .AddOAuth("...", options =>
    {
        options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;

        options.ClientId = configuration["...:ClientId"];
        options.ClientSecret = configuration["...:ClientSecret"];
        options.AuthorizationEndpoint = configuration["...:AuthorizationEndpoint"];
        options.CallbackPath = new PathString(configuration["...:CallbackPath"]);
        options.TokenEndpoint = configuration["...:TokenEndpoint"];

        options.Scope.Add("...");

        options.Events = new OAuthEvents()
        {
            OnCreatingTicket = async context =>
            {
                var email = context.TokenResponse.Response.RootElement.GetProperty("userinfo").GetProperty("email").ToString();
                context.Identity.AddClaim(new Claim(ClaimTypesEnum.UserEmail.GetDescription(), email));
                Console.WriteLine(email);

                // TODO Link external user to internal, authorize user as internal user
            }
        };
    });

This works, in that the user is set to the external oauth service to login, redirected back to our identity server with the authorization code, and we are able to request a token from their oauth service.

It does not work in that the user just lands on our Identity Server login screen again.

I am also unsure how to link the user to our internal user, and then authorize them as an internal user.

I tried to access the UserManager and SigninManager in the OnTicketCreating event, but I do not seem to have access to them.

UPDATE

This is what I have now:

OnCreatingTicket = async context =>
{
    var email = context.TokenResponse.Response.RootElement.GetProperty("userinfo").GetProperty("email").ToString();
    var userManager = context.HttpContext.RequestServices.GetRequiredService<UserManager<IdentityUser>>();
    var signinManager = context.HttpContext.RequestServices.GetRequiredService<SignInManager<IdentityUser>>();

    var user = await userManager.FindByEmailAsync(email);
    await signinManager.SignInAsync(user, true);
}

But I get the following error:

The navigation 'IdentityUser.Claims' cannot be loaded because the entity is not being tracked. Navigations can only be loaded for tracked entities.

UPDATE 2024-06-11

This is what I have now:

context.Services.AddAuthentication()
    .AddOAuth("...", options =>
    {
        options.SignInScheme = IdentityConstants.ExternalScheme;

        options.ClientId = configuration["...:ClientId"];
        options.ClientSecret = configuration["...:ClientSecret"];
        options.AuthorizationEndpoint = configuration["...:AuthorizationEndpoint"];
        options.CallbackPath = new PathString(configuration["...:CallbackPath"]);
        options.TokenEndpoint = configuration["...:TokenEndpoint"];

        options.Scope.Add("...");

        // options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "sub");
        options.ClaimActions.MapJsonSubKey(ClaimTypes.Email, "userinfo", "email");
        options.ClaimActions.MapJsonSubKey(ClaimTypes.NameIdentifier, "userinfo", "id");
    });

And I get the following warning: External login info is not available.. I get the same error using the sub value or the userinfo.id value.

I suspect this line in SigninManager is returning null:

AuthenticateResult auth = await this.Context.AuthenticateAsync(IdentityConstants.ExternalScheme);

UPDATE 2024-06-12 The following seemed to work for me:

context.Services.AddAuthentication()
    .AddOAuth("...", options =>
    {
        options.SignInScheme = IdentityConstants.ExternalScheme;

        options.ClientId = configuration["...:ClientId"];
        options.ClientSecret = configuration["...:ClientSecret"];
        options.AuthorizationEndpoint = configuration["...:AuthorizationEndpoint"];
        options.CallbackPath = new PathString(configuration["...:CallbackPath"]);
        options.TokenEndpoint = configuration["...:TokenEndpoint"];

        options.Scope.Add("...:auth ...:userinfo");

        options.Events = new OAuthEvents()
        {
            OnCreatingTicket = async context =>
            {
                var userInfo = context.TokenResponse.Response.RootElement.GetProperty("userinfo");
                var email = userInfo.GetProperty("email").ToString();
                var sub = userInfo.GetProperty("sub").ToString();

                context.Principal.Identities.First().AddClaim(new Claim(ClaimTypes.NameIdentifier, sub));
                context.Principal.Identities.First().AddClaim(new Claim(ClaimTypes.Email, email));
            }
        };
    });

Solution

  • Update

    My original answer didn't take into consideration that you are using ABP Framework, or that you are connecting to a custom-build OAuth2 provider, I apologize for the misunderstanding.

    Most known OAuth2 providers send token-related properties only in the Token Exchange step of the OAuth workflow. See the following RFC 6749 example. The rest of the user information is then sent using a User Information Endpoint, that's why I suggested at first that you use ClaimActions to map JSON keys from the user information payload.

    However, if in your case the user information is sent with the token response and can be found in the root element like this:

    context.TokenResponse.Response.RootElement.GetProperty("userinfo");
    

    Then all you need in order to add claims is just add an OnCreatingTicket event, as you did in your first attempt when you added the Email, but you only missed the NameIdentifier claim.

    Here's what your options should look like to add all claims:

    context.Services.AddAuthentication()
        .AddOAuth("...", options =>
        {
            // ...
    
            options.Events.OnCreatingTicket = context =>
            {
                var userInfo = context.TokenResponse.Response?
                    .RootElement.GetProperty("userinfo");
    
                var id = userInfo?.GetString("id");
                if (id is not null)
                {
                    context.Identity?.AddClaim(new Claim(ClaimTypes.NameIdentifier, id));
                }
    
                var email = userInfo?.GetString("email");
                if (email is not null)
                {
                    context.Identity?.AddClaim(new Claim(ClaimTypes.Email, email));
                }
                return Task.CompletedTask;
            };
        });
    

    Again, this will only work if your TokenResponse actually contains a userinfo object in the root element with id and email properties.

    If the issue persists, I suggest that you add a breakpoint inside the event delegate and inspect in debug mode what are the contents of the context.TokenResponse, and other OAuthCreatingTicketContext properties.


    Original Answer

    Once the user is logged in to their external account and redirected back, the OAuthHandler creates an external cookie containing the necessary information. However, you need to store that information in your identity database the same way you store emails and passwords.

    The magic happens in the login callback path you provide in the ChallengeResult, not in the OnCreatingTicket event.

    For example, if your login action looks like this:

    [HttpPost]
    [AllowAnonymous]
    public IActionResult Login(string provider, string? returnUrl, string? failureUrl)
    {
        // Request a redirect to the external login provider.
        var redirectUrl = Url
            .Action(nameof(LoginCallback), values: new { returnUrl, failureUrl });
            
        var properties = _signInManager
            .ConfigureExternalAuthenticationProperties(provider, redirectUrl);
    
        return new ChallengeResult(provider, properties);
    }
    

    Then your callback action (LoginCallback) should probably look like this:

    [Authorize(AuthenticationSchemes = "Identity.External")]
    public async Task<IActionResult> LoginCallback(
        string? returnUrl, 
        string? failureUrl, 
        CancellationToken cancellationToken)
    {
        // Get the external login information from the "External" cookie.
        var info = await _signInManager.GetExternalLoginInfoAsync();
        if (info is not null)
        {
            // Sign the user in with this provider if they already have a login linked.
            var signinResult = await _signInManager.ExternalLoginSignInAsync(
                info.LoginProvider, 
                info.ProviderKey, 
                isPersistent: false, 
                bypassTwoFactor: true);
    
            if (signinResult.Succeeded)
            {
                // User signed in, make sure tokens are up to date.
                await _signInManager.UpdateExternalAuthenticationTokensAsync(info);
                return Redirect(returnUrl);
            }
    
            // The user doesn't have an external login linked,
            // find a user with the same email and link them.
            var email = info.Principal.FindFirstValue(ClaimTypes.Email);
            if (email is not null)
            {
                var user = await _userManager.FindByEmailAsync(email);
                if (user is not null)
                {
                    // Here we add the external login to the user so we can log in later.
                    await _userManager.AddLoginAsync(user, info);
    
                    // Finally, store external OAuth tokens for api access.
                    await _signInManager.UpdateExternalAuthenticationTokensAsync(info);
                    return Redirect(returnUrl);
                }
            }
        }
        // If we got here, then something went wrong.
        return Redirect(failureUrl);
    }
    

    Note that we got information about each cookie separately, the external login info from the Identity.External cookie, and the stored user from the Identity.Application cookie, and then linked both of them.

    That works with the assumption that you added a claim of type ClaimTypes.Email when the external authentication was added:

    context.Services.AddAuthentication()
        .AddOAuth("...", options =>
        {
            // Code skipped for brevity.
            // ...
    
            options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
            // Or in your case:
            options.ClaimActions.MapJsonSubKey(ClaimTypes.Email, "userinfo", "email");
    
            // Code skipped for brevity.
            // ...
        });