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)
- Created the host and a tenant named "Dev" (database-per-tenant; Dev has its own database).
- Logged in to the host as the default
admin. - Invited an existing user (
userA— emailuserA@example.com) into tenant Dev using the invitation feature. - 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
- Why can
userAnot log in after being invited+accepted under Shared accounts? - Is the host record supposed to have
TenantId = NULL? In my host DB the user only appears with a tenant id, and there is noTenantId = NULLhost 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? - How is a Shared user supposed to log in to a tenant? It looks like the
linksetenantidheader is intentionally ignored at/connect/tokenonce Shared is enabled (theAbpAccounttenant resolver forces Host). Is the intended flow: log in host-side (no tenant), then use theSwitchTenantgrant? If so, host login requires a host (TenantId = NULL) record — which I don't seem to have for this user. - 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?
- 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.
5 Answer(s)
-
0
- With
TenantUserSharingStrategy.Shared, the credential verification/login happens on the Host side (global identity). That’s why/connect/tokenresolves 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,
userAmust 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 foruserAhasTenantId = <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)
- 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).
- 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
- Why can’t
userAlog in?
Because under Shared accounts,/connect/tokenauthenticates on Host, but the Host-side lookup can’t finduserA(your Host DB record is not a Host record; it’s stored with a tenant id). - Is Host record supposed to have
TenantId = NULL?
In ABP’s multi-tenancy model,TenantId = nullmeans Host-owned data. For shared-account login being Host-side, there must be a Host-side user identity to authenticate against. Seeing only aTenantId = Devrecord in the Host database explains the behavior you observe. - 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). - 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). - 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/tokenis 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) - With
-
0
Hi,
This is a bug in the OpenIddict password grant under Shared User Accounts. At
/connect/tokenthe user is looked up in the Host context with the plain lookup, which only matches host accounts (TenantId = null). The row you see foruserAin the host DB has the DevTenantId— it's a tenant-association record created by accept-invite, not a host account, anduserAhas 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/tokenis 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,
userAlogs in at/connect/tokenwith 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_granton 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) -
0
FOLLOW-UP (after applying the TokenController workaround)
We applied the password-grant workaround (overriding
TokenController.HandlePasswordAsyncto resolve the user across tenants from the Host context viaUserSharingManager.GetUsersByUserNameFromHostAsync/GetUsersByEmailFromHostAsync, then sign in underuser.TenantId). Login at/connect/tokennow works and a tenant-scoped token is issued. Thank you.Two follow-up issues remain:
1)
EntityNotFoundExceptionin IdentityDynamicClaimsPrincipalContributor after loginOn 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-configurationreturns:"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
IdentityDynamicClaimsPrincipalContributorshared-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 theEntityNotFoundExceptionstops 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
AbpUsersrows for them:| Database | TenantId | IdentityUser.Id | EmailConfirmed | |---|---|---|---| | Host DB — dev association | dev
e2d504fe|01524844-…| 0 | | Host DB — batu association | batua0cadb7b|F8BB49C3-…| 0 | | dev tenant DB | deve2d504fe|3751F3BB-…| 1 | | batu tenant DB | batua0cadb7b|9488521D-…| 1 |So for the same human in the same tenant, there are two different
IdentityUser.Idvalues: 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
subis the HOST association id, but every tenant API resolves against the TENANT DB idLogin under Shared resolves
/connect/tokento Host and signs the user in under their host association row. The issued token'ssub(=AbpClaimTypes.UserId) is therefore the host-association id (e.g.01524844).Every authenticated request then runs in the tenant context, where the user's id is different (
3751F3BBin dev's DB). So:- Dynamic claims:
IdentityDynamicClaimsPrincipalContributorCache.GetAsync(sub, tenantId)doesGetByIdAsync(01524844)in the tenant DB →EntityNotFoundException→ emptygrantedPolicies(no permissions). (This is issue #1 above.) - Any endpoint that reads
subdirectly (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 knows3751F3BB.
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.
- Dynamic claims:
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: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 viaUserSharingManager.GetUsersByUserNameFromHostAsync/GetUsersByEmailFromHostAsync, signs in under the user's tenant, and auto-confirms the email (the invitation already proved ownership). This fixed login.SharedAwareDynamicClaimsCache([ExposeServices(typeof(IdentityDynamicClaimsPrincipalContributorCache))]): to fix empty permissions, we translate the host-association id → tenant-DB id by matching onNormalizedUserNameinside the target tenant, then callbase.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
subdirectly still 404s, becausesubis still the host id. Patching endpoint-by-endpoint is clearly the wrong layer.Question A — what is the intended
subfor a shared user under db-per-tenant?Under Shared + database-per-tenant, which id is the access token's
subsupposed 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
AbpAccountPublicWebOpenIddictModuleregisters the"SwitchTenant"flow name (GrantTypes.Add("SwitchTenant")) but does not add a handler toAbpOpenIddictExtensionGrantsOptions.Grants(it addsLinkLoginandImpersonation, but notSwitchTenant— it appears wired only to the MVCSwitchTenantLoginModelpage). SoPOST /connect/tokenwithgrant_type=SwitchTenantreturns "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
SwitchTenanttoken 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'ssubbe 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.CreateAsynchasAssignedRoles— is that the recommended channel for per-tenant guest roles, and does it work withDirectlyAddToTenantunder Shared?Summary of what we need from you
- 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. - The supported headless tenant-switch mechanism for an SPA (is manual
SwitchTenantExtensionGrantregistration correct?). - 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) - ABP 10.3.0, database-per-tenant,
-
0
SHORT QUESTION —
GET /api/account/user-sharingonly returns 1 of 2 tenantsSetup: ABP 10.3.0 commercial, .NET 10, database-per-tenant,
TenantUserSharingStrategy.Shared.Goal: one shared user logs in once, lists their tenants, then uses
SwitchTenantto switch byTenantId.Problem: A user invited into two tenants can log in and
SwitchTenantinto both, butGET /api/account/user-sharingreturns only one tenant.Cause we found: in the host DB there is no
TenantId = NULLmaster row for this user — only two per-tenant association rows. Theadminuser has a NULL-tenant row; this invited user does not. HostAbpUsersfor 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→GetUserWithTenantsFromHostAsyncenumerates 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:
- We invited the user via the Saas tenant
invite-userflow (POST /api/saas/tenants/{id}/invite-user) and accepted viaPOST /api/account/user-sharing/invitation/accept. This created the per-tenant rows but noTenantId = NULLmaster 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 runsUserSharingManager.CreateUserInHostAsync), or is it a bug that the master row is not created? - 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?
- For users already in this broken state, is there a supported manager call to backfill the host
master row, so
/api/account/user-sharinglists all their tenants?
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - We invited the user via the Saas tenant
-
0
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.Emptyroot 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 withTenantId = Guid.Empty, not SQLNULL— that's why yourWHERE TenantId IS NULLquery 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'ssub. In your override you resolve the user withGetUsersByUserNameFromHostAsync, which returns the host association row, so the token'ssubbecomes the host Id — and the tenant database doesn't contain that Id, soGetByIdAsync(sub)throws and the dynamic claims come back empty. Switch the override toFindSharedUserByNameAsync/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 thesubis the tenant-DB Id and the claims andsub-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 itssubis that tenant's user — the Id consistency you're after.GET /api/account/user-sharingreturning 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 tokensubis the tenant-DB user from the fix above, this lines up. If you still think a row is missing, send the host-DB result ofSELECT 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
adminrole; 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)