The flow on your AuthServer is actually succeeding (authorization code + token). The failure happens after that, when the Blazor WebApp’s OpenIdConnectHandler tries to call /connect/userinfo:
The userinfo request was rejected because the mandatory 'access_token' parameter was missing.OpenIdConnectHandler.GetUserInformationAsync(...) -> HttpRequestException 401 UnauthorizedSo:
GetClaimsFromUserInfoEndpoint = true./connect/userinfo (most commonly because tokens are not saved).There are two practical ways to fix this for a Blazor WebApp with ABP/OpenIddict.
In ABP with OpenIddict, the standard user claims (subject, name, email, roles, etc.) are already available in the ID token, as long as you request the right scopes. In most cases you don’t need /connect/userinfo at all.
Configure your Blazor WebApp authentication like this (Program.cs / Startup):
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
options.Authority = configuration["AuthServer:Authority"]; // e.g. https://auth.staging.myproject.io
options.RequireHttpsMetadata = true;
options.ClientId = "BlazorWebApp";
options.ClientSecret = configuration["AuthServer:ClientSecret"]; // your client secret
options.ResponseType = OpenIdConnectResponseType.Code;
options.UsePkce = true;
// Tokens can still be saved if you want to call APIs later
options.SaveTokens = true;
// IMPORTANT: disable UserInfo call to avoid your 401 error
options.GetClaimsFromUserInfoEndpoint = false;
// Request the scopes that ABP/OpenIddict uses to put claims into id_token
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("phone");
options.Scope.Add("roles");
options.Scope.Add("offline_access");
// Plus any API scopes you need, e.g.:
options.Scope.Add("AuthServer");
options.Scope.Add("IdentityService");
options.Scope.Add("AdministrationService");
options.Scope.Add("SaasService");
options.Scope.Add("AuditLoggingService");
options.Scope.Add("GdprService");
options.Scope.Add("ChatService");
});
Also ensure that in your AuthServer/OpenIddict client definition for "BlazorWebApp" the same scopes are allowed.
With GetClaimsFromUserInfoEndpoint = false, the handler will not call /connect/userinfo, so the missing_token / 401 error disappears, and the Blazor WebApp uses the claims in id_token.
If you explicitly want to use /connect/userinfo:
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
"BlazorWebApp" client in AuthServer:
But since ABP/OpenIddict already includes the usual claims in id_token, Option A is usually simpler and more robust, especially when migrating from Blazor WASM to Blazor WebApp.
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.
You’re running into two different, but related, problems:
Below is what is confirmed to work with ABP + OpenIddict in multi-tenant setups and should address your two concrete follow‑up issues.
invalid_grant when not setting ctx.Options.ClientIdThis OpenIddict error:
{ "error": "invalid_grant",
"error_description": "The specified authorization code cannot be used by this client application.",
"error_uri": "https://documentation.openiddict.com/errors/ID2069"
}
happens when the client ID used at the token endpoint doesn’t match the one that got the authorization code.
In the auth code flow with OpenIdConnect handler:
/connect/authorize) uses a client_id that comes from:
OpenIdConnectOptions.ClientId by default, unless overridden by ProtocolMessage.ClientId on redirect./connect/token) always uses OpenIdConnectOptions.ClientId (the options’ value), not the ProtocolMessage you set during redirect.That means:
ClientId only on ProtocolMessage (e.g., ctx.ProtocolMessage.ClientId) but keep options.ClientId unchanged, authorize and token endpoints will use different client IDs:
invalid_grant / ID2069.For multi-tenant with per-tenant ClientId, you have 2 realistic options:
With one OpenIdConnect scheme, you can only have one actual ClientId that participates correctly in both authorize and token stages (because options.ClientId is global to the scheme). You may alter authority/issuer/redirect dynamically, but not the client id per tenant without hacks.
So if you need per-tenant domain but same client id (e.g., a single OpenIddict application with wildcard redirect URIs), you can:
options.ClientId fixed.AbpOpenIddictWildcardDomainOptions) in AuthServer to accept multiple domains.ctx.ProtocolMessage.IssuerAddress based on tenant; don’t touch ClientId.This avoids invalid_grant.
If each tenant truly must have its own ClientId, the clean way is:
ClientId and Authority.Pattern:
// Configure all known (static) tenants
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = "oidc-default"; // or dynamically choose in code
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect("oidc-tenantA", options =>
{
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.Authority = "https://auth-for-tenantA.example.com";
options.ClientId = "tenantA_client";
options.ResponseType = "code";
// ...
})
.AddOpenIdConnect("oidc-tenantB", options =>
{
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.Authority = "https://auth-for-tenantB.example.com";
options.ClientId = "tenantB_client";
options.ResponseType = "code";
// ...
});
Then:
return Challenge(
new AuthenticationProperties { RedirectUri = "/" },
$"oidc-{currentTenantName}");
This way, each scheme’s options.ClientId is consistent across authorize and token requests. You can still compute Authority from your per‑tenant host mapping.
Given your constraints (no DB on Blazor server, fully different domains), this is usually the most robust approach.
Invalid_issuer only for the custom controller + YARPFacts from your description:
Invalid_issuer.Owl.TokenWildcardIssuerValidator) and it is called for app services but not for this custom controller.There are two sides to this:
Base class doesn’t matter. A controller that derives from a 3rd‑party base class works fine with [Authorize], as long as:
IssuerValidator) applies.You need to verify:
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.Authority = configuration["AuthServer:Authority"]; // maybe wildcard format
options.RequireHttpsMetadata = true;
options.TokenValidationParameters.ValidateIssuer = true;
options.TokenValidationParameters.IssuerValidator =
TokenWildcardIssuerValidator.IssuerValidator; // or your own
});
[Authorize] // DO NOT override AuthenticationSchemes here unless necessary
[Route("api/third-party")]
public class MyThirdPartyController : Required3rdPartyBaseController
{
// ...
}
If you write:
[Authorize(AuthenticationSchemes = "Bearer")]
but your AddJwtBearer is registered only as the default scheme (or under a different name), your custom controller might end up using a different bearer scheme without the IssuerValidator. Result: different behavior versus ABP controllers.
To prove this, log the user in that controller:
[HttpGet("debug-auth")]
public IActionResult DebugAuth()
{
var user = HttpContext.User;
return Ok(new {
authenticated = user.Identity?.IsAuthenticated,
name = user.Identity?.Name,
authType = user.Identity?.AuthenticationType
});
}
If authenticated is false, or authType doesn’t match your expected scheme, that’s where the mismatch is.
In ABP, all controllers (ABP or your own) share the same authentication pipeline as long as you don’t override schemes per-controller or per-policy.
The Invalid_issuer you mentioned is almost certainly thrown by the downstream service, not your ABP host, because:
That means:
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = true;
options.TokenValidationParameters.ValidateIssuer = true;
// Either a list of issuers:
options.TokenValidationParameters.ValidIssuers = new[]
{
"https://tenantA-auth.example.com/",
"https://tenantB-auth.example.com/"
};
// Or the same custom validator you registered in the ABP host:
options.TokenValidationParameters.IssuerValidator =
TokenWildcardIssuerValidator.IssuerValidator; // or your custom implementation
});
If you don’t control the downstream service (3rd‑party), that service must be configured to trust your OpenIddict issuer(s); otherwise, no amount of work in your ABP host will fix its Invalid_issuer.
With YARP:
Authorization header is forwarded.ReverseProxy config.Example route:
"Routes": {
"TenantService": {
"ClusterId": "TenantCluster",
"Match": {
"Path": "/api/thirdparty/{**catch-all}"
}
// No header transforms that strip Authorization
}
}
Make sure there’s no transform like:
"Transforms": [
{ "RequestHeadersCopy": "false" }
]
without then explicitly re‑adding Authorization.
For per-tenant ClientId:
AbpOpenIddictWildcardDomainOptions) and domain‑based tenant resolution only. Then you don’t change ClientId at all and avoid invalid_grant.ClientId/Authority.options.ClientId at runtime for a single scheme; it will always mismatch on the token endpoint at some point.For the custom controller + YARP:
AuthenticationSchemes unless you duplicate the full JwtBearer config).DefaultAuthenticateScheme and DefaultChallengeScheme).Owl.TokenWildcardIssuerValidator).Multi-tenancy and tenant resolution:
https://abp.io/docs/latest/framework/architecture/multi-tenancy
OpenIddict integration in ABP (incl. wildcard domain support):
https://abp.io/docs/latest/modules/openiddict
Wildcard issuer validator library (often used with multi-tenant issuers):
https://github.com/maliming/Owl.TokenWildcardIssuerValidator
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.
The behavior you’re seeing is expected:
OpenIdConnectOptions is registered as a singleton per scheme.ctx.Options.ClientId / ctx.Options.Authority inside an event (e.g. OnRedirectToIdentityProvider), you are mutating that singleton.To make ClientId and Authority per-request and per-tenant:
ctx.Options.* in the event.ctx.ProtocolMessage, which is specific to the current redirect.Example:
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options;
using Volo.Abp.MultiTenancy;
public class TenantOpenIdConnectEvents : IConfigureNamedOptions<OpenIdConnectOptions>
{
private readonly IConfiguration _configuration;
private readonly ICurrentTenant _currentTenant;
public TenantOpenIdConnectEvents(
IConfiguration configuration,
ICurrentTenant currentTenant)
{
_configuration = configuration;
_currentTenant = currentTenant;
}
public void Configure(string name, OpenIdConnectOptions options)
{
if (name != "oidc") return; // your scheme name
options.Events ??= new OpenIdConnectEvents();
var existingRedirect = options.Events.OnRedirectToIdentityProvider;
options.Events.OnRedirectToIdentityProvider = async context =>
{
// Determine tenant (you said URL-based tenant resolution already works)
var tenantName = _currentTenant.Name; // or from Host header if needed
// Get per-tenant settings from configuration (or any store) –
// no DB needed on the Blazor server, just a mapping.
// appsettings.json:
// "TenantAuth": {
// "tenantA": { "Authority": "https://auth-a.example.com", "ClientId": "ClientA" },
// "tenantB": { "Authority": "https://auth-b.example.com", "ClientId": "ClientB" }
// }
var section = _configuration.GetSection("TenantAuth");
if (!tenantName.IsNullOrWhiteSpace())
{
var tenantSection = section.GetSection(tenantName!);
var authority = tenantSection["Authority"];
var clientId = tenantSection["ClientId"];
if (!string.IsNullOrEmpty(authority))
{
// Set per-request issuer address for this redirect
context.ProtocolMessage.IssuerAddress =
authority.TrimEnd('/') + "/connect/authorize";
}
if (!string.IsNullOrEmpty(clientId))
{
// Set per-request client id – DO NOT touch options.ClientId here
context.ProtocolMessage.ClientId = clientId;
}
}
if (existingRedirect != null)
{
await existingRedirect(context);
}
};
}
public void Configure(OpenIdConnectOptions options)
=> Configure(Options.DefaultName, options);
}
Register:
services.AddSingleton<IConfigureOptions<OpenIdConnectOptions>, TenantOpenIdConnectEvents>();
Key points:
options.ClientId / options.Authority stay as a default (e.g. host or fallback).context.ProtocolMessage.ClientIdcontext.ProtocolMessage.IssuerAddressThis pattern works fine even when the Blazor server has no DB access; just keep a configuration-based map from tenant (or domain) →
Authority/ClientId.
Relevant docs for how ABP wires OIDC (for reference of where these events fit):
A few important clarifications:
AbpController / ApplicationService base classes.[Authorize] works on any controller; only the authentication configuration and attributes matter.AddAuthentication / AddJwtBearer or OpenIddict validation.2.1. Ensure the same authentication scheme is used
Check:
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// your issuer / audience settings
options.RequireHttpsMetadata = true;
// Custom issuer validator – MUST be here
options.TokenValidationParameters.ValidAudience = "your-api";
options.TokenValidationParameters.IssuerValidator = (issuer, token, parameters) =>
{
// your custom logic, e.g. per-tenant issuers
// return a valid issuer string or throw
return issuer;
};
});
[Authorize] // IMPORTANT: do NOT override the scheme unless you really need to
public class ThirdPartyController : ThirdPartyBaseController
{
// ...
}
Common pitfalls:
[Authorize(AuthenticationSchemes = "Bearer")] while you configured the custom IssuerValidator only on the default scheme (JwtBearerDefaults.AuthenticationScheme), or vice versa.AddJwtBearer(...) calls with different scheme names and only configuring the IssuerValidator on one.Make sure:
[Authorize] on your custom controller is the one where you attached the custom IssuerValidator.2.2. YARP forwarding and Invalid_issuer from the downstream service
From your description:
401, 302, sometimes Invalid_issuer.This means:
Invalid_issuer occurs.Checklist:
Authorization header as-is:services.AddReverseProxy()
.LoadFromConfig(Configuration.GetSection("ReverseProxy"));
// default YARP preserves Authorization; only custom transforms may strip it
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = true;
options.TokenValidationParameters.ValidateIssuer = true;
// Either allow all tenant issuers:
options.TokenValidationParameters.ValidIssuers = new[]
{
"https://auth-tenantA.example.com/",
"https://auth-tenantB.example.com/",
// ...
};
// Or use the same custom validator you use in your ABP host:
options.TokenValidationParameters.IssuerValidator = (issuer, token, parameters) =>
{
// same logic as ABP host, e.g. look up tenant by issuer
return issuer;
};
});
If you’re using OpenIddict validation instead of pure JwtBearer, configure it similarly on that service.
AddJwtBearer("SomeScheme", ...)) while callers send a token to the default Bearer scheme without the same IssuerValidator.2.3. Why the custom validator is “never called” for the custom controller
There are only a few reasons this can happen inside the same host:
[Authorize] / default scheme).[AllowAnonymous] (not your case) or uses a policy mapped to a different scheme.Action steps:
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(...);
[Authorize]
[ApiController]
[Route("api/thirdparty")]
public class ThirdPartyController : ThirdPartyBaseController
{
private readonly IHttpContextAccessor _httpContextAccessor;
public ThirdPartyController(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
[HttpGet("debug-auth")]
public IActionResult DebugAuth()
{
var user = _httpContextAccessor.HttpContext!.User;
// Inspect claims/principal to confirm which scheme authenticated it
return Ok(new { authenticated = user.Identity?.IsAuthenticated, name = user.Identity?.Name });
}
}
If IsAuthenticated is false here while it’s true on app services for the same token, that confirms the scheme mismatch problem.
Per-tenant OIDC config in Blazor Web App:
ctx.Options.ClientId or .Authority inside events; that changes them globally.ctx.ProtocolMessage.ClientIdctx.ProtocolMessage.IssuerAddressICurrentTenant or domain, and keep a config mapping for each tenant’s Authority / ClientId.Custom controller + YARP + Invalid_issuer:
AbpController vs 3rd-party base) is irrelevant to authentication.Invalid_issuer you see is thrown by the downstream tenant-specific service; configure the same issuer validation (ValidIssuers or IssuerValidator) there.Authorization header and that the downstream API’s auth configuration matches the expectations of the token’s issuer.Sources (for patterns used above):
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.
MvcCachedApplicationConfigurationClientHelper.CreateCacheKey(CurrentUser) builds a cache key per user (tenant + user id + culture).MvcCurrentApplicationConfigurationCacheResetEventHandler.HandleEventAsync does:public virtual async Task HandleEventAsync(CurrentApplicationConfigurationCacheResetEventData eventData)
{
await Cache.RemoveAsync(CreateCacheKey());
}
protected virtual string CreateCacheKey()
{
return MvcCachedApplicationConfigurationClientHelper.CreateCacheKey(CurrentUser);
}
CurrentUser here is the user who is executing the current request (the one who publishes CurrentApplicationConfigurationCacheResetEventData).So when your admin user changes settings/permissions and triggers the reset event, the handler removes only the admin’s cache entry, because CurrentUser is admin.
Other users’ cache keys remain in Redis and expire only when the cache timeout (5 minutes) is reached. That matches exactly what you’re seeing: “deleted is always admin, other users can’t be deleted, only wait 5 minutes timeout”.
This is by design: CurrentApplicationConfigurationCacheResetEventData is meant to reset the current user’s application configuration, not all users.
/api/abp/application-configuration again and refreshes its cache, orChoose depending on your requirement:
If it is acceptable that other users see updated permissions after at most 5 minutes (or after they refresh / re-login), then you don’t need any change. This is ABP’s default behavior.
You can lower the cache duration if 5 minutes is too long by customizing distributed cache options for ApplicationConfigurationDto, but that increases load:
Configure<AbpDistributedCacheOptions>(options =>
{
options.CacheConfigurators.Add(cacheName =>
{
if (cacheName == typeof(ApplicationConfigurationDto).FullName)
{
return entryOpts =>
{
// e.g. 1 minute
entryOpts.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
};
}
return null;
});
});
If you prefer always fresh config and can accept more backend traffic:
Configure<AbpAspNetCoreMvcClientOptions>(options =>
{
options.CacheApplicationConfiguration = false;
});
Then MvcCurrentApplicationConfigurationCacheResetEventHandler effectively becomes unnecessary because nothing is cached.
If you really need “when admin changes something, all users must see it immediately”:
Keep using distributed cache, but:
That would require:
MvcCachedApplicationConfigurationClient (or helper) to use your own cache key strategy.This is not provided out-of-the-box; you must implement and test it yourself.
MvcCurrentApplicationConfigurationCacheResetEventHandler.HandleEventAsync always uses the CurrentUser of the request that fired the event.admin’s cache entries are removed.If you need immediate reflection for other users, use option B (disable cache) or implement a custom global invalidation strategy as in option C.
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.
[Authorize(ChildProjectPermissions.Something)] on ChildProject endpoints…as long as both API hosts validate the same access token correctly and the permissions are defined/seeded consistently.
Your 401 means the ChildProject is not accepting the token (authentication fails) before it even reaches the ABP permission system.
In ChildProject.HttpApi.Host:
appsettings.json
"AuthServer": {
"Authority": "https://MAIN-PROJECT-AUTH-URL", // MainProject Auth server URL
"RequireHttpsMetadata": "false", // true in production with HTTPS
"SwaggerClientId": "ChildProject_Swagger" // optional, for swagger auth
},
"Jwt": {
"Authority": "https://MAIN-PROJECT-AUTH-URL",
"Audience": "ChildProject" // see step 3
}
(or follow the same AuthServer section structure you already use in MainProject HttpApi.Host, just changing the Audience if needed.)
HttpApiHostModule – ConfigureServices
public override void ConfigureServices(ServiceConfigurationContext context)
{
var configuration = context.Services.GetRequiredService<IConfiguration>();
ConfigureAuthentication(context, configuration);
}
private void ConfigureAuthentication(ServiceConfigurationContext context, IConfiguration configuration)
{
context.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = configuration["AuthServer:Authority"];
options.RequireHttpsMetadata =
Convert.ToBoolean(configuration["AuthServer:RequireHttpsMetadata"]);
options.Audience = "ChildProject"; // must match the audience of the token
// (temporarily you can disable audience validation to test)
// options.TokenValidationParameters.ValidateAudience = false;
});
}
Startup pipeline (OnApplicationInitialization)
Make sure the middleware order includes both authentication and authorization:
public override void OnApplicationInitialization(ApplicationInitializationContext context)
{
var app = context.GetApplicationBuilder();
app.UseCorrelationId();
app.UseRouting();
app.UseCors();
app.UseAuthentication(); // BEFORE multi-tenancy & authorization
app.UseAbpRequestLocalization();
app.UseMultiTenancy();
app.UseAuthorization();
app.UseSwagger();
app.UseAbpSwaggerUI(options => { ... });
app.UseAuditing();
app.UseConfiguredEndpoints();
}
If UseAuthentication() is missing or in wrong order, you will also constantly get 401.
The token you get from MainProject must be accepted by ChildProject:
In MainProject Auth Server (OpenIddict data seed / identity seed contributor), you must define an API scope / resource that represents ChildProject, for example "ChildProject".
Add that scope to the client that obtains the token (Angular, Swagger, Postman client, etc.).
When you request a token, request that scope:
ChildProject in the scopes.scope=... ChildProject.The resulting token must either:
aud = "ChildProject", and you set options.Audience = "ChildProject", orValidateAudience = false (for testing only) so that any token from the same authority is accepted.If the aud in the token doesn’t match what ChildProject expects, you will see 401 from the JWT middleware, even though the same token works on the MainProject API Host.
To be able to use:
[Authorize(xxxWebAPIPermissions.WCFService.Default)]
public class MyChildController : AbpController
{
...
}
do the following:
Permission definition provider
Put your xxxWebAPIPermissions and the corresponding PermissionDefinitionProvider in a shared application contracts project that is referenced by:
Module dependency
In ChildProjectApplicationContractsModule (or HttpApi module), depend on the module that contains the permission provider:
[DependsOn(
typeof(AbpAuthorizationAbstractionsModule),
typeof(MySharedPermissionsModule) // where your PermissionDefinitionProvider lives
)]
public class ChildProjectApplicationContractsModule : AbpModule
{
}
If authentication is correct and the user really has the permission, then failing [Authorize(…)] would give 403 Forbidden, not 401.
Since you get 401, it’s almost certainly token validation (authentication) in ChildProject, not the permission policy itself.
On ChildProject HttpApi.Host:
UseAuthentication() is present and before UseAuthorization().AddAuthentication().AddJwtBearer() is configured to use MainProject AuthServer Authority.Audience and token’s aud (or ValidateAudience = false) are aligned.Once these are done, you can:
Authorization: Bearer <token>.[Authorize(xxxWebAPIPermissions.WCFService.Default)] will work as expected.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.
Verify you run a single-origin Blazor Server host
Don’t add the “access_token to Authorization” middleware
Ensure ABP antiforgery defaults aren’t overridden
Confirm ChatSignalRModule is active in the same host
using Volo.Abp.Modularity;
using Volo.Chat.Blazor.Server;
[DependsOn(typeof(ChatBlazorServerModule))]
public class YourAppBlazorModule : AbpModule
{
}
Check app startup logs to see endpoint mapping for /signalr-hubs/chat.
Check SameSite and HTTPS
Known ASP.NET Core SignalR option on Blazor Server
Configure<Microsoft.AspNetCore.SignalR.HubOptions>(options =>
{
options.DisableImplicitFromServicesParameters = true;
});
This is a general ASP.NET Core quirk; not normally required for the Chat module, but harmless if added.
If you align to a single Blazor Server host (no gateway), leave cookie auth only, and keep ABP antiforgery defaults, the negotiate POST will succeed and the chat toolbar component will connect without the antiforgery 400.
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.
Your error indicates the antiforgery protection blocked the SignalR negotiate request. In ABP, antiforgery is automatically enforced for browser-originated POSTs, and the client sends the RequestVerificationToken header automatically when using ABP’s HTTP pipeline. However, SignalR’s negotiate POST (from the chat client component) must also include the antiforgery token. In Blazor Server, this works when:
Do the following checks/fixes:
Ensure correct module dependencies in your Blazor Server host (single host)
Keep cookie auth only (no bearer conversion)
Do not disable ABP antiforgery
Same-site and domain consistency
Verify hub mapping and startup
Permissions and Feature are already set (you did this), but confirm the logged-in user stays authenticated during the negotiate call (no redirect/401). If your layout triggers a redirect for unauthenticated negotiate, the antiforgery error can follow.
After aligning the above, the negotiate POST should include antiforgery automatically in Blazor Server and succeed with cookie auth, and the chat toolbar component will connect without the 400.
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.
Install the correct packages into the Blazor Server host
Add module dependencies
using Volo.Abp.Modularity;
using Volo.Chat.Blazor;
using Volo.Chat.SignalR;
[DependsOn(
typeof(ChatBlazorModule),
typeof(ChatSignalRModule)
)]
public class YourAppBlazorModule : AbpModule
{
}
using Volo.Abp.Modularity;
using Volo.Chat;
using Volo.Chat.SignalR;
[DependsOn(
typeof(ChatHttpApiModule),
typeof(ChatApplicationModule),
typeof(ChatSignalRModule)
)]
public class YourAppHttpApiHostModule : AbpModule
{
}
Enable the Chat feature (tenant/host)
Grant Chat permissions to the user/role
Ensure SignalR hub is reachable
app.Use(async (ctx, next) =>
{
var accessToken = ctx.Request.Query["access_token"];
if (!string.IsNullOrEmpty(accessToken) &&
ctx.Request.Path.StartsWithSegments("/signalr-hubs/chat"))
{
ctx.Request.Headers["Authorization"] = "Bearer " + accessToken;
}
await next();
});
Blazor Server toolbar visibility
Database/migration
Common pitfalls
If you complete the steps above, the chat icon should appear in the top toolbar and the API calls to /api/chat/... and SignalR to /signalr-hubs/chat should succeed.
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.
A) Remove the tenant switch UI on the Account layout (server-side) for LeptonX
B) Add a Tenant Name textbox to the Angular login component and send it with the login request
Example steps:
Create a form control and UI for tenant input:
Set the tenant header before login:
Example (illustrative, place inside your custom login component):
import { Component, inject } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { RestService } from '@abp/ng.core';
import { AccountService } from '@abp/ng.account/public/config'; // or your own auth call
@Component({
selector: 'app-custom-login',
template: `
<form [formGroup]="form" (ngSubmit)="login()">
<label>Tenant Name</label>
<input formControlName="tenantName" type="text" autocomplete="organization" />
<label>Username</label>
<input formControlName="username" type="text" autocomplete="username" />
<label>Password</label>
<input formControlName="password" type="password" autocomplete="current-password" />
<button type="submit" [disabled]="form.invalid || submitting">Login</button>
</form>
`
})
export class CustomLoginComponent {
private fb = inject(FormBuilder);
private rest = inject(RestService);
private account = inject(AccountService); // if you use the ABP Account endpoints
submitting = false;
form = this.fb.group({
tenantName: [''],
username: ['', Validators.required],
password: ['', Validators.required],
rememberMe: [false],
});
private setTenantCookie(name: string | null | undefined) {
// When empty, clear cookie; when not empty, set cookie.
const cookieName = 'abp.tenant';
const encoded = name ? encodeURIComponent(name) : '';
const expires = new Date();
expires.setDate(expires.getDate() + 30);
if (encoded) {
document.cookie = `${cookieName}=${encoded}; path=/; expires=${expires.toUTCString()}; SameSite=Lax`;
} else {
// clear
document.cookie = `${cookieName}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`;
}
}
async login() {
if (this.form.invalid) return;
this.submitting = true;
try {
const tenantName = (this.form.value.tenantName || '').trim();
// Ensure the backend resolves the request under this tenant:
this.setTenantCookie(tenantName || null);
// If you call login via a custom HTTP request, you can also set __tenant header:
// Example:
// await this.rest.request({
// method: 'POST',
// url: '/api/account/login',
// body: { userName: this.form.value.username, password: this.form.value.password },
// headers: tenantName ? { __tenant: tenantName } : {}
// }).toPromise();
// If using ABP AccountService (OIDC flow), just trigger sign-in flow after setting cookie
await this.account.navigateToLogin(); // or start the flow used in your app
} finally {
this.submitting = false;
}
}
}
Notes:
C) Ensure tenant resolution is configured to accept cookie/header
Example to ensure defaults (in your HttpApiHostModule or AuthServer):
using Volo.Abp.MultiTenancy;
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpTenantResolveOptions>(options =>
{
// Keep default resolvers, or ensure header/cookie are present
// options.TenantResolvers.Clear();
// options.TenantResolvers.Add(new HeaderTenantResolveContributor());
// options.TenantResolvers.Add(new CookieTenantResolveContributor());
// options.TenantResolvers.Add(new QueryStringTenantResolveContributor());
});
}
Outcome
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.
What they do
Why ABP moved from CheckTokenExpiration to IntrospectAccessToken for Blazor Server templates
When to use which
How to configure in Blazor Server (ABP 8.x)
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme, options =>
{
// Preferred in v8.x:
options.IntrospectAccessToken(oidcAuthenticationScheme: OpenIdConnectDefaults.AuthenticationScheme);
// If you cannot introspect, fallback:
// options.CheckTokenExpiration(); // optional: pass advance/validationInterval
});
}
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.