Authentication System
Closes #1430
For the detailed implementation reference covering two-mode JWT architecture, session management, and RLS internals, see Authentication & Authorization System (General Guide).
This document provides a developer-oriented guide to Kulcare's authentication system — what the code looks like, where it lives, and how the pieces fit together across backend and frontend.
Overview
Kulcare uses a multi-profile, multi-tenant authentication system designed for healthcare professionals who may belong to multiple organizations (clinics, hospitals, labs) and switch between them in a single session.
Key Features
- Two-Mode JWT Architecture: Tenant-agnostic login token → tenant-scoped profile-switch token
- Multi-Tenant RLS: PostgreSQL Row-Level Security enforces tenant isolation at the database level
- Session-Backed Context: Tenant/facility context stored in a
Sessionrecord, not the JWT — enables switching without re-authentication - Centralized Token Issuance: Single
TokenIssuanceServicefor all token flows - Frontend Auth: Zustand store + JWT-derived profile selection +
kulcare-unauthorizedevent bus
Dual Legacy / New System
Note: Both the legacy and new auth systems are currently live:
System Routes Status Legacy /auth/*Active (legacy clients) New /identity/*Active (new clients, frontend) This document covers the new system (
domains/identity/). Legacy/auth/*routes are retained for backward compatibility and will be deprecated.
Backend Architecture
The authentication system lives under backend/domains/identity/:
backend/domains/identity/
├── controllers/
│ ├── sessions_controller.rb # Login, logout, refresh
│ ├── registrations_controller.rb # Signup, OTP verify, resend
│ ├── profiles_controller.rb # List profiles, switch
│ ├── passwords_controller.rb # Reset, verify, change
│ ├── two_factor_controller.rb # 2FA enable/verify/disable
│ └── ... # Facilities, tenants, practitioners, etc.
├── services/
│ ├── tokens/
│ │ └── token_issuance_service.rb # Single source of truth for all token issuance
│ ├── sessions/
│ │ ├── authentication_organizer.rb # Login orchestrator (interactor chain)
│ │ ├── create_auth_session_service.rb
│ │ ├── refresh_token_service.rb
│ │ ├── logout_service.rb
│ │ └── ...
│ ├── profiles/
│ │ ├── switch_profile_service.rb # Profile switch logic
│ │ └── get_available_profiles_service.rb
│ ├── identity_token_issuer.rb # Low-level JWT encode/decode
│ └── ...
├── models/
│ ├── session.rb # identity.sessions — auth token hashes, tenant/facility context
│ ├── refresh_token.rb # Hashed refresh tokens
│ ├── profile_switch.rb # Audit trail of profile switches
│ ├── user.rb # Users with UUID primary keys
│ ├── tenant.rb # Organizations (multi-tenant)
│ ├── user_membership.rb # User ↔ Tenant memberships
│ └── ...
├── forms/
│ ├── user_sign_in_form.rb # Login validation
│ └── user_sign_up_form.rb # Registration validation
├── blueprints/ # JSON serialization (AMS-like)
├── policies/ # Pundit authorization
└── routes.rb # /identity/* route definitionsSupporting Shared Modules
| Module | Location | Purpose |
|---|---|---|
TenantContext | domains/shared/concerns/tenant_context.rb | Controller concern: sets RLS GUC from session |
RlsContext | domains/shared/services/tenancy/rls_context.rb | Low-level app.current_tenant_id GUC management |
Shared::Current | domains/shared/models/current.rb | Thread-local request context (tenant, facility, user) |
TenantAwareJob | domains/shared/concerns/tenant_aware_job.rb | Sidekiq middleware for RLS in background jobs |
Two-Mode JWT Architecture
The system issues JWTs in two distinct modes depending on the authentication stage:
Mode 1: Login (Tenant-Agnostic)
When: Initial login, signup verification, 2FA verification
JWT Payload:
{
"user_id": "uuid-v7",
"exp": 1737392000
}Purpose: Allows users to view all their available tenants/organizations and pick one before committing. No tenant context is embedded.
Session State:
# identity.sessions
{ user_id: "uuid-v7", tenant_id: "primary-tenant-uuid", facility_id: nil }Mode 2: Profile Switch (Tenant-Scoped)
When: User selects a specific tenant/facility after login
JWT Payload:
{
"user_id": "uuid-v7",
"tenant_id": "selected-tenant-uuid",
"facility_id": "selected-facility-uuid",
"role": "admin",
"exp": 1737392000
}Purpose: Commits user to a specific tenant context. All subsequent requests run under this tenant's RLS policies.
Session State (updated in place):
# identity.sessions — same record, updated context
{ user_id: "uuid-v7", tenant_id: "selected-tenant-uuid", facility_id: "selected-facility-uuid" }Why Two Modes?
- Profile picker UX: After login, users see all their organizations and choose — no need to pre-select during authentication.
- No token re-issuance for switching: Tenant context lives in the
Sessionrecord; switching updates the session and issues new tokens, but doesn't require re-authentication. - Explicit commitment: RLS policies only activate after profile switch — no accidental cross-tenant data leakage during the browsing phase.
Authentication Flows
Login
Endpoint: POST /identity/sessions
AuthenticationOrganizer
├── PrepareUserService # Find user by email/phone, validate credentials
├── CheckTwoFactorService # Check if 2FA is enabled
├── ProcessUserService # Record login metrics (no token issuance)
├── SaveDeviceService # Save device info
└── CreateAuthSessionService # Create session + issue tenant-agnostic tokensResponse includes available_profiles — the list of tenants the user belongs to:
{
"data": {
"id": "session-uuid",
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"available_profiles": [
{ "tenant": { "id": "...", "name": "City Hospital" }, "role": "admin", "is_primary": true },
{ "tenant": { "id": "...", "name": "Clinic XYZ" }, "role": "practitioner", "is_primary": false }
]
}
}Failed logins & account lockout
PrepareUserService checks the lock before the password, then the password, then the account state:
| Situation | Response | Counter effect |
|---|---|---|
Any password while locked_until is in the future | 403 "Account is temporarily locked…" | none — the lock runs its 30 minutes and is not extended |
| Wrong password | 401 "Invalid credentials" | User#record_failed_login!: +1, persisted under a row lock |
5th consecutive wrong password (User::MAX_FAILED_LOGIN_ATTEMPTS) | 401 | sets locked_until = now + 30 min (User::LOGIN_LOCK_DURATION) and logs [auth] tag=login_locked |
| Correct password | continues (active check, app access, verification, 2FA, session) | User#reset_failed_logins!: counter → 0. Runs on any proven password, including an inactive account's, so a run of failures does not survive reactivation |
| Wrong password after the lock has expired | 401 | the count restarts at 1 and the stale lock is cleared |
| Either password on a row that locked after this request's lock check | 401 "Invalid credentials" | none — the counter is not advanced and the lock is not re-stamped |
The lock is checked first so a locked account gives the same answer to every password. If the password were checked first, the 401/403 difference would let guessing continue through the lock. The trade-off is that a 403 tells the caller the identifier exists and is locked; the per-IP rate limit bounds how cheaply that can be probed. The counter resets on the correct password inside PrepareUserService, not in record_login!, so a sign-in that stops at the 2FA challenge still resets it.
The race window keeps the same answer. check_lock reads the copy loaded at the start of the request, so a burst of parallel guesses can all pass it and then lock the row. Those requests answer 401 whatever password they carried: reset_failed_logins! refuses a row locked now and the sign-in is refused, and record_failed_login! neither counts nor re-stamps a lock it finds already set. Answering the locked 403 for the correct password and 401 for the wrong ones would have marked the right password in the burst — the same oracle the ordering closes, reopened inside the window. The audit row still records account_locked; only the response is generic.
A password reset clears the lock. Passwords::ChangeService calls User#clear_login_lockout! on a successful change, zeroing failed_login_attempts and locked_until. Without it, a user locked out by someone guessing at their account would reset their password — the advice we give — and still be refused for the rest of the 30 minutes. Both entry points prove identity first (the current password or code, or the post-reset temp token), so it is not a way around the lock.
Known trade-off — targeted lockout. Anyone who knows an identifier can lock that account with 5 wrong passwords, repeatedly, and the only exit is waiting out the 30 minutes or resetting the password: there is no admin unlock endpoint and the owner is not notified. The per-IP rate limit bounds the cost from one address; a distributed caller is not bounded. This is the usual lockout trade-off and is accepted.
Each failure is also written to the audit trail as login_failed (failure_reason: invalid_password, account_locked, account_not_active, …). Failures with no resolvable tenant, such as an unknown identifier, only leave a log line (#1810).
Profile Switch
Endpoint: POST /identity/profiles/switch
SwitchProfileService
├── validate_access # Check active membership in target tenant
├── validate_facility_access # (Optional) Check practitioner role at facility
├── deactivate_old_context # Mark old ProfileSwitch as inactive
├── create_new_context # Create new ProfileSwitch record (audit trail)
└── issue_tokens_via_service # Delegate to TokenIssuanceService (tenant-scoped)Response includes new tokens with full context:
{
"profile_switch": { "id": "...", "tenant_id": "...", "facility_id": "...", "user_role": "admin" },
"context": { "tenant": {...}, "facility": {...}, "role": "admin" },
"tokens": { "access_token": "eyJ...", "refresh_token": "eyJ...", "session_id": "..." }
}Token Refresh
Endpoint: POST /identity/sessions/refresh
- Validate refresh token (hash, expiration, revocation)
- Issue new access token (same tenant context)
- Rotate refresh token (invalidate old one)
- Update session with new token hashes
Registration
| Endpoint | Method | Purpose |
|---|---|---|
/identity/registrations | POST | Create account (sends OTP) |
/identity/registrations/verify | POST | Verify OTP + issue tokens |
/identity/registrations/resend_otp | POST | Resend verification code |
The create response tells the client whether the code went out. The account exists either way.
POST /identity/registrationsanswers 201 withmeta.already_registeredandmeta.otp_sent(trueorfalse). Whenotp_sentisfalse, the SMS failed. The client says so and points to Resend.POST /identity/registrations/resend_otpanswers 200{ "message": "Verification code sent" }when the SMS goes out. When it fails, it answers 422 witherror.codeOTP_NOT_SENTand the message "We could not send the code. Try again in a minute."- The backend stores a new code only when its SMS goes out. A failed send (resend, or a repeat signup of an unverified user) keeps the code the user already holds, so that code still verifies.
- Outside production, the SMS service does not send to some numbers: Indian
numbers that start with 5, US numbers that start with 4 or 5, and every UAE
mobile. For these numbers,
otp_sentisfalseand resend answersOTP_NOT_SENT.
When the SMS does not go out. The create response carries meta.otp_sent (boolean). When it is
false, the shell opens the OTP page with the warning "We could not send the code. Try Resend.", and
Resend is open at once and highlighted. A response with no otp_sent counts as sent (older backend).
When delivery fails on resend, resend_otp answers 422 with error code OTP_NOT_SENT. The backend
stores no new code, so a code the user already holds still works. The shell shows the same warning and
opens Resend again after a 30-second countdown. The countdown stops quick clicks from using up the
resend limit (5 calls in 5 minutes for each IP). After a resend that goes out, the shell removes
otpSent: false from the history entry, so a reload does not show the warning again
(VerifyOtpPage.tsx, OtpNotSentError in apps/shell/src/services/identity/auth/errors.ts).
Password Management
| Endpoint | Method | Purpose |
|---|---|---|
/identity/passwords/reset | POST | Request password reset (sends OTP) |
/identity/passwords/verify_code | POST | Verify reset code → get temp token |
/identity/passwords/change | POST | Change password using temp token |
Code channels (#2641). A one-time code goes by SMS. The password reset code and the public
booking code (/patient/public_appointments create, send_code, resend_code) also go by WhatsApp
only when the person has turned WhatsApp on: whatsapp_preferred: true in the user's
communication_preferences. An absent key means SMS only for codes, while other notifications still
treat it as on. Shared::Notifications::CodeWhatsapp is the one gate for the WhatsApp copy; it reads
ChannelPreferenceResolver.opted_in?, never fails the caller, and is not counted by the SMS code cap.
GET /identity/my/communication_preferences and GET /patient/preferences return
codes: { sms: true, whatsapp: <bool> }, which is what codes use; channels.whatsapp stays the
notification switch. codes: { whatsapp: true } in the PATCH/PUT stores whatsapp_preferred: true.
false removes a stored true, and keeps a stored false. The patient app shows this as "Also send
codes on WhatsApp". The 2FA, re-authentication, signup and identifier-change codes go by SMS only.
Sessions & 2FA
| Endpoint | Method | Purpose |
|---|---|---|
/identity/sessions/logout | DELETE | Invalidate session |
/identity/profiles | GET | List available profiles |
/identity/2fa/status | GET | Check 2FA enrollment |
/identity/2fa/enable | POST | Begin 2FA enrollment |
/identity/2fa/verify | POST | Verify 2FA code during login |
Token & Session Management
TokenIssuanceService
Location: backend/domains/identity/services/tokens/token_issuance_service.rb
Centralized service for all token generation. Single source of truth across login, signup, profile switch, and refresh flows.
# Login (tenant-agnostic)
result = Identity::Tokens::TokenIssuanceService.call(
user: user, device: device, membership: membership
)
# Profile switch (tenant-scoped)
result = Identity::Tokens::TokenIssuanceService.call(
user: user, device: device, membership: membership,
tenant_scoped: true, facility_id: facility.id,
existing_session: session, revoke_previous_tokens: true,
profile_switch_id: switch.id
)Key behaviors:
- Access tokens: HS256, 24-hour expiry
- Refresh tokens: RS256, 30-day expiry, stored as SHA256 hash
- On profile switch: old tokens revoked after 2-minute grace period via
SessionExpirationJob - Session created or updated in place (no duplicate sessions)
IdentityTokenIssuer
Location: backend/domains/identity/services/identity_token_issuer.rb
Low-level JWT encode/decode. Called by TokenIssuanceService — not used directly by controllers.
Session Model
Location: backend/domains/identity/models/session.rb
| Field | Type | Purpose |
|---|---|---|
user_id | UUID | Session owner |
device_id | UUID | Associated device |
tenant_id | UUID (nullable) | Current tenant context (set after profile switch) |
facility_id | UUID (nullable) | Current facility context |
auth_token_hash | String | SHA256 of current access token |
refresh_token_id | UUID | Reference to refresh token |
status | Enum | ACTIVE, REVOKED, EXPIRED |
last_activity_at | Timestamp | Last API activity |
expires_at | Timestamp | Session expiration |
ProfileSwitch Model
Location: backend/domains/identity/models/profile_switch.rb
Audit trail of every profile switch:
| Field | Purpose |
|---|---|
session_id | Which session |
tenant_id / facility_id | Target context |
switched_from_tenant_id / switched_from_facility_id | Source context |
is_active | Only one active per session |
ip_address, user_agent, switch_reason | Audit metadata |
Cross-Tenant Invite Audit
Cross-tenant invite accept / decline emits an Audit::ActivityLog row for compliance (closed in #1498). These are the events where a user gains access to a tenant they weren't previously authenticated against — exactly the surface where compliance audit matters.
| Caller | Helper | Action |
|---|---|---|
Identity::Invites::AcceptPractitionerInviteService | Audit::Dispatch.log_invite_action(action: :accept) | create (new membership + role) |
Identity::Invites::AcceptStaffInviteService | Audit::Dispatch.log_invite_action(action: :accept) | create |
Identity::Invites::DeclineInviteService | Audit::Dispatch.log_invite_action(action: :decline) | update (invite status transitions to declined) |
Each emission runs inside the same Shared::Tenancy::RlsContext.with(invite.tenant_id) block as the role/membership writes so ActivityLog (RLS-forced) accepts the INSERT under WITH CHECK. The emission is also inside the outer transaction — an audit failure rolls back the membership grant, satisfying audit-loss safety from #1344 item 7.
Audit metadata captured per event:
invite_action—accept/declineinvite_type—practitioner/staffinvite_id— the invite UUIDtenant_id_at_grant— the granting tenantinvited_by_user_id— who issued the inviterequest_id— auto-merged fromShared::Current.request_id- For staff:
staff_role+resulting_staff_id - For practitioner:
resulting_practitioner_id
RLS Context Propagation
Request Lifecycle
Every authenticated request goes through the TenantContext concern (domains/shared/concerns/tenant_context.rb):
1. set_tenant_from_session (before_action)
├── Extract user_id from JWT
├── Find Session by auth token hash
├── Set Shared::Current.tenant / facility / user / session
└── Verify user membership in tenant
2. set_rls_context (before_action)
├── SELECT set_config('app.current_tenant_id', '<tenant_id>', false)
└── SELECT set_config('app.current_facility_id', '<facility_id>', false)
Note: session-level (not transaction-local) because controllers aren't
wrapped in explicit transactions.
3. clear_rls_context (after_action)
├── Reset both GUCs to ''
├── Evict poisoned connection on failure
└── Shared::Current.resetCross-Tenant Operations
Some services need to read data across multiple tenants (e.g., a patient's records across all facilities they've visited). This uses Shared::Tenancy::RlsContext:
# Block-scoped tenant switch — restores on exit, even on exception
Shared::Tenancy::RlsContext.with(tenant_id) do
Clinical::Encounter.where(patient_id: patient_id).to_a
endKey methods:
| Method | Use Case |
|---|---|
RlsContext.activate(tenant) | Request/job entry point. Sets Shared::Current.tenant AND GUC. |
RlsContext.with(tenant_id) { } | Block-scoped switch. Captures + restores on exit. |
RlsContext.clear | Teardown. Releases tenant AND GUC. Must pair with activate. |
Cross-tenant services:
Clinical::Patients::LongitudinalRecordService— clinical history aggregationPatient::Appointments::ListService— appointments union across facilitiesIdentity::Facilities::WizardSetupService— new tenant creation from Connect dashboard
Sidekiq / Background Jobs
Shared::Current is thread-local but not auto-reset between Sidekiq jobs. The TenantAwareJob concern handles setup/teardown. Jobs that skip it must pair activate / clear manually.
For full RLS details, see Authentication & Authorization System (General Guide) → "Row Level Security (RLS)" section.
Frontend Auth Implementation
Architecture
The frontend auth system splits responsibilities between two identityAuthService implementations:
| Aspect | Shell Service | Shared Package Service |
|---|---|---|
| File | apps/shell/src/services/identity/auth/index.ts | packages/auth-identity/src/services/identityAuthService.ts |
| Purpose | Full auth page flows | Layout-level operations |
| Handles | Signup, login, OTP verify, password reset, profile switch | Logout + facility switch |
| Used by | Shell app (auth pages) | CMSLayout, ReportsLayout, GrowthLayout |
| Dependencies | @kulcare/api-client, @kulcare/auth-identity | @kulcare/api-client only |
Why two? The shell owns complex auth-page flows (signup, OTP, passwords). The shared package owns logout and facilitySwitch — operations every MFE layout needs. Previously each MFE inlined apiClient.delete('/identity/sessions/logout') directly, causing security fixes to drift between MFEs.
Auth Store (packages/auth-identity/src/store.ts)
Zustand store with persist middleware. Manages:
user,token,refreshToken,sessionIdavailableProfiles— list of tenant membershipstenantPermissions,enabledPermissions— RBACsubscription— billing stateuserFeatureFlags,tenantFeatureFlags— feature flags
Key design decision: The store does NOT persist a selectedProfile field. The active profile is derived from JWT claims at read time via useProfile(). The JWT is the canonical source — no parallel cache, no drift.
// Store listens for unauthorized events (401s)
window.addEventListener('kulcare-unauthorized', () => {
store.reset()
localStorage.removeItem('kulcare-auth-identity')
})useProfile() Hook
Location: packages/auth-identity/src/hooks/useAuth.ts
Derives the active profile from the JWT, not from a cached field:
const claims = useMemo(() => decodeToken(token), [token])
// claims.tenant_id, claims.facility_id, claims.role — set after profile switch
// During login (before switch): claims has only user_id → selectedProfile is null
const selectedProfile = useMemo(
() => deriveSelectedProfile(availableProfiles, claims),
[availableProfiles, claims]
)During the profile-picker phase (after login, before switch), selectedProfile returns null — this is expected and signals the UI to show the profile picker.
kulcare-unauthorized Event Flow
When any API call returns 401:
1. packages/api-client/src/client.ts
→ window.dispatchEvent(new CustomEvent('kulcare-unauthorized'))
2. Listeners (4 layouts + store):
├── apps/shell/src/App.tsx → redirect to /signin
├── apps/cms/src/layouts/CMSLayout → redirect to /signin
├── apps/reports/src/layouts/ReportsLayout → redirect to /signin
├── apps/growth/src/layouts/GrowthLayout → redirect to /signin
└── packages/auth-identity/src/store.ts → store.reset() + clear localStorageKnown issue (see
docs/todos/auth-401-race-condition.md, #1427): Currently logs out immediately on any 401, even when the refresh token is still valid. Should attempt token refresh before the nuclear option.
Logout Race-Free Ordering
Both identityAuthService implementations use the same race-free logout sequence:
1. queryClient.cancelQueries() ← stop in-flight fetches
2. API call: DELETE /identity/sessions/logout ← while token is still valid
3. (finally) store.reset() ← clear all auth state
4. (finally) localStorage.removeItem() ← clear persisted tokens
5. (finally) queryClient.clear() ← wipe entire query cacheThis ordering prevents:
- Token leak: Old-token requests completing after the bearer is revoked
- Stale cache: Query cache wiped after store reset, not before
- Half-cleared state:
finallyblock ensures cleanup even if API call fails
Multi-Profile Switching (End-to-End)
Complete Flow
┌─────────┐ POST /identity/profiles/switch ┌──────────────┐
│ Frontend │ ──────────────────────────────────────→│ Backend │
│ │ { tenant_id, facility_id } │ │
│ │ │ Validate │
│ │ │ membership │
│ │ │ Validate │
│ │ │ facility │
│ │ │ access │
│ │ │ │
│ │ │ Deactivate │
│ │ │ old switch │
│ │ │ │
│ │ │ Create new │
│ │ │ ProfileSwitch│
│ │ │ (audit) │
│ │ │ │
│ │ │ TokenIssuance│
│ │ │ Service │
│ │ │ (tenant- │
│ │ │ scoped) │
│ │ ← { tokens, context, profile_switch}│ │
│ │ │ │
│ Update │ │ │
│ store: │ │ │
│ token, │ │ │
│ refresh │ │ │
│ Token, │ │ │
│ session │ │ │
│ │ │ │
│ useProfile() │ │
│ re-derives │ │
│ from new JWT │ │
│ │ │ │
│ queryClient │ │
│ .invalidateQueries() │ │
│ → refetch under │ │
│ new tenant │ │
└─────────┘ └──────────────┘Frontend Code Paths
Shell app (auth pages):
// apps/shell/src/services/identity/auth/index.ts
await identityAuthService.switchProfile(tenantId, facilityId)
// → POST /identity/profiles/switch
// → store updated with new tokens
// → fetchTenantAuthData() loads permissions + flagsShared package (layout level, used by the CMS, Reports, Growth, Connect and Research layouts):
// packages/auth-identity/src/services/identityAuthService.ts
await identityAuthService.switchFacility({ tenantId, facilityId, queryClient, reason })
// → POST /identity/profiles/switch
// → cancelQueries before, invalidateQueries after
// → store updated with new tokens
// → on any failure after the cancel: restart the cancelled queries (not awaited), then re-throwThe cancel runs before the POST, and it reverts every in-flight fetch to the state that fetch
started from. A first load goes back to pending with nothing fetching. A refetch that an
invalidation started goes back to its old data, still marked isInvalidated. Nothing restarts
either one. So if anything after the cancel throws (the POST, or the token handling after it),
restartCancelledQueries invalidates the queries that are pending or isInvalidated, and then
the service re-throws. Otherwise the page would stay on its skeleton, or on data from before the
event.
If the new token was already in the store when the throw happened (for example, a store subscriber
threw after setState), every cached old-tenant answer is stale. In that case the recovery
invalidates the whole cache, not just the reverted queries. The check compares the store with the
token that this switch issued, not with the token from before the POST. An api-client refresh
during the POST also changes the store's token, and that must not count as the switch landing.
The recovery runs on a 403, which is what /identity/profiles/switch returns for any rejected
target (a revoked membership, a stale row). It is skipped on a 401: the api-client interceptor
handles an expired session, and more requests would only add more 401s. The api-client does
nothing for a 403 except dispatch kulcare-forbidden. A failure of the recovery refetch is only
logged, so the error the caller receives is always the switch error.
The cancel stays before the POST on purpose. Moving it after the POST would let requests under the old session complete after the server has already switched.
queryClient is required in the signature. Without it, the switch does not cancel or invalidate
queries, and the previous tenant's cached answers stay on screen under the new token. Until #2395
the parameter was optional, and Connect and Research did not pass it.
Every onFacilitySelect handler follows this rule:
- Never re-throw.
MicroAppLayoutdoes not await the handler, so a thrown error becomes an unhandled rejection. Catch the error, log it, and show atoast.error. CMS, Reports, Growth, Connect, Research and Hospital all do.
A second rule applies only to the handlers that resolve the picked row against
availableProfiles before switching, because only those have a branch that can end without
calling the service:
- Never return silently. A row can refer to a profile that no longer exists (the membership
was revoked, or the list was refreshed while the modal was open). Log the ids and show a toast.
CMS, Reports and Hospital do this lookup and follow the rule. Growth, Connect and Research pass
the row's ids straight to
switchFacilitywith no lookup, so they have no silent branch — a rejected target comes back from the endpoint and lands in their catch.
apps/<app>/tests/<App>Layout.facility.test.tsx covers CMS, Reports, Growth, Connect and
Research. The test replaces MicroAppLayout with a stub that records its props. It checks that
selectedFacility and every row round-trip through parseFacilityKey, and that a pick passes both
ids and the query client to switchFacility. Hospital has no such test yet.
API Endpoints Summary
New System (domains/identity/)
| Endpoint | Method | Purpose | JWT Mode |
|---|---|---|---|
/identity/registrations | POST | Create account | — |
/identity/registrations/verify | POST | Verify OTP | Agnostic |
/identity/registrations/resend_otp | POST | Resend OTP | — |
/identity/sessions | POST | Login | Agnostic |
/identity/sessions/refresh | POST | Refresh token | Same as original |
/identity/sessions/logout | DELETE | Logout | — |
/identity/profiles | GET | List profiles | — |
/identity/profiles/switch | POST | Switch profile | → Scoped |
/identity/passwords/reset | POST | Reset password | — |
/identity/passwords/verify_code | POST | Verify reset code | — |
/identity/passwords/change | POST | Change password | — |
/identity/2fa/* | Various | 2FA management | — |
Legacy Endpoints — Retained for Backward Compatibility
| Endpoint | Method | Purpose |
|---|---|---|
/auth/sign_up | POST | Register |
/auth/otp_sign_up | POST | OTP register |
/auth/verify_user | POST | Verify OTP |
/auth/sign_in | POST | Login |
/auth/refresh | POST | Refresh token |
/auth/logout | POST | Logout |
/auth/social_connect | POST/GET | OAuth |
/auth/reset_password | POST | Reset password |
/auth/change_password | POST | Change password |
Security Considerations
| Aspect | Implementation |
|---|---|
| Access token | HS256, 24-hour expiry, stateless |
| Refresh token | RS256, 30-day expiry, SHA256 hashed in DB |
| Password rules | 8+ chars, lowercase + digit/symbol |
| OTP | 5-digit code via SMS/WhatsApp, 10-minute expiry |
| Session management | Single session per device, token hash validation |
| RLS enforcement | PostgreSQL app.current_tenant_id GUC, database-level isolation |
| Token revocation | Grace period on profile switch (2 min), then old tokens revoked |
| HTTPS | Required in production |
| CORS | *.kulcare.com origins only |
| Rate limiting | Built into verification and API endpoints |
Key Files Reference
Backend Services
| File | Purpose |
|---|---|
backend/domains/identity/services/tokens/token_issuance_service.rb | Centralized token issuance |
backend/domains/identity/services/identity_token_issuer.rb | JWT encode/decode |
backend/domains/identity/services/profiles/switch_profile_service.rb | Profile switching |
backend/domains/identity/services/sessions/authentication_organizer.rb | Login flow |
backend/domains/identity/services/sessions/create_auth_session_service.rb | Session creation |
backend/domains/identity/services/sessions/refresh_token_service.rb | Token refresh |
backend/domains/identity/services/sessions/logout_service.rb | Logout |
Backend Models
| File | Purpose |
|---|---|
backend/domains/identity/models/session.rb | Session with tenant/facility context |
backend/domains/identity/models/refresh_token.rb | Hashed refresh tokens |
backend/domains/identity/models/profile_switch.rb | Profile switch audit trail |
backend/domains/identity/models/user.rb | Users |
backend/domains/identity/models/tenant.rb | Organizations |
backend/domains/identity/models/user_membership.rb | User ↔ Tenant memberships |
Backend Shared
| File | Purpose |
|---|---|
backend/domains/shared/concerns/tenant_context.rb | Controller RLS setup/teardown |
backend/domains/shared/services/tenancy/rls_context.rb | GUC management |
backend/domains/shared/models/current.rb | Thread-local request context |
backend/domains/shared/concerns/tenant_aware_job.rb | Sidekiq RLS middleware |
Frontend
| File | Purpose |
|---|---|
frontend/apps/shell/src/services/identity/auth/index.ts | Full auth flows (shell) |
frontend/packages/auth-identity/src/services/identityAuthService.ts | Logout + switch (shared) |
frontend/packages/auth-identity/src/store.ts | Zustand auth store |
frontend/packages/auth-identity/src/hooks/useAuth.ts | useProfile(), useAuth() hooks |
frontend/packages/api-client/src/client.ts | HTTP client + 401 event dispatch |
Related Documentation
- Authentication & Authorization System (General Guide) — Detailed implementation reference with JWT internals, session schema, and RLS policies
- Users Management Guide — User and profile management features
- 2FA Working Flow — Two-factor authentication details
- Access Control & User Management — RBAC and permissions
- Authentication API Reference — API endpoint documentation
- Auth Migration Docs — Migration from legacy to new auth system