Skip to main content

Authentication Architecture

Card-core separates Authentication (who are you — proven by some identity provider) from Authorization (what may you do — always decided by Xperise). Identity can come from several providers; authorization never leaves Xperise.

Principles​

  • AuthN ≠ AuthZ. A provider only vouches for an identity. Roles, tenant and permissions are resolved by Xperise and stamped into a Xperise-issued token.
  • Pluggable providers. Adding a provider (e.g. an SSO IdP) means adding one class that implements AuthenticationProvider; nothing else changes.
  • Business APIs trust only Xperise tokens, never a third-party token, for human/interactive access. Server-to-server keys (PlatformKey, MerchantKey) are a separate machine surface and are intentionally left as-is. A parent MerchantKey is accepted only through X-Merchant-Key, never as a Bearer token.
  • Merchant access verify is signature-only — no DB on the hot path. Access tokens are short-lived and stateless; revocation is a refresh-time concern (below). Tenant scope (tid) and role come from the token, never the client.

Two token models​

Merchant human-auth uses the unified identities / principals / auth_sessions model with a short access token + a rotating refresh token. Corporate (and any future SSO-to-corporate) keeps the legacy stateless Xperise JWT — a short-lived token that mirrors the external Corporate session, with no refresh and no server-side session. Both are minted by XperiseTokenService; they are distinguished by claim shape and by using different signing secrets.

TokenTTL (default)SecretVerify
Merchant access15 min (ACCESS_TOKEN_TTL_SECONDS)XPERISE_ACCESS_SECRET (falls back to XPERISE_JWT_SECRET)signature + exp only
Merchant refresh30 d (REFRESH_TOKEN_TTL_DAYS)XPERISE_REFRESH_SECRET (distinct)signature and an auth_sessions row
Corporate (owner)3600 s (OWNER_TOKEN_TTL_SECONDS)XPERISE_ACCESS_SECRETsignature + exp, stateless

The refresh token is delivered on one of two channels, chosen by the client: in the response body for native/mobile clients (POST /merchant/login, and POST /auth/refresh/switch-context when the request carries the token in the body), or as an httpOnly refreshToken cookie (Secure, SameSite, path=/auth) for browsers (POST /auth/token). /auth/refresh and /auth/switch-context mirror the channel they receive.

Providers​

ProvidernameIdentity sourceXperise audience
Local (Staff)localmerchant sub-account (username, phone or email + password)merchant
Xperise (platform email)xperise-merchantXperise-backend access token → profile email (getEmail) → any sub-account with that emailmerchant
Xperise (Corporate Admin)xperise-corporateCorporate GraphQL access token (external auth service)corporate
Future SSOsso:*Azure AD / Okta / Google Workspace(per mapping)

A provider does authentication only and returns an AuthenticatedPrincipal (subject, audience, tenantId, roles, username). It never mints a token.

The unified model (merchant)​

identities -- a person + credential (email/phone optional but platform-unique;
-- password_hash scrypt, NULL for external)
principals -- a membership: (identity, tenant_type, tenant_id, role, parent_id, permission_version)
auth_sessions -- a revocable session behind ONE refresh token (jti + family_id)

Email and phone are identity-level identifiers, unique platform-wide (idx_identities_email, idx_identities_phone): one address and one number are one person. merchant_sub_accounts.email / .phone are the per-merchant login handles and copies of them, unique within the merchant. Username has no identity-level counterpart — it names a job at a merchant, not a person.

  • One identity → N principals (multi-tenant / multi-membership). An access token names the active principal (pid); /auth/switch-context re-points it.
  • tenant_type ∈ hotel | corporate | merchant and role ∈ hotel_admin | corporate_admin | corporate_staff | merchant_manager | merchant_sub. Only the merchant_* roles are wired end-to-end today; hotel/corporate values are scaffolding for the future.
  • A merchant sub-account (merchant_sub_accounts) is the merchant-domain profile (username / display_name / status) of a principal, linked by identity_id / principal_id. The domain vocabulary admin/operator maps to the actor roles merchant_manager/merchant_sub (bridged in src/common/rbac.ts).
  • The identity + principal are created inline wherever a sub-account is created — sub-account CRUD (MerchantAccountsService) and merchant onboarding (POST /merchants, which provisions the owner's identity + principal atomically).
  • Multi-membership is reachable, through an OTP-verified link. One identity can hold principals at several merchants, and one email (or phone) can name a staff account at each of them: the sub-account indexes are per-merchant, while idx_identities_email / idx_identities_phone stay platform-wide because an identity is a person. Those two together define the only way in. createSubAccount always INSERTs a new identity, so re-using an email there still collides on the identity index (409 email_owned_by_identity) — attaching a second principal to the existing identity happens only via POST /merchant/sub-accounts/link/request + /confirm, which is OTP-gated because the two memberships then share one credential (see "Linking an existing identity" below). POST /merchant/sub-accounts and PATCH /merchant/sub-accounts/:id answer the same codes for the same collisions, on both handles — 409 email_taken / phone_taken when this merchant already uses it, 409 email_owned_by_identity / phone_owned_by_identity when a platform identity does — checked before anything is written (assertHandlesFree), because moving a handle onto an existing account is the same claim as creating one with it. The check runs on creation too, not just the unique indexes, because Postgres reports only the first index a write violates: an email and a phone that are both somebody else's would come back as one error, and the caller would learn about the second one request later. So the 409 names every collision at once, in details:
    { "code": "email_owned_by_identity", "message": "…",
    "details": { "conflicts": { "email": "owned_by_identity", "phone": "owned_by_identity" },
    "same_identity": false } }
    conflicts lists each colliding handle as taken (this merchant) or owned_by_identity (the platform); code stays the first of them in the order email_taken, phone_taken, email_owned, phone_owned, so existing clients keep working. same_identity appears only when both handles are owned by platform identities: true means one person holds both, so a link by either handle brings the other along; false means the email and the phone name two different people — the caller has to decide which one is this person, link by that handle, and give up the other, because the system never merges two identities on a merchant's say-so (a recycled number or a one-digit typo would join two people's credentials). Identity ids are never returned. Each request is otherwise judged on its own: adding an email and then a phone is two claims, and the second being refused leaves the first standing. Consequences of the shared credential, all deliberate: one password and one password-reset for every membership; RENAMING an identity handle is refused (409 email_shared_identity / phone_shared_identity) while the identity is linked to more than one merchant, since identities.email is not merchant A's to rename — renaming only, so a handle the identity does not carry yet can still be set (nobody's login moves, and otherwise a linked account could never be given a phone), and a value equal to the stored one writes nothing at all; but disable/delete is membership-scoped (revokeByPrincipal), so losing the job at merchant A does not sign the person out of merchant B. GET /auth/memberships lists what /auth/switch-context can switch between — a principal_id appears in no other response, so without it the switch endpoint has nothing to name.

Entities live in src/database/entities/identity.entities.ts; sessions are managed by AuthSessionService (raw SQL on PG_POOL).

The flow​

client ── credentials ─▶ POST /auth/token (or POST /merchant/login for local)
│
▼
AuthenticationProvider.authenticate() ← the ONLY provider-specific step
│ AuthenticatedPrincipal
▼
AuthenticationService ── merchant: resolve identity+principal, open
│ an auth_sessions row → access + refresh
│ corporate: legacy stateless Xperise JWT
▼
client ── Authorization: Bearer <access> ─▶ business API
── refreshToken cookie ─▶ POST /auth/refresh (rotate)
│
▼
AuthGuard: verifyAccess() (signature only) → req.principal,
and for merchant routes hydrate req.merchant/req.subAccount
by principal id (active-status checked, no session read)

Merchant access-token claims​

{
"iss": "xperise", "aud": "card-core", "iat": 0, "exp": 0,
"knd": "access",
"sub": "<identity id>", // the person
"pid": "<principal id>", // the active membership
"tt": "merchant", // tenant_type
"tid": "<merchant_id>", // tenant_id → filter every query
"role": "merchant_manager",
"pv": 1, // permission version (re-evaluated at refresh)
"fid": "<family id>", // rotation family → lets logout revoke this device
"tc": ["taxi"] // merchants.type_codes — descriptive, never authorization
}

tc mirrors the merchant's business verticals at mint time so a client can branch its UI without a /merchant/me round-trip. It is descriptive only — never an authorization input — and, like role/pv, is re-read at refresh rather than live. Tokens minted before tc existed stay valid: a missing claim verifies as an empty list.

Refresh-token claims: { knd:"refresh", sub, pid, jti, fid }. Corporate tokens keep the legacy { sub, jti, provider, xperise:{audience,tenant_id,roles,username} }.

RBAC​

Roles resolve to permissions in-memory from a static map (ROLE_PERMISSIONS in src/common/rbac.ts) — no per-user permission store, no DB hit. @RequirePermission(...) + the global PermissionGuard enforce it; routes that must also accept a machine MerchantKey (which carries no role) check access in the handler instead (see MerchantAccountsController.assertCanManage). Sub-accounts do not inherit their manager's permissions.

Revocation​

  • pv is enforced at refresh, not on the hot path (principle above): refresh re-reads the principal and mints the new access token with the current role/pv; a disabled/removed principal → revoke the family → 401.
  • Refresh rotation with reuse detection. Each refresh revokes the presented jti and issues a new one in the same family_id. A refresh that presents a stale/revoked jti is treated as reuse → the whole family is revoked.
  • What each action cuts, and when:
    • disable/delete sub-account → immediate: the guard hydrates the merchant + sub-account by principal id on every merchant request and rejects a non-active account. Sessions are cut by principal (revokeByPrincipal), not by identity, so a linked person keeps their sessions at their other merchant.
    • logout (this device) → revoke the token's family_id; refresh dies now, the live access token expires within ≤ ACCESS_TOKEN_TTL_SECONDS.
    • logout-all / password change / password reset (admin or OTP) → revoke every session for the identity — credential-level, so every membership, by design.
  • Corporate tokens are short-lived and stateless — they mirror the lifetime of the external session that produced them (unchanged).

Expired sessions are swept by a daily @Cron in AuthSessionService.

Configuration​

Env varPurposeDefault
XPERISE_ACCESS_SECRETaccess-token HS256 secret (falls back to XPERISE_JWT_SECRET)—
XPERISE_REFRESH_SECRETrefresh-token HS256 secret (distinct; unset ⇒ no refresh issued)—
ACCESS_TOKEN_TTL_SECONDSmerchant access lifetime900
REFRESH_TOKEN_TTL_DAYSmerchant refresh / session lifetime30
REFRESH_COOKIE_SAMESITElax | none (cross-site FE) | strictlax
REFRESH_COOKIE_SECUREset false only for local http devtrue
XPERISE_JWT_ISSUERiss claimxperise
XPERISE_JWT_AUDIENCEaud claimcard-core
OWNER_TOKEN_TTL_SECONDScorporate (owner) token lifetime3600
OTP_CHALLENGE_TTL_SECONDSpassword-reset OTP lifetime (shared OTP mechanism)300
OTP_GRANT_TTL_SECONDSlifetime of the reset_token a proven OTP mints300
NOTIFICATION_CENTER_ORIGINSMS gateway for the reset OTP (unset ⇒ dev: the code is logged)—

A cross-site frontend (SameSite=none) also needs the deployment's CORS config to allow credentials.

Endpoints​

EndpointAuthPurpose
POST /auth/tokenpublicprovider-agnostic login → access token (+ refresh cookie for merchant)
POST /auth/refreshrefresh cookierotate the refresh token, issue a fresh access token
POST /auth/switch-contextmerchant access + cookieswitch the active principal (multi-membership)
POST /auth/logoutaccess tokenrevoke this device's session
POST /auth/logout-allmerchant accessrevoke every session for the identity
POST /auth/password-reset/requestpublicsend a reset OTP to the handle on file (merchant staff; identify by exactly one of phone/email — that handle is also the channel: email → email, phone → SMS. A username cannot receive a code, so it is not accepted here)
POST /auth/password-reset/confirmpublicspend the OTP → single-use reset_token
POST /auth/password-reset/update-passwordpublic (reset_token)set the new password, revoke every session
GET /auth/membershipsmerchant accesslist this identity's memberships (the principalId source for switch-context)
POST /merchant/sub-accounts/link/requestmerchant adminOTP to the phone on an existing identity's other account, to link it here
POST /merchant/sub-accounts/link/confirmmerchant adminspend the code → the linked sub-account at this merchant
POST /merchant/loginpubliclocal-staff convenience alias (also sets the refresh cookie)
POST /merchant/logoutaccess tokenthis-device logout alias

Local login for everyone (alias + handle + password)​

Every merchant user — staff and owner — can sign in through the local provider, provided their identity carries a password:

POST /merchant/login # or POST /auth/token with provider=local
{ "alias": "acme", "username": "owner", "password": "<md5>" }
{ "alias": "acme", "email": "owner@acme.vn", "password": "<md5>" } # equivalent
{ "alias": "acme", "phone": "+84901234567", "password": "<md5>" } # equivalent

The owner is not special here: their sub-account is username = 'owner', role admin → merchant_manager, and the same three handles resolve it. What used to block them was the credential, not the login path — the onboarding CTE inserted password_hash NULL.

Where an owner password comes from:

SituationHow
New merchantPOST /merchants with owner_email + owner_password (and owner_phone, so the owner can self-reset later)
Existing owner with password_hash NULLPOST /merchant/sub-accounts/{id}/reset-password with the merchant's X-Merchant-Key (a machine credential passes assertCanManage), or from any manager session
Forgotten passwordthe OTP flow — but it needs a handle on the channel being named (phone or email) and an existing password (findResettableAccount), so an owner provisioned without either must be reset by an admin

owner_password is optional and hashed with the same scrypt hasher as every sub-account password (MerchantAccountsService.hashPassword) before it reaches the onboarding statement. Omit it and nothing changes from before: the owner has no local credential and signs in only through provider=xperise-merchant, which stays supported in parallel. Both fields require owner_email (422 owner_email_required) — there is no owner account to hang them on otherwise.

Staff login handles: username, phone or email​

A staff login is the merchant alias, exactly one handle, and the password:

HandleUniqueness
usernameper merchant (merchant_sub_accounts_merchant_id_username_key)
phoneper merchant (merchant_sub_accounts_merchant_id_phone_key) — and platform-wide on the identity behind it
emailper merchant (idx_sub_accounts_merchant_email, on (merchant_id, lower(email))) — and platform-wide on the identity behind it

Migration note. identities.phone was backfilled only where a number mapped to exactly one identity; a number that already appeared under two identities was left NULL rather than merged (they may be two people). Those legacy rows keep working and are simply not covered by the platform-wide constraint — see AddIdentityPhone for the query that lists them.

The alias is required with all three, and for email it is now load-bearing rather than merely uniform: all three handles identify an account only within a merchant, so one person may hold a sub-account at two merchants under the same number OR the same email. Requiring the alias from the start is what let email become per-merchant with no breaking change to any login request.

Phones are stored in E.164 (normalizePhone) so a number matches however it was typed; emails are matched case-insensitively, like every other email lookup here.

Sending more than one handle is a 400 (which of them names the account would be a guess, and they may point at different accounts); sending none fails DTO validation. A phone that cannot be parsed is not a distinct error — it is looked up as a number that matches nothing, so "no such number" stays indistinguishable from "wrong password", the same property verifyStaffCredentials has always had for usernames. Both the credential check and the forgot-password lookup build their WHERE clause from the same handleLookup, so they resolve an account identically.

Only accounts with a local password can use any of this: an owner/SSO identity (password_hash IS NULL) has an email but signs in through xperise-merchant.

Who can change a password at all (has_password)​

Two things that look like one are not: which provider a session logged in with and whether the account has a local password. They are independent —

  • An owner created with owner_email but no owner_password has password_hash IS NULL. There is no password here to change: card-core never sees the credential, it lives on the Xperise platform.
  • An owner created with both signs in either way, and can change the local password from a session that was opened through xperise-merchant — POST /merchant/me/password verifies current_password and does not care which provider minted the token.

So the client gates the "change password" affordance on the account, not the session. GET /merchant/me reports it as sub_account.has_password, hydrated from identities.password_hash IS NOT NULL in the guard's resolveAccessPrincipal join — which means it survives a reload and a refresh, unlike the provider field, which is returned once by POST /auth/token and appears in neither the access token (AccessClaims) nor /auth/refresh.

has_password: false means both password paths are closed:

PathBehaviour with no local credential
POST /merchant/me/password409 no_local_credential
POST /auth/password-reset/* (OTP)account never matches findResettableAccount

The 409 replaced an invalid_credentials 401 that was technically true and practically misleading — it read as "wrong password" to someone who has none. It discloses nothing: the caller already authenticated as that account, and /merchant/me tells them the same thing up front.

Forgot password (OTP, merchant staff)​

Staff who forget their password reset it themselves, over whichever channel they named, on the shared OTP-confirm mechanism (OtpChallengeService, purpose reset_merchant_password):

POST /auth/password-reset/request { alias, phone | email } (alias always; EXACTLY one handle)
│ that handle is also the channel — email → email_otp, phone → sms_otp
│ findResettableAccount → sub-account with a local password
│ OTP (6 digits, OTP_CHALLENGE_TTL_SECONDS, 5 attempts) ──▶ that handle ON FILE
▼ { challenge_id, method, destination_masked, expires_at, attempts_remaining }
POST /auth/password-reset/confirm { challenge_id, code }
│ consume the challenge, mint a single-use grant (OTP_GRANT_TTL_SECONDS)
▼ { reset_token, expires_at }
POST /auth/password-reset/update-password { reset_token, password }
│ in ONE transaction: clear the grant, replace identities.password_hash,
▼ revoke every auth_sessions row for the identity
{ ok: true } ──▶ every device must log in again with the new password

Properties worth knowing:

  • The handle IS the channel, and exactly one is required. Name the account by email and the code is emailed; by phone and it is sent by SMS. Naming neither handle or both is 422 invalid_request, refused on the request's shape before any lookup runs.

    The destination is read off the account row rather than copied from the request — but the two necessarily agree, because the lookup matches the handle exactly (case aside, for email). Saying "never the value in the request" about this flow is therefore empty; it was an actual rule only in the older design, where a username found the account and the code went to the phone on file.

    What holds instead, structurally: the code cannot be redirected, because the request has nowhere to redirect it to. There is no destination field, and no way to name an account by one handle and be answered at another — a request pairing somebody else's account with an attacker's address matches no row, so it gets a decoy and nothing is sent at all. The same match-or-nothing rule is why a caller cannot confirm a handle they merely guessed.

    A username is not accepted here. It identifies without being able to receive anything, so a username-only request left the flow with nowhere to send the code — it used to fall back to the phone, which silently stranded anyone who had no phone on the account. Staff who remember only their username go through their merchant admin instead.

    Two consequences of making the channel a function of the request: nobody is answered on a channel they did not ask about (replying to an email request with the tail of a phone number tells an anonymous caller something about a person they have proved nothing about), and the decoy below has nothing left to guess — see its bullet.

    On why email is allowed to authorize a reset at all: a sub-account's email is set by a merchant admin and never verified — but so is its phone, so the two are not asymmetric in proof strength, and an admin who wanted in can already reset any of their staff directly (POST /merchant/sub-accounts/{id}/reset-password). Email therefore grants no capability that did not already exist. That argument holds within one merchant, which is why the admin reset is refused when the identity is a member of two — see its bullet below.

  • The new password is chosen last, after the code is proven, so it is never persisted next to an OTP nobody has proven yet. It reaches the action as OtpAction.apply's confirmParams.

  • reset_token, not challenge_id, authorizes step 3. Splitting confirm in two opens a window between "code proven" and "password written", and the thing that crosses it must be a secret that only a successful verification produces. challenge_id is not: the public /request endpoint hands it out before anything is proven, so by then it has travelled through logs and FE state. So confirm mints 32 random bytes, stores only their sha256 (otp_challenges.grant_hash), and returns the token once. Redemption looks the row up by that hash and NULLs it in the same transaction as the write, which is what makes it single-use — a replay matches no row and gets otp_grant_invalid, exactly like a made-up token. The request carries no account handle at all, so a token cannot be aimed at a different account. OTP_GRANT_TTL_SECONDS is its own (shorter) clock: the code TTL covers a human reading an SMS, this one covers a form already on screen.

  • The split does not weaken the decoy. A decoy's code hash matches nothing, so it takes the wrong-code branch and never reaches the mint — step 2 answers otp_invalid and counts down the same 5 attempts either way.

  • The response never reveals whether an account exists. An unresolved request still returns a well-formed challenge — a decoy that can never verify (initiateDecoy) — so the envelope, the masked destination, the wrong-code errors and the lockout all look identical either way — including method, which is fixed by the request rather than by what the account holds, so there is no channel for a decoy to get wrong. Only the message that is (not) sent differs. Indistinguishability has to hold under repetition too: a real subject trips the 3-per-10-minutes OTP velocity gate with a 429, so a decoy is counted against a deterministic fingerprint of the submitted handle (handleDigest → subject decoy:<hash>) and trips it at exactly the same point. Without that, "ask four times and see which one 429s" would be an existence oracle regardless of how identical a single response looks. The endpoint @Throttle (5/min) bounds probing on top of it.

  • Known limit — response timing. The real path calls the SMS gateway inside the transaction (up to a 4 s timeout); a decoy only writes a row. Latency therefore still separates the two, and during a gateway outage a real account gets 502 otp_delivery_failed while a decoy gets 200. Closing that means delivering out-of-band, which would give up the property that an undeliverable OTP rolls its challenge back — a deliberate choice in this codebase (see OtpDeliveryService).

  • Not every account qualifies, and eligibility is per-channel. Nothing on the handle that was named → nothing to send to (an account with no email cannot be reset by email even though it exists, and likewise for SMS). No local password (an owner/SSO identity, password_hash IS NULL) → a "reset" would be minting a credential on an externally-authenticated account. Both are refused as a decoy, so neither is distinguishable from an account that does not exist. Eligibility is re-checked at confirm, since the account may have been disabled in between.

  • Admin-driven reset (POST /merchant/sub-accounts/{id}/reset-password) covers what the OTP flow cannot, and the two sets are disjoint: the OTP flow serves "I forgot, but I still control my handle"; the admin one serves "the handle is gone or was never there" — no phone and no email on file, a number or mailbox the person has lost, a desk account whose handles are the merchant's, staff who know only their username, or a gateway outage.

    It is refused on an identity that is an active member of another merchant (409 password_shared_identity). One password serves every membership, so merchant A resetting it would hand itself a working login at merchant B — the same reason A cannot rename that identity's email. Within one merchant nothing is escalated (an admin already manages the account); across two it is a takeover. That person uses the OTP flow instead, which needs no merchant's help because it proves control of their own handle. Only ACTIVE principals count: a membership that ended (its principal disabled) is nobody's login left to take over.

    It deliberately DOES mint a password on an account that has none (the owner/SSO identity). At the owning merchant's request that ADDS a way for the person to sign in and removes none — unlike the same act in the OTP flow, where whoever holds a handle could turn an externally-authenticated account into a password one with nobody asking.

OTP channels​

OtpDeliveryService posts to {NOTIFICATION_CENTER_ORIGIN}/otp and the gateway renders the template, so only the code and the address travel. The subject type picks the channel — not a caller-supplied field, because a flow proves ownership of whatever it delivers to:

Subject typeResolves tomethodGateway body
merchant_sub_accountmerchant_sub_accounts.phone (E.164)sms_otp{ channel:'SMS', otp, country_calling_code, national_phone_number }
merchant_sub_account_emailmerchant_sub_accounts.email (folded)email_otp{ channel:'EMAIL', otp, email }
identity_emailidentities.emailemail_otpas EMAIL above
identity_phoneidentities.phonesms_otpas SMS above
usercorporate_staffs phonesms_otpas SMS above

Each pair is one subject addressed two ways, and neither member falls back to the other: a subject with no value on the named channel is 422 no_registered_email / no_registered_phone, never a delivery to the other one. For the identity_* pair that is about proof — an email claim proven by an SMS to some number on the row would prove the wrong thing. For the merchant_sub_account* pair it is about the person: they typed the handle they can actually reach.

otp_challenges.method / .destination already carried both (plain text), so the email channel needed no migration. destination_masked in every response masks per channel: ****4567 for a phone, o***@acme.vn for an email — the domain is kept because it tells the recipient which mailbox to check, while the local part is what a merchant guessing an address must not get confirmed.

Linking an existing identity (one email, two merchants)​

POST /merchant/sub-accounts/link/request { email | phone, username, role?, … }
│ identity by that ONE handle → subject identity_email | identity_phone
│ prepare(): not already a member here, username/phone/email free here
│ OTP (6 digits, OTP_CHALLENGE_TTL_SECONDS, 5 attempts) ──▶ that same handle
▼ (EMAIL or SMS)
{ challenge_id, method, destination_masked: 'o***@acme.vn' | '****4567', … }
POST /merchant/sub-accounts/link/confirm { challenge_id, code }
│ apply(): INSERT principals + merchant_sub_accounts for the SAME identity
▼ (no identities write at all — the credential is untouched)
the new sub-account at this merchant

Properties worth knowing:

  • The code goes to the handle being claimed, and that is the whole point. A flow only proves ownership of what it delivers to, and here the handle the caller named is the identifier under dispute. Delivering anywhere else — to the phone on the person's older account when the claim was an email, say — would put consent in the hands of whoever created that account first: a merchant that had merely guessed the handle (nothing verifies one at creation time) would be approving its own link.
  • The new sub-account copies the identity's handles, and the request cannot override them. A per-merchant email or phone that differs from the identity's would make "which one is really this person's" unanswerable.
  • The OTP is consent, not identity proof. The requesting merchant already knows the email; what it cannot do is complete a link without the person reading out a code sent to a mailbox it does not control and only sees masked (o***@acme.vn). Attaching to an existing identity means sharing that person's password, so it cannot be a unilateral act by whoever types the email.
  • The identity comes from the challenge subject, never the request body. prepare/apply take it from subject_id — the person whose mailbox proved the code — so the proven address and the linked person can never be two different people.
  • No password in the request. The credential already exists on the identity; letting the new merchant set one would hand it a login for the account the person holds at their original merchant.
  • The confirm is pinned to the merchant that started it. The challenge is found by challenge_id alone (the caller must never learn whose sub-account the OTP subject is), so merchant_id travels as a confirm param and apply refuses a mismatch — otherwise any merchant holding a challenge id could finish someone else's link.
  • Eligibility fails before the code is sent. Already a member here, or a username/phone/email already used at this merchant → 409 at prepare, so nobody types a code for a link that cannot commit. Only the named handle is consulted, so a person with just an email (an owner provisioned without owner_phone) or just a phone (a driver with no mailbox) is equally linkable — which is the point of making the two symmetric.
  • An identity with no local password can be linked (an owner/SSO person), and then signs in through xperise-merchant — which is exactly why that provider now demands an alias when an email resolves to several merchants.

Platform-email sign-in (the xperise-merchant provider)​

Named for its first user — the merchant owner — but not restricted to owners. findStaffByEmail filters on email + active status only, with no condition on role or username, so any sub-account carrying an email can sign in this way and gets its own role (an operator receives merchant.operator → merchant_sub, with the usual RBAC and transaction scoping).

FE ── sign in on the Xperise backend ──▶ external access_token
FE ── POST /auth/token { provider:'xperise-merchant', external_token } ──▶ card-core
│ StoreGraphQLClient.getEmail(token)
│ findStaffByEmail(email[, alias]) → sub-account → identity + principal
▼
access token (audience merchant) + refresh cookie ──▶ FE uses it on /merchant/*

What a sub-account needs to use it: email set on the row, equal to the profile.email on the platform, the account and merchant active, and the person must actually have an account on the Xperise platform. That last one is the real limit in practice — an owner nearly always does; a driver or cashier usually exists only in card-core, so local (alias + username/phone + password) is their only way in. There is no email sync: if the platform email changes, this path starts failing 403 no_linked_account until the sub-account row is updated.

Merchant onboarding (POST /merchants) is what auto-creates one of these: with owner_email set, an admin owner sub-account linked to that email is created atomically with the merchant — with a local password only if owner_password was supplied too (see "Local login for everyone" above).

The third-party token is used exactly once, at exchange time — business APIs only ever see the Xperise access token.

findStaffByEmail returns every matching account, since an email is unique only per merchant. With more than one and no alias in the request, the login fails 409 merchant_alias_required and lists the memberships to choose from (the caller just proved they hold that email's platform session, so naming them leaks nothing). Picking the first row instead would mint a token for whichever merchant Postgres happened to return first, with nothing in the response to show the tenant was a coin flip.

Adding an SSO provider (worked example)​

  1. class AzureAdProvider implements AuthenticationProvider { name = 'sso:azure'; async authenticate(c) { /* verify IdP assertion, map to a Xperise principal */ } }
  2. Register it in AuthModule's AUTH_PROVIDERS factory.

The guard, token service and every business API are untouched — they already speak only Xperise tokens.