Skip to content

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

ItemDetail
ScopeMember App only
Identity keyEmail or phone number
Session carrierHttpOnly cookie (vio_sso_token)
Token modelJWT access/refresh tokens remain portal-specific (one per tenant + sub-company combination)
WalletOne blockchain wallet (same address) shared across all linked portals
Data isolationEach portal (tenant + sub-company) has its own User record and business data
Portal keyA 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

ModelPurpose
GlobalIdentityStores unified email/phone + hashed password. Links to User records across portals via linkedTenants[], where each entry contains tenantId, subCompanyId, and userId.
SSOSessionRepresents an active global login session. Token stored in an HttpOnly cookie.
User.globalIdentityIdReference from a portal-specific user to its global identity.
User.walletAddressShared 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)

  1. User registers at Company A.
  2. Backend creates a GlobalIdentity (if new) and links the Company A User.
  3. An SSOSession is created; its token is set as an HttpOnly cookie.
  4. User receives tenant-specific JWT tokens for Company A.
  5. A custodial blockchain wallet is created; its address is stored on the User record.

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:

  1. User opens Company B's Member App and navigates to the Sign Up page.
  2. User enters an email or phone number that is already registered in Company A.
  3. Frontend calls POST /api/auth/sso/check-identifier to detect cross-tenant accounts.
  4. 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?"
  5. If the user clicks Yes, they are redirected to Company B's Login page.
  6. User enters the same email and password, then clicks Log In.
  7. Backend detects the tenant mismatch and, because the user confirmed linking, creates a new User in Company B, links it to the GlobalIdentity, and shares the existing wallet address.
  8. Login succeeds. A confirmation popup appears: "Account successfully linked to Company B."
  9. User can now explore Company B's Member App.
  1. User opens Company B's URL while an SSO session cookie is still active from Company A.
  2. SSOProvider automatically calls GET /api/auth/sso/check and detects the existing identity.
  3. If already linked and active: auto-login via token exchange (no popup).
  4. If not yet linked or new profile needed: the same popup from Scenario A appears automatically (without needing to enter an email).
  5. 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)

  1. User goes directly to Company B's Login page and enters the credentials from Company A.
  2. Backend finds the user in Company A, validates the password, and detects the tenant mismatch.
  3. Backend returns a crossTenantRequired response with the source company name.
  4. A popup appears: "You have already registered in Company A using this account. Would you like to link your account to Company B?"
  5. If the user clicks Yes, the login request is re-sent with crossTenantLink: true.
  6. Backend creates the user in Company B, links to GlobalIdentity, shares the wallet.
  7. Login succeeds. A confirmation popup appears: "Account successfully linked to Company B."
  8. User can now explore Company B's Member App.

Scenario E — Sub-company member on parent company URL (same tenant)

  1. User registered only at Company A / Branch X opens Company A's parent Member App login URL (no sub-company segment).
  2. Backend may return parentPortalLinkRequired with sourceSubCompanyName, or resolve a previously linked parent-level profile if one exists.
  3. User confirms linking; the client retries with crossParentPortalLink: true.
  4. Backend creates or links a tenant-level user (subCompanyId null), links GlobalIdentity, and returns JWTs for the parent portal.

Scenario D — Direct Login at Sub-Company (same tenant)

  1. User registered at Company A's parent portal goes to Company A / Branch X's Login page and enters their credentials.
  2. Backend finds the user in Company A parent, validates the password, and detects a sub-company mismatch (same tenant, different sub-company).
  3. Backend returns a crossSubCompanyRequired response with the source sub-company name.
  4. A popup appears: "You have already registered in Company A. Would you like to link your account to Branch X?"
  5. If the user clicks Yes, the login request is re-sent with crossSubCompanyLink: true.
  6. Backend creates a new User in Company A with subCompanyId set to Branch X, links it to the same GlobalIdentity, and shares the wallet.
  7. Login succeeds.
  8. 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"]
    end

3. 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

  1. On the Home page, tap your profile avatar in the top-left corner.
  2. A bottom sheet appears showing all linked portals (companies and sub-companies).
  3. Sub-company entries are displayed as "Company Name — Sub-Company Name".
  4. The current portal is highlighted with a "Current" badge.
  5. Tap any other portal to switch instantly.
  6. 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

  1. Navigate to Account page in the Member App.
  2. Tap Linked Accounts to see all linked portals.
  3. Each entry shows the company name (and sub-company name if applicable).
  4. Tap the Switch button on any non-current portal.
  5. The app navigates to that portal's URL (e.g. /{company-slug}/ or /{company-slug}/{sub-company-slug}/).
  6. SSOProvider detects the cookie, finds an already-linked account, and auto-logs in.
  7. 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 createProfileForTenant is called for subsequent portals, it checks for an existing wallet among linked users and reuses that address instead of creating a new one.
  • Each User record stores the shared address in its walletAddress field, but only one Wallet document exists in the database.

Data Isolation

SSO shares the wallet address and credentials across portals. All other business data remains strictly isolated per portal:

  • User records (different _id, tenantId, subCompanyId)
  • Token balances, vouchers, transactions, campaigns
  • Push notification subscriptions and preferences
  • Store associations and membership tiers

Isolation Guarantees

LayerMechanism
JWTEvery token contains the target portal's tenantId and subCompanyId.
Auth middlewareauthenticate() derives req.tenantId from the JWT user, overriding any request headers.
Request interceptorFrontend detects cross-tenant/sub-company navigation and does not send stale auth tokens.
Database indicesUser.email + tenantId + subCompanyId compound unique index ensures per-portal uniqueness.
SSO exchangeGenerates 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-identifier

Auth: 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/login

The 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/exchange

Auth: None (reads SSO cookie)

Body:

json
{
  "tenantId": "target-tenant-id",
  "createProfile": false,
  "subCompanyId": "optional",
  "rememberMe": false
}

Response: { user, accessToken, refreshToken }

POST /api/auth/sso/link

Auth: 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-tenants

Auth: 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"
  }
]
POST /api/auth/sso/unlink

Auth: 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/logout

Auth: 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=30
EnvironmentsecuresameSitehttpOnly
NODE_ENV=developmentfalselaxtrue
NODE_ENV=productiontruelaxtrue

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 / ModuleRole
SSOProviderWraps 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.
CrossTenantDetectedModalUnified 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.
AccountLinkedConfirmationModalSuccess confirmation shown after a cross-tenant/sub-company login completes.
authStore._tenantSlugPersisted field tracking which tenant the current tokens belong to.
authStore.crossTenantInfoTransient state holding cross-tenant prompt data from the login response (includes optional sourceSubCompanyName).
authStore.crossSubCompanyInfoTransient state holding cross-sub-company prompt data from the login response.
authStore.crossParentPortalInfoTransient state when parentPortalLinkRequired is returned (sub-company member logging in on parent URL).
authStore.accountLinkedInfoTransient state set after successful linking, drives the confirmation modal.
authStore.checkCrossTenantIdentifier()Calls POST /auth/sso/check-identifier to detect cross-tenant accounts during signup.
RegisterPageOn 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 interceptorCompares URL tenant slug with _tenantSlug; skips sending stale tokens on mismatch.
api.js response interceptorOn 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 / GlobalIdentity values.
  • On parent portal requests (subCompanyId null), the service prefers a tenant-level user document (subCompanyId null) 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

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 home

Scenario 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 home

Scenario 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 home

Scenario 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 home

Account Switching

Users can switch between linked companies in two ways:

Quick Switch (Home Page Avatar)

  1. User taps the profile avatar on the Home page.
  2. HomePage shows an Account Switcher bottom sheet listing all linked portals.
  3. Sub-company entries display as "Company Name — Sub-Company Name".
  4. Data is fetched via authStore.fetchLinkedTenants() (calls GET /api/auth/me/linked-tenants).
  5. Tapping a non-current portal triggers window.location.href = /{company-slug}/ or /{company-slug}/{sub-company-slug}/.
  6. SSOProvider on the new portal detects the cookie and auto-logs in.

Full Page (Account → Linked Accounts)

  1. Navigate to Account → Linked Accounts.
  2. The page shows all linked portals with their logos, company names, and sub-company names.
  3. Tap the Switch (arrow) button on a non-current portal.
  4. The browser navigates to /{company-slug}/ or /{company-slug}/{sub-company-slug}/.
  5. 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.js

This script:

  • Finds all users without a globalIdentityId
  • Groups them by email/phone
  • Creates GlobalIdentity records 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.js

This script:

  • Drops old 2-field compound unique indexes on the users collection
  • Drops the old single-field linkedTenants.tenantId index on globalidentities
  • New 3-field indexes are auto-created by Mongoose on next application startup
  • Existing data is fully compatible — no data migration needed
  • The GlobalIdentity.linkedTenants entries without subCompanyId are treated as subCompanyId: 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)

#CaseStepsExpected
CT-1Parent-level registration at A then login at BRegister at A parent URL; open B login; enter same email/passwordcrossTenantRequired (or signup flow); modal shows Tenant A name; Yes → link succeeds; confirmation modal; no duplicate SSO+Login modals on B /login.
CT-2Sub-company registration at A then login at BRegister at A / Sub X only; open B login; same credentialsModal bold line shows Sub X name (sourceSubCompanyName), not only Tenant A; link succeeds.
CT-3Signup at B with existing A emailOn B Register, enter email already used at Acheck-identifier returns exists; modal shows sub-company or tenant name; Yes → navigate to login; Send OTP proceeds (no silent no-op).
CT-4Signup at B with SSO cookie from ALog in at A; open B /register in same browser; submit same emailSend OTP still runs (registration not blocked by canSSO alone); cross-tenant or OTP flow works.

Same tenant — sub-company vs parent

#CaseStepsExpected
SC-1Parent user → Sub X loginRegister at parent; open A / Sub X login; same credentialscrossSubCompanyRequired; modal; Yes + crossSubCompanyLink → profile at Sub X; wallet shared.
SC-2Sub X user → parent loginRegister at A / Sub X only; open A parent loginparentPortalLinkRequired or direct match if parent profile already linked; confirm → parent-level profile; no "Access denied" for valid linked parent user.
SC-3Sub X → Sub Y loginLink or register at Sub X; open A / Sub Y loginAppropriate cross-sub prompt or existing profile; no erroneous 409 duplicate email on confirm.

SSO session, switching, and UI

#CaseStepsExpected
SS-1SSO check on BVisit B with valid SSO cookie; not linked yetGET /auth/sso/check runs; modal or silent exchange per rules; no two stacked Account Detected modals on /login.
SS-2Account switch parent ↔ subLink parent + Sub X; from home, switch to other portalNavigates by URL; silent ssoLogin on /login if session cleared; no forced password re-entry when exchange succeeds.
SS-3Linked accounts listAccount → Linked AccountsLists tenant + sub-company names; switch control works.

Edge / regression

#CaseStepsExpected
EG-1Email caseRegister with mixed-case email; login with different casingLogin and linking succeed (normalized email).
EG-2Unlink not last portalUnlink one linked portalSucceeds; cannot unlink last remaining portal.

Security Considerations

  • The POST /auth/sso/check-identifier endpoint 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 GlobalIdentity password should also be updated. This is handled by the existing changePassword flow.
  • The crossTenantLink login parameter only takes effect when a genuine tenant mismatch is detected; it cannot be abused to bypass normal login restrictions.

VIO v4 Platform Documentation