Kulcare Docs

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 Session record, not the JWT — enables switching without re-authentication
  • Centralized Token Issuance: Single TokenIssuanceService for all token flows
  • Frontend Auth: Zustand store + JWT-derived profile selection + kulcare-unauthorized event bus

Dual Legacy / New System

Note: Both the legacy and new auth systems are currently live:

SystemRoutesStatus
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 definitions

Supporting Shared Modules

ModuleLocationPurpose
TenantContextdomains/shared/concerns/tenant_context.rbController concern: sets RLS GUC from session
RlsContextdomains/shared/services/tenancy/rls_context.rbLow-level app.current_tenant_id GUC management
Shared::Currentdomains/shared/models/current.rbThread-local request context (tenant, facility, user)
TenantAwareJobdomains/shared/concerns/tenant_aware_job.rbSidekiq 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?

  1. Profile picker UX: After login, users see all their organizations and choose — no need to pre-select during authentication.
  2. No token re-issuance for switching: Tenant context lives in the Session record; switching updates the session and issues new tokens, but doesn't require re-authentication.
  3. 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 tokens

Response 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:

SituationResponseCounter effect
Any password while locked_until is in the future403 "Account is temporarily locked…"none — the lock runs its 30 minutes and is not extended
Wrong password401 "Invalid credentials"User#record_failed_login!: +1, persisted under a row lock
5th consecutive wrong password (User::MAX_FAILED_LOGIN_ATTEMPTS)401sets locked_until = now + 30 min (User::LOGIN_LOCK_DURATION) and logs [auth] tag=login_locked
Correct passwordcontinues (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 expired401the count restarts at 1 and the stale lock is cleared
Either password on a row that locked after this request's lock check401 "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

  1. Validate refresh token (hash, expiration, revocation)
  2. Issue new access token (same tenant context)
  3. Rotate refresh token (invalidate old one)
  4. Update session with new token hashes

Registration

EndpointMethodPurpose
/identity/registrationsPOSTCreate account (sends OTP)
/identity/registrations/verifyPOSTVerify OTP + issue tokens
/identity/registrations/resend_otpPOSTResend verification code

The create response tells the client whether the code went out. The account exists either way.

  • POST /identity/registrations answers 201 with meta.already_registered and meta.otp_sent (true or false). When otp_sent is false, the SMS failed. The client says so and points to Resend.
  • POST /identity/registrations/resend_otp answers 200 { "message": "Verification code sent" } when the SMS goes out. When it fails, it answers 422 with error.code OTP_NOT_SENT and 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_sent is false and resend answers OTP_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

EndpointMethodPurpose
/identity/passwords/resetPOSTRequest password reset (sends OTP)
/identity/passwords/verify_codePOSTVerify reset code → get temp token
/identity/passwords/changePOSTChange 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

EndpointMethodPurpose
/identity/sessions/logoutDELETEInvalidate session
/identity/profilesGETList available profiles
/identity/2fa/statusGETCheck 2FA enrollment
/identity/2fa/enablePOSTBegin 2FA enrollment
/identity/2fa/verifyPOSTVerify 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

FieldTypePurpose
user_idUUIDSession owner
device_idUUIDAssociated device
tenant_idUUID (nullable)Current tenant context (set after profile switch)
facility_idUUID (nullable)Current facility context
auth_token_hashStringSHA256 of current access token
refresh_token_idUUIDReference to refresh token
statusEnumACTIVE, REVOKED, EXPIRED
last_activity_atTimestampLast API activity
expires_atTimestampSession expiration

ProfileSwitch Model

Location: backend/domains/identity/models/profile_switch.rb

Audit trail of every profile switch:

FieldPurpose
session_idWhich session
tenant_id / facility_idTarget context
switched_from_tenant_id / switched_from_facility_idSource context
is_activeOnly one active per session
ip_address, user_agent, switch_reasonAudit 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.

CallerHelperAction
Identity::Invites::AcceptPractitionerInviteServiceAudit::Dispatch.log_invite_action(action: :accept)create (new membership + role)
Identity::Invites::AcceptStaffInviteServiceAudit::Dispatch.log_invite_action(action: :accept)create
Identity::Invites::DeclineInviteServiceAudit::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 / decline
  • invite_type — practitioner / staff
  • invite_id — the invite UUID
  • tenant_id_at_grant — the granting tenant
  • invited_by_user_id — who issued the invite
  • request_id — auto-merged from Shared::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.reset

Cross-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
end

Key methods:

MethodUse 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.clearTeardown. Releases tenant AND GUC. Must pair with activate.

Cross-tenant services:

  • Clinical::Patients::LongitudinalRecordService — clinical history aggregation
  • Patient::Appointments::ListService — appointments union across facilities
  • Identity::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:

AspectShell ServiceShared Package Service
Fileapps/shell/src/services/identity/auth/index.tspackages/auth-identity/src/services/identityAuthService.ts
PurposeFull auth page flowsLayout-level operations
HandlesSignup, login, OTP verify, password reset, profile switchLogout + facility switch
Used byShell 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, sessionId
  • availableProfiles — list of tenant memberships
  • tenantPermissions, enabledPermissions — RBAC
  • subscription — billing state
  • userFeatureFlags, 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 localStorage

Known 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 cache

This 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: finally block 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 + flags

Shared 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-throw

The 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. MicroAppLayout does not await the handler, so a thrown error becomes an unhandled rejection. Catch the error, log it, and show a toast.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 switchFacility with 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/)

EndpointMethodPurposeJWT Mode
/identity/registrationsPOSTCreate account—
/identity/registrations/verifyPOSTVerify OTPAgnostic
/identity/registrations/resend_otpPOSTResend OTP—
/identity/sessionsPOSTLoginAgnostic
/identity/sessions/refreshPOSTRefresh tokenSame as original
/identity/sessions/logoutDELETELogout—
/identity/profilesGETList profiles—
/identity/profiles/switchPOSTSwitch profile→ Scoped
/identity/passwords/resetPOSTReset password—
/identity/passwords/verify_codePOSTVerify reset code—
/identity/passwords/changePOSTChange password—
/identity/2fa/*Various2FA management—

Legacy Endpoints — Retained for Backward Compatibility

EndpointMethodPurpose
/auth/sign_upPOSTRegister
/auth/otp_sign_upPOSTOTP register
/auth/verify_userPOSTVerify OTP
/auth/sign_inPOSTLogin
/auth/refreshPOSTRefresh token
/auth/logoutPOSTLogout
/auth/social_connectPOST/GETOAuth
/auth/reset_passwordPOSTReset password
/auth/change_passwordPOSTChange password

Security Considerations

AspectImplementation
Access tokenHS256, 24-hour expiry, stateless
Refresh tokenRS256, 30-day expiry, SHA256 hashed in DB
Password rules8+ chars, lowercase + digit/symbol
OTP5-digit code via SMS/WhatsApp, 10-minute expiry
Session managementSingle session per device, token hash validation
RLS enforcementPostgreSQL app.current_tenant_id GUC, database-level isolation
Token revocationGrace period on profile switch (2 min), then old tokens revoked
HTTPSRequired in production
CORS*.kulcare.com origins only
Rate limitingBuilt into verification and API endpoints

Key Files Reference

Backend Services

FilePurpose
backend/domains/identity/services/tokens/token_issuance_service.rbCentralized token issuance
backend/domains/identity/services/identity_token_issuer.rbJWT encode/decode
backend/domains/identity/services/profiles/switch_profile_service.rbProfile switching
backend/domains/identity/services/sessions/authentication_organizer.rbLogin flow
backend/domains/identity/services/sessions/create_auth_session_service.rbSession creation
backend/domains/identity/services/sessions/refresh_token_service.rbToken refresh
backend/domains/identity/services/sessions/logout_service.rbLogout

Backend Models

FilePurpose
backend/domains/identity/models/session.rbSession with tenant/facility context
backend/domains/identity/models/refresh_token.rbHashed refresh tokens
backend/domains/identity/models/profile_switch.rbProfile switch audit trail
backend/domains/identity/models/user.rbUsers
backend/domains/identity/models/tenant.rbOrganizations
backend/domains/identity/models/user_membership.rbUser ↔ Tenant memberships

Backend Shared

FilePurpose
backend/domains/shared/concerns/tenant_context.rbController RLS setup/teardown
backend/domains/shared/services/tenancy/rls_context.rbGUC management
backend/domains/shared/models/current.rbThread-local request context
backend/domains/shared/concerns/tenant_aware_job.rbSidekiq RLS middleware

Frontend

FilePurpose
frontend/apps/shell/src/services/identity/auth/index.tsFull auth flows (shell)
frontend/packages/auth-identity/src/services/identityAuthService.tsLogout + switch (shared)
frontend/packages/auth-identity/src/store.tsZustand auth store
frontend/packages/auth-identity/src/hooks/useAuth.tsuseProfile(), useAuth() hooks
frontend/packages/api-client/src/client.tsHTTP client + 401 event dispatch

On this page