Activities of "nacho"

Hi Maliming, have you created a ticket we can follow up?

Just posting a comment so ABP team is aware of this issue.

AIManagement: workspace configuration cache is never invalidated when the reader's ApplicationName differs from the entity's ApplicationName

Module: Volo.AIManagement (ABP Commercial) Version: observed on 10.4.0 (code paths unchanged in current source) Database provider: any (reproduced with MongoDB)

Summary

WorkspaceConfigurationStore and WorkspaceChangedHandler build the distributed cache key from different sources, so editing a workspace does not invalidate the cached configuration that other applications are actually reading. Those applications keep serving the stale provider/model/API key until the cache entry expires on its own.

  • Read path — WorkspaceConfigurationStore.GetOrNullAsync caches under the current application's name:

    // Volo.AIManagement.Domain/Volo/AIManagement/Workspaces/Configuration/WorkspaceConfigurationStore.cs
    var cacheKey = WorkspaceConfigurationCacheKeyGenerator.Create(
        ApplicationInfoAccessor.ApplicationName!, name);
    
  • Invalidation path — WorkspaceChangedHandler removes the key built from the entity's ApplicationName:

    // Volo.AIManagement.Domain/Volo/AIManagement/Workspaces/WorkspaceChangedHandler.cs
    return _cache.RemoveAsync(
        WorkspaceConfigurationCacheKeyGenerator.Create(
            eventData.Entity.ApplicationName ?? string.Empty,
            eventData.Entity.Name));
    

Whenever Workspace.ApplicationName ≠ the reading process's IApplicationInfoAccessor.ApplicationName, the handler removes a key nobody reads, and the key everybody reads is never removed.

This is easy to hit in a normal multi-application ABP solution because ApplicationWorkspaceManager.CreateAsync stamps the workspace with the ApplicationName of the process that created it:

// Volo.AIManagement.Domain/Volo/AIManagement/Workspaces/ApplicationWorkspaceManager.cs
newWorkspace.ApplicationName = ApplicationInfoAccessor.ApplicationName ?? string.Empty;

A workspace seeded by the DbMigrator carries the migrator's ApplicationName forever, while the Blazor host, the HttpApi host, etc. each cache it under their own ApplicationName.

Steps to reproduce

  1. Solution with separate DbMigrator and web host (standard ABP tiered/multi-app template), distributed cache backed by Redis.
  2. Seed a workspace from the DbMigrator (e.g. via ApplicationWorkspaceManager.CreateAsync("MyWorkspace", "OpenAI", "model-a")).
  3. From the web host, resolve it once (IChatClientResolver.ResolveAsync("MyWorkspace") or IWorkspaceConfigurationStore.GetOrNullAsync("MyWorkspace")) → the configuration is cached under (webHostAppName, "MyWorkspace").
  4. Edit the workspace from the AI Management UI (change ModelName to model-b) → WorkspaceChangedHandler removes (dbMigratorAppName, "MyWorkspace").
  5. Resolve again from the web host.

Expected

The resolver returns a chat client configured with model-b (admin edits take effect on the next call).

Actual

The resolver keeps returning model-a until the cache entry expires (or the host restarts). With a long default cache expiration this looks like "workspace edits are silently ignored".

Suggested fix

Make both sides derive the key from the same source. Options:

  • Key the cache by the entity's ApplicationName on the read path too (requires a lookup, or dropping ApplicationName from the key entirely since Workspace.Name is already unique), or
  • On change, invalidate all application-scoped keys for that workspace name.

Related observation

AIManagementChatClient<T> resolves the inner client once and reuses it for the instance's lifetime (EnsureClientCreatedAsync), so long-lived consumers (Blazor Server circuits, background workers) additionally pin the configuration snapshot from their first call even after the cache is fixed. Consider re-resolving when the underlying configuration changes.

OK, thank you

Hi @maliming,

Thanks for the clarification. We understand the limitation, but using Isolated means giving up unified user accounts, which is the main reason we'd want to upgrade to Shared in the first place.

We'd like to propose a flow that could make per-tenant external IdP configuration work with Shared strategy:

Proposed Flow:

  1. User enters email on the login page (no tenant context yet)
  2. System looks up matching user records across tenants for that email (in Shared mode, with the IMultiTenant data filter disabled, the system can query all IdentityUser records matching the email — each with a different TenantId. ABP already does something similar in IdentityUserManager.FindSharedUserByEmailAsync)
  3. User selects a tenant from the list (or auto-selects if only one)
  4. Tenant context is now resolved → tenant-specific IdP configuration is loaded
  5. Authentication is requested using the appropriate method (Entra ID, password, etc.)

Note: This flow could be opt-in via configuration (e.g., AbpAccountOptions.EnableTenantSelectionBeforeAuth), so it only activates when the application explicitly requires per-tenant IdP resolution in Shared mode. The default behavior would remain unchanged.

So, in Shared mode, the email is a cross-tenant identifier — we don't need domain-based tenant resolution. The tenant selection happens before authentication but after user identification, giving us the context needed to load per-tenant IdP settings.

Security Consideration:

This flow could expose tenant membership to unauthenticated users (email enumeration). This can be mitigated by:

  • Returning a generic response regardless of whether the email exists (e.g., always show "check your email")
  • Sending a verification code to the email before revealing tenant options
  • Rate limiting the lookup endpoint

We believe this is a reasonable tradeoff, especially since domain-based tenant resolution already implicitly reveals tenant existence today.

Request:

Would the ABP team consider either supporting this flow natively, or providing the necessary extensibility points (e.g., virtual methods, overridable services, or hooks in the login pipeline) so that we can implement this flow ourselves on top of the Shared strategy?

Any ABP team insights?

Environment

  • ABP Framework version: 10.2
  • Feature: Shared User Accounts (TenantUserSharingStrategy.Shared)

Description

The new Shared User Accounts feature introduces a "global authentication first, tenant selection after" model. While this is a great UX improvement for multi-tenant users, it creates a fundamental conflict with a very common B2B SaaS requirement: per-tenant external identity provider (IdP) configuration.

In enterprise B2B scenarios, each tenant (customer organization) typically brings their own Azure AD / Entra ID (or Okta, etc.) with their own ClientId, ClientSecret, and TenantId. This is standard practice — each organization controls their own identity infrastructure.

With the Shared strategy, authentication happens at the Host level before the tenant is known, which means the system cannot determine which tenant's IdP credentials to use for the OIDC challenge.

Scenarios that break

Scenario 1: Per-tenant Entra ID credentials

A SaaS platform serves multiple enterprise customers. Each customer has configured their own Azure AD application registration:

| Tenant | Entra ID ClientId | Entra ID TenantId | | ------ | ----------------- | ----------------- | | Acme Corp | aaa-111 | acme-tenant-id | | Contoso | bbb-222 | contoso-tenant-id |

With the Isolated strategy, the tenant is resolved first (via subdomain, email domain, etc.), so the system knows which Entra ID credentials to use for the OIDC challenge.

With the Shared strategy, the user authenticates globally first — but against which Entra ID? The system doesn't know the target tenant yet.

Scenario 2: Mixed authentication methods across tenants

A user (john@example.com) belongs to two tenants:

  • Tenant A: Requires Entra ID SSO (enterprise policy, no password login allowed)
  • Tenant B: Uses standard username/password (small team, no IdP)

With Shared, authentication is global — so which method applies? If the Host enforces Entra ID, Tenant B users can't log in with passwords. If it allows passwords, Tenant A's enterprise SSO requirement is bypassed.

Scenario 3: Per-tenant security policies

The documentation states that security settings (2FA, lockout, password policies) are managed at the Host level with Shared strategy. In B2B SaaS, each tenant admin typically controls their own security policies:

  • Tenant A (financial services): Mandatory 2FA, strict password policy, 3-attempt lockout
  • Tenant B (startup): No 2FA, relaxed password policy

Centralizing these at Host level removes a key selling point for enterprise customers who need to enforce their own compliance requirements.

Why this matters

Per-tenant IdP configuration is not an edge case — it's a core requirement for B2B SaaS platforms:

  • Enterprise customers expect SSO with their own IdP (Azure AD, Okta, Auth0, etc.)
  • Compliance frameworks (SOC 2, ISO 27001, HIPAA) often require organizations to control their own authentication policies
  • Zero Trust architectures mandate that each organization manages their own identity boundary

Proposed Solutions

Option A: Hybrid flow — Tenant selection before authentication

Allow a pre-authentication tenant selection step. The flow would be:

  1. User enters email → system resolves possible tenants (from membership)
  2. User selects tenant → system loads tenant's IdP configuration
  3. Authentication happens within the tenant's context (using tenant's Entra ID, security policies, etc.)
  4. Post-authentication, tenant switching is available (but re-authentication may be required when switching to a tenant with a different IdP)

This preserves the UX benefits of Shared (one account, multiple tenants) while supporting per-tenant IdP.

Option B: Per-tenant security policy overrides

Even if authentication is global, allow tenants to define security policy overrides that apply when a user is operating within that tenant's context (e.g., require 2FA to access Tenant A, but not Tenant B).

Hi, just to know if there is any feedback about this issue

[Performance] Default Blazor Server template includes IntrospectAccessToken() which is redundant with UseDynamicClaims() — causes ~50 extra HTTP calls per page load

Summary

The default tiered Blazor Server project template includes options.IntrospectAccessToken() in the cookie authentication configuration. This causes an HTTP POST to the AuthServer's /connect/introspect endpoint for every HTTP request — including static assets (CSS, JS, images), SignalR negotiation, and _blazor calls.

When UseDynamicClaims() + IsDynamicClaimsEnabled = true are also configured (which they are by default in the same template), session validation is already handled via Redis/DB by IdentitySessionDynamicClaimsPrincipalContributor. The introspection is completely redundant.

The result is ~50 unnecessary HTTP POST calls to the AuthServer after login, adding 5–8 seconds of latency to the first page load.

Current behavior (default template)

// Generated by ABP template in BlazorModule.cs (tiered Blazor Server)
context.Services.AddAuthentication(options =>
{
    options.DefaultScheme = "Cookies";
    options.DefaultChallengeScheme = "oidc";
})
    .AddCookie("Cookies", options =>
    {
        options.ExpireTimeSpan = TimeSpan.FromDays(365);
        options.IntrospectAccessToken(); // ❌ Causes HTTP POST per request
    })
    .AddAbpOpenIdConnect("oidc", options => { ... });

// Also in the same template:
context.Services.Configure<AbpClaimsPrincipalFactoryOptions>(options =>
{
    options.IsDynamicClaimsEnabled = true; // ✅ Already validates sessions
});

// In OnApplicationInitialization:
app.UseDynamicClaims(); // ✅ Already validates sessions via Redis/DB

What happens on every request

With IntrospectAccessToken() enabled, after the user authenticates:

[Browser] GET /css/styles.css
  → [Blazor Server] POST /connect/introspect → [AuthServer]  ~50-200ms
  ← 200 OK (token is active)
  ← serves CSS

[Browser] GET /js/app.js
  → [Blazor Server] POST /connect/introspect → [AuthServer]  ~50-200ms
  ← 200 OK (token is active)
  ← serves JS

[Browser] POST /_blazor/negotiate
  → [Blazor Server] POST /connect/introspect → [AuthServer]  ~50-200ms
  ...

(repeated ~50 times for all resources loaded during page initialization)

Two redundant session validation mechanisms

The default template configures both mechanisms, but only one is needed:

| Mechanism | Cost per request | Session revocation | Source | |-----------|-----------------|-------------------|--------| | IntrospectAccessToken() | HTTP POST to AuthServer (50–200ms) | Yes | Cookie middleware | | UseDynamicClaims() + IsDynamicClaimsEnabled | Redis/DB lookup (<1ms) | Yes | IdentitySessionDynamicClaimsPrincipalContributor |

IdentitySessionDynamicClaimsPrincipalContributor (part of UseDynamicClaims()) validates the session on every request by checking the session ID in Redis/distributed cache. If an admin revokes a session (e.g., via Identity Management → Users → Sessions), the user is logged out on the next request — exactly the same behavior as IntrospectAccessToken(), but using a local Redis lookup instead of an HTTP round-trip to the AuthServer.

Measured impact

Tested on a Blazor Server tiered application (ABP 10.0.0 Commercial, .NET 10, localhost).

Post-login page load

| Metric | With IntrospectAccessToken | Without IntrospectAccessToken | Improvement | |--------|---------------------------|-------------------------------|-------------| | Introspection calls | ~50 POST requests | 0 | -100% | | AuthServer load | ~50 requests/page load | 0 | -100% | | Post-login page load time | ~8 seconds | ~2 seconds | ~6 seconds faster |

Why the impact is so large

  1. No static file exclusion: The cookie authentication middleware runs for ALL requests, including static files. IntrospectAccessToken() adds an HTTP call to each one.
  2. Blazor Server is request-heavy: A single page load involves CSS, JS, images, fonts, _blazor/negotiate, SignalR WebSocket upgrade, and multiple _blazor framework calls — easily 50+ requests.
  3. Serial bottleneck: Many of these requests happen concurrently, creating a burst of ~50 simultaneous introspection calls against the AuthServer.
  4. Latency compounds: Even at 50ms per introspection on localhost, 50 calls × 50ms = 2.5 seconds of added AuthServer processing time, causing request queuing and slower responses.

Proposed fix

Remove IntrospectAccessToken() from the default Blazor Server tiered template, since UseDynamicClaims() + IsDynamicClaimsEnabled = true already provide session validation:

.AddCookie(&quot;Cookies&quot;, options =&gt;
{
    options.ExpireTimeSpan = TimeSpan.FromDays(365);
    // IntrospectAccessToken() removed — session validation is handled by
    // UseDynamicClaims() + IsDynamicClaimsEnabled via IdentitySessionDynamicClaimsPrincipalContributor
})

If IntrospectAccessToken() is intentionally kept for scenarios where UseDynamicClaims() is not enabled, consider:

  1. Documenting the interaction between the two mechanisms so users understand they're redundant
  2. Conditionally including IntrospectAccessToken() only when IsDynamicClaimsEnabled is false
  3. At minimum: excluding static files from introspection (e.g., by path prefix)

Why removing it is safe

  1. Session revocation still works: IdentitySessionDynamicClaimsPrincipalContributor checks session validity in Redis/DB on every request. Revoked sessions are detected immediately.

  2. Token validation still works: The HttpApi.Host validates JWT tokens locally with AddAbpJwtBearer(). The Blazor Server app sends the access token in API calls, and the API host validates it without needing introspection.

  3. The AuthServer uses UseLocalServer(): It validates its own tokens locally, not via introspection.

  4. No behavioral change for users: Login, logout, session timeout, and session revocation all work identically. The only difference is the elimination of redundant HTTP calls.

Verification steps

After removing IntrospectAccessToken():

  1. Login → verify access to protected pages works normally
  2. Check AuthServer logs → confirm zero /connect/introspect requests from Blazor
  3. Revoke a session via Identity Management → Users → Sessions → verify the user is logged out on next request (proves UseDynamicClaims() handles it)
  4. Measure post-login page load time → confirm ~5-8 second improvement
  5. Let the cookie expire → verify re-authentication is triggered normally

Affected scenarios

  • Tiered Blazor Server — severely affected (50+ introspection calls per page load)
  • Tiered MVC — also affected (fewer requests per page load, but still impacted)
  • Non-tiered / monolith — not affected (no remote introspection endpoint)

Workaround

Remove the IntrospectAccessToken() call from the Blazor module:

.AddCookie("Cookies", options =>
{
    options.ExpireTimeSpan = TimeSpan.FromDays(365);
    // options.IntrospectAccessToken(); // Remove this line
})

Ensure UseDynamicClaims() and IsDynamicClaimsEnabled = true remain configured (they are by default).

Environment

  • ABP Framework version: 10.0.0
  • ABP Commercial version: 10.0.0
  • .NET version: 10.0
  • Hosting: Blazor Server (tiered architecture)
  • Template: Default ABP Commercial Blazor Server (tiered)

[Performance] MvcCachedApplicationConfigurationClient fetches configuration and localization sequentially — easy ~400ms savings on cold cache

Summary

MvcCachedApplicationConfigurationClient.GetRemoteConfigurationAsync() fetches application-configuration and application-localization sequentially, but they can safely run in parallel. The localization call depends on a culture name that is already available from CultureInfo.CurrentCulture (set by ABP's RequestLocalizationMiddleware) — it does not need to wait for the configuration response.

This is a low-risk, high-impact optimization for all tiered Blazor Server deployments.

Current behavior (sequential)

[Request starts]
  → GET /api/abp/application-configuration    ~120ms
  ← Response received
  → GET /api/abp/application-localization      ~200ms  (waits for config to finish)
  ← Response received
[Total: ~320ms]

The sequential dependency exists because the current code extracts cultureName from config.Localization.CurrentCulture.Name before calling the localization endpoint:

// Current implementation in MvcCachedApplicationConfigurationClient
private async Task<ApplicationConfigurationDto> GetRemoteConfigurationAsync()
{
    var config = await ApplicationConfigurationAppService.GetAsync(
        new ApplicationConfigurationRequestOptions { IncludeLocalizationResources = false }
    );

    // ❌ Waits for config just to get cultureName
    config.Localization.Resources = (await ApplicationLocalizationClientProxy.GetAsync(
        new ApplicationLocalizationRequestDto
        {
            CultureName = config.Localization.CurrentCulture.Name,
            OnlyDynamics = true
        }
    )).Resources;

    return config;
}

Proposed behavior (parallel)

[Request starts]
  → GET /api/abp/application-configuration    ~120ms  (parallel)
  → GET /api/abp/application-localization      ~200ms  (parallel)
  ← Both responses received
[Total: ~200ms — savings: ~120ms per cache miss]

The key insight is that CultureInfo.CurrentCulture.Name is already set by ABP's RequestLocalizationMiddleware before MvcCachedApplicationConfigurationClient is invoked. It will always match config.Localization.CurrentCulture.Name, so we can use it directly without waiting for the configuration response:

// Proposed implementation
private async Task<ApplicationConfigurationDto> GetRemoteConfigurationAsync()
{
    // CultureInfo.CurrentCulture is already set by ABP's RequestLocalizationMiddleware
    var cultureName = CultureInfo.CurrentCulture.Name;

    var configTask = ApplicationConfigurationAppService.GetAsync(
        new ApplicationConfigurationRequestOptions { IncludeLocalizationResources = false }
    );

    var localizationTask = ApplicationLocalizationClientProxy.GetAsync(
        new ApplicationLocalizationRequestDto
        {
            CultureName = cultureName,
            OnlyDynamics = true
        }
    );

    await Task.WhenAll(configTask, localizationTask);

    var config = await configTask;
    config.Localization.Resources = (await localizationTask).Resources;

    return config;
}

Measured performance impact

Tested on a Blazor Server tiered application (ABP 10.0.0 Commercial, .NET 10, localhost).

Cold cache (first request after app start)

| Metric | Sequential (before) | Parallel (after) | Savings | |--------|---------------------|-------------------|---------| | application-configuration | 124ms | 117ms | — | | application-localization | 208ms | 237ms | — | | Total wall time | 332ms (sum) | 238ms (max) | ~94ms |

Warm cache miss (authenticated user, new cache key)

| Metric | Sequential (before) | Parallel (after) | Savings | |--------|---------------------|-------------------|---------| | application-configuration | 95ms | 116ms | — | | application-localization | 73ms | 120ms | — | | Total wall time | 168ms (sum) | 121ms (max) | ~47ms |

Note: In production environments with higher latency to the API host, the savings scale proportionally. If the shorter call takes 200ms in production, you save ~200ms per cache miss.

Impact per page load

Each page load triggers this on cache miss during both prerender and interactive phases. Savings compound:

  • Cold start (no cache): ~120ms × 2 phases = ~240ms saved
  • Warm cache miss (new user/session): ~50–120ms saved

Why this is safe

  1. CultureInfo.CurrentCulture is guaranteed correct: ABP's RequestLocalizationMiddleware runs early in the pipeline and sets the culture before any application code executes. The MvcCachedApplicationConfigurationClient is invoked later (during Razor page rendering or Blazor circuit initialization), so the culture is already set.

  2. No behavioral change: The localization endpoint receives the exact same CultureName — we're just reading it from a different (but equivalent) source.

  3. No API contract change: Both endpoints are called with the same parameters as before.

  4. Cache key unchanged: The distributed cache key generation (MvcCachedApplicationConfigurationClientHelper.CreateCacheKey) is not affected.

Affected scenarios

  • Tiered Blazor Server — primary beneficiary (highest frequency of cache misses)
  • Tiered MVC — also benefits
  • Non-tiered / monolith — not affected (doesn't use MvcCachedApplicationConfigurationClient)

Workaround

Until this is addressed in ABP, applications can replace the service by implementing ICachedApplicationConfigurationClient directly with [Dependency(ReplaceServices = true)]:

[ExposeServices(typeof(ICachedApplicationConfigurationClient))]
[Dependency(ReplaceServices = true)]
public class ParallelCachedApplicationConfigurationClient
    : ICachedApplicationConfigurationClient, ITransientDependency
{
    // ... (full implementation available on request)
}

Important: The module containing this replacement must declare [DependsOn(typeof(AbpAspNetCoreMvcClientModule))] to ensure it loads after ABP's default registration.

Environment

  • ABP Framework version: 10.0.0
  • ABP Commercial version: 10.0.0
  • .NET version: 10.0
  • Hosting: Blazor Server (tiered architecture)
  • Database: MongoDB (not relevant to this issue)
Showing 1 to 10 of 32 entries
Boost Your Development
ABP Live Training
Packages
See Trainings
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v10.8.0-preview. Updated on September 28, 2026, 11:44
1
ABP Assistant
🔐 You need to be logged in to use the chatbot. Please log in first.