Recommended approach for ABP v8.x AuthServer (OpenIddict-based):
Example: Razor Pages filter registered in AuthServer
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.WebUtilities;
using System.Web;
using Volo.Abp.OpenIddict.Applications;
using Volo.Abp.DependencyInjection;
public class EnforceReturnUrlFilter : IAsyncPageFilter, ITransientDependency
{
private readonly IOpenIddictApplicationRepository _appRepo;
public EnforceReturnUrlFilter(IOpenIddictApplicationRepository appRepo)
{
_appRepo = appRepo;
}
public Task OnPageHandlerSelectionAsync(PageHandlerSelectedContext context) => Task.CompletedTask;
public async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
{
var http = context.HttpContext;
var path = http.Request.Path.Value?.ToLowerInvariant() ?? string.Empty;
// Pages we want to restrict when unauthenticated:
var isAccountEntry = path.Equals("/account/login", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/account/register", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/account/forgotpassword", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/account", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/", StringComparison.OrdinalIgnoreCase);
if (isAccountEntry && !http.User.Identity?.IsAuthenticated == true)
{
var returnUrl = http.Request.Query["returnUrl"].ToString();
// Require a returnUrl
if (string.IsNullOrWhiteSpace(returnUrl))
{
context.Result = new ForbidResult(); // triggers challenge in most setups; you can also Redirect to a safe page
return;
}
// Validate that returnUrl ultimately maps to a registered client redirect URI
// Typical returnUrl on login is '/connect/authorize?...redirect_uri=...'
var absoluteReturn = returnUrl.StartsWith("/", StringComparison.Ordinal) ? $"{http.Request.Scheme}://{http.Request.Host}{returnUrl}" : returnUrl;
var uri = new Uri(absoluteReturn, UriKind.Absolute);
var query = QueryHelpers.ParseQuery(uri.Query);
if (!query.TryGetValue("client_id", out var clientId) ||
!query.TryGetValue("redirect_uri", out var redirectUri))
{
context.Result = new ForbidResult();
return;
}
var app = await _appRepo.FindByClientIdAsync(clientId!);
if (app == null || app.RedirectUris == null || !app.RedirectUris.Contains(redirectUri!))
{
context.Result = new ForbidResult();
return;
}
}
await next();
}
}
Register the filter in your AuthServer Web module:
using Microsoft.AspNetCore.Mvc.RazorPages;
using Volo.Abp.Modularity;
public class AuthServerWebModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<RazorPagesOptions>(options =>
{
// Authorize everything by default
options.Conventions.AuthorizeFolder("/");
// Allow anonymous access to Account pages because they must be reachable for the OIDC flow,
// but guarded by our EnforceReturnUrlFilter.
options.Conventions.AllowAnonymousToFolder("/Account");
options.Conventions.AllowAnonymousToPage("/Error");
// Add our enforcement filter globally
options.Conventions.AddFolderApplicationModelConvention("/",
model => model.Filters.Add(new ServiceFilterAttribute(typeof(EnforceReturnUrlFilter))));
});
}
}
Behavior:
Alternative minimal option:
Notes:
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.
Add this to your Web module’s ConfigureServices:
using Microsoft.AspNetCore.Mvc.RazorPages;
using Volo.Abp.Modularity;
[DependsOn(typeof(AbpAspNetCoreMvcUiThemeSharedModule))] // typical Web module deps
public class YourProjectWebModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<RazorPagesOptions>(options =>
{
// Option A: Authorize only the index page
options.Conventions.AuthorizePage("/Index");
// Option B (broader): Authorize the entire site, then allow anonymous only for account pages you need
// options.Conventions.AuthorizeFolder("/");
// options.Conventions.AllowAnonymousToAreaPage("Account", "/Login");
// options.Conventions.AllowAnonymousToAreaPage("Account", "/Register");
// options.Conventions.AllowAnonymousToAreaPage("Account", "/ForgotPassword");
// options.Conventions.AllowAnonymousToAreaPage("Account", "/ResetPassword");
// options.Conventions.AllowAnonymousToAreaPage("Account", "/EmailConfirmation");
// options.Conventions.AllowAnonymousToAreaPage("Account", "/TwoFactor");
// options.Conventions.AllowAnonymousToPage("/Error");
});
}
}
Notes:
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.
If you have ABP Commercial (ABP Studio/Business+ license) installed, the new ABP CLI defaults to the unified template system and may include Pro modules when you create a solution via Studio or the new CLI. Use one of these verified ways to create a Free (non‑Pro) Blazor Server project:
Ensure you are using the correct CLI:
If you already created a solution that contains Pro modules and want to remove them:
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.
Fix it by aligning the API resources/audiences:
Ensure the Hangfire client requests an API scope that maps to a resource with a known audience.
Recommended OIDC client options for Hangfire MVC:
services.AddAuthentication()
.AddCookie()
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
options.Authority = configuration["AuthServer:Authority"];
options.RequireHttpsMetadata = true; // behind ingress, ensure ForwardedHeaders or force https if needed
options.ClientId = configuration["AuthServer:HangfireClientId"];
options.ClientSecret = configuration["AuthServer:HangfireClientSecret"];
options.ResponseType = OpenIdConnectResponseType.Code;
options.UsePkce = true;
options.SaveTokens = true;
// If you DON’T need /userinfo, disable it to avoid the "valid audience" check there:
options.GetClaimsFromUserInfoEndpoint = false;
// Otherwise, keep it true and request a scope that maps to a resource with accepted audience:
// options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("phone");
options.Scope.Add("offline_access");
options.Scope.Add("HangfireService"); // must be a registered API scope/resource
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
});
Register the API resource/scope on AuthServer properly:
If you don’t need the UserInfo endpoint at all:
Reverse proxy/K8s scheme consistency:
Summary of minimal changes to stop the 500 now:
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.
In Kubernetes, the browser reaches the Hangfire Dashboard via the public host (ingress), but your Hangfire app is sending an internal host (treva-hangfire) in the OIDC request. OpenIddict requires an exact string match.
Apply all steps below:
Fix the redirect_uri to use the public HTTPS host of the Hangfire Dashboard
Register the exact public callback(s) on the Hangfire client in AuthServer
Keep HTTPS everywhere
Seed the Hangfire OpenIddict client with the correct URIs Example (adjust hosts to your real public domains):
await CreateOrUpdateApplicationAsync(
name: "Hangfire",
type: OpenIddictConstants.ClientTypes.Confidential,
consentType: OpenIddictConstants.ConsentTypes.Implicit,
displayName: "Hangfire Client",
secret: "1q2w3e*",
grantTypes:
[
OpenIddictConstants.GrantTypes.AuthorizationCode,
OpenIddictConstants.GrantTypes.ClientCredentials,
OpenIddictConstants.GrantTypes.RefreshToken,
"pushed_authorization"
],
scopes:
[
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Profile,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Phone,
OpenIddictConstants.Scopes.OfflineAccess,
"roles",
"HangfireService"
],
redirectUris:
[
"https://treva-hangfire.tav.aero/signin-oidc"
],
postLogoutRedirectUris:
[
"https://treva-hangfire.tav.aero/signout-callback-oidc"
]
);
Notes:
For a server-side MVC client, Authorization Code (+ optional PKCE) with Refresh Token is sufficient. You typically don’t need Implicit or Password grants.
PAR is fine; it still requires the redirect_uri to match exactly.
Clear caches and restart AuthServer
Verify the actual value sent
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.
Do all of the following:
Register every real redirect URI used by Hangfire in the remote environment:
Seed the Hangfire OpenIddict client with those exact values (seed order doesn’t matter, but strings must match 1:1 with what the browser will use):
await CreateOrUpdateApplicationAsync(
name: "Hangfire",
type: OpenIddictConstants.ClientTypes.Confidential,
consentType: OpenIddictConstants.ConsentTypes.Implicit,
displayName: "Hangfire Client",
secret: "1q2w3e*",
grantTypes:
[
OpenIddictConstants.GrantTypes.AuthorizationCode,
OpenIddictConstants.GrantTypes.ClientCredentials,
OpenIddictConstants.GrantTypes.RefreshToken,
// Remove Password & Implicit unless you really need them; not required for server-side MVC OIDC
"pushed_authorization"
],
scopes:
[
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Profile,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Phone,
OpenIddictConstants.Scopes.OfflineAccess,
"roles",
"HangfireService"
],
redirectUris:
[
"https://treva-hangfire.tav.aero/signin-oidc",
"https://treva-web.tav.aero/signin-oidc",
"https://treva-webgateway.tav.aero/signin-oidc",
"https://treva-authserver.tav.aero/signin-oidc"
],
postLogoutRedirectUris:
[
"https://treva-hangfire.tav.aero/signout-callback-oidc",
"https://treva-web.tav.aero/signout-callback-oidc",
"https://treva-webgateway.tav.aero/signout-callback-oidc",
"https://treva-authserver.tav.aero/signout-callback-oidc"
]
);
Ensure the Hangfire Dashboard app challenges with the same authority and callback path:
If you enabled PAR (pushed_authorization):
Clear caches after seeding:
Common pitfalls to avoid:
Verify
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.
using Microsoft.Extensions.AI;
using OpenAI;
[DependsOn(
typeof(AbpAIModule), // AI infrastructure
typeof(AiManagementModule) // AI Management module
)]
public class MyProjectWebModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
var configuration = context.Services.GetConfiguration();
context.Services.AddSingleton(new OpenAIClient(configuration["AI:OpenAI:Key"]));
context.Services.AddChatClient(services =>
services.GetRequiredService<OpenAIClient>()
.AsChatClient(configuration["AI:OpenAI:Chat:ModelId"] ?? "gpt-4o-mini"));
}
}
- appsettings.json example:
{
"AI": {
"OpenAI": {
"Key": "YOUR-KEY",
"Chat": { "ModelId": "gpt-4o-mini" }
}
}
}
Current limitations to be aware of (preview):
Roadmap / planning
Custom chat client calling an external API (AI agents)
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
public sealed class HttpForwardingChatClient : IChatClient
{
private readonly HttpClient _http;
private readonly string _endpoint; // e.g. https://customer-api.example.com/agent/chat
public HttpForwardingChatClient(HttpClient http, string endpoint)
{
_http = http;
_endpoint = endpoint;
}
public async Task<ChatCompletion> CompleteAsync(
ChatMessage message,
ChatOptions options = null,
CancellationToken cancellationToken = default)
{
var payload = new
{
input = message.Text,
// optionally map options (system prompt, temperature, tools, etc.)
};
var response = await _http.PostAsJsonAsync(_endpoint, payload, cancellationToken);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ExternalResponse>(cancellationToken: cancellationToken);
return new ChatCompletion(new ChatMessage(Role.Assistant, result?.Output ?? string.Empty));
}
// Implement other IChatClient members if needed by your use-cases (e.g., streaming)
private sealed class ExternalResponse
{
public string Output { get; set; }
}
}
Registering the custom client with ABP AI:
[DependsOn(typeof(AbpAIModule), typeof(AiManagementModule))]
public class MyProjectWebModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
var configuration = context.Services.GetConfiguration();
context.Services.AddHttpClient<HttpForwardingChatClient>(client =>
{
client.BaseAddress = new Uri(configuration["AI:ExternalAgent:BaseUrl"]);
});
context.Services.AddChatClient(sp =>
sp.GetRequiredService<HttpForwardingChatClient>());
}
}
After registration, you can inject IChatClient anywhere (app services, pages, components) and AI Management can treat it similarly to built-in providers, as the integration point is the same abstraction (IChatClient). If your scenario requires streaming, tools/functions, or multi-turn context, implement the corresponding members or map the needed options to your external API.
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.
Background
Part A: Host admin can still see “Management Service” when RequireFeatures is used
public class CarbonFeatureDefinitionProvider : FeatureDefinitionProvider
{
public override void Define(IFeatureDefinitionContext context)
{
var group = context.AddGroup("CarbonFeatures");
group.AddFeature("CarbonFeatures.Categories", defaultValue: "true"); // host-default
group.AddFeature("CarbonFeatures.Categories.Create", defaultValue: "true");
group.AddFeature("CarbonFeatures.Categories.Edit", defaultValue: "true");
group.AddFeature("CarbonFeatures.Categories.Delete", defaultValue: "true");
group.AddFeature("CarbonFeatures.CategoryTypes", defaultValue: "true");
group.AddFeature("CarbonFeatures.CategoryTypes.Create", defaultValue: "true");
group.AddFeature("CarbonFeatures.CategoryTypes.Edit", defaultValue: "true");
group.AddFeature("CarbonFeatures.CategoryTypes.Delete", defaultValue: "true");
}
}
public sealed class HostBypassFeatureChecker : ISimpleStateChecker<PermissionDefinition>
{
public async Task<bool> IsEnabledAsync(SimpleStateCheckerContext<PermissionDefinition> context)
{
var currentTenant = context.ServiceProvider.GetRequiredService<ICurrentTenant>();
if (!currentTenant.IsAvailable) // Host
return true;
var featureChecker = context.ServiceProvider.GetRequiredService<IFeatureChecker>();
// Map permission name to feature name as you need
var featureName = MapPermissionToFeature(context.State.Name);
return await featureChecker.IsEnabledAsync(featureName);
}
private static string MapPermissionToFeature(string permissionName)
{
// Example mapping: adjust to your permission constants
if (permissionName.StartsWith(ManagementServicePermissions.Categories.Default))
return "CarbonFeatures.Categories";
if (permissionName.StartsWith(ManagementServicePermissions.CategoryTypes.Default))
return "CarbonFeatures.CategoryTypes";
return permissionName; // fallback
}
}
// Apply to your permission definitions:
var category = myGroup.AddPermission(ManagementServicePermissions.Categories.Default, L("Permission:Categories"));
category.StateCheckers.Add(new HostBypassFeatureChecker());
This approach keeps the Host menu visible without requiring Host features to be explicitly enabled.
{
path: 'category-types',
component: CategoryTypesComponent,
canActivate: [PermissionGuard],
data: { requiredPolicy: 'ManagementService.CategoryTypes' }
}
- Menu item:
this.context.addItem({
id: 'ManagementService.CategoryTypes',
text: 'Category Types',
icon: 'fa fa-list',
order: 10,
requiredPolicy: 'ManagementService.CategoryTypes'
}, MANAGEMENT_MENU_NAME);
- If you need feature-level hiding too, add a feature check in the menu contributor and skip adding the item when feature is disabled for the current tenant.
2) Do not grant the permission in the tenant’s package if the page must be hidden. If you are generating grants dynamically, verify PermissionGrant table has no rows for the excluded pages. Your observation is correct: once the grants are removed, the menu is hidden provided you use requiredPolicy on the menu item.
3) If you keep using StateCheckers (bypass for host, check feature for tenant), keep menu items bound to those exact permissions. Since ABP UI reads permission states, menu entries disappear for tenants when the feature is disabled.
4) For microservice solutions with dynamic permissions: verify the PermissionDefinition names you bind in Angular match the definitions pushed by that microservice. A mismatch leads to menu items always visible due to missing policy binding.
Part C: Known edge cases and recommendations
Minimal example putting all together
public class ManagementServicePermissionDefinitionProvider : PermissionDefinitionProvider
{
public override void Define(IPermissionDefinitionContext context)
{
var group = context.AddGroup("ManagementService");
var categories = group.AddPermission("ManagementService.Categories", L("Permission:Categories"));
categories.StateCheckers.Add(new HostBypassFeatureChecker()); // host bypass
categories.RequireFeatures("CarbonFeatures.Categories"); // tenant feature
var categoryTypes = group.AddPermission("ManagementService.CategoryTypes", L("Permission:CategoryTypes"));
categoryTypes.StateCheckers.Add(new HostBypassFeatureChecker());
categoryTypes.RequireFeatures("CarbonFeatures.CategoryTypes");
}
}
// route
{ path: 'categories', component: CategoriesComponent, canActivate: [PermissionGuard], data: { requiredPolicy: 'ManagementService.Categories' } }
{ path: 'category-types', component: CategoryTypesComponent, canActivate: [PermissionGuard], data: { requiredPolicy: 'ManagementService.CategoryTypes' } }
// menu
this.context.addItem({
id: 'ManagementService',
text: 'ManagementService',
icon: 'fa fa-book',
order: 30
});
this.context.addItem({
id: 'ManagementService.Categories',
text: 'Categories',
parentId: 'ManagementService',
requiredPolicy: 'ManagementService.Categories'
});
this.context.addItem({
id: 'ManagementService.CategoryTypes',
text: 'Category Types',
parentId: 'ManagementService',
requiredPolicy: 'ManagementService.CategoryTypes'
});
public class CarbonFeatureDefinitionProvider : FeatureDefinitionProvider
{
public override void Define(IFeatureDefinitionContext context)
{
var g = context.AddGroup("CarbonFeatures");
g.AddFeature("CarbonFeatures.Categories", defaultValue: "true");
g.AddFeature("CarbonFeatures.CategoryTypes", defaultValue: "true");
}
}
This approach will:
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.
Please contact us at https://abp.io/contact.
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.
Cause: CMS Kit Blog pages ship their own Bootstrap container/row markup and some generic CSS. In LeptonX for Blazor, the application body already uses a flex/grid layout and spacing utilities. When the blog list and post components render inside the layout, duplicated container/row wrappers and a few global CSS classes from the Blog module can break the page width and push the sidebar/header out of alignment.
Fix 1 — Update LeptonX and CMS Kit to the latest compatible versions for ABP 9.3.x:
Fix 2 — Remove conflicting container wrappers in your CMS Kit page overrides:
<!-- Before -->
<div class="container">
<div class="row">
<div class="col-12">
@* Blog content *@
</div>
</div>
</div>
<!-- After -->
@* Use LeptonX content area; no extra container *@
@* Blog content *@
Fix 3 — Ensure you’re not importing Blog CSS globally:
Fix 4 — Verify LeptonX app layout and content container:
Fix 5 — Clear cached assets:
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.