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:
- https://abp.io/community/articles/how-to-use-the-azure-active-directory-authentication-for-mvc-razor-page-applications-4603b9cf#gsc.tab=0
- https://abp.io/support/questions/3735/ABP-and-AddMicrosoftIdentityWebApp
- https://abp.io/support/questions/1672/Access-Token-Conflict-while-Integrating-with-Microsoft-Graph
- https://github.com/abpframework/abp/issues/12911
- https://learn.microsoft.com/en-us/aspnet/core/security/authentication/social/additional-claims?view=aspnetcore-9.0#add-and-update-user-claims
- https://stackoverflow.com/questions/78593410/adding-external-oauth-authentication-via-addoauth
- https://medium.com/abp-community/how-claim-type-works-in-asp-net-core-and-abp-framework-6655203a309e
- https://haacked.com/archive/2019/07/16/external-claims/ + https://haacked.com/archive/2019/07/17/should-you-flow-external-claims/
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?
- https://stackoverflow.com/questions/51383647/accessing-token-used-by-identity-external-authentication-in-net-core
- https://abp.io/qa/questions/6193/3a0f02c3-31b9-e61d-9c27-f5b059a88eb8
- https://abp.io/qa/questions/2604/b2f914ae-a820-c983-4573-3a021d98fec3
- https://abp.io/docs/latest/framework/fundamentals/dynamic-claims#webremotedynamicclaimsprincipalcontributoroptions
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)
-
0
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:
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.
- When a user logs in via an external provider (e.g., Azure AD), the claims from the external identity (like
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.
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.
- 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
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
orIAbpOpenIddictClaimDestinationsProvider
to control claim destinations.
- By default, ABP's OpenIddict integration will include
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
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:
- ABP OpenIddict: Updating Claims In Access_token and Id_token
- ABP Claims Principal Factory
- ABP Dynamic Claims
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.
-
0
hi
You can add a new
IAbpClaimsPrincipalContributor
and check results fromGetExternalLoginInfoAsync
.Your
roles
claims will exist inexternalLoginInfo
.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)); } } }
-
0
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 fromvar identity = context.ClaimsPrincipal.Identities.FirstOrDefault();
has the new claim added when I callAddClaim
, but theContributeAsync
is being called 3x during (external) login, and each time theIdentity.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 methodContributeAsync
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
andAbpRoles
: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
-
0
hi
Can you share a template project?
I will check the
await signInManager.GetExternalLoginInfoAsync();
problem.liming.ma@volosoft.com
Thanks.
-
0
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
-
0
hi
I will check your project asap
Thanks.
-
0
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 userCan you add some custom claims to my account?
liming.ma@volosoft.com
I will try to get it instead of reading the database.
Thanks.
-
0
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
-
0
Great 👍