Hi,
Sorry to hear about the trouble. To narrow this down on 8.3.0 we'll need a bit more info from your side:
Volo.Abp.BackgroundJobs store, Hangfire, Quartz, or RabbitMQ?BackgroundJobOptions configuration — anything you've customized (e.g. IsJobExecutionEnabled, custom JobExecutionTimeout, DefaultTimeout)?AbpBackgroundJobs table — a snapshot of a few stuck rows showing IsAbandoned, NextTryTime, TryCount, LastTryTime would help a lot.Error / Warning level from Volo.Abp.BackgroundJobs.* or Volo.Abp.DistributedLocking.*.Debug level logging for the background job namespaces and capture a few minutes of output while jobs are sitting in the queue. Steps here: https://abp.io/support/questions/8622/How-to-enable-Debug-logs-for-troubleshoot-problemseA few common causes we've seen for this exact symptom:
IsJobExecutionEnabled = false got set, or only the API host is running and the job host isn't).IAbpDistributedLock) is stuck — leftover lock row in DB / Redis that never expires, blocking the periodic queue poller.AbpBackgroundJobs table preventing the poller from reading the queue.IsAbandoned = true after exceeding MaxTryCount (default 3), which is why "previously failing" jobs stopped retrying.If you can share the logs and a sample of the AbpBackgroundJobs rows, we can dig in from there.
Thanks
Hi,
Yes, you can keep using AutoMapper. Starting from ABP 10.3.0, we shipped a new package Volo.Abp.LuckyPenny.AutoMapper exactly for this case — it's a drop-in replacement built on the commercial AutoMapper (15.x+) by LuckyPenny Software, which is the patched version (the free 14.x has a DoS vulnerability that won't be fixed).
Migration is just two steps in your own modules:
// *.csproj
-<PackageReference Include="Volo.Abp.AutoMapper" />
+<PackageReference Include="Volo.Abp.LuckyPenny.AutoMapper" />
// YourModule.cs
-[DependsOn(typeof(AbpAutoMapperModule))]
+[DependsOn(typeof(AbpLuckyPennyAutoMapperModule))]
All the types stay in the same namespaces (AbpAutoMapperOptions, IMapperAccessor, etc.), so your existing Profiles and CreateMap calls keep working as-is. License key goes through AbpAutoMapperOptions.Configurators:
Configure<AbpAutoMapperOptions>(options =>
{
options.Configurators.Add(ctx =>
{
ctx.MapperConfiguration.LicenseKey = "YOUR_LICENSE_KEY";
});
});
Note: Volo.Abp.LuckyPenny.AutoMapper and Volo.Abp.AutoMapper are mutually exclusive — pick one.
About Text Template Management (and other ABP modules migrated to Mapperly): you can use them just fine alongside AutoMapper in your own code. The Mapperly usage is internal to those modules — you don't need to write any Mapperly mappers yourself. The only thing you can't do is fully remove Mapperly from the solution, since the module assemblies are compiled against it. But it's just a transitive dependency, not something you have to interact with.
So the supported setup is:
Volo.Abp.LuckyPenny.AutoMapper if you hold a commercial license)Docs: https://abp.io/docs/latest/framework/infrastructure/luckypenny-automapper
Thanks
Hi,
A couple of things to clear up first:
TenantId" trick won't work — the tenant admin lives in the tenant DB (Isolated mode) and just changing TenantId on a host user breaks the multi-tenant integrity.SwitchTenant grant only works when AbpMultiTenancyOptions.UserSharingStrategy == Shared. In Isolated mode the grant handler explicitly returns unsupported_grant_type, so adding it on the Angular OpenIddict client has no effect — please remove SwitchTenant from the Angular app's ExtensionGrantTypes.For your "no SMTP allowed" scenario, the cleanest workaround is to replace the OOTB IIdentityEmailSender so the existing "Invite admin" button never calls SMTP. Instead, the accept-link is written to the log (or your own table / webhook / Redis — whatever your ops team can read). The host admin grabs the link from there and forwards it to the invitee through any internal channel (Slack/Teams/internal portal). The invitee opens the link, the OOTB Account/InviteUser page loads, they pick a username/password and accept.
Zero UI change, zero new endpoints, no client-proxy regeneration in layered solutions. The OOTB "Invite admin" button keeps working unchanged in MVC, Blazor, and Angular.
[Dependency(ReplaceServices = true)]
[ExposeServices(typeof(IIdentityEmailSender))]
public class NoSmtpIdentityEmailSender : IdentityEmailSender
{
public NoSmtpIdentityEmailSender(
IEmailSender emailSender,
ITemplateRenderer templateRenderer,
IStringLocalizer<IdentityResource> stringLocalizer,
IAppUrlProvider appUrlProvider,
UserSharingManager userSharingManager,
UserInvitationManager userInvitationManager)
: base(emailSender, templateRenderer, stringLocalizer, appUrlProvider,
userSharingManager, userInvitationManager)
{
}
public override async Task SendInviteEmailAsync(UserInvitation invitation, bool requireRegister, string appName)
{
var url = await AppUrlProvider.GetInviteUserUrlAsync(appName);
var token = await UserInvitationManager.GenerateTokenFromInvitationIdAsync(invitation.Id);
var link = $"{url}?token={UrlEncoder.Default.Encode(token)}";
Logger.LogWarning(
"[Invite] Tenant={tenantId} Email={email} RequireRegister={requireRegister} Link={link}",
invitation.InviterTenantId, invitation.InviteeEmail, requireRegister, link);
// Or persist to your own table / push to a webhook / write to Redis -
// whatever your ops team can read from to retrieve the link.
}
}
Drop this class into your Application module. Each invite produces a log line like:
[Invite] Tenant=... Email=admin@mycorp.com RequireRegister=True Link=https://your-app/Account/InviteUser?token=CfDJ8...
Just copy the Link=... value and forward it to the invitee. Done — no SMTP, no SwitchTenant, no host-user copying.
Thanks
Hi,
The "Switch Tenant" feature relies on a custom OpenIddict extension grant (SwitchTenant) plus a few /api/account/user-sharing endpoints. Here is everything you need to wire it up in Next.js.
Read multiTenancy.userSharingStrategy from the application configuration endpoint. Only show the "Switch tenant" UI when it equals 1 (Shared).
GET {HttpApiHost}/api/abp/application-configuration
{
"multiTenancy": {
"isEnabled": true,
"userSharingStrategy": 1 // 0 = Isolated, 1 = Shared
}
}
GET {HttpApiHost}/api/account/user-sharing
Authorization: Bearer <current_access_token>
Response:
{
"items": [
{ "tenantId": "guid-or-null", "tenantName": "TenantA", "email": "user@x.com" },
{ "tenantId": "guid-or-null", "tenantName": "TenantB", "email": "user@x.com" }
]
}
SwitchTenant grant typeThis is the key part. Post to the AuthServer token endpoint with grant_type=SwitchTenant. The current access_token is sent as a parameter (the grant handler introspects it to identify the user) and a new token set scoped to the target tenant is returned.
POST {AuthServerUrl}/connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=SwitchTenant
&client_id=<your_client_id>
&client_secret=<your_client_secret> // only for confidential clients
&access_token=<current_valid_access_token>
&TenantId=<target_tenant_guid> // omit / leave empty to switch to Host
Response is a standard OAuth token response:
{
"access_token": "...",
"refresh_token": "...",
"id_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "..."
}
The new access_token already has the target tenant's tenantid claim, so every subsequent API call will run in the new tenant context.
After receiving the response in Next.js you should:
access_token / refresh_token / id_tokenThis is exactly what Angular does — see SwitchTenantService in commercial-ui/config/src/services/switch-tenant.service.ts, which calls loginUsingGrant('SwitchTenant', { TenantId }) and then location.href = baseUrl to refresh.
DELETE {HttpApiHost}/api/account/user-sharing/leave?tenantId=<guid>
Authorization: Bearer <current_access_token>
GET /api/account/user-sharing/invitation?token=<inviteToken>
POST /api/account/user-sharing/invitation/accept // body: { token, ... }
POST /api/account/user-sharing/invitation/reject // body: { token, ... }
Configure<AbpMultiTenancyOptions>(options =>
{
options.IsEnabled = true;
options.UserSharingStrategy = TenantUserSharingStrategy.Shared;
});
If UserSharingStrategy is not Shared, the SwitchTenant grant returns unsupported_grant_type.
GET /api/abp/application-configuration → check multiTenancy.userSharingStrategy === 1GET /api/account/user-sharing → render the tenant listPOST /connect/token with grant_type=SwitchTenant&access_token=<current>&TenantId=<guid>Thanks
Hi @rcalv002,
Thanks for letting us know. We will continue working on the Elsa module, and any updates will be included in the release notes for upcoming versions.
Thanks
Hi,
Can you replace the contents of the inline workaround script block in your _Host.cshtml with this (the surrounding script tag and the rest of the page stay as-is):
// LeptonX 5.3.0 side-menu rebind workaround.
// initSideMenu runs once from SideMenuLayout.OnAfterRenderAsync and may
// execute against an empty #lpx-sidebar (when MainMenuProvider yields) or
// be skipped on impersonation rebuild. We watch the sidebar and, whenever
// an outer menu item ends up without click listeners, remove every
// previously attached listener (so we never double-bind a preserved
// element) and call initSideMenu once more to re-attach a single clean
// set. window.initLeptonX is wrapped so the natural call from
// OnAfterRenderAsync goes through the same cleanup path.
(function () {
try {
const allBinds = new WeakMap();
let timer = null;
const origAdd = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, fn, opts) {
if (this instanceof Element
&& this.classList && this.classList.contains('lpx-menu-item')
&& this.closest && this.closest('#lpx-sidebar')) {
let arr = allBinds.get(this);
if (!arr) { arr = []; allBinds.set(this, arr); }
arr.push({ type: type, fn: fn, opts: opts });
}
return origAdd.call(this, type, fn, opts);
};
function cleanupBinds() {
document.querySelectorAll('#lpx-sidebar .lpx-menu-item').forEach(function (el) {
const arr = allBinds.get(el);
if (arr) {
arr.forEach(function (b) { el.removeEventListener(b.type, b.fn, b.opts); });
allBinds.delete(el);
}
});
}
const origInitLeptonX = window.initLeptonX;
window.initLeptonX = function () {
cleanupBinds();
return origInitLeptonX.apply(this, arguments);
};
function reinit() {
cleanupBinds();
const fn = window.leptonx
&& window.leptonx.init
&& window.leptonx.init.initializers
&& window.leptonx.init.initializers.get('initSideMenu');
if (fn) fn();
}
function check() {
const items = document.querySelectorAll('#lpx-sidebar .outer-menu-item > .lpx-menu-item');
if (items.length === 0) return;
let needs = false;
for (let i = 0; i < items.length; i++) {
if (!allBinds.has(items[i])) { needs = true; break; }
}
if (needs) reinit();
}
function debounced() { clearTimeout(timer); timer = setTimeout(check, 300); }
new MutationObserver(debounced).observe(document.body, { childList: true, subtree: true });
debounced();
} catch (err) {
console.error('[lpx-fix] failed:', err);
}
})();
A proper upstream fix is also queued for the next LeptonX patch release, so once you upgrade you can drop this whole inline script.
Thanks
Hi,
Thanks for the update, glad to hear you have an internal procedure in place for now. The fix is already merged and will ship in the next release, so you can drop the workaround once you upgrade.
Thanks
Hi,
Found the root cause. In your NotificationController.cs:
[HttpGet("/{id}")]
public Task<NotificationDto> GetAsync(Guid id)
{
return _notificationAppService.GetAsync(id);
}
The leading / in [HttpGet("/{id}")] makes the route absolute, which overrides the controller-level [Route("api/app/notification")]. As a result this action matches any single-segment URL at the root — including /home, /about, etc.
Since Emerald.Web.Public depends on EmeraldHttpApiModule, this controller is registered in the Public website's routing table. When your HomeController redirects unauthenticated users to /home, ASP.NET Core matches NotificationController.GetAsync, tries to bind home to the Guid id parameter, fails, and returns 400. The [Authorize] attribute on the controller is also what triggers the AuthServer redirect you observed.
The log confirms this:
Executing endpoint 'Emerald.Controllers.Notifications.NotificationController.GetAsync (Emerald.HttpApi)'
Route matched with {area = "app", controller = "Notification", action = "Get"}. Executing controller action with signature ... GetAsync(System.Guid) ...
Request finished HTTP/2 GET https://localhost:44362/home - 400
Fix: Remove the leading / so the route becomes relative to the controller:
[HttpGet("{id}")]
public Task<NotificationDto> GetAsync(Guid id)
{
return _notificationAppService.GetAsync(id);
}
The endpoint will then be GET /api/app/notification/{id} as intended.
Thanks
Hi,
Confirmed — with the default ABP setup you're running, your app isn't hit by CVE-2026-40372. ABP does not call UseDataProtection() on the OpenIddict server/validation builder, so all OpenIddict tokens (auth code, refresh, device code, state) are encrypted with the X.509 certificate you register via AddEncryptionCertificate(...), and access tokens are JWTs (DisableAccessTokenEncryption() is applied by default). The DP flaw only reaches the ASP.NET Core side of your app (cookies, antiforgery, ASP.NET Core Identity DP tokens), not OpenIddict tokens.
On top of that, Azure App Service Linux has already rolled the shared framework to 10.0.7+, which is what your framework-dependent hosts actually load for Microsoft.AspNetCore.DataProtection. So in practice you're already on the patched assembly.
Per-question answers below.
1. Transitive exposure through ABP packages
ABP core and Volo.Abp.OpenIddict.* don't pull Microsoft.AspNetCore.DataProtection as a NuGet reference on net10.0 — on that TFM, OpenIddict.*.AspNetCore gets DataProtection from the Microsoft.AspNetCore.App shared framework. The only NuGet-level DP refs in ABP templates are Microsoft.AspNetCore.DataProtection.StackExchangeRedis and, on the Pro side, a direct Microsoft.AspNetCore.DataProtection reference in Volo.Abp.Identity.Pro.Domain and Volo.FileManagement.Domain.
Loading rule is max(sharedFramework, PackageReference), and it's safe iff that max is ≥ 10.0.7. As long as your runtime patch is 10.0.7+, the shared framework wins and you're safe even with the current 10.0.2 PackageReference in ABP 10.2.
We already pushed PRs on April 23 to bump all Microsoft.* / System.* packages to 10.0.7 across ABP, ABP Pro, and ABP Studio, so you'll pick that up in the next 10.2.x / 10.3.x patch. If you want a hard floor now without waiting for the bump, add these to each host csproj — this guarantees NuGet wins even if the runtime patch somewhere lags:
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.StackExchangeRedis" Version="10.0.7" />
2. Recommended revocation pattern for tiered solutions
Your plan is exactly what we'd recommend: one-shot IHostedService on the AuthServer, gated by a config flag. The AuthServer owns the key-ring registration and the OpenIddict domain services, so both IKeyManager.RevokeAllKeys() and IOpenIddictTokenManager.RevokeAsync(null, null, null, null) belong there. ABP doesn't have a dedicated idiomatic hook for this; IHostedService or OnApplicationInitializationAsync both work. Use a DB row / Redis key as an idempotency marker so it doesn't re-run on every restart.
Sketch:
public class CveKeyRevocationHostedService : IHostedService
{
private readonly IServiceProvider _serviceProvider;
private readonly IConfiguration _configuration;
public CveKeyRevocationHostedService(IServiceProvider serviceProvider, IConfiguration configuration)
{
_serviceProvider = serviceProvider;
_configuration = configuration;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
if (!_configuration.GetValue<bool>("CabMD:DataProtection:RevokeOnStartup"))
{
return;
}
using var scope = _serviceProvider.CreateScope();
var keyManager = scope.ServiceProvider.GetRequiredService<IKeyManager>();
keyManager.RevokeAllKeys(DateTimeOffset.UtcNow, "CVE-2026-40372");
var tokenManager = scope.ServiceProvider.GetRequiredService<IOpenIddictTokenManager>();
await tokenManager.RevokeAsync(subject: null, client: null, status: null, type: null);
// persist an idempotency marker here (DB row / Redis key) so a later restart skips this
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
Register it in the AuthServer module's ConfigureServices:
context.Services.AddHostedService<CveKeyRevocationHostedService>();
3. ABP-internal consumers of Data Protection
Here's the full list for an ABP tiered setup. Everything below is invalidated by RevokeAllKeys — but none of it stores DP-protected ciphertext in the database, so revocation only forces re-auth and invalidates pending email/share links. It does not break any at-rest data.
state parameter (Google/Microsoft/etc. handlers)AbpSingleActiveTokenProvider — stores a SHA256 hash in AbpUserTokens, not the DP ciphertext, so revoke is safeUserInvitationTokenProvider — pending invitation emails become invalidShareFileTokenProvider in File Management — any outstanding share link becomes invalidNot affected: all OpenIddict tokens (auth code, refresh, device code, state, access token) — they use the OpenIddict encryption/signing certificate, not DP.
4. OpenIddict encryption/signing certificate vs. DP key ring
Correct — they're completely separate. The certificate registered via AddEncryptionCertificate / AddSigningCertificate on OpenIddictServerBuilder encrypts OpenIddict's own tokens. The DP key ring is only used by ASP.NET Core (cookies, antiforgery) and ASP.NET Core Identity DP token providers. CVE-2026-40372 is purely about the DP encryptor HMAC flaw, so you do not need to rotate the OpenIddict encryption/signing cert for this CVE.
5. SetApplicationName interop after revocation
There is a per-host cache. KeyRingProvider caches the active key ring, and KeyManagementOptions.KeyRingRefreshPeriod controls refresh (default 24h). RevokeAllKeys does not push a notification to other hosts — they'll only pick up the revocation and the new active key on their next refresh.
That means there is a transient window after you call RevokeAllKeys where the other hosts are still holding the stale key ring: unprotect will fail (keys revoked), and protect operations on those nodes can race when auto-generating a new active key.
Coordinated restart of all hosts (AuthServer + HttpApi.Host + Web + Web.Public + Hangfire) immediately after revocation is the right call. Don't rely on the 24h refresh for incident response.
Recommended sequence for your deployment window:
CabMD:DataProtection:RevokeOnStartup=true on the AuthServer only.RevokeAllKeys + RevokeAsync.Thanks.
Hi,
Thanks for catching this during testing — really appreciate it, that kind of feedback before production saves us a lot of pain. The fix will land in the next release, so you can grab it from there.
Thanks