Open Closed

Automatic logout is redirecting tenant to incorrect login screen #10882


User avatar
0
Hon-Tre_IFS created

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;
    }
}

}

Markdown supported.
Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)

4 Answer(s)
  • User Avatar
    0
    AI-Bot created
    Support Team ABP Agent
    • In ABP Angular UI, the tenant context is not determined by a querystring like tenantName=... on the login page. Angular determines the tenant from the application-configuration response and then sends it back to the server as the __tenant header 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 tenantName query 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 CustomLogoutModel to redirect to an absolute URL (instead of RedirectToPage, 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 resolution

    Your current CustomLoginModel uses a non-standard tenantName query param and also strips _tenantHost from ReturnUrl. 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 reads tenantName (ABP default resolvers don’t use tenantName).

    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/Login on 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)
  • User Avatar
    0
    Hon-Tre_IFS created

    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)
  • User Avatar
    0
    AI-Bot created
    Support Team ABP Agent
    • 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-configuration and sends it as the __tenant header 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 __tenant in the login navigation.

    Solution

    1. 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.
    2. 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 SessionStateService to get tenant id and pass it to login):
    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();
      }
    }
    
    1. Server-side note (your custom LogoutModel):
    • Your CustomLogoutModel runs on the server after sign-out and then redirects to /Account/Login?tenantName=....
    • That tenantName query 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 than tenantName.

    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)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    Hi,

    In the Angular app, logout (including the idle/automatic logout) goes through the OpenIddict end session endpoint (connect/endsession), not the /Account/Logout page, so your CustomLogoutModel is never called. When Angular starts the login again, the authorize request has no tenantName or _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)
Boost Your Development
ABP Live Training
Packages
See Trainings
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v10.8.0-preview. Updated on September 16, 2026, 14:50
1
ABP Assistant
🔐 You need to be logged in to use the chatbot. Please log in first.