Appearance
Cross-Tenant SSO
Cross-tenant SSO allows a user who has registered at Company A to link their account and log in to Company B using the same credentials. This works across different tenants and across sub-companies within the same tenant. Identity is unified by email or phone number through a GlobalIdentity layer, while each portal's business data (tokens, vouchers, etc.) remains strictly isolated. A single blockchain wallet is shared across all linked portals.
Overview
| Item | Detail |
|---|---|
| Scope | Member App only |
| Identity key | Email or phone number |
| Session carrier | HttpOnly cookie (vio_sso_token) |
| Token model | JWT access/refresh tokens remain portal-specific (one per tenant + sub-company combination) |
| Wallet | One blockchain wallet (same address) shared across all linked portals |
| Data isolation | Each portal (tenant + sub-company) has its own User record and business data |
| Portal key | A portal is identified by the composite key (tenantId, subCompanyId). The parent company uses subCompanyId = null. |
Architecture
┌──────────────┐ cookie ┌─────────────────┐ cookie ┌──────────────┐
│ Company A │ ──────────► │ SSO Session │ ◄────────── │ Company B │
│ Member App │ │ (Global) │ │ Member App │
└──────┬───────┘ └────────┬────────┘ └──────┬───────┘
│ │ │
│ JWT (portal-a) │ GlobalIdentity │ JWT (portal-b)
│ │ │
▼ ▼ ▼
User (portal-a) Email / Phone User (portal-b)
│ Password (hashed) │
└────────────────► Shared Wallet Address ◄─────────────┘A "portal" is a unique combination of tenant and sub-company. For example, Tenant A's parent company and Tenant A's Sub-Company X are two different portals within the same tenant. Users can SSO between them just like they SSO between different tenants.
Key Models
| Model | Purpose |
|---|---|
GlobalIdentity | Stores unified email/phone + hashed password. Links to User records across portals via linkedTenants[], where each entry contains tenantId, subCompanyId, and userId. |
SSOSession | Represents an active global login session. Token stored in an HttpOnly cookie. |
User.globalIdentityId | Reference from a portal-specific user to its global identity. |
User.walletAddress | Shared blockchain wallet address. All linked users reference the same address. |
Portal-Keyed Identity
GlobalIdentity.linkedTenants uses a composite key of (tenantId, subCompanyId) to identify each portal. This allows a single user to have separate profiles at:
- Different tenants (e.g. Company A and Company B)
- The same tenant's parent company and its sub-company (e.g. Company A parent and Company A / Branch X)
- Different sub-companies within the same tenant (e.g. Company A / Branch X and Company A / Branch Y)
The User model enforces uniqueness with a compound index on (email, tenantId, subCompanyId). MongoDB treats null as a distinct value, so a parent-level user (subCompanyId: null) and a sub-company user (subCompanyId: X) with the same email in the same tenant are both allowed.
User Flow
1. First Registration (Company A)
- User registers at Company A.
- Backend creates a
GlobalIdentity(if new) and links the Company AUser. - An
SSOSessionis created; its token is set as anHttpOnlycookie. - User receives tenant-specific JWT tokens for Company A.
- A custodial blockchain wallet is created; its address is stored on the
Userrecord.
2. Cross-Tenant & Cross-Sub-Company Scenarios
The following scenarios apply to both cross-tenant linking (different tenants) and cross-sub-company linking (same tenant, different sub-company). The flows are identical — the system detects the mismatch and prompts the user to link.
There are three scenarios for how a user can link to a new company or sub-company:
Scenario A — Sign Up at Company B (no cookie)
- User opens Company B's Member App and navigates to the Sign Up page.
- User enters an email or phone number that is already registered in Company A.
- Frontend calls
POST /api/auth/sso/check-identifierto detect cross-tenant accounts. - A popup appears: "You have already registered in Company A using this account. Would you like to link your account to Company B and use the same credentials to log in?"
- If the user clicks Yes, they are redirected to Company B's Login page.
- User enters the same email and password, then clicks Log In.
- Backend detects the tenant mismatch and, because the user confirmed linking, creates a new
Userin Company B, links it to theGlobalIdentity, and shares the existing wallet address. - Login succeeds. A confirmation popup appears: "Account successfully linked to Company B."
- User can now explore Company B's Member App.
Scenario B — Visit Company B (cookie available)
- User opens Company B's URL while an SSO session cookie is still active from Company A.
SSOProviderautomatically callsGET /api/auth/sso/checkand detects the existing identity.- If already linked and active: auto-login via token exchange (no popup).
- If not yet linked or new profile needed: the same popup from Scenario A appears automatically (without needing to enter an email).
- If the user clicks Yes, they are redirected to Company B's Login page. The remaining flow is the same as Scenario A from step 6 onward.
Scenario C — Direct Login at Company B (no sign up)
- User goes directly to Company B's Login page and enters the credentials from Company A.
- Backend finds the user in Company A, validates the password, and detects the tenant mismatch.
- Backend returns a
crossTenantRequiredresponse with the source company name. - A popup appears: "You have already registered in Company A using this account. Would you like to link your account to Company B?"
- If the user clicks Yes, the login request is re-sent with
crossTenantLink: true. - Backend creates the user in Company B, links to GlobalIdentity, shares the wallet.
- Login succeeds. A confirmation popup appears: "Account successfully linked to Company B."
- User can now explore Company B's Member App.
Scenario E — Sub-company member on parent company URL (same tenant)
- User registered only at Company A / Branch X opens Company A's parent Member App login URL (no sub-company segment).
- Backend may return
parentPortalLinkRequiredwithsourceSubCompanyName, or resolve a previously linked parent-level profile if one exists. - User confirms linking; the client retries with
crossParentPortalLink: true. - Backend creates or links a tenant-level user (
subCompanyIdnull), linksGlobalIdentity, and returns JWTs for the parent portal.
Scenario D — Direct Login at Sub-Company (same tenant)
- User registered at Company A's parent portal goes to Company A / Branch X's Login page and enters their credentials.
- Backend finds the user in Company A parent, validates the password, and detects a sub-company mismatch (same tenant, different sub-company).
- Backend returns a
crossSubCompanyRequiredresponse with the source sub-company name. - A popup appears: "You have already registered in Company A. Would you like to link your account to Branch X?"
- If the user clicks Yes, the login request is re-sent with
crossSubCompanyLink: true. - Backend creates a new
Userin Company A withsubCompanyIdset to Branch X, links it to the sameGlobalIdentity, and shares the wallet. - Login succeeds.
- User can now explore Branch X's Member App.
mermaid
flowchart TD
subgraph scenarioA ["Scenario A: Sign Up (no cookie)"]
A1["User enters email at Company B Sign Up"] --> A2["API: POST /auth/sso/check-identifier"]
A2 --> A3{"Exists in other tenant?"}
A3 -->|Yes| A4["Show popup: 'Registered in Company A'"]
A4 -->|Yes| A5["Navigate to Company B Login"]
A5 --> A6["User enters credentials, clicks Login"]
A6 --> A7["API: POST /auth/login with crossTenantLink=true"]
A7 --> A8["Backend creates User, links identity, shares wallet"]
A8 --> A9["Show confirmation: 'Account linked'"]
A3 -->|No| A10["Continue normal registration"]
end
subgraph scenarioB ["Scenario B: Cookie Available"]
B1["User visits Company B"] --> B2["SSOProvider: GET /auth/sso/check"]
B2 --> B3{"SSO session found?"}
B3 -->|"Already linked"| B7["Auto SSO login"]
B3 -->|"Not linked / new"| B4["Show popup automatically"]
B4 -->|Yes| B5["Navigate to Company B Login"]
B5 --> B6["Same as Scenario A step 6 onward"]
end
subgraph scenarioC ["Scenario C: Direct Login"]
C1["User enters Company A creds at Company B Login"] --> C2["API: POST /auth/login"]
C2 --> C3{"Tenant mismatch?"}
C3 -->|Yes| C4["Return crossTenantRequired"]
C4 --> C5["Show popup: 'Registered in Company A'"]
C5 -->|Yes| C6["Re-send login with crossTenantLink=true"]
C6 --> C7["Same as Scenario A step 8 onward"]
end
subgraph scenarioD ["Scenario D: Cross-Sub-Company Login"]
D1["User enters parent creds at Branch X Login"] --> D2["API: POST /auth/login"]
D2 --> D3{"Same tenant, sub-company mismatch?"}
D3 -->|Yes| D4["Return crossSubCompanyRequired"]
D4 --> D5["Show popup: 'Registered in parent company'"]
D5 -->|Yes| D6["Re-send login with crossSubCompanyLink=true"]
D6 --> D7["Backend creates User in Branch X, links identity, shares wallet"]
D7 --> D8["Login success"]
end3. Account Switching
After a user has linked their account to multiple companies or sub-companies, there are two ways to switch:
Quick Switch from Home Page
- On the Home page, tap your profile avatar in the top-left corner.
- A bottom sheet appears showing all linked portals (companies and sub-companies).
- Sub-company entries are displayed as "Company Name — Sub-Company Name".
- The current portal is highlighted with a "Current" badge.
- Tap any other portal to switch instantly.
- The app navigates to that portal's URL and auto-logs in via SSO.
Switch Indicator
When multiple accounts are linked, a small switch icon appears on the profile avatar to indicate quick switching is available.
Full Linked Accounts Page
- Navigate to Account page in the Member App.
- Tap Linked Accounts to see all linked portals.
- Each entry shows the company name (and sub-company name if applicable).
- Tap the Switch button on any non-current portal.
- The app navigates to that portal's URL (e.g.
/{company-slug}/or/{company-slug}/{sub-company-slug}/). SSOProviderdetects the cookie, finds an already-linked account, and auto-logs in.- User is now in the other portal's app with its branding and data.
From this page, you can also unlink accounts if you no longer want them connected.
Wallet Sharing
When a user links across portals (tenants or sub-companies), all linked User records share the same blockchain wallet address. This means:
- Tokens earned in any linked portal go to the same wallet.
- The wallet is only created once (during the first registration).
- When
createProfileForTenantis called for subsequent portals, it checks for an existing wallet among linked users and reuses that address instead of creating a new one. - Each
Userrecord stores the shared address in itswalletAddressfield, but only oneWalletdocument exists in the database.
Data Isolation
SSO shares the wallet address and credentials across portals. All other business data remains strictly isolated per portal:
Userrecords (different_id,tenantId,subCompanyId)- Token balances, vouchers, transactions, campaigns
- Push notification subscriptions and preferences
- Store associations and membership tiers
Isolation Guarantees
| Layer | Mechanism |
|---|---|
| JWT | Every token contains the target portal's tenantId and subCompanyId. |
| Auth middleware | authenticate() derives req.tenantId from the JWT user, overriding any request headers. |
| Request interceptor | Frontend detects cross-tenant/sub-company navigation and does not send stale auth tokens. |
| Database indices | User.email + tenantId + subCompanyId compound unique index ensures per-portal uniqueness. |
| SSO exchange | Generates a new JWT for the target portal only. |
API Endpoints
All SSO endpoints are under /api/auth/sso/.
Check Cross-Tenant Identifier
POST /api/auth/sso/check-identifierAuth: None (public, rate-limited)
Checks whether an email or phone number is registered in any tenant other than the target. Used by the Sign Up page to detect cross-tenant accounts before OTP is sent.
Body:
json
{
"identifier": "user@example.com",
"identifierType": "email",
"targetTenantId": "current-tenant-id"
}Response (account found):
json
{
"success": true,
"data": {
"exists": true,
"sourceTenantName": "Company A",
"sourceTenantSlug": "company-a",
"sourceSubCompanyName": "Branch X"
}
}sourceSubCompanyName is present when the matched account is scoped to a sub-company within the source tenant (omitted when the user only has a parent-level profile).
Response (not found):
json
{
"success": true,
"data": {
"exists": false
}
}Login with Cross-Tenant / Cross-Sub-Company Linking
POST /api/auth/loginThe standard login endpoint supports crossTenantLink and crossSubCompanyLink parameters for linking accounts.
Body:
json
{
"identifier": "user@example.com",
"identifierType": "email",
"password": "...",
"rememberMe": false,
"crossTenantLink": true,
"crossSubCompanyLink": false,
"crossParentPortalLink": false
}When crossTenantLink is false (or omitted) and a tenant mismatch is detected:
json
{
"success": true,
"data": {
"crossTenantRequired": true,
"sourceTenantName": "Company A",
"sourceTenantSlug": "company-a",
"sourceSubCompanyName": "Branch X"
}
}sourceSubCompanyName is included when the matched user belongs to a sub-company portal so the client can show "You have already registered in Branch X" (or similar) instead of only the parent tenant name.
When crossSubCompanyLink is false (or omitted) and a same-tenant sub-company mismatch is detected:
json
{
"success": true,
"data": {
"crossSubCompanyRequired": true,
"sourceSubCompanyName": "Parent Company"
}
}When logging in on the parent company URL (no sub-company in the path) while the only matching profile is a sub-company member (same tenant), the API may return:
json
{
"success": true,
"data": {
"parentPortalLinkRequired": true,
"sourceSubCompanyName": "Branch X"
}
}After the user confirms, the client retries login with crossParentPortalLink: true to create or link a tenant-level (subCompanyId null) profile.
When crossTenantLink, crossSubCompanyLink, or crossParentPortalLink is true, the backend creates or links the user in the target portal, links it to the GlobalIdentity, shares the wallet, and returns:
json
{
"success": true,
"data": {
"user": { "...": "..." },
"accessToken": "...",
"refreshToken": "...",
"accountLinked": true,
"linkedTenantName": "Company B",
"linkedSubCompanyName": "Branch X"
}
}Check SSO Status
GET /api/auth/sso/check?tenantId={targetTenantId}&subCompanyId={optionalSubCompanyId}Auth: None (reads SSO cookie)
The subCompanyId parameter is optional. When provided, the backend performs a portal-keyed lookup to check if the user has a profile at the exact portal (tenant + sub-company combination).
Response (canSSO: true example):
json
{
"success": true,
"data": {
"canSSO": true,
"requiresLink": false,
"newProfile": true,
"globalIdentityId": "...",
"tenantName": "Company B"
}
}Exchange SSO Token
POST /api/auth/sso/exchangeAuth: None (reads SSO cookie)
Body:
json
{
"tenantId": "target-tenant-id",
"createProfile": false,
"subCompanyId": "optional",
"rememberMe": false
}Response: { user, accessToken, refreshToken }
Link Existing Account
POST /api/auth/sso/linkAuth: None (reads SSO cookie)
Body:
json
{
"tenantId": "target-tenant-id",
"password": "user-password-for-confirmation",
"subCompanyId": "optional-sub-company-id"
}Response: { user, accessToken, refreshToken }
Get Linked Tenants
GET /api/auth/me/linked-tenantsAuth: JWT Bearer
Response: Array of linked portals with tenant names, slugs, logos, sub-company names/slugs, and user profiles.
json
[
{
"tenantId": "...",
"tenantName": "Company A",
"tenantSlug": "company-a",
"tenantLogo": "...",
"subCompanyId": null,
"subCompanyName": null,
"subCompanySlug": null,
"userId": "...",
"userDisplayName": "John",
"linkedAt": "2025-01-01T00:00:00Z"
},
{
"tenantId": "...",
"tenantName": "Company A",
"tenantSlug": "company-a",
"tenantLogo": "...",
"subCompanyId": "...",
"subCompanyName": "Branch X",
"subCompanySlug": "branch-x",
"userId": "...",
"userDisplayName": "John",
"linkedAt": "2025-03-15T00:00:00Z"
}
]Unlink Tenant
POST /api/auth/sso/unlinkAuth: JWT Bearer
Body:
json
{
"tenantId": "tenant-to-unlink",
"subCompanyId": "optional-sub-company-to-unlink"
}Cannot unlink the current portal or the last remaining portal.
Revoke SSO Session (Global Logout)
POST /api/auth/sso/logoutAuth: None (reads SSO cookie)
Revokes the SSO session and clears the cookie. Does not invalidate individual tenant JWTs.
Configuration
Backend .env
bash
# Cookie domain for SSO token sharing across subdomains.
# Leave empty for localhost / path-based routing.
# For production: .yourdomain.com
SSO_COOKIE_DOMAIN=
# Cookie name (default: vio_sso_token)
SSO_COOKIE_NAME=vio_sso_token
# Session expiry in days
SSO_SESSION_EXPIRY_DAYS=7
# "Remember Me" session expiry in days
SSO_REMEMBER_ME_EXPIRY_DAYS=30Cookie Behaviour
| Environment | secure | sameSite | httpOnly |
|---|---|---|---|
NODE_ENV=development | false | lax | true |
NODE_ENV=production | true | lax | true |
For localhost development with path-based tenants (/tenant-a/, /tenant-b/), leave SSO_COOKIE_DOMAIN empty. The cookie is shared across all paths on the same host.
For subdomain-based tenants (tenant-a.app.com, tenant-b.app.com), set SSO_COOKIE_DOMAIN=.app.com.
Frontend Architecture
Key Components
| Component / Module | Role |
|---|---|
SSOProvider | Wraps the app. Calls GET /auth/sso/check when unauthenticated; runs silent ssoLogin (token exchange) on all routes including /login so account switching can recover without re-entering the password. The SSO Account Detected modal is not shown on /login or /forgot-password (those flows use LoginPage API-driven prompts) to avoid duplicate modals. |
CrossTenantDetectedModal | Unified popup for all scenarios (cross-tenant, cross-sub-company, parent-portal link). The bold source line prefers sourceSubCompanyName over sourceTenantName when the existing account was created under a sub-company. |
AccountLinkedConfirmationModal | Success confirmation shown after a cross-tenant/sub-company login completes. |
authStore._tenantSlug | Persisted field tracking which tenant the current tokens belong to. |
authStore.crossTenantInfo | Transient state holding cross-tenant prompt data from the login response (includes optional sourceSubCompanyName). |
authStore.crossSubCompanyInfo | Transient state holding cross-sub-company prompt data from the login response. |
authStore.crossParentPortalInfo | Transient state when parentPortalLinkRequired is returned (sub-company member logging in on parent URL). |
authStore.accountLinkedInfo | Transient state set after successful linking, drives the confirmation modal. |
authStore.checkCrossTenantIdentifier() | Calls POST /auth/sso/check-identifier to detect cross-tenant accounts during signup. |
RegisterPage | On submit, does not abort when checkSSO would return canSSO (e.g. user has an SSO cookie from another company); cross-tenant detection and requestOTP must still run. |
authStore.clearAuthState() | Synchronous state clear (no API calls) for instant cross-tenant cleanup. |
api.js request interceptor | Compares URL tenant slug with _tenantSlug; skips sending stale tokens on mismatch. |
api.js response interceptor | On 401 during cross-tenant transition: does not redirect, lets SSOProvider handle it. |
Backend login resolution (summary)
- Email identifiers are normalized to lowercase for lookup and linking so they match stored
User/GlobalIdentityvalues. - On parent portal requests (
subCompanyIdnull), the service prefers a tenant-level user document (subCompanyIdnull) over a sub-company row when both exist, so the correct profile is used for JWT issuance. - Parent portal link (
parentPortalLinkRequired/crossParentPortalLink): when a sub-company member signs in on the parent company URL, the API can create or link a parent-level profile for the same global identity.
Cross-Tenant Navigation Sequence
Scenario B (Cookie Available)
1. URL changes to /company-b/
2. Request interceptor detects _tenantSlug mismatch → skips auth header
3. SSOProvider detects user.tenantId ≠ brandingData.tenantId → clearAuthState()
4. SSOProvider calls GET /api/auth/sso/check (cookie sent automatically)
5. If already linked → auto-login via token exchange
6. If not linked → show CrossTenantDetectedModal
7. User confirms → navigate to /company-b/login with crossTenantConfirmed state
8. Login page sends POST /auth/login with crossTenantLink=true
9. On success → confirmation modal shown, then navigate to homeScenario A (Signup Detection)
1. User enters email on /company-b/register
2. On submit, RegisterPage calls POST /auth/sso/check-identifier
3. If exists in another tenant → show CrossTenantDetectedModal
4. User confirms → navigate to /company-b/login with crossTenantConfirmed state
5. Login page sends POST /auth/login with crossTenantLink=true
6. On success → confirmation modal, then navigate to homeScenario C (Direct Login — Cross-Tenant)
1. User enters Company A creds on /company-b/login and clicks Log In
2. POST /auth/login returns { crossTenantRequired: true, sourceTenantName }
3. LoginPage shows CrossTenantDetectedModal
4. User confirms → re-send POST /auth/login with crossTenantLink=true
5. On success → confirmation modal, then navigate to homeScenario D (Direct Login — Cross-Sub-Company)
1. User enters parent creds on /company-a/branch-x/login and clicks Log In
2. POST /auth/login returns { crossSubCompanyRequired: true, sourceSubCompanyName }
3. LoginPage shows CrossTenantDetectedModal (reused with sub-company wording)
4. User confirms → re-send POST /auth/login with crossSubCompanyLink=true
5. On success → navigate to homeAccount Switching
Users can switch between linked companies in two ways:
Quick Switch (Home Page Avatar)
- User taps the profile avatar on the Home page.
HomePageshows an Account Switcher bottom sheet listing all linked portals.- Sub-company entries display as "Company Name — Sub-Company Name".
- Data is fetched via
authStore.fetchLinkedTenants()(callsGET /api/auth/me/linked-tenants). - Tapping a non-current portal triggers
window.location.href = /{company-slug}/or/{company-slug}/{sub-company-slug}/. - SSOProvider on the new portal detects the cookie and auto-logs in.
Full Page (Account → Linked Accounts)
- Navigate to Account → Linked Accounts.
- The page shows all linked portals with their logos, company names, and sub-company names.
- Tap the Switch (arrow) button on a non-current portal.
- The browser navigates to
/{company-slug}/or/{company-slug}/{sub-company-slug}/. - SSOProvider detects the cookie, finds the linked account, and auto-logs in.
Migration
Initial SSO Setup
For existing deployments, run the migration script to create GlobalIdentity records for all existing users:
bash
cd vio-v4-api
node scripts/migrate-sso-global-identity.jsThis script:
- Finds all users without a
globalIdentityId - Groups them by email/phone
- Creates
GlobalIdentityrecords and links existing users - Is idempotent and safe to run multiple times
Portal-Keyed Index Migration
After enabling intra-tenant cross-sub-company SSO, the database indexes must be updated. The old 2-field unique indexes (email, tenantId) and (phone, tenantId) must be replaced with 3-field indexes (email, tenantId, subCompanyId) and (phone, tenantId, subCompanyId).
bash
cd vio-v4-api
# Preview what will be changed (safe, no modifications)
node scripts/migrate-user-indexes-portal-keyed.js --dry-run
# Apply the migration
node scripts/migrate-user-indexes-portal-keyed.jsThis script:
- Drops old 2-field compound unique indexes on the
userscollection - Drops the old single-field
linkedTenants.tenantIdindex onglobalidentities - New 3-field indexes are auto-created by Mongoose on next application startup
- Existing data is fully compatible — no data migration needed
- The
GlobalIdentity.linkedTenantsentries withoutsubCompanyIdare treated assubCompanyId: null(parent company)
QA test cases (Cross-Tenant SSO)
Use at least two tenants (e.g. Tenant A, Tenant B) and, within one tenant, two sub-companies (Sub X, Sub Y) plus the parent URL (subCompanyId null). Prepare email/phone test accounts and clear cookies between cases where noted.
Cross-tenant (different companies)
| # | Case | Steps | Expected |
|---|---|---|---|
| CT-1 | Parent-level registration at A then login at B | Register at A parent URL; open B login; enter same email/password | crossTenantRequired (or signup flow); modal shows Tenant A name; Yes → link succeeds; confirmation modal; no duplicate SSO+Login modals on B /login. |
| CT-2 | Sub-company registration at A then login at B | Register at A / Sub X only; open B login; same credentials | Modal bold line shows Sub X name (sourceSubCompanyName), not only Tenant A; link succeeds. |
| CT-3 | Signup at B with existing A email | On B Register, enter email already used at A | check-identifier returns exists; modal shows sub-company or tenant name; Yes → navigate to login; Send OTP proceeds (no silent no-op). |
| CT-4 | Signup at B with SSO cookie from A | Log in at A; open B /register in same browser; submit same email | Send OTP still runs (registration not blocked by canSSO alone); cross-tenant or OTP flow works. |
Same tenant — sub-company vs parent
| # | Case | Steps | Expected |
|---|---|---|---|
| SC-1 | Parent user → Sub X login | Register at parent; open A / Sub X login; same credentials | crossSubCompanyRequired; modal; Yes + crossSubCompanyLink → profile at Sub X; wallet shared. |
| SC-2 | Sub X user → parent login | Register at A / Sub X only; open A parent login | parentPortalLinkRequired or direct match if parent profile already linked; confirm → parent-level profile; no "Access denied" for valid linked parent user. |
| SC-3 | Sub X → Sub Y login | Link or register at Sub X; open A / Sub Y login | Appropriate cross-sub prompt or existing profile; no erroneous 409 duplicate email on confirm. |
SSO session, switching, and UI
| # | Case | Steps | Expected |
|---|---|---|---|
| SS-1 | SSO check on B | Visit B with valid SSO cookie; not linked yet | GET /auth/sso/check runs; modal or silent exchange per rules; no two stacked Account Detected modals on /login. |
| SS-2 | Account switch parent ↔ sub | Link parent + Sub X; from home, switch to other portal | Navigates by URL; silent ssoLogin on /login if session cleared; no forced password re-entry when exchange succeeds. |
| SS-3 | Linked accounts list | Account → Linked Accounts | Lists tenant + sub-company names; switch control works. |
Edge / regression
| # | Case | Steps | Expected |
|---|---|---|---|
| EG-1 | Email case | Register with mixed-case email; login with different casing | Login and linking succeed (normalized email). |
| EG-2 | Unlink not last portal | Unlink one linked portal | Succeeds; cannot unlink last remaining portal. |
Security Considerations
- The
POST /auth/sso/check-identifierendpoint reveals whether an email/phone is registered. It should be rate-limited to prevent enumeration attacks. - Password sync: when a user changes their password in any tenant, the
GlobalIdentitypassword should also be updated. This is handled by the existingchangePasswordflow. - The
crossTenantLinklogin parameter only takes effect when a genuine tenant mismatch is detected; it cannot be abused to bypass normal login restrictions.