Open Closed

What is the recommended way of making Identity.External Claims available in Identity.Application #9721


User avatar
0
mharnos created

Hi,

the application we're currently developing has a couple of tenants, that already have established access management solutions they want to keep using. We have already agreed on using specific role names, that they can manage through Entra App. Registrations with specific App Roles, so they can keep their internal Security Group naming, but the specific role names can be recognized on our end. This works fine on a technical level, and is also an established way of handling such requests, e.g. Grafana describes the same setup for access to Entra / AzureAD with app roles like Viewer, Editor or Admin.

I have verified that I can access the roles when performing an external login, through either AddMicrosoftAccount or AddOpenIdConnect, in both cases I have an authentication event I can use to access the roles claims from the returned id_token:

This microsoft azure example application also works as expected and I can see the assigned roles for my external identity: https://github.com/azure-samples/active-directory-aspnetcore-webapp-openidconnect-v2/blob/master/5-WebApp-AuthZ/5-1-Roles/README.md

I have seen the articles and other questions regarding the general question of how to use the external identity / login with SSO:

But at the moment I have not found a definitive answer on how to make those claims available in ABP itself. My current best guess is, to use the dynamic claims concept and re-read the id token with SignInManager when the user first registers, logs in, and maybe even periodically, and remove + add all roles "managed" by the external identity?

I don't think that I have to use the Claims Principal Factory itself, because the role claim is already exposed to the OpenIddict access_token and id_token by default: https://abp.io/docs/latest/modules/openiddict#updating-claims-in-access_token-and-id_token or is that the correct mechanisms to "create" this claim through SignInManager when OpenIddic accesses the claims? https://abp.io/docs/latest/framework/fundamentals/authorization#claims-principal-factory

How would you suggest that I implement this kind of "forwarding" of claims? What the most idiomatic way of doing this with ABP? Because hooking into the LoginModel seems kinda hacky.

Kind Regards, Marc


9 Answer(s)
  • User Avatar
    0
    AI-Bot created
    Support Team ABP Agent

    To make external claims (such as Azure AD roles) available in the Identity.Application scheme within ABP, and to ensure these claims are available for authorization and token generation, you should use a combination of claim mapping and ABP's extensibility points. Here is the recommended, idiomatic approach for ABP:

    1. Map External Claims to ABP User Claims on Login

      • When a user logs in via an external provider (e.g., Azure AD), the claims from the external identity (like roles) are available in the external principal.
      • You need to map these claims to the ABP user or session so they are available in the Identity.Application context.
    2. Use ClaimsPrincipalFactory for Custom Mapping

      • ABP uses a ClaimsPrincipalFactory to build the application user principal. You can override or extend this to include external claims.
      • This is the most idiomatic and maintainable way to "forward" claims from external identities into the ABP user principal.

      Example:

    public class MyUserClaimsPrincipalFactory : AbpUserClaimsPrincipalFactory
       {
           public MyUserClaimsPrincipalFactory(
               UserManager<IdentityUser> userManager,
               IOptions<IdentityOptions> optionsAccessor)
               : base(userManager, optionsAccessor)
           {
           }
    
           protected override async Task<ClaimsIdentity> GenerateClaimsAsync(IdentityUser user)
           {
               var identity = await base.GenerateClaimsAsync(user);
    
               // Example: Add external roles from a custom user property or external login info
               // You may need to persist external roles in user claims or a custom property on login
               var externalRoles = ... // Retrieve roles from external login/session
               foreach (var role in externalRoles)
               {
                   identity.AddClaim(new Claim(ClaimTypes.Role, role));
               }
    
               return identity;
           }
       }
    

    Register your factory in DI to replace the default.

    1. Persist External Claims if Needed

      • If you want the roles to persist across sessions, you should store them as user claims in the database during the external login process (e.g., in the OnExternalLoginCallbackAsync of the login page).
      • This way, the ClaimsPrincipalFactory can always read them from the user store.
    2. OpenIddict Token Claims

      • By default, ABP's OpenIddict integration will include role claims in the access_token and id_token if they are present on the user principal.
      • If you need to customize which claims go into tokens, implement IAbpOpenIddictClaimsPrincipalHandler or IAbpOpenIddictClaimDestinationsProvider to control claim destinations.
    3. Dynamic Claims (Optional)

      • If you want to add claims dynamically at runtime (e.g., from an external API), you can use ABP's Dynamic Claims system.
      • See: Dynamic Claims Contributor Options
    4. Do Not Rely on LoginModel for Claims Forwarding

      • Hooking into the LoginModel is not recommended for production logic. Use the extensibility points above for maintainability and clarity.

    Summary of Steps:

    • Map external claims to user claims during external login.
    • Use a custom ClaimsPrincipalFactory to include these claims in the application principal.
    • Optionally, persist claims for future sessions.
    • Use OpenIddict claim handlers if you need to control token contents.

    Sources:


    This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    hi

    You can add a new IAbpClaimsPrincipalContributor and check results from GetExternalLoginInfoAsync.

    Your roles claims will exist in externalLoginInfo.

    using System.Collections.Generic;
    using System.Linq;
    using System.Security.Claims;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Identity;
    using Volo.Abp.DependencyInjection;
    using Volo.Abp.Security.Claims;
    using IdentityUser = Volo.Abp.Identity.IdentityUser;
    
    namespace MyCompanyName.MyProjectName.Web;
    
    public class MyClaimsPrincipalContributor : IAbpClaimsPrincipalContributor, ITransientDependency
    {
        protected SignInManager<IdentityUser> SignInManager { get; }
    
        public MyClaimsPrincipalContributor(SignInManager<IdentityUser> signInManager)
        {
            SignInManager = signInManager;
        }
        
        public async Task ContributeAsync(AbpClaimsPrincipalContributorContext context)
        {
            var identity = context.ClaimsPrincipal.Identities.FirstOrDefault();
            if (identity == null)
            {
                return;
            }
            var externalLoginInfo = await SignInManager.GetExternalLoginInfoAsync();
            if (externalLoginInfo == null)
            {
                return;
            }
    
            var roles = externalLoginInfo.Principal.Claims
                .Where(c => c.Type == "roles")
                .Select(c => c.Value)
                .ToList();
    
            if (roles.IsNullOrEmpty())
            {
                return;
            }
    
            foreach (var role in roles)
            {
                identity.AddClaim(new Claim("MyRoles", role));
            }
        }
    }
    
    
  • User Avatar
    0
    mharnos created

    Hi @maliming thanks for the suggestion.

    I had a similar idea of using the IAbpClaimsPrincipalContributor but the Claims are not persisted if I use your code. I can see, that the identity from var identity = context.ClaimsPrincipal.Identities.FirstOrDefault(); has the new claim added when I call AddClaim, but the ContributeAsync is being called 3x during (external) login, and each time the Identity.Application is missing this newly added claim, so after adding it the first time, it is not available in the Claims the second, or third time that the method ContributeAsync is being called - it has no lasting effect on the Principal Identity.

    In addition only the first two times GetExternalLoginInfoAsync returns a non-null response, on the third and final call, I have no ExternalLogin context left anymore. (I suppose this is when OpenIddict issues its own Identity?)

    Currently my solution is, that I add the Claims (+Roles) into the database so that they are available as AbpUserClaims and AbpRoles:

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Security.Claims;
    using System.Threading.Tasks;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using OpenIddict.Abstractions;
    using Volo.Abp.DependencyInjection;
    using Volo.Abp.Identity;
    using Volo.Abp.Identity.AspNetCore;
    using Volo.Abp.Security.Claims;
    
    public class ExternalRoleClaimsPrincipalContributor : IAbpClaimsPrincipalContributor, ITransientDependency
    {
        public async Task ContributeAsync(AbpClaimsPrincipalContributorContext context)
        {
            var roleRepository = context.ServiceProvider.GetRequiredService<IIdentityRoleRepository>();
            var signInManager = context.ServiceProvider.GetRequiredService<AbpSignInManager>();
            var configuration = context.ServiceProvider.GetRequiredService<IConfiguration>();
    
            var availableRoles = (await roleRepository.GetListAsync())?
                .Select(i => i.NormalizedName)
                .ToHashSet();
    
            if (availableRoles == null)
            {
                return;
            }
    
            var externalLoginInfo = await signInManager.GetExternalLoginInfoAsync();
            if (externalLoginInfo == null)
            {
                return;
            }
    
            var user = await signInManager.UserManager.GetUserAsync(context.ClaimsPrincipal).ConfigureAwait(false);
            if (user == null)
            {
                return;
            }
    
            var externallyManagedRoles = configuration["ExternalIdentityMapping:ExternallyManagedRoles"]?
                .Split(',', StringSplitOptions.RemoveEmptyEntries)
                .Select(r => r.ToUppervariant())
                .ToHashSet();
    
            if (externallyManagedRoles.IsNullOrEmpty())
            {
                return;
            }
    
            await signInManager.UserManager.RemoveFromRolesAsync(user, externallyManagedRoles!).ConfigureAwait(false);
    
            var roleClaims = externalLoginInfo.Principal?
                .GetClaims(ClaimTypes.Role)
                .Where(roleClaim => availableRoles.Contains(roleClaim.ToUppervariant()))
                .Where(roleClaim => externallyManagedRoles!.Contains(roleClaim.ToUppervariant()))
                .ToList();
    
            if (roleClaims.IsNullOrEmpty())
            {
                return;
            }
    
            await signInManager.UserManager.AddToRolesAsync(user, roleClaims!).ConfigureAwait(false);
        }
    }
    

    And I had to modify the code for the authentication, because the dynamic options are not working for OpenIdConnect, I used your answer from https://abp.io/qa/questions/2468/3c6595db-97b4-07b0-2a33-3a0219d08a3c (but it would be cool if this was added to upstream)

        private void ConfigureExternalProviders(ServiceConfigurationContext context)
        {
            context.Services.AddAuthentication()
                // provide an empty DisplayName so the 'Microsoft/OpenIdConnect'-Button is not rendered on the login page
                .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, "", options =>
                {
                    options.CallbackPath = new PathString("/signin-microsoft");
                    options.ResponseType = OpenIdConnectResponseType.Code;
    
                    options.UsePkce = true;
                    options.SaveTokens = true;
                    options.SignInScheme = IdentityConstants.ExternalScheme;
    
                    options.Scope.Add("email");
                    options.Scope.Add("https://graph.microsoft.com/user.read");
                    options.Scope.Add("https://graph.microsoft.com/user.readbasic.all");
    
                    // this is required for ClaimActions, otherwise they're not evaluated (missing User json)
                    // but we are calling a custom endpoint, so we don't have to set this to 'true'
                    // options.GetClaimsFromUserInfoEndpoint = true;
    
                    // copy the default microsoft account claim actions into this openidconnect handler
                    var microsoftOptions = new MicrosoftAccountOptions();
                    foreach (var claimAction in microsoftOptions.ClaimActions)
                    {
                        options.ClaimActions.Add(claimAction);
                    }
    
                    options.ClaimActions.MapCustomJson(AbpClaimTypes.Picture,
                        user => user.GetString("id") == null ? null : "https://graph.microsoft.com/v1.0/me/photo/$value");
    
                    options.ClaimActions.MapCustomJson(AbpClaimTypes.PhoneNumber,
                        user => user.GetString("mobilePhone") ?? user.TryGetStringArray("businessPhones").FirstOrDefault());
    
                    options.ClaimActions.MapJsonKey("job_title", "jobTitle");
    
                    options.Events = new OpenIdConnectEvents
                    {
                        OnTokenValidated = CallCustomUserEndpointOnTokenValidated
                    };
                })
                .WithDynamicOptions<OpenIdConnectOptions, OpenIdConnectHandler>(
                    OpenIdConnectDefaults.AuthenticationScheme,
                    options =>
                    {
                        options.WithProperty<string?>(o => o.Authority);
                        options.WithProperty<string?>(o => o.ClientId);
                        options.WithProperty<string?>(o => o.ClientSecret, isSecret: true);
                    }
                );
    
            // add bugfix from https://abp.io/qa/questions/2468/3c6595db-97b4-07b0-2a33-3a0219d08a3c
            context.Services.Replace(ServiceDescriptor
                .Scoped<AccountExternalProviderOptionsManager<OpenIdConnectOptions>,
                    OpenIdAccountExternalProviderOptionsManager>());
        }
    
        private async Task CallCustomUserEndpointOnTokenValidated(TokenValidatedContext eventContext)
        {
            var identity = (ClaimsIdentity?)eventContext.Principal?.Identity;
            if (identity == null)
            {
                return;
            }
    
            var accessToken = eventContext.TokenEndpointResponse?.AccessToken;
            if (accessToken == null)
            {
                return;
            }
    
            // implementation copied from [Microsoft.AspNetCore.Authentication.MicrosoftAccount.MicrosoftAccountHandler.CreateTicketAsync]
            var request = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me");
            request.Version = eventContext.Options.Backchannel.DefaultRequestVersion;
            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
    
            var response =
                await eventContext.Options.Backchannel.SendAsync(request, eventContext.HttpContext.RequestAborted);
            response.EnsureSuccessStatusCode();
    
            using var payload =
                JsonDocument.Parse(await response.Content.ReadAsStringAsync(eventContext.HttpContext.RequestAborted));
            foreach (var action in eventContext.Options.ClaimActions)
            {
                action.Run(payload.RootElement, identity, eventContext.Options.ClaimsIssuer ?? eventContext.Scheme.Name);
            }
        }
    
    public class OpenIdAccountExternalProviderOptionsManager : AccountExternalProviderOptionsManager<OpenIdConnectOptions>
    {
        private readonly OpenIdConnectPostConfigureOptions _openIdConnectPostConfigureOptions;
    
        public OpenIdAccountExternalProviderOptionsManager(
            IOptionsFactory<OpenIdConnectOptions> factory,
            IAccountExternalProviderAppService accountExternalProviderAppService,
            IStringEncryptionService stringEncryptionService,
            ITenantConfigurationProvider tenantConfigurationProvider,
            IEnumerable<IPostConfigureAccountExternalProviderOptions<OpenIdConnectOptions>> postConfigures,
            ICurrentTenant currentTenant,
            IDataProtectionProvider dataProtection) :
            base(factory, accountExternalProviderAppService, stringEncryptionService, tenantConfigurationProvider, postConfigures, currentTenant)
        {
            _openIdConnectPostConfigureOptions = new OpenIdConnectPostConfigureOptions(dataProtection);
        }
    
        protected async override Task OverrideOptionsAsync(string name, OpenIdConnectOptions options)
        {
            await base.OverrideOptionsAsync(name, options);
            _openIdConnectPostConfigureOptions.PostConfigure(name, options);
        }
    }
    

    Not sure if this a good idea though, calling the DB during those contributor calls; what do you think? Kind Regards, Marc

  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    hi

    Can you share a template project?

    I will check the await signInManager.GetExternalLoginInfoAsync(); problem.

    liming.ma@volosoft.com

    Thanks.

  • User Avatar
    0
    mharnos created

    Hi @maliming,

    I've created a dummy project and sent it to your mail just now. The first e-mail was bounced, I hope you have received and can download the files from the second e-mail.

    Kind Regards, Marc

  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    hi

    I will check your project asap

    Thanks.

  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    hi

    In addition only the first two times GetExternalLoginInfoAsync returns a non-null response, on the third and final call, I have no ExternalLogin context left anymore. (I suppose this is when OpenIddict issues its own Identity?)

    You can ignore this case, It's the same as if (identity == null), the claim has been added to the current user

    Can you add some custom claims to my account?

    liming.ma@volosoft.com

    I will try to get it instead of reading the database.

    Thanks.

  • User Avatar
    0
    mharnos created

    Hello @maliming,

    thank you for the support over e-mail and here in the forum; I can confirm, that outside of the IAbpClaimsPrincipalContributor itself, the claims are persisted in the correct / current Principal/Identity, so this is just an issue, where I was expecting the Contributors to be called with already updated contexts, but they all get the same (initial) context 👍 So it doesn't matter if the external login is not available for the third call, it was already written in the first one.

    Nevertheless, we will probably stick with the "persist in database" solution for now, so we can a) easier see who has what role and claim and b) ExternalLogin context is not always available, e.g. after logging in, the External identity gets logged out, meaning, if I then (after some time) the user gets a new session, or if the application restarts, there is no external login context, but the user is still considered to be logged in (through cookie for example) - I think if we would rely on this, it would lead to some rather confusing bugs on our side, where a user "sometimes" has a claim, depending on whether he logged in through an external provider in the current session or not, and sometimes it wouldn't have those claims; worst, this would probably happen on any "deployment day" which is absolutely the worst time for a bug to happen 😅

    I think overall it is easier to reason about this issue, with persisting it into AbpUserClaims and then it doesn't matter if the user is external or not.

    Thanks again! Marc

  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    Great 👍

Learn More, Pay Less
33% OFF
All Trainings!
Get Your Deal
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v10.0.0-preview. Updated on September 12, 2025, 10:20