During automatic logout, the web app is redirecting to default department host login screen instead of tenant login screen.
This is not allowing user to navigate to the tenant portal:
** For Reference Loging Paged Overridden code **
using Hon.IFS.DepartmentHost.Pages.Account; using Hon.IFS.DeptManagement.FireDepartments; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using Owl.reCAPTCHA; using Microsoft.Extensions.Logging; using System; using System.Linq; using System.Threading.Tasks; 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.DependencyInjection; using Volo.Abp.Identity; using Volo.Abp.Security.Claims; using Volo.Saas.Tenants;
namespace Hon.IFS.DepartmentHost.Pages.Account { [ExposeServices(typeof(CustomLoginModel), typeof(LoginModel))] public class CustomLoginModel : LoginModel { private readonly ITenantRepository _tenantRepository; private readonly DepartmentHostBrandingProvider _brandingProvider; private readonly IFireDepartmentRepository _fireDepartmentRepository; private readonly IConfiguration _configuration;
public string? TenantHostName { get; set; }
[BindProperty]
public new LoginInput LoginInput { get; set; } = new();
public CustomLoginModel(
IAuthenticationSchemeProvider schemeProvider,
IOptions<AbpAccountOptions> accountOptions,
IAccountExternalProviderAppService accountExternalProviderAppService,
ICurrentPrincipalAccessor currentPrincipalAccessor,
IAbpRecaptchaValidatorFactory recaptchaValidatorFactory,
IOptions<IdentityOptions> identityOptions,
IOptionsSnapshot<reCAPTCHAOptions> reCaptchaOptions,
ITenantRepository tenantRepository,
DepartmentHostBrandingProvider brandingProvider,
IFireDepartmentRepository fireDepartmentRepository,
IConfiguration configuration)
: base(schemeProvider, accountOptions, recaptchaValidatorFactory,
accountExternalProviderAppService, currentPrincipalAccessor,
identityOptions, reCaptchaOptions)
{
_tenantRepository = tenantRepository;
_brandingProvider = brandingProvider;
_fireDepartmentRepository = fireDepartmentRepository;
_configuration = configuration;
}
public override async Task<IActionResult> OnGetAsync()
{
ParseTenantHostName();
// If tenantName is not in the query string but __tenant exists in the ReturnUrl
// (passed through the OIDC authorize URL), redirect with tenantName so the
// branding provider and tenant resolution work correctly.
if (string.IsNullOrWhiteSpace(TenantHostName) && !string.IsNullOrWhiteSpace(ReturnUrl))
{
var tenantFromReturnUrl = ExtractTenantFromReturnUrl(ReturnUrl);
if (!string.IsNullOrWhiteSpace(tenantFromReturnUrl))
{
// Strip _tenantHost from ReturnUrl — it was only needed to resolve the tenant.
// Keeping it would leak it into the final URL the user sees after login.
var cleanReturnUrl = StripTenantHostFromUrl(ReturnUrl);
var redirectUrl = $"{Request.Path}?tenantName={Uri.EscapeDataString(tenantFromReturnUrl)}&ReturnUrl={Uri.EscapeDataString(cleanReturnUrl)}";
if (!string.IsNullOrWhiteSpace(ReturnUrlHash))
{
redirectUrl += $"&ReturnUrlHash={Uri.EscapeDataString(ReturnUrlHash)}";
}
return Redirect(redirectUrl);
}
}
return await base.OnGetAsync();
}
public override async Task<IActionResult> OnPostAsync(string action)
{
ParseTenantHostName();
try
{
var tenant = await ResolveTenantAsync();
if (tenant == null && !string.IsNullOrEmpty(_brandingProvider.TenantAppName))
{
return AddErrorAndReturnPage("LoginInput.UserNameOrEmailAddress", "Invalid tenant configuration.");
}
if (!ValidateCredentials(out var username, out var password))
{
return Page();
}
return await ProcessLoginAsync(tenant, username, password, action);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "An error occurred during login.");
Alerts.Danger("An error occurred during login. Please try again.");
return Page();
}
}
private void ParseTenantHostName()
{
var tenantName = Request.Query["tenantName"].ToString();
TenantHostName = string.IsNullOrWhiteSpace(tenantName) ? null : Uri.UnescapeDataString(tenantName);
}
/// <summary>
/// Extracts the _tenantHost parameter from the OIDC authorize URL embedded in ReturnUrl.
/// This is used when the notification deep link passes _tenantHost through the OIDC flow.
/// </summary>
private string? ExtractTenantFromReturnUrl(string returnUrl)
{
try
{
var queryIndex = returnUrl.IndexOf('?');
if (queryIndex < 0) return null;
var queryString = returnUrl[(queryIndex + 1)..];
var queryParams = QueryHelpers.ParseQuery(queryString);
if (queryParams.TryGetValue("_tenantHost", out var tenantValues) && tenantValues.Count > 0)
{
var tenant = tenantValues.First();
return string.IsNullOrWhiteSpace(tenant) ? null : tenant;
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to extract tenant from ReturnUrl: {ReturnUrl}", returnUrl);
}
return null;
}
/// <summary>
/// Removes the _tenantHost query parameter from a URL after it has been used
/// for tenant resolution, so it doesn't leak into the final redirect URL.
/// </summary>
private string StripTenantHostFromUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
{
return url;
}
try
{
var queryIndex = url.IndexOf('?');
if (queryIndex < 0)
{
return url;
}
var path = url[..queryIndex];
var queryParams = QueryHelpers.ParseQuery(url[(queryIndex + 1)..]);
if (!queryParams.Remove("_tenantHost"))
{
return url;
}
if (queryParams.Count == 0)
{
return path;
}
var result = path;
foreach (var param in queryParams)
{
foreach (var value in param.Value)
{
result = QueryHelpers.AddQueryString(result, param.Key, value ?? string.Empty);
}
}
return result;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to strip _tenantHost from URL: {Url}", url);
return url;
}
}
private async Task<Tenant?> ResolveTenantAsync()
{
var tenantKey = _brandingProvider.TenantAppName;
return string.IsNullOrEmpty(tenantKey) ? null : await _tenantRepository.FindByNameAsync(tenantKey);
}
private bool ValidateCredentials(out string username, out string password)
{
username = LoginInput?.UserNameOrEmailAddress?.Trim() ?? string.Empty;
password = LoginInput?.Password?.Trim() ?? string.Empty;
var isValid = true;
if (string.IsNullOrEmpty(username))
{
ModelState.AddModelError("LoginInput.UserNameOrEmailAddress", "Username is required.");
isValid = false;
}
if (string.IsNullOrEmpty(password))
{
ModelState.AddModelError("LoginInput.Password", "Password is required.");
isValid = false;
}
return isValid;
}
private async Task<IActionResult> ProcessLoginAsync(Tenant? tenant, string username, string password, string action)
{
using (CurrentTenant.Change(tenant?.Id, tenant?.Name))
{
var user = await FindUserAsync(username);
if (user == null)
{
return AddErrorAndReturnPage("LoginInput.UserNameOrEmailAddress", "Username is incorrect.");
}
if (!await UserManager.CheckPasswordAsync(user, password))
{
return AddErrorAndReturnPage("LoginInput.Password", "Invalid password.");
}
SyncBaseLoginInput();
// Strip _tenantHost from ReturnUrl before the OIDC flow — it was only
// needed for tenant resolution and shouldn't appear in the final URL.
if (!string.IsNullOrWhiteSpace(ReturnUrl))
{
ReturnUrl = StripTenantHostFromUrl(ReturnUrl);
}
var result = await base.OnPostAsync(action);
if (IsLoginFailed(result))
{
return AddErrorAndReturnPage("LoginInput.UserNameOrEmailAddress", "The username or password is incorrect.");
}
user = await UserManager.FindByIdAsync(user.Id.ToString());
if (user == null)
{
Alerts.Danger("User not found after login. Please contact support.");
return Page();
}
if (user.ShouldChangePasswordOnNextLogin)
{
return await RedirectToChangePasswordAsync(user);
}
// If ReturnUrl is set (e.g., from OIDC/OAuth flow), respect it
// so the user is redirected back to the originally requested page
if (!string.IsNullOrWhiteSpace(ReturnUrl) && result != null)
{
return result;
}
return await RedirectAfterLoginAsync(tenant);
}
}
private void SyncBaseLoginInput()
{
var baseProperty = typeof(LoginModel).GetProperty("LoginInput");
var baseLoginInput = baseProperty?.GetValue(this);
if (baseLoginInput == null)
{
var loginInputType = baseProperty?.PropertyType;
if (loginInputType != null)
{
baseLoginInput = Activator.CreateInstance(loginInputType);
baseProperty?.SetValue(this, baseLoginInput);
}
}
if (baseLoginInput != null)
{
var baseType = baseLoginInput.GetType();
baseType.GetProperty("UserNameOrEmailAddress")?.SetValue(baseLoginInput, LoginInput.UserNameOrEmailAddress);
baseType.GetProperty("Password")?.SetValue(baseLoginInput, LoginInput.Password);
baseType.GetProperty("RememberMe")?.SetValue(baseLoginInput, LoginInput.RememberMe);
}
}
private static bool IsLoginFailed(IActionResult? result)
=> result == null || (result is RedirectToPageResult redirect && redirect.PageName == "./Login");
private IActionResult AddErrorAndReturnPage(string key, string message)
{
ModelState.AddModelError(key, message);
return Page();
}
private async Task<IActionResult> RedirectToChangePasswordAsync(Volo.Abp.Identity.IdentityUser user)
{
var resetToken = await UserManager.GeneratePasswordResetTokenAsync(user);
return RedirectToPage("./ChangePassword", new
{
userId = user.Id,
resetToken,
tenantId = CurrentTenant.Id
});
}
private async Task<IActionResult> RedirectAfterLoginAsync(Tenant? tenant)
{
var angularUrl = _configuration["App:AngularUrl"]
?? throw new InvalidOperationException("App:AngularUrl is not configured.");
var baseUrl = angularUrl.TrimEnd('/');
if (tenant?.Id == null)
{
return Redirect($"{baseUrl}/dashboard");
}
var departments = await _fireDepartmentRepository.GetCountAsync();
return Redirect(departments > 0 ? $"{baseUrl}/dashboard" : $"{baseUrl}/onboarding");
}
protected virtual async Task<Volo.Abp.Identity.IdentityUser?> FindUserAsync(string usernameOrEmail)
{
return await UserManager.FindByNameAsync(usernameOrEmail)
?? await UserManager.FindByEmailAsync(usernameOrEmail);
}
}
}
Logout
using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Volo.Abp.Account.Public.Web.Pages.Account; using Volo.Abp.DependencyInjection; using Volo.Saas.Tenants;
namespace Hon.IFS.DepartmentHost.Pages.Account { [ExposeServices(typeof(CustomLogoutModel), typeof(LogoutModel))] public class CustomLogoutModel : LogoutModel { private readonly ITenantRepository _tenantRepository;
public CustomLogoutModel(ITenantRepository tenantRepository)
{
_tenantRepository = tenantRepository;
}
public override async Task<IActionResult> OnGetAsync()
{
// Resolved before base signs out, while the cookie still carries the tenant.
var tenantHostName = await GetTenantHostNameAsync(CurrentTenant.Id ?? CurrentUser.TenantId);
var result = await base.OnGetAsync();
if (!string.IsNullOrWhiteSpace(tenantHostName) &&
result is RedirectToPageResult redirect &&
redirect.PageName?.EndsWith("Login", StringComparison.OrdinalIgnoreCase) == true)
{
return RedirectToPage("/Account/Login", new { tenantName = tenantHostName });
}
return result;
}
private async Task<string?> GetTenantHostNameAsync(Guid? tenantId)
{
if (!tenantId.HasValue)
{
return null;
}
var tenant = await _tenantRepository.FindByIdAsync(tenantId.Value);
return tenant?.ExtraProperties.TryGetValue("tenantHostName", out var tenantHostName) == true
? tenantHostName?.ToString()
: null;
}
}
}
4 Answer(s)
-
0
- In ABP Angular UI, the tenant context is not determined by a querystring like
tenantName=...on the login page. Angular determines the tenant from theapplication-configurationresponse and then sends it back to the server as the__tenantheader on subsequent requests. So, after an automatic logout, if you redirect to a login URL that doesn’t cause ABP to resolve the tenant again (cookie/header/domain/route), you can easily end up on the host login experience (default/department host) instead of the intended tenant context. - The robust fix is: redirect to the login page in a way that ABP can resolve the tenant again using a built-in tenant resolver (domain or route), not by a custom
tenantNamequery parameter.
1) Fix the redirect target so tenant can be resolved by ABP (recommended)
Pick one of these approaches:
A) Use Domain-based tenant resolution (best if you already have
tenantHostName)- After logout, redirect the browser to the tenant’s actual host (domain) login URL, e.g.:
https://{tenantHostName}/Account/Login(or your tenant portal root, which then triggers auth).
- This guarantees ABP resolves the tenant from the domain again.
Update your
CustomLogoutModelto redirect to an absolute URL (instead ofRedirectToPage, which keeps you on the current host):public override async Task<IActionResult> OnGetAsync() { var tenantHostName = await GetTenantHostNameAsync(CurrentTenant.Id ?? CurrentUser.TenantId); await base.OnGetAsync(); // sign out if (!string.IsNullOrWhiteSpace(tenantHostName)) { var scheme = Request.Scheme; // "https" var url = $"{scheme}://{tenantHostName}/Account/Login"; return Redirect(url); } // fallback: host login return RedirectToPage("/Account/Login"); }B) Use Route-based tenant resolution (
/{__tenant}/...)- If your tenant URLs are like
https://yourdomain.com/{tenantName}/..., then redirect to:/{tenantName}/Account/Login(and make sure route tenant resolution is configured).
2) Don’t rely on
tenantName=...for tenant resolutionYour current
CustomLoginModeluses a non-standardtenantNamequery param and also strips_tenantHostfromReturnUrl. That can work for branding/UI, but it won’t automatically make the ABP tenant resolution pipeline select the tenant unless you also implemented a tenant resolver that readstenantName(ABP default resolvers don’t usetenantName).So the logout redirect should go to a URL that triggers ABP’s normal tenant resolution again (domain/route/header/cookie).
3) Why the current logout override often fails
base.OnGetAsync()signs out, which can clear cookies used by tenant resolution (depending on your setup).- After sign-out, you redirect to
/Account/Loginon the same host, and tenant resolution falls back to host, so you see the department host login.
Sources
- https://abp.io/docs/10.0/framework/ui/angular/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) - In ABP Angular UI, the tenant context is not determined by a querystring like
-
0
Are you talking about the auto login once the session is expired
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
- Yes—this is exactly about what happens when the session expires (automatic logout / access token becomes invalid) and the Angular app triggers a re-login (or you hit a protected endpoint and ABP redirects you to
/Account/Login). - The key point in ABP Angular multi-tenancy is: Angular determines the tenant from
application-configurationand sends it as the__tenantheader on API calls. When you are redirected to the MVC login page after session expiration, that tenant context may be lost unless you resolve tenant again via domain/route or explicitly pass__tenantin the login navigation.
Solution
If you use domain/subdomain per tenant (recommended):
- On session expiry, redirect the browser to the tenant’s actual domain (e.g.
https://{tenantHostName}/Account/Login). - This forces ABP’s domain tenant resolver to resolve the correct tenant again.
- On session expiry, redirect the browser to the tenant’s actual domain (e.g.
If you don’t (or can’t) use tenant domains/routes, then pass tenant to the login redirect using
__tenant:- ABP Angular supports passing query parameters to
navigateToLogin, including__tenant. - Example pattern (using
SessionStateServiceto get tenant id and pass it to login):
- ABP Angular supports passing query parameters to
import { AuthService, SessionStateService } from '@abp/ng.core'; constructor( private authService: AuthService, private sessionStateService: SessionStateService ) {} login() { const tenantId = this.sessionStateService.getTenant().id || null; if (tenantId) { this.authService.navigateToLogin({ __tenant: `${tenantId}` }); } else { this.authService.navigateToLogin(); } }- Server-side note (your custom LogoutModel):
- Your
CustomLogoutModelruns on the server after sign-out and then redirects to/Account/Login?tenantName=.... - That
tenantNamequery param is not used by ABP Angular tenant resolution by default, so it may still show host context. - Prefer redirecting to the tenant domain (solution #1), or align everything around
__tenant(solution #2) rather thantenantName.
Sources
- https://abp.io/docs/10.0/framework/ui/angular/multi-tenancy
- https://abp.io/support/questions/9661
- https://abp.io/community/articles/v9y8da7v
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) - Yes—this is exactly about what happens when the session expires (automatic logout / access token becomes invalid) and the Angular app triggers a re-login (or you hit a protected endpoint and ABP redirects you to
-
0
Hi,
In the Angular app, logout (including the idle/automatic logout) goes through the OpenIddict end session endpoint (
connect/endsession), not the/Account/Logoutpage, so yourCustomLogoutModelis never called. When Angular starts the login again, the authorize request has notenantNameor_tenantHost, so the login page falls back to the host.You can remember the tenant in a cookie on the auth server after a tenant login, and use it when the login page has no tenant info. Changes in
CustomLoginModel:using Microsoft.AspNetCore.Http; private const string TenantHostCookieName = "Ifs.TenantHostName";In
OnGetAsync, fall back to the cookie:var tenantFromReturnUrl = ExtractTenantFromReturnUrl(ReturnUrl) ?? Request.Cookies[TenantHostCookieName];In
ProcessLoginAsync, save it after a successful login:var result = await base.OnPostAsync(action); if (IsLoginFailed(result)) { return AddErrorAndReturnPage("LoginInput.UserNameOrEmailAddress", "The username or password is incorrect."); } RememberTenantHost(tenant);private void RememberTenantHost(Tenant? tenant) { if (tenant == null || string.IsNullOrWhiteSpace(TenantHostName)) { Response.Cookies.Delete(TenantHostCookieName); return; } Response.Cookies.Append(TenantHostCookieName, TenantHostName, new CookieOptions { HttpOnly = true, Secure = Request.IsHttps, SameSite = SameSiteMode.Lax, IsEssential = true, Expires = DateTimeOffset.UtcNow.AddDays(30) }); }Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)