Hi,
I didn't really get an answer for this topic before it was auto-closed. Could you have another look into this, or give me some feedback on what the right approach would be on providing global styling, that can override LeptonX Themes?
Kind Regards, Marc
Hello Mrs. Kurtuluş,
I was able to download the example project successfully and will look into it soon. Thank you for the example and explanation; I was somewhat aware that the data on the angular-routes was used for ABP.Routes (+Navbar generation +Breadcrum generation) but I'll look into your example in detail.
We have some other problems related to Navbar (in particular the groups are not selected correctly, so sometimes the Admin-Group gets selected, even though I'm in my application route) -> maybe if we change our code to align with your example this will be resolved too, otherwise I'll open another ticket for it 👍
I'll give feedback about this example shortly, but I'm not sure when exactly I'll be able to. (Maybe beginning of next week)
Thanks, Marc
Hi, the ABP.Routes are already configured with the placeholder inside the path variable (like the AI suggested): /${path}/:exampleId/${EXAMPLE_ROUTE_NAMES.b}
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
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
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
Hi,
we had a similar issue, where if the returnUrl points to a guarded route (the default points to / / rootUrl and is not configurable) then the authGuard code would run in parallel to the angular-oauth2-oidc authentication code resulting in a loop if the authGuard was faster than the authentication code: https://github.com/manfredsteyer/angular-oauth2-oidc/issues/861
Our solution was to create an additional route in angular oauth/code that has no authGuard and just displays a placeholder with the empty layout (no menus) eLayoutType.empty, and the angular-oauth2-oidc code handles the redirect to the redirectUrl (not returnUrl) when it is finished with authentication. The redirectUrl can be authGuarded as you would expect.
We had to update the oAuthConfig in our environment.ts files (redirectUri: baseUrl + '/oauth/code') and extend the OpenIddictDataSeedContributor where the redirectUri in CreateApplicationAsync is always set to be the rootUrl, i.e. redirectUris: new List<string> { consoleAndAngularClientRootUrl } needs to be changed to include /oauth/code as well.
That resolved our issue, but it would've made it significantly easier to configure, if the "OpenIddict:Applications" config would include the RedirectUris and PostLogoutRedirectUris options instead of assuming they're RootUrl or point to a ABP configured, non-AuthGuard-ed route per default.
Hope this helps, Marc