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.
| Token | TTL (default) | Secret | Verify |
|---|---|---|---|
| Merchant access | 15 min (ACCESS_TOKEN_TTL_SECONDS) | XPERISE_ACCESS_SECRET (falls back to XPERISE_JWT_SECRET) | signature + exp only |
| Merchant refresh | 30 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_SECRET | signature + 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
| Provider | name | Identity source | Xperise audience |
|---|---|---|---|
| Local (Staff) | local | merchant sub-account (username, phone or email + password) | merchant |
| Xperise (platform email) | xperise-merchant | Xperise-backend access token → profile email (getEmail) → any sub-account with that email | merchant |
| Xperise (Corporate Admin) | xperise-corporate | Corporate GraphQL access token (external auth service) | corporate |
| Future SSO | sso:* | 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-contextre-points it. tenant_type∈hotel | corporate | merchantandrole∈hotel_admin | corporate_admin | corporate_staff | merchant_manager | merchant_sub. Only themerchant_*roles are wired end-to-end today;hotel/corporatevalues 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 byidentity_id/principal_id. The domain vocabularyadmin/operatormaps to the actor rolesmerchant_manager/merchant_sub(bridged insrc/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_phonestay platform-wide because an identity is a person. Those two together define the only way in.createSubAccountalways 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 viaPOST /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-accountsandPATCH /merchant/sub-accounts/:idanswer the same codes for the same collisions, on both handles —409 email_taken/phone_takenwhen this merchant already uses it,409 email_owned_by_identity/phone_owned_by_identitywhen 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, indetails:{ "code": "email_owned_by_identity", "message": "…","details": { "conflicts": { "email": "owned_by_identity", "phone": "owned_by_identity" },"same_identity": false } }conflictslists each colliding handle astaken(this merchant) orowned_by_identity(the platform);codestays the first of them in the order email_taken, phone_taken, email_owned, phone_owned, so existing clients keep working.same_identityappears only when both handles are owned by platform identities:truemeans one person holds both, so a link by either handle brings the other along;falsemeans 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, sinceidentities.emailis 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/membershipslists what/auth/switch-contextcan switch between — aprincipal_idappears 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
pvis enforced at refresh, not on the hot path (principle above): refresh re-reads the principal and mints the new access token with the currentrole/pv; a disabled/removed principal → revoke the family → 401.- Refresh rotation with reuse detection. Each refresh revokes the presented
jtiand issues a new one in the samefamily_id. A refresh that presents a stale/revokedjtiis 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.
- 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 (
- 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 var | Purpose | Default |
|---|---|---|
XPERISE_ACCESS_SECRET | access-token HS256 secret (falls back to XPERISE_JWT_SECRET) | — |
XPERISE_REFRESH_SECRET | refresh-token HS256 secret (distinct; unset ⇒ no refresh issued) | — |
ACCESS_TOKEN_TTL_SECONDS | merchant access lifetime | 900 |
REFRESH_TOKEN_TTL_DAYS | merchant refresh / session lifetime | 30 |
REFRESH_COOKIE_SAMESITE | lax | none (cross-site FE) | strict | lax |
REFRESH_COOKIE_SECURE | set false only for local http dev | true |
XPERISE_JWT_ISSUER | iss claim | xperise |
XPERISE_JWT_AUDIENCE | aud claim | card-core |
OWNER_TOKEN_TTL_SECONDS | corporate (owner) token lifetime | 3600 |
OTP_CHALLENGE_TTL_SECONDS | password-reset OTP lifetime (shared OTP mechanism) | 300 |
OTP_GRANT_TTL_SECONDS | lifetime of the reset_token a proven OTP mints | 300 |
NOTIFICATION_CENTER_ORIGIN | SMS 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
| Endpoint | Auth | Purpose |
|---|---|---|
POST /auth/token | public | provider-agnostic login → access token (+ refresh cookie for merchant) |
POST /auth/refresh | refresh cookie | rotate the refresh token, issue a fresh access token |
POST /auth/switch-context | merchant access + cookie | switch the active principal (multi-membership) |
POST /auth/logout | access token | revoke this device's session |
POST /auth/logout-all | merchant access | revoke every session for the identity |
POST /auth/password-reset/request | public | send 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/confirm | public | spend the OTP → single-use reset_token |
POST /auth/password-reset/update-password | public (reset_token) | set the new password, revoke every session |
GET /auth/memberships | merchant access | list this identity's memberships (the principalId source for switch-context) |
POST /merchant/sub-accounts/link/request | merchant admin | OTP to the phone on an existing identity's other account, to link it here |
POST /merchant/sub-accounts/link/confirm | merchant admin | spend the code → the linked sub-account at this merchant |
POST /merchant/login | public | local-staff convenience alias (also sets the refresh cookie) |
POST /merchant/logout | access token | this-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:
| Situation | How |
|---|---|
| New merchant | POST /merchants with owner_email + owner_password (and owner_phone, so the owner can self-reset later) |
Existing owner with password_hash NULL | POST /merchant/sub-accounts/{id}/reset-password with the merchant's X-Merchant-Key (a machine credential passes assertCanManage), or from any manager session |
| Forgotten password | the 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:
| Handle | Uniqueness |
|---|---|
username | per merchant (merchant_sub_accounts_merchant_id_username_key) |
phone | per merchant (merchant_sub_accounts_merchant_id_phone_key) — and platform-wide on the identity behind it |
email | per merchant (idx_sub_accounts_merchant_email, on (merchant_id, lower(email))) — and platform-wide on the identity behind it |
Migration note.
identities.phonewas 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 — seeAddIdentityPhonefor 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_emailbut noowner_passwordhaspassword_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/passwordverifiescurrent_passwordand 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:
| Path | Behaviour with no local credential |
|---|---|
POST /merchant/me/password | 409 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'sconfirmParams. -
reset_token, notchallenge_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_idis not: the public/requestendpoint 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 getsotp_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_SECONDSis 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_invalidand 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 — includingmethod, 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→ subjectdecoy:<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_failedwhile 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 type | Resolves to | method | Gateway body |
|---|---|---|---|
merchant_sub_account | merchant_sub_accounts.phone (E.164) | sms_otp | { channel:'SMS', otp, country_calling_code, national_phone_number } |
merchant_sub_account_email | merchant_sub_accounts.email (folded) | email_otp | { channel:'EMAIL', otp, email } |
identity_email | identities.email | email_otp | as EMAIL above |
identity_phone | identities.phone | sms_otp | as SMS above |
user | corporate_staffs phone | sms_otp | as 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/applytake it fromsubject_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_idalone (the caller must never learn whose sub-account the OTP subject is), somerchant_idtravels as a confirm param andapplyrefuses 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 withoutowner_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 throughxperise-merchant— which is exactly why that provider now demands analiaswhen 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)
class AzureAdProvider implements AuthenticationProvider { name = 'sso:azure'; async authenticate(c) { /* verify IdP assertion, map to a Xperise principal */ } }- Register it in
AuthModule'sAUTH_PROVIDERSfactory.
The guard, token service and every business API are untouched — they already speak only Xperise tokens.