Open Closed

# Shared User Accounts: invited+accepted user cannot log in ("Invalid username or password") #10741


User avatar
0
batuhankara created

Shared User Accounts: invited+accepted user cannot log in ("Invalid username or password")

Environment

  • ABP version: 10.3.0 (commercial — Identity.Pro, Account.Pro, Saas)
  • .NET: 10
  • UI: none (backend API only; testing via Postman)
  • Multi-tenancy: enabled, database-per-tenant (each tenant has its own DB; host is a separate DB)
  • User sharing strategy: TenantUserSharingStrategy.Shared
  • Distributed lock: configured (Redis) — required by Shared strategy
  • Custom tenant key (header): options.TenantKey = "linksetenantid"

Relevant config:

// Domain module
Configure<AbpMultiTenancyOptions>(options =>
{
    options.IsEnabled = true;
    options.UserSharingStrategy = TenantUserSharingStrategy.Shared;
});

// HttpApi.Host module
Configure<AbpAspNetCoreMultiTenancyOptions>(options =>
{
    options.TenantKey = "linksetenantid"; // custom header name instead of __tenant
});

What I did (steps to reproduce)

  1. Created the host and a tenant named "Dev" (database-per-tenant; Dev has its own database).
  2. Logged in to the host as the default admin.
  3. Invited an existing user (userA — email userA@example.com) into tenant Dev using the invitation feature.
  4. Received the invitation email and accepted it via the user-sharing accept endpoint:

POST /api/account/user-sharing/invitation/accept Content-Type: application/json { "token": "

The accept returned success. (Before accepting, `GET /api/account/user-sharing/invitation?token=...`
returned `requireRegister: true`, `tenantName: "Dev"`, `inviteeEmail: "userA@example.com"`.)

## What I see in the databases after accept
- In the **host** database `AbpUsers`: I see a record for `userA`, **but it has a non-null `TenantId`** (the Dev tenant's id) — I did **not** see a `TenantId = NULL` (host) record for this user.
- In the **Dev tenant** database `AbpUsers`: I see 1 record for `userA` (with the Dev `TenantId`).

(Question: under Shared accounts, should there be a host record with `TenantId = NULL` for this user? That is the part I'm unsure about — see below.)

## The problem
When `userA` tries to log in, I get **"Invalid username or password!"** (`invalid_grant`).

From the server logs, the token request resolves the tenant to **Host**, then searches for the user
in the **host** context and finds nothing:

```xml
Starting resolving tenant...
Trying to resolve tenant through 'CurrentUser'...
Trying to resolve tenant through 'AbpAccount'...
Tenant resolved by 'AbpAccount' as 'Host'.
No tenant resolved.
...
No user found matching username: "userA@example.com"
→ invalid_grant: "Invalid username or password!"

Notice my custom header resolver (linksetenantid) is never reached — the AbpAccount contributor resolves to Host and the chain stops.

I send the login request like this:

POST /connect/token
linksetenantid: <Dev tenant id>          <-- my custom tenant header
grant_type=password
username=userA@example.com
password=<password>
client_id=...&client_secret=...&scope=...

…but the linksetenantid header appears to be ignored at the token endpoint.

My questions

  1. Why can userA not log in after being invited+accepted under Shared accounts?
  2. Is the host record supposed to have TenantId = NULL? In my host DB the user only appears with a tenant id, and there is no TenantId = NULL host record. Is the accept flow supposed to create a host (TenantId = NULL) record, and could the missing host record be why login can't find the user in host context?
  3. How is a Shared user supposed to log in to a tenant? It looks like the linksetenantid header is intentionally ignored at /connect/token once Shared is enabled (the AbpAccount tenant resolver forces Host). Is the intended flow: log in host-side (no tenant), then use the SwitchTenant grant? If so, host login requires a host (TenantId = NULL) record — which I don't seem to have for this user.
  4. Does database-per-tenant change any of this? Since host and tenant are separate databases, does the invite/accept flow correctly create the host record in the host DB and the tenant copy in the tenant DB? Could a cross-database issue cause the host record to be missing?
  5. Is the custom tenant header (TenantKey = "linksetenantid") supported with Shared accounts, or does Shared mode change tenant resolution at login such that the header is not used?

Exception message and full stack trace

(login response)
{
  "error": "invalid_grant",
  "error_description": "Invalid username or password!",
  "error_uri": "https://documentation.openiddict.com/errors/ID2024"
}

(No server-side exception — the request completes; the log shows "No user found matching username".)

Summary of what I think is happening

After enabling Shared accounts, login resolves to Host (the linksetenantid header is not used at the token endpoint), and the user appears to have no host (TenantId = NULL) record — only records with a tenant id — so host-side login can't find the user. I'd like to know the correct way to make an invited+accepted Shared user able to log in, and whether the missing TenantId = NULL host record (or the custom tenant header being ignored at login) is the root cause.

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

5 Answer(s)
  • User Avatar
    0
    AI-Bot created
    Support Team ABP Agent
    • With TenantUserSharingStrategy.Shared, the credential verification/login happens on the Host side (global identity). That’s why /connect/token resolves the tenant as Host and why your custom tenant header is not used there. In other words: the password grant is intentionally host-scoped in Shared User Accounts.
    • Therefore, userA must exist in the Host database in a way that the Host-side user lookup can find. In your case, the Host DB row you see for userA has TenantId = <DevTenantId>, so when the token endpoint runs in the Host context (TenantId = null), it won’t match that user and you get “No user found … → invalid_grant”.

    Fix (what to do)

    1. Ensure the global (Host-side) user record is created/kept in the Host DB for shared accounts (this is required especially in database-per-tenant setups because Host login can’t query tenant DBs).
    2. After the user can authenticate on Host, they should enter a tenant context using the tenant selection/switch flow (UI does this automatically; for API-only you typically use the switch-tenant extension grant / flow supported by ABP’s Shared Accounts feature rather than trying to pass tenant header to /connect/token).

    Answers to your specific questions

    1. Why can’t userA log in?
      Because under Shared accounts, /connect/token authenticates on Host, but the Host-side lookup can’t find userA (your Host DB record is not a Host record; it’s stored with a tenant id).
    2. Is Host record supposed to have TenantId = NULL?
      In ABP’s multi-tenancy model, TenantId = null means Host-owned data. For shared-account login being Host-side, there must be a Host-side user identity to authenticate against. Seeing only a TenantId = Dev record in the Host database explains the behavior you observe.
    3. How is a Shared user supposed to log in to a tenant?
      Not by sending a tenant header to /connect/token. The Shared User Accounts design is: authenticate globally first, then choose/switch the active tenant (tenant selection in UI; switch-tenant flow for programmatic usage).
    4. Does database-per-tenant change any of this?
      Yes: it makes the Host-side record mandatory, because Host authentication cannot “see” tenant databases. Shared strategy relies on having the global identity available at Host (and then associating/syncing to tenant DBs as needed).
    5. Is custom tenant header supported with Shared accounts?
      Custom tenant resolution headers are generally supported by ABP tenant resolvers, but Shared accounts change the intended login flow: /connect/token is Host-side for shared identity, so a tenant header is not the mechanism to select tenant at that stage.

    Links (verified):

    • https://abp.io/docs/latest/modules/account/shared-user-accounts
    • https://abp.io/docs/10.3/framework/architecture/multi-tenancy/
    • https://abp.io/docs/10.3/framework/data/entity-framework-core/migrations

    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,

    This is a bug in the OpenIddict password grant under Shared User Accounts. At /connect/token the user is looked up in the Host context with the plain lookup, which only matches host accounts (TenantId = null). The row you see for userA in the host DB has the Dev TenantId — it's a tenant-association record created by accept-invite, not a host account, and userA has no host-level account at all. So the host-context lookup filters that row out and the token endpoint reports "no user found" → invalid_grant. The MVC/Blazor login already uses the shared-account-aware lookup, which is why the same user can sign in there.

    It's not caused by database-per-tenant or your custom tenant header — under Shared accounts the login is Host-side by design, so the tenant header at /connect/token is expected to be ignored.

    As a workaround until the fix ships, replace the token controller in your AuthServer/API host project so the password grant uses the shared lookup:

    using System.Collections.Generic;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Authentication;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.Extensions.Options;
    using OpenIddict.Abstractions;
    using OpenIddict.Server.AspNetCore;
    using Volo.Abp.AspNetCore.Controllers;
    using Volo.Abp.MultiTenancy;
    using Volo.Abp.OpenIddict.Controllers;
    using Volo.Abp.Uow;
    
    namespace MyCompanyName.MyProjectName;
    
    [ReplaceControllers(typeof(TokenController))]
    public class SharedAwareTokenController : TokenController
    {
        [UnitOfWork]
        protected override async Task<IActionResult> HandlePasswordAsync(OpenIddictRequest request)
        {
            var tenant = await TenantConfigurationProvider.GetAsync(saveResolveResult: false);
    
            using (CurrentTenant.Change(tenant?.Id))
            {
                await IdentityOptions.SetAsync();
    
                // Resolve the user across tenants from the Host context.
                var user = await UserManager.FindSharedUserByNameAsync(request.Username)
                           ?? await UserManager.FindSharedUserByEmailAsync(request.Username);
                if (user == null)
                {
                    // Unknown user: let the base controller return the standard invalid_grant.
                    return await base.HandlePasswordAsync(request);
                }
    
                using (CurrentTenant.Change(user.TenantId))
                {
                    await IdentityOptions.SetAsync();
    
                    var result = await SignInManager.CheckPasswordSignInAsync(user, request.Password, true);
                    if (!result.Succeeded)
                    {
                        return Forbid(
                            new AuthenticationProperties(new Dictionary<string, string>
                            {
                                [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant,
                                [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Invalid username or password!"
                            }),
                            OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
                    }
    
                    if (await IsTfaEnabledAsync(user))
                    {
                        return await HandleTwoFactorLoginAsync(request, user);
                    }
    
                    return await SetSuccessResultAsync(request, user);
                }
            }
        }
    }
    

    With this in place, userA logs in at /connect/token with just username/email + password (no tenant header needed), and the issued token is already scoped to the tenant the user belongs to.

    This is a minimal override focused on the lookup — it returns a generic invalid_grant on a failed sign-in. We'll fix this in the framework itself in an upcoming release (keeping the detailed lockout/inactive/change-password handling), so the override won't be needed.

    Thanks

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    batuhankara created

    FOLLOW-UP (after applying the TokenController workaround)

    We applied the password-grant workaround (overriding TokenController.HandlePasswordAsync to resolve the user across tenants from the Host context via UserSharingManager.GetUsersByUserNameFromHostAsync / GetUsersByEmailFromHostAsync, then sign in under user.TenantId). Login at /connect/token now works and a tenant-scoped token is issued. Thank you.

    Two follow-up issues remain:

    1) EntityNotFoundException in IdentityDynamicClaimsPrincipalContributor after login

    On the token request and on subsequent authenticated calls, we see this repeatedly:

    [WRN] User not found: 01524844-e59b-7751-25a3-3a21e45548bf
    Volo.Abp.Domain.Entities.EntityNotFoundException: There is no such an entity.
      Entity type: Volo.Abp.Identity.IdentityUser, id: 01524844-e59b-7751-25a3-3a21e45548bf
       at Volo.Abp.Identity.IdentityUserManager.GetByIdAsync(Guid id)
       ...
       at Volo.Abp.Identity.IdentityDynamicClaimsPrincipalContributorCache.GetAsync(Guid userId, Nullable`1 tenantId)
       at Volo.Abp.Identity.IdentityDynamicClaimsPrincipalContributor.ContributeAsync(...)
    

    The id 01524844-... is the invited user's tenant row (it exists in the tenant DB, not the host DB). The dynamic-claims contributor appears to look the user up in a context/DB where that id is not found (host/db-per-tenant), so it throws and the dynamic claims (roles/permissions) are not contributed.

    After login, /api/abp/application-configuration returns:

    "auth": { "grantedPolicies": {} },
    "currentUser": { "isAuthenticated": false, "roles": [] }
    

    i.e. the user has a valid token but no roles/permissions (dynamic claims didn't load).

    Question: Is IdentityDynamicClaimsPrincipalContributor shared-account/db-per-tenant aware in 10.3.0? How should we make dynamic claims resolve the user in the correct tenant DB for a shared user, so the EntityNotFoundException stops and the user's roles/permissions are populated?

    Environment note

    • ABP 10.3.0, database-per-tenant, TenantUserSharingStrategy.Shared, dynamic claims enabled (IsDynamicClaimsEnabled = true).

    UPDATE — the core problem: one shared user, multiple tenants, different roles per tenant (db-per-tenant)

    What we are trying to build (the goal)

    One user account must have access to multiple tenants, with a different role in each tenant.

    Concretely: a supplier's user (home tenant = "dev") is invited as a guest into a sourcing company's tenant ("batu"). They keep one login. In "dev" they have their normal role; in "batu" they get a restricted "External QC" role. They log in once, then switch tenant in-session to move between workspaces. This is exactly the use case Shared User Accounts seems designed for, on a database-per-tenant deployment.

    The data we observe for one shared user (batuhan@linkse.io)

    After inviting + accepting this user into two tenants, here are all the AbpUsers rows for them:

    | Database | TenantId | IdentityUser.Id | EmailConfirmed | |---|---|---|---| | Host DB — dev association | dev e2d504fe | 01524844-… | 0 | | Host DB — batu association | batu a0cadb7b | F8BB49C3-… | 0 | | dev tenant DB | dev e2d504fe | 3751F3BB-… | 1 | | batu tenant DB | batu a0cadb7b | 9488521D-… | 1 |

    So for the same human in the same tenant, there are two different IdentityUser.Id values: a host-side association row and the tenant-DB row. They are matched only by normalized username/email — the Ids differ across databases.

    The central issue — the token's sub is the HOST association id, but every tenant API resolves against the TENANT DB id

    1. Login under Shared resolves /connect/token to Host and signs the user in under their host association row. The issued token's sub (= AbpClaimTypes.UserId) is therefore the host-association id (e.g. 01524844).

    2. Every authenticated request then runs in the tenant context, where the user's id is different (3751F3BB in dev's DB). So:

      • Dynamic claims: IdentityDynamicClaimsPrincipalContributorCache.GetAsync(sub, tenantId) does GetByIdAsync(01524844) in the tenant DB → EntityNotFoundException → empty grantedPolicies (no permissions). (This is issue #1 above.)
      • Any endpoint that reads sub directly (e.g. GET /api/identity/users/{sub}, user profile, GET /api/account/user-sharing) → 404 "There is no entity IdentityUser with id = 01524844…", because the tenant DB only knows 3751F3BB.

      This is not one buggy endpoint — it is a systemic mismatch: the token carries the host id, tenant-context code expects the tenant-DB id.

    How we are currently working around it (and why it feels wrong)

    We have had to add two [Dependency(ReplaceServices)] overrides to get a shared user logged in and permissioned at all:

    1. SharedAwareTokenController ([ReplaceControllers(typeof(TokenController))]): the stock password grant only matches host accounts (TenantId == null), so an invited+accepted user with only tenant-association rows is reported "no user found" → invalid_grant. Our override resolves the user across tenants via UserSharingManager.GetUsersByUserNameFromHostAsync / GetUsersByEmailFromHostAsync, signs in under the user's tenant, and auto-confirms the email (the invitation already proved ownership). This fixed login.

    2. SharedAwareDynamicClaimsCache ([ExposeServices(typeof(IdentityDynamicClaimsPrincipalContributorCache))]): to fix empty permissions, we translate the host-association id → tenant-DB id by matching on NormalizedUserName inside the target tenant, then call base.GetAsync(tenantRow.Id, targetTenantId). This fixed permissions.

    These two get login + permissions working, but they only patch the dynamic-claims path. Every other endpoint that reads sub directly still 404s, because sub is still the host id. Patching endpoint-by-endpoint is clearly the wrong layer.

    Question A — what is the intended sub for a shared user under db-per-tenant?

    Under Shared + database-per-tenant, which id is the access token's sub supposed to be — the host-association id or the tenant-DB id? If it is supposed to be the host id, how is tenant-context code (dynamic claims, IIdentityUserAppService.GetAsync, profile, user-sharing) meant to resolve the correct tenant-DB user from it? Is there a built-in id-translation we are missing, or is the intended design that the host and tenant rows share the same Id (and our data is wrong because invite/accept created differing Ids)?

    If host and tenant rows are supposed to share the same Id, what is the supported way to create a shared membership so the tenant-DB row reuses the host id? (We invited via the Saas tenant invite-user flow and via UserInvitationManager — both produced differing Ids.)

    Question B — tenant switching for a headless SPA (no MVC UI)

    We need the user to switch tenant after login. We found that AbpAccountPublicWebOpenIddictModule registers the "SwitchTenant" flow name (GrantTypes.Add("SwitchTenant")) but does not add a handler to AbpOpenIddictExtensionGrantsOptions.Grants (it adds LinkLogin and Impersonation, but not SwitchTenant — it appears wired only to the MVC SwitchTenantLoginModel page). So POST /connect/token with grant_type=SwitchTenant returns "The specified grant type SwitchTenant is not implemented."

    We worked around it by registering the handler ourselves:

    options.Grants.TryAdd(
        SwitchTenantExtensionGrant.ExtensionGrantName,        // "SwitchTenant"
        new Volo.Abp.Account.Web.ExtensionGrants.SwitchTenantExtensionGrant());
    

    Is this the supported way to enable headless SwitchTenant token exchange for an SPA, or is there an intended API/grant for "switch the current shared user into another of their tenants"? And after a switch, will the new token's sub be the target tenant's id (so tenant APIs resolve), or again a host-association id (re-triggering Question A in the target tenant)?

    Question C — different roles per tenant

    The user must have different roles in each tenant (normal role in dev, "External QC" in batu). With Shared accounts + db-per-tenant, is per-tenant role assignment simply "assign roles on the tenant-DB row" (each tenant DB has its own AbpUserRoles), and is that the supported model? The Saas invite-user flow assigns no roles; UserInvitationManager.CreateAsync has AssignedRoles — is that the recommended channel for per-tenant guest roles, and does it work with DirectlyAddToTenant under Shared?

    Summary of what we need from you

    1. The intended sub/id model for a shared user under database-per-tenant, so we can stop patching id-translation in dynamic claims and (worse) per-endpoint.
    2. The supported headless tenant-switch mechanism for an SPA (is manual SwitchTenantExtensionGrant registration correct?).
    3. The supported per-tenant role assignment path for invited guests.

    If the differing-Id-per-database situation is itself the bug (host and tenant rows should share one Id), please tell us the correct provisioning call so we can fix the data instead of the framework.

    Our environment recap

    • ABP 10.3.0 commercial (Identity.Pro, Account.Pro, Saas), .NET 10, database-per-tenant.
    • TenantUserSharingStrategy.Shared, dynamic claims enabled, Redis distributed lock configured.
    • Custom tenant header linksetenantid (options.TenantKey).
    • Encrypted (JWE) tokens.
    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    batuhankara created

    SHORT QUESTION — GET /api/account/user-sharing only returns 1 of 2 tenants

    Setup: ABP 10.3.0 commercial, .NET 10, database-per-tenant, TenantUserSharingStrategy.Shared.

    Goal: one shared user logs in once, lists their tenants, then uses SwitchTenant to switch by TenantId.

    Problem: A user invited into two tenants can log in and SwitchTenant into both, but GET /api/account/user-sharing returns only one tenant.

    Cause we found: in the host DB there is no TenantId = NULL master row for this user — only two per-tenant association rows. The admin user has a NULL-tenant row; this invited user does not. Host AbpUsers for the user:

    | Id | TenantId | IsActive | Leaved | IsDeleted | |---|---|---|---|---| | 01524844… | dev tenant id | 1 | 0 | 0 | | f8bb49c3… | batu tenant id | 1 | 0 | 0 | | (no row) | NULL | — | — | — |

    UserSharingAppService.GetAllListAsync → GetUserWithTenantsFromHostAsync enumerates the user's tenants from the host master (TenantId = NULL) row; with no master, it returns only the current tenant.

    We are not manually editing any rows — we expect ABP's own invite/accept flow to create the host master row for us. Our questions:

    1. We invited the user via the Saas tenant invite-user flow (POST /api/saas/tenants/{id}/invite-user) and accepted via POST /api/account/user-sharing/invitation/accept. This created the per-tenant rows but no TenantId = NULL master row. Should this flow have created the host master automatically? Is this the wrong invite endpoint for Shared accounts (i.e. we should be calling a different built-in invite that runs UserSharingManager.CreateUserInHostAsync), or is it a bug that the master row is not created?
    2. What is the correct built-in invite/accept path for Shared + db-per-tenant so the host master row is created by ABP, with no manual steps on our side?
    3. For users already in this broken state, is there a supported manager call to backfill the host master row, so /api/account/user-sharing lists all their tenants?
    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,

    Glad login is working. The missing host "root" record isn't actually what's breaking things, so let me clear that up first and then go through your points.

    A user who was only ever invited (never self-registered) has no TenantId = null/Guid.Empty root row — just one host "association" row per tenant, plus the tenant-DB copy. That's a supported shape: switching tenants and listing them do not need a root, and you don't need to backfill one. (A root is only created by self-registration, and it's stored with TenantId = Guid.Empty, not SQL NULL — that's why your WHERE TenantId IS NULL query finds nothing.) The host association row and the tenant-DB row are deliberately separate records with different Ids, matched by username/email — that part is by design too.

    What's actually behind the EntityNotFoundException / empty permissions / 404s is the token's sub. In your override you resolve the user with GetUsersByUserNameFromHostAsync, which returns the host association row, so the token's sub becomes the host Id — and the tenant database doesn't contain that Id, so GetByIdAsync(sub) throws and the dynamic claims come back empty. Switch the override to FindSharedUserByNameAsync / FindSharedUserByEmailAsync (that's what the override we sent earlier used): they resolve the host row, switch into the user's tenant, and return the tenant-DB user, so the sub is the tenant-DB Id and the claims and sub-based endpoints resolve. That single change clears the first problem.

    On the rest:

    • Switch Tenant "not implemented" — that's a known bug that's fixed: the SwitchTenant grant was registered with the OpenIddict server but missing from the extension-grants options, so the token endpoint reported "not implemented". Upgrade to a current 10.4.x that includes the fix and your manual Grants.TryAdd(...) won't be needed (tell us your exact patch and we'll confirm it's in there). After a switch the token is issued for the target tenant, so its sub is that tenant's user — the Id consistency you're after.

    • GET /api/account/user-sharing returning one tenant — that endpoint lists the other tenants you can switch into; it excludes the one you're currently signed into. So a user in two tenants, signed into one, gets the other one back (combine it with the current tenant for the full set). Once the token sub is the tenant-DB user from the fix above, this lines up. If you still think a row is missing, send the host-DB result of SELECT Id, TenantId, IsActive, Leaved FROM AbpUsers WHERE NormalizedEmail = '<UPPERCASE EMAIL>' and we'll check.

    • Per-tenant roles — roles are per tenant, on the tenant-side user row. The SaaS Invite User flow assigns the admin role; for a custom guest role like "External QC", either pass the roles you want through the Identity user-invitation, or assign the role inside that tenant after the user accepts.

    On versions: several Shared User Accounts fixes landed after 10.3.0 (the Switch Tenant one included), so upgrading to the latest 10.4.x is worth doing. The password-grant lookup itself we'll fix in the framework in an upcoming release; until that ships, keep the FindSharedUserByNameAsync-based override.

    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 24, 2026, 12:09
1
ABP Assistant
🔐 You need to be logged in to use the chatbot. Please log in first.