Hi Team,
We are investigating a multi-tenant authentication approach and would like some guidance on the recommended ABP implementation.
Scenario
We have a single Blazor application and AuthServer.
We would like to support tenant-specific URLs such as:
https://portal.auth.com/tenant1 https://portal.auth.com/tenant2 https://portal.auth.com/tenant3
The tenant should be resolved from the route segment.
Requirements
- Resolve the tenant from the route parameter.
- Allow each tenant to have its own authentication configuration.
- Support different authentication providers per tenant, for example: Tenant1 → Entra ID + MyID Tenant2 → Challenge Tenant3 → Facebook + Local Login
- Once a user accesses the application through a tenant-specific endpoint, we would like the tenant context to be fixed.
Questions
- What is the recommended way to resolve the tenant from a route parameter such as /Tenant1 in AuthServer?
- Does ABP provide built-in support for mapping tenants to different external authentication providers?
- Can external authentication providers (Entra, OpenID Connect providers, Facebook, etc.) be enabled/disabled per tenant using the existing Account/OpenIddict modules?
- If different tenants require different OpenID Connect configurations (different ClientIds, Authorities, ClientSecrets), what is the recommended ABP approach?
- Is there an example of implementing tenant-specific authentication providers while still using the standard ABP Account and OpenIddict modules?
Any guidance, documentation, or sample implementations would be greatly appreciated.
Thank you.
4 Answer(s)
-
0
- Resolve the tenant from a route parameter such as
/tenant1in AuthServer- Use ABP’s route tenant resolver (
RouteTenantResolveContributor) by adding the special route parameter name{__tenant}to your routes.- For MVC/Razor Pages endpoints on the AuthServer, the pattern is:
- Use ABP’s route tenant resolver (
[Route("{__tenant}/[controller]")] public class MyController : Controller { }or for Razor Pages:@page "{__tenant?}/Account/Login"- If you want it globally (so you don’t have to edit every page/controller route), you can apply a convention to prepend
{__tenant}to all routes (controllers/pages) as shown in the referenced article. - If you also need correct link generation/navigation behavior when tenant is in the first path segment, the referenced article shows a middleware approach using
PathBaseand a custom route contributor derived fromRouteTenantResolveContributor.
Fix tenant context once a user enters via tenant-specific endpoint
- The “fixed tenant” behavior is effectively achieved by only resolving tenant from the route (and not from headers/cookies/querystring), i.e. configure tenant resolve contributors to only use your preferred resolver. The route-based approach ensures subsequent requests under that base path keep resolving the same tenant.
Mapping tenants to different external authentication providers / enabling-disabling per tenant
- No verified source available for built-in “tenant → provider mapping” that automatically enables/disables external providers per tenant inside the standard Account/OpenIddict UI out of the box.
- Practically, to achieve “Tenant1 uses Entra+MyID, Tenant2 uses Challenge, Tenant3 uses Facebook+Local”، you typically need to customize the Account module login UI/flow and decide which schemes to show/challenge based on
ICurrentTenant(tenant resolved before hitting/Account/Loginvia{__tenant}route).
Different OpenID Connect configurations per tenant (ClientId/Authority/Secret)
- No verified source available for an official ABP sample implementing per-tenant external provider options (authority/client id/secret) while still using standard ABP Account + OpenIddict as-is.
- The generally aligned approach in ABP apps is:
- Store per-tenant provider settings (e.g., in your own table or ABP Setting Management per-tenant).
- At runtime, when the tenant is resolved, configure/challenge the correct external scheme for that tenant (or implement dynamic options resolution).
- Customize the login page to only present the allowed providers for the current tenant.
Example / sample
- I couldn’t find a verified official ABP sample that demonstrates “tenant-specific external authentication provider selection + per-tenant OIDC options” for AuthServer using standard Account/OpenIddict modules without customization.
- The closest verified, relevant implementation guidance for tenant resolution from route is the community article below.
Links (validated):
- https://abp.io/community/articles/ah7oru97
- https://docs.abp.io/en/abp/latest/Multi-Tenancy
- https://abp.io/docs/latest/framework/architecture/multi-tenancy
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.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - Resolve the tenant from a route parameter such as
-
0
Hi,
Let me walk through this in two parts: route-based tenant resolution and per-tenant external providers. Both are doable with the existing Account Pro and OpenIddict modules.
1) Resolving the tenant from a route segment like
/tenant1ABP has a built-in
RouteTenantResolveContributorthat reads the{__tenant}route value. The non-trivial part is getting{__tenant}to apply to every route in the AuthServer (Razor Pages, controllers) and making links/redirects keep the prefix.I wrote a community article that covers the full setup — route conventions for pages and controllers, a
PathBasemiddleware so generated links automatically include/tenantX, a customRouteTenantResolveContributorthat reads the tenant fromPathBase, andAbpThemingOptions.BaseUrlso the client-sideabp.appPathis correct:https://abp.io/community/articles/Resolving-Tenant-from-Route-in-ABP-Framework/ah7oru97
The AuthServer is just a Razor Pages app under the hood, so the same conventions apply to
/Account/Login,/Account/Register, the OpenIddict authorize endpoints, etc. Once the article's pieces are wired up, hittinghttps://portal.auth.com/tenant1/Account/Loginresolvestenant1automatically and the context stays "fixed" for the rest of the request chain — subsequent links and cookies stay under/tenant1.If you also want to limit resolution to only the route (so cookies/headers can't override the tenant), you can configure
AbpTenantResolveOptionsand clear the other contributors:Configure<AbpTenantResolveOptions>(options => { options.TenantResolvers.Clear(); options.TenantResolvers.Add(new MyRouteTenantResolveContributor()); });2) Per-tenant external authentication providers (Entra ID, Facebook, Google, …)
The Account Pro module already supports this scenario out of the box — each tenant can enable/disable an external provider and configure its own
ClientId/ClientSecret/Authorityfrom the Settings page. You don't need to write a custom dynamic options provider for the built-in schemes; the module wiresIOptionsMonitor<TOptions>per tenant for you.The pattern is: call the standard
.AddXxx(scheme, …)and chain.WithDynamicOptions<TOptions, THandler>(scheme, …)to mark which option properties should come from the per-tenant settings.Documentation: https://abp.io/docs/latest/modules/account-pro#sociallong-external-logins
For Entra ID, use
OpenIdConnect. Here's the full sample from the docs (works the same way for any OIDC provider — different ClientId / Authority / ClientSecret per tenant):context.Services.AddAuthentication() .AddOpenIdConnect("AzureOpenId", "Azure AD", options => { options.ResponseType = OpenIdConnectResponseType.CodeIdToken; options.RequireHttpsMetadata = false; options.SaveTokens = true; options.GetClaimsFromUserInfoEndpoint = true; options.Scope.Add("email"); options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "sub"); options.CallbackPath = configuration["AzureAd:CallbackPath"]; }) .WithDynamicOptions<OpenIdConnectOptions, OpenIdConnectHandler>("AzureOpenId", options => { options.WithProperty(x => x.Authority); options.WithProperty(x => x.ClientId); options.WithProperty(x => x.ClientSecret, isSecret: true); }); // OIDC needs metadata to be re-resolved when Authority changes public class OpenIdConnectPostConfigureAccountExternalProviderOptions : IPostConfigureAccountExternalProviderOptions<OpenIdConnectOptions> { private readonly IEnumerable<IPostConfigureOptions<OpenIdConnectOptions>> _postConfigureOptions; public OpenIdConnectPostConfigureAccountExternalProviderOptions( IEnumerable<IPostConfigureOptions<OpenIdConnectOptions>> postConfigureOptions) { _postConfigureOptions = postConfigureOptions; } public Task PostConfigureAsync(string name, OpenIdConnectOptions options) { foreach (var configureOption in _postConfigureOptions) { configureOption.PostConfigure(name, options); } return Task.CompletedTask; } } context.Services.TryAddEnumerable( ServiceDescriptor.Singleton< IPostConfigureAccountExternalProviderOptions<OpenIdConnectOptions>, OpenIdConnectPostConfigureAccountExternalProviderOptions>());After this, an admin signs in to the host, switches the management UI to the target tenant, and fills in
Authority/ClientId/ClientSecretper tenant on the External Providers settings page. The login page automatically shows only the providers enabled for the current tenant — so combined with part 1, a user landing on/tenant1/Account/Loginonly sees Entra + MyID,/tenant2only sees Challenge, etc.The template already wires Google / Microsoft / Twitter this way — see
MyProjectNameAuthServerModule.ConfigureExternalProviders(...)in your AuthServer project as a working reference.3) Tiered solution — important
Because you have a separated
AuthServer+Blazorhost (tiered), both projects need the.WithDynamicOptions<…>(…)registration. TheAuthServerruns the actual login/challenge flow, but the host also needs the dynamic registration so external login callbacks resolve correctly. You can copy the same.WithDynamicOptions<…>(…)block into the host module without the static.AddOpenIdConnect(…)call (the host doesn't need to challenge directly).4) MyID / Challenge
If "MyID" is an OIDC-compliant IdP, the snippet above works as-is — register one more scheme like
.AddOpenIdConnect("MyID", …).WithDynamicOptions<OpenIdConnectOptions, OpenIdConnectHandler>("MyID", …). Could you share more details about what the "Challenge" and "MyID" providers actually are (OAuth 2.0? OIDC? SAML? a custom protocol?) so we can suggest the right approach for those two specifically?Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
Scenario
We need to determine both the user type and authentication provider from the URL structure. Example URLs: /tenant1/internal/entraid /tenant1/internal/myid /tenant1/external/local /tenant1/external/facebook
/tenant2/internal/entraid /tenant2/external/local
For this question, please assume the tenant has already been resolved before authentication begins.
Requirements
User Type Resolution The URL should determine the user type: /internal or /external Example: /tenant1/internal/entraid should resolve: UserType = Internal and /tenant1/external/facebook should resolve: UserType = External
Authentication Provider Resolution The authentication provider should be selected from the route. Example: /tenant1/internal/entraid should automatically initiate authentication using: Entra ID while, /tenant1/external/facebook should automatically initiate authentication using: Facebook and, /tenant1/external/local should display the standard username/password login page.
Authentication Rules Each tenant may have different authentication configurations. Example: Tenant1 Internal: - Entra ID - MyID
External: - Local Login - FacebookTenant2 Internal: - Entra ID
External: - Local Login
The available providers are not globally fixed and may vary by tenant.
Questions
- Does ABP provide a supported mechanism for dynamically selecting an external authentication provider based on the route?
- Is it possible to automatically trigger a specific authentication challenge based on a route parameter without first displaying the standard login page? For example: /tenant1/internal/entraid, should immediately redirect to the Entra ID authentication flow.
What is the recommended way to model tenant-specific authentication provider configurations?
Would this typically be implemented using:
- Tenant settings
- External provider settings
- OpenIddict applications
- A custom configuration store
- Can ABP support different external provider configurations (ClientId, ClientSecret, Authority, etc.) per tenant?
- How can we ensure that users entering through a URL such as: /tenant1/internal/entraid remain bound to: Internal user type Entra ID provider throughout the authentication flow and cannot switch to another provider that is not configured for that entry point?
Are there any existing ABP samples, best practices, or recommended architectures for implementing URL-driven authentication provider selection?
Any guidance or recommendations would be greatly appreciated.
Thank you.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
There's no built-in "route → provider" switch in ABP, but all the pieces are there. The pattern below is one custom
LoginModelplus a small URL-rewrite middleware. A couple of subtleties to call out up front:/external/localshould not invokeChallenge("local")—localis not a registered scheme. The override below special-cases it and falls through to the standard login page.- The local login page should hide external providers that aren't in the current tenant's whitelist. The override of
GetExternalProvidersbelow handles that. - Both
[ExposeServices(typeof(LoginModel))]and[Dependency(ReplaceServices = true)]are required to fully replace the defaultLoginModel.
Custom
LoginModelusing System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Owl.reCAPTCHA; using Volo.Abp.Account; using Volo.Abp.Account.ExternalProviders; using Volo.Abp.Account.Public.Web; using Volo.Abp.Account.Public.Web.Pages.Account; using Volo.Abp.Account.Security.Recaptcha; using Volo.Abp.Account.Web.Pages.Account; using Volo.Abp.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.MultiTenancy; using Volo.Abp.OpenIddict; using Volo.Abp.Security.Claims; using Volo.Saas.Tenants; namespace MyCompanyName.MyProjectName.Pages.Account; [Dependency(ReplaceServices = true)] [ExposeServices(typeof(LoginModel))] public class MyLoginModel : OpenIddictSupportedLoginModel { public string UserType { get; private set; } public string Provider { get; private set; } protected ITenantRepository TenantRepository { get; } public MyLoginModel( IAuthenticationSchemeProvider schemeProvider, IOptions<AbpAccountOptions> accountOptions, IAbpRecaptchaValidatorFactory recaptchaValidatorFactory, IAccountExternalProviderAppService accountExternalProviderAppService, ICurrentPrincipalAccessor currentPrincipalAccessor, IOptions<IdentityOptions> identityOptions, IOptionsSnapshot<reCAPTCHAOptions> reCaptchaOptions, AbpOpenIddictRequestHelper openIddictRequestHelper, ITenantRepository tenantRepository) : base(schemeProvider, accountOptions, recaptchaValidatorFactory, accountExternalProviderAppService, currentPrincipalAccessor, identityOptions, reCaptchaOptions, openIddictRequestHelper) { TenantRepository = tenantRepository; } public override async Task<IActionResult> OnGetAsync() { UserType = RouteData.Values["userType"]?.ToString() ?? Request.Query["userType"].ToString(); Provider = RouteData.Values["provider"]?.ToString() ?? Request.Query["provider"].ToString(); // /tenant1/external/local → render the standard local login page; do not challenge. if (string.Equals(Provider, "local", System.StringComparison.OrdinalIgnoreCase)) { return await base.OnGetAsync(); } if (!string.IsNullOrWhiteSpace(Provider)) { if (!await IsProviderAllowedForCurrentTenantAsync(Provider, UserType)) { Alerts.Danger(L["The provider is not allowed for this entry point."]); return await base.OnGetAsync(); } return await OnPostExternalLogin(Provider); } return await base.OnGetAsync(); } public override async Task<IActionResult> OnPostExternalLogin(string provider) { var redirectUrl = Url.Page("./Login", pageHandler: "ExternalLoginCallback", values: new { ReturnUrl, ReturnUrlHash, LinkTenantId, LinkUserId, LinkToken }); var properties = SignInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); properties.Items["scheme"] = provider; // Bind UserType into the auth properties so the callback can validate it. if (!string.IsNullOrWhiteSpace(UserType)) { properties.Items["UserType"] = UserType; } if (CurrentTenant.Id.HasValue) { properties.Items[TenantResolverConsts.DefaultTenantKey] = CurrentTenant.Id.ToString(); } return Challenge(properties, provider); } public override async Task<IActionResult> OnGetExternalLoginCallbackAsync(string remoteError = null) { var loginInfo = await SignInManager.GetExternalLoginInfoAsync(); if (loginInfo != null) { string userType = null; loginInfo.AuthenticationProperties?.Items.TryGetValue("UserType", out userType); // If you require all external callbacks to come from a URL-driven entry point, // reject when UserType is missing. if (string.IsNullOrWhiteSpace(userType)) { Alerts.Danger(L["External login must be initiated from a tenant entry point."]); return await InitPageAsync() ?? Page(); } if (!await IsProviderAllowedForCurrentTenantAsync(loginInfo.LoginProvider, userType)) { Alerts.Danger(L["The provider is not allowed for this entry point."]); return await InitPageAsync() ?? Page(); } // Optionally promote UserType to a user claim / extra property here. } return await base.OnGetExternalLoginCallbackAsync(remoteError); } // Hide non-whitelisted external providers on the standard login page (used when /external/local is opened). protected override async Task<List<ExternalProviderModel>> GetExternalProviders() { var providers = await base.GetExternalProviders(); if (string.IsNullOrWhiteSpace(UserType)) { return providers; } var result = new List<ExternalProviderModel>(); foreach (var p in providers) { if (await IsProviderAllowedForCurrentTenantAsync(p.AuthenticationScheme, UserType)) { result.Add(p); } } return result; } protected virtual async Task<bool> IsProviderAllowedForCurrentTenantAsync(string provider, string userType) { if (!CurrentTenant.Id.HasValue || string.IsNullOrWhiteSpace(userType)) { return true; // host, or no UserType bound → don't restrict } // Tenant entity lives in host scope; switch to host before querying (same pattern ABP's own TenantStore uses). var tenantId = CurrentTenant.Id.Value; Tenant tenant; using (CurrentTenant.Change(null)) { tenant = await TenantRepository.FindAsync(tenantId); } if (tenant == null) { return false; } // ExtraProperties may hold the value as a JSON string or as a nested object — handle both. var raw = tenant.ExtraProperties.GetOrDefault("AllowedProviders"); if (raw == null) { return false; } var json = raw is string s ? s : System.Text.Json.JsonSerializer.Serialize(raw); if (string.IsNullOrWhiteSpace(json)) { return false; } using var doc = System.Text.Json.JsonDocument.Parse(json); if (!doc.RootElement.TryGetProperty(userType, out var arr)) { return false; } return arr.EnumerateArray() .Any(x => string.Equals(x.GetString(), provider, System.StringComparison.OrdinalIgnoreCase)); } }Notes:
OnPostExternalLoginis a plain virtual method, so calling it fromOnGetAsyncworks.UserTypeis added toAuthenticationProperties.Items. ASP.NET Core protects this state when the OIDC handler builds theauthorizeURL, and it comes back via the external login info on callback — so the value is tamper-resistant for the OAuth round-trip.- Reading
ExtraPropertiesrequiresITenantRepository, notITenantStore—TenantConfigurationdoes not exposeExtraProperties. TheTenantentity lives in the host scope, so the repository call is wrapped inCurrentTenant.Change(null)to bypass the tenant data filter (same pattern ABP's ownTenantStoreuses internally). In production, cache the per-tenant whitelist (e.g. viaIDistributedCacheorIMemoryCache) to avoid a DB hit on every login. - If your
UserTyperepresents a fixed property of the user (e.g. Internal = employee, External = customer), also validate after local login (OnPostAsync) that the signed-in user's storedUserTypematches the URL'suserType. That code is short but very tied to your domain, so it's not shown here.
Routing the URL —
/{tenant}/{userType}/{provider}→/Account/LoginThe route-based tenant resolution side is covered in Resolving Tenant from Route in ABP Framework. After that middleware runs,
/tenant1is moved intoPathBase, so the remaining path is/internal/entraid. Add one more small middleware to forward that into the standard/Account/Loginpage:// Place after the tenant PathBase middleware and before app.UseRouting(). app.Use(async (httpContext, next) => { var path = httpContext.Request.Path; string userType = null; PathString rest = default; if (path.StartsWithSegments("/internal", out rest)) { userType = "internal"; } else if (path.StartsWithSegments("/external", out rest)) { userType = "external"; } // Accept only one trailing segment (the provider name) — reject e.g. /internal/a/b. if (userType != null) { var provider = rest.HasValue ? rest.Value!.Trim('/') : null; if (string.IsNullOrEmpty(provider) || provider.Contains('/')) { await next(httpContext); return; } var query = QueryString.Create(new[] { new KeyValuePair<string, string>("userType", userType), new KeyValuePair<string, string>("provider", provider), }); httpContext.Request.Path = "/Account/Login"; httpContext.Request.QueryString = httpContext.Request.QueryString.Add(query); } await next(httpContext); });/tenant1/internal/entraidthen resolves as: PathBase=/tenant1, Path=/Account/Login, Query=userType=internal&provider=entraid. Hitting that URL goes straight into the IdP redirect — the login page never renders.How to model the per-tenant configuration
Don't use OpenIddict applications for this — those are for the client apps that authenticate against your AuthServer, not for external login providers.
Two separate storage concerns:
| Data | Where to store | | --- | --- | | ClientId / ClientSecret / Authority per tenant per provider | Account Pro's External Provider Settings (already multi-tenant; see my previous reply) | | The list of providers allowed for
(tenant, userType)| TenantExtraPropertiesas JSON, or a small custom table if you prefer relational storage |Tenant
ExtraPropertiesis convenient for the whitelist. Example payload:{ "AllowedProviders": { "internal": ["AzureOpenId", "MyID"], "external": ["local", "Facebook"] } }Read it via
ITenantRepository.FindAsync(tenantId)insideIsProviderAllowedForCurrentTenantAsync. Don't useITenantStore— the cachedTenantConfigurationit returns does not exposeExtraProperties. Add your own caching layer if read frequency is a concern.Per-tenant ClientId / Secret / Authority
This is the
WithDynamicOptions<TOptions, THandler>block from my previous reply. ForMyID, register it the same way as Azure AD if it speaks OIDC. The OIDC handler resolves discovery on everyAuthoritychange, so you must also register anIPostConfigureAccountExternalProviderOptions<OpenIdConnectOptions>post-configurator alongside the dynamic options:using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Volo.Abp.Account.ExternalProviders; using Volo.Abp.Account.Public.Web.ExternalProviders; // inside ConfigureServices context.Services.AddAuthentication() .AddOpenIdConnect("MyID", "MyID", options => { /* static defaults */ }) .WithDynamicOptions<OpenIdConnectOptions, OpenIdConnectHandler>("MyID", o => { o.WithProperty(x => x.Authority); o.WithProperty(x => x.ClientId); o.WithProperty(x => x.ClientSecret, isSecret: true); }); context.Services.TryAddEnumerable( ServiceDescriptor.Singleton< IPostConfigureAccountExternalProviderOptions<OpenIdConnectOptions>, OpenIdConnectPostConfigureAccountExternalProviderOptions>());public class OpenIdConnectPostConfigureAccountExternalProviderOptions : IPostConfigureAccountExternalProviderOptions<OpenIdConnectOptions> { private readonly IEnumerable<IPostConfigureOptions<OpenIdConnectOptions>> _postConfigureOptions; public OpenIdConnectPostConfigureAccountExternalProviderOptions( IEnumerable<IPostConfigureOptions<OpenIdConnectOptions>> postConfigureOptions) { _postConfigureOptions = postConfigureOptions; } public Task PostConfigureAsync(string name, OpenIdConnectOptions options) { foreach (var configureOption in _postConfigureOptions) { configureOption.PostConfigure(name, options); } return Task.CompletedTask; } }If
MyIDis not OIDC (SAML, custom protocol, etc.), let me know what it is and I'll send the right registration shape.Sample / reference
There's no ready-made ABP sample combining all of this, but the closest official pieces are:
- Account Pro External Providers docs: https://abp.io/docs/latest/modules/account-pro#sociallong-external-logins
- Maliming's community article on route-based tenant resolution: https://abp.io/community/articles/Resolving-Tenant-from-Route-in-ABP-Framework/ah7oru97
- The
OpenIddictSupportedLoginModelalready present in your AuthServer project — the override above follows the same pattern.
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)