# Auth Module Handles all authentication concerns: password login, SSO (Google, Azure, Apple, Okta), SAML IdP, OTP (one-time passwords), two-factor authentication, API key auth, bot-app auth, token refresh, session management, and password reset. Before touching this module, read `humand-packages/monolith/AGENTS.md` for hexagonal architecture rules, naming conventions, DI patterns, and testing conventions. This document focuses on what is non-obvious or specific to auth. --- ## Directory Structure ``` auth/ ├── business/ │ ├── constants/ │ │ ├── audits.ts # Audit action constants │ │ ├── externalApp.ts # ExternalAppProtocol enum (SAML, OAUTH) │ │ ├── idpBindings.ts # SAML binding (POST only) │ │ └── saml.ts # SamlAuthMethods enum, NameIdField enum, NameIdExtractor │ ├── interfaces/ │ │ └── locals.ts # AuthLocals — res.locals contract for all auth'd requests │ ├── models/ │ │ └── externalApp.ts # ExternalAppForCreation / ExternalApp domain models │ ├── ports/ │ │ ├── externalAppsRepositoryPort.ts # ExternalApp CRUD │ │ ├── janusSessionsPort.ts # gRPC Janus session lookups (FF-gated) │ │ ├── notificationsPort.ts # stopNotificationsOfSessions │ │ ├── serviceProvidersRepositoryPort.ts # SAML service provider CRUD │ │ ├── sessionCachePort.ts # Extends CachePort — routes to the dedicated sessions ElastiCache cluster │ │ └── tokenManagerPort.ts # create2faToken / verify2faToken │ ├── services/ │ │ ├── auth.ts # Main auth service (see below) │ │ ├── botAppAuth.ts # Bot app session management │ │ ├── apiKey.ts # API key generate / rotate / revoke / authenticate │ │ ├── externalApps.ts # ExternalAppsService — create and list external apps (SAML + OAUTH) │ │ └── samlIdentityProvider.ts # Humand-as-SAML-IdP (not SP) │ ├── types/ │ │ ├── apiKeyAuthResult.ts │ │ ├── apiKeyResult.ts │ │ ├── botAppLoginResult.ts │ │ ├── init2faResult.ts │ │ ├── loginResult.ts # LoginResult, RegularLoginResult, TokenLoginResult │ │ ├── response.ts # AuthResponse = HumandResponse │ │ ├── samlLoginResponse.ts │ │ └── samlOTCMetadataType.ts │ └── utils/ │ ├── clientIp.ts # getClientIp — priority: cf-connecting-ip, true-client-ip, x-real-ip, x-forwarded-for │ └── ipAllowlist.ts # isIpAllowed, isValidIpOrCidr (IPv4+IPv6+CIDR) ├── infrastructure/ │ ├── adapters/ │ │ ├── externalAppsRepositoryAdapter.ts │ │ ├── notificationsAdapter.ts # Wraps CloudNotificationsService │ │ ├── serviceProvidersRepositoryAdapter.ts │ │ └── tokenManagerAdapter.ts # HMAC-based 2FA token (NOT a JWT) │ ├── dataAccessObjects/ │ │ ├── botAppSessionDAO.ts # Table: BotAppSessions (paranoid, soft-delete) │ │ ├── externalApp.ts # Table: ExternalApps │ │ ├── otpDAO.ts # Table: Otp (per-instance OTP channel config) │ │ ├── samlServiceProvider.ts │ │ ├── sessionDAO.ts # Table: Sessions (compound PK: id+userId, no paranoid) │ │ └── userResetPasswordCodeDAO.ts │ └── mappers/ │ └── externalApp.ts # externalAppDAOToExternalApp, externalAppForCreationToDAO ├── externalAppsPortsDI.ts # DI wiring for ExternalAppsRepositoryPort ├── portsDI.ts # DI wiring: 3 ports → 3 adapters └── presentation/ ├── controllers/ │ ├── auth.ts # AuthController (password login, API keys, logout, etc.) │ ├── backofficeApiKeys.ts # BackofficeApiKeysController — customer-managed API keys (gated on MANAGE_API_KEYS) │ ├── botAppAuth.ts # BotAppAuthController │ ├── externalApps.ts # ExternalAppsController — list (app) and create (backoffice) │ ├── otp/otp.ts # OtpController │ ├── publicApiApiKeys.ts # Public API key management controller │ ├── samlIdentityProvider.ts │ └── sso/ │ ├── sso.ts # SsoController abstract base │ ├── apple.ts, azure.ts, google.ts, okta.ts, saml.ts │ └── (concrete SSO providers extend SsoController) ├── middlewares/ │ ├── apiKeyAuthenticator.ts # Factory: createValidateApiKeyMiddleware(apiKeyService) │ ├── cookies.ts # JWT cookie helpers (set/clear, httpOnly, sameSite: none) │ └── jwtAuthenticator.ts # Core auth middleware — see critical section below ├── routers/ │ ├── auth.ts # startAuthPublicAppRouter, startAuthAppRouter, startAuthRefreshAppRouter, + backoffice variants │ ├── backofficeApiKeys.ts # startApiKeysBackofficeRouter — mounted at /backoffice/api-keys │ ├── botApp.ts │ ├── identityProvider.ts # SAML IdP routes + ExternalApps routes (GET app, POST backoffice) │ ├── otp.ts │ ├── publicApiApiKeys.ts │ └── sso/ ├── serializationClasses/ │ ├── backofficeApiKeySC.ts # BackofficeApiKeySC, BackofficeApiKeyDTO (list/create response shape) │ ├── externalAppSC.ts # ExternalAppSC, ExternalAppDTO (unified shape for SAML and OAUTH) │ └── (loginSerializer, botAppLoginSerializer, apiKeySerializer, otp SCs) ├── validationClasses/ │ ├── backofficeApiKeyTargetVC.ts # BackofficeApiKeyTargetVC — { keyId } body for revoke/rotate │ ├── backofficeListApiKeysQueryVC.ts # BackofficeListApiKeysQueryVC — userId? + includeRevoked? query params │ ├── createBackofficeApiKeyVC.ts # CreateBackofficeApiKeyVC — optional { name (≤255), description (≤1024) } │ ├── createExternalAppVC.ts # CreateExternalAppVC — validates protocol-specific fields │ ├── updateIpAllowlistVC.ts # UpdateIpAllowlistVC — { allowedIps[] } with IPv4/IPv6/CIDR validation, ≤15 entries │ └── (loginVC, SSOLoginVC, getOTPVC, sendOTPVC, loginOTPVC, etc.) └── dataTransferObjects/ # otpOptionDTO ``` --- ## Services ### `AuthService` (`business/services/auth.ts`) The main service. Responsible for: - **`login`** — Password login for app and backoffice. Checks `forceOTP`/`otpAfterRegularLogin` flags and rejects non-bot users if either is set. - **`init2fa`** — Called when `otpAfterRegularLogin` is enabled: validates password, returns an `init2faToken` (custom HMAC token — not a JWT). - **`loginWithSSO` / `loginWithSamlSSO`** — Delegates to `loginOrJIT` (just-in-time user creation on first SSO login if instance allows it). - **`loginWithOtp`** — OTP-only login. If `otpAfterRegularLogin`, also verifies the `init2faToken` from `init2fa`. - **`refresh`** — Rotates access + refresh tokens. Keeps last N refresh JTIs (configurable via `MAX_CONCURRENT_REFRESH_TOKENS_ON_SESSION`). - **`createChildSession`** — Issues a shorter-lived session with `parentSessionId` (used for download tokens). Parent sessions only; excluded from concurrent session eviction. - **`ensureValidSessionId`** — Session validation used by JWT middleware. Pyramid: Redis (existence check) → Janus `GetSessionById` (when `SESSIONS_VIA_JANUS_ENABLED` is true for the instance) → DB fallback (also re-hydrates Redis with remaining TTL from `lastRefreshExpires`). Janus hits return immediately without writing Redis — the cluster is Janus-owned in the end state, so the monolith does not produce new writes from this path. Janus NOT_FOUND yields 401 with reason `session-id-not-found-in-janus`; gRPC errors silently fall through to DB and increment the `monolith.auth.janus_lookup_failure` Datadog metric. Every successful lookup also emits `monolith.auth.session_lookup` with `source: 'redis' | 'janus' | 'db'` so the pyramid efficiency is observable end-to-end. - **`logoutSession` / `logoutSessionsOfScope`** — Remove from DB and the sessions Redis cluster (via `SessionCachePort`). - **`logoutAllSessionsOfUser(userId, instanceId)`** — Removes all user sessions from DB and Redis. Both `userId` and `instanceId` are required to construct the cache key. - **`impersonateUser`** — Creates an access+refresh token pair with `ClientScopes.INTERNAL` scope; used for backoffice impersonation. - **`tokenLogin`** — Legacy: converts an old token format into a new session (marked for removal after SAML OTC is fully deployed). - **`verifyPasswordOfUser`** — bcrypt comparison with master password backdoor (see gotchas). - **`createSAMLOneTimeCode` / `findSAMLOneTimeCode`** — SAML callback flow issues a one-time code (via `oneTimeCodesService`); frontend exchanges it for tokens. **Dependencies on legacy services** (NOT injected via ports): `JwtService`, `EmailsService`, `AuditsService`, `TermsAndConditionsService`, `RealTimeNotificationsService`, `AttachmentsService`. These come from `src/api/services/` (the legacy directory). Touching token shape requires also reading `src/api/services/jwt.ts`. ### `AuthBotAppAuthService` (`business/services/botAppAuth.ts`) Parallel to `AuthService` but for bot applications. Uses `BotAppSessionDAO` (`BotAppSessions` table) and `jwtService.buildBotAppToken` / `decodeBotAppTokens`. Hard limit of 1 concurrent session per bot (`BOT_MAX_CONCURRENT_SESSIONS = 1`). Listens for `Events.BOT_APP_DELETION` to logout all sessions when a bot is deleted. ### `ApiKeyService` (`business/services/apiKey.ts`) Two API key formats coexist: - **New format:** `base64(userId:randomPart)`. The userId is embedded, enabling indexed lookup without a full table scan. Stored hashed (bcrypt). - **Old format:** Opaque string. Lookup requires an LRU-cached bcrypt hash comparison against all records. Old format usage emits a metric (`old_api_key_usage`). Both formats must continue to work until migration is complete. Key management: `generateApiKey`, `listApiKeys`, `rotateApiKey`, `revokeApiKey` (soft-delete via Sequelize `paranoid`), `updateIpAllowlist`. Max 50 IPs per key (`ApiKeyService.MAX_ALLOWED_IPS`); both `generateApiKey` and `updateIpAllowlist` enforce it via the private `validateAllowedIps` helper, so gRPC bypasses of the VC still get the same validation. `generateApiKey(userId, options?)` accepts a flat options bag: `username` and `password` (legacy username:password keys — when both are present, the raw key is `base64(username:password)` instead of a random nanoid), `name` (≤255), `description` (≤1024), `allowedIps` (≤50, validated as IPv4/IPv6/CIDR; empty array or `null` means no allowlist). All optional. `rotateApiKey(keyId, instanceId, userId?)` and `revokeApiKey(keyId, instanceId, userId?)` share a uniform shape: `userId` is an optional ownership constraint (public API passes its `loggedUser.id`, backoffice omits it). The rotated key's owner is always derived from the DB row, never from the caller — so an admin rotating another user's key produces a new key still owned by the original user. `rotateApiKey` preserves `name`, `description`, and `allowedIps` onto the new key by forwarding them into `generateApiKey` (no separate post-create update). All three columns are nullable — legacy keys created before these fields existed continue to work as `null`. Malformed keyIds raise `BadRequestError(ErrorCodes.INVALID_DATA)` (400), not 404. `ApiKeySerializer` (used by both backoffice create and public-API rotate responses) returns `{ apiKey, keyId }` so the caller can reference the new key immediately without a follow-up list call. The old `ApiKey` model lives at `src/api/models/apiKey/apiKey.ts` (legacy directory) — not inside this hexagonal module. ### `BackofficeApiKeysController` (`presentation/controllers/backofficeApiKeys.ts`) Customer-managed API keys, mounted at `/backoffice/api-keys/`. Every handler gates on `permissionsService.validateManageApiKeys(loggedUser)` (i.e. the `MANAGE_API_KEYS` capability). **Ownership semantics — the only asymmetric rule:** - **Create is self-only.** A key is always owned by `loggedUser.id`. No body field can override that. The VC has no `userId` field, and the controller hardcodes `generateApiKey(loggedUser.id, ...)`. This is a deliberate product constraint — even an admin with `MANAGE_API_KEYS` cannot mint a key on behalf of another user. - **Everything else is instance-scoped.** List, revoke, rotate, and IP-allowlist endpoints act on any key in the admin's instance, regardless of which user originally created it. The service signatures pass `instanceId` only and omit the optional `userId` argument that would otherwise enforce ownership match. - **Rotation preserves the original owner.** `rotateApiKey(keyId, decodedKeyId.userId, instanceId)` — the second argument comes from the keyId itself, not `loggedUser.id`. So admin A rotating user B's key produces a new key still owned by B; A merely triggered the rotation. Don't add a "the caller must own the key" check on revoke/rotate/IP-allowlist — that contradicts the product rule and breaks the cross-user management flow. | Method | Path | Purpose | |--------|------|---------| | `GET` | `/` | List keys for the instance. Optional `?userId` filter and `?includeRevoked` toggle. Paginated via `validatePageLimitPagination()`. | | `GET` | `/:keyId` | Get a single key by `keyId`, scoped to the admin's instance. Optional `?includeRevoked=true` returns soft-deleted keys with `revokedAt` populated; without it, revoked keys 404. Returns the same `BackofficeApiKeySC` DTO as a list entry. Declared **last** in the router so the literal `GET /allowed-ips` matches before this wildcard. | | `POST` | `/` | Create a key for `loggedUser.id`. Optional `name`, `description`, and `allowedIps` (≤50, IPv4/IPv6/CIDR) in the body. Response includes `{ apiKey, keyId }` so the caller can immediately reference the new key. | | `POST` | `/revoke` | Revoke by `keyId`. | | `POST` | `/rotate` | Rotate by `keyId`; new key inherits `name`, `description`, and `allowedIps` and stays owned by the original user. | | `GET` `PUT` `DELETE` | `/allowed-ips` | Manage the per-key IP/CIDR allowlist. **No "client IP must be in allowlist" rule** here — that lockout-prevention is public-API only. | Two practical invariants: - The controller's `getKeyIdFromQuery` helper throws `BadRequestError` synchronously. **Every call to it must live inside the `endpointHandlerWithGenerics` callback**, otherwise the rejection escapes the async arrow and Express never responds (the request hangs until client timeout). This is the canonical pattern for any helper that throws. - `MANAGE_API_KEYS` exists in the global `Capabilities` registry but is **not** auto-granted to instances. Per-instance enablement is handled out-of-band (ops/onboarding); after that, customer admins must have it granted explicitly to their roles/permissions. ### `JwtAuthenticatorService` (`business/services/jwtAuthenticator.ts`) Thin singleton wrapper over `@humand/security-authentication`'s `JwtAuthenticator`. Verifies RS256-signed access tokens against either the monolith's own JWKS endpoint or Janus's JWKS endpoint, routed by the `iss` claim. Consumed by the legacy `JwtService` (`src/api/services/jwt.ts`) for user/bot tokens and by the M2M middleware (`presentation/middlewares/m2mJwtAuthenticator.ts`) for Janus-issued M2M tokens. Construction is lazy: the underlying library client is built on the first `verify()` call. Nodes that only sign tokens (EVENT_HANDLER, WORKER, CDC) can boot without `JANUS_*` env vars being wired — they just must never call `verify()`. **Out of scope: query tokens.** `JwtService` also exposes `verifyAndGetSymmetricDecodedToken(token, symmetricKey)` — a separate HS256-only verification path used by the `validateQueryToken` middleware (`src/api/middlewares/tokenQueryAuthentication.ts`). Query tokens are minted by external services that share only the symmetric `JWT_KEY` secret with the monolith; they do not go through the JWKS / RS256 library path. ### `SamlIdentityProviderService` (`business/services/samlIdentityProvider.ts`) Humand acts as a **SAML Identity Provider** (not a Service Provider). Manages per-instance `IdentityProviderInstance` objects (cached in memory via `Map`). The signing cert and private key come from config (`idpSigningCert`, `idpPrivateKey`). Uses the `samlify` library with `@authenio/samlify-node-xmllint` schema validation. Custom attributes in SAML assertions: `email`, `employeeInternalId`, `firstName`, `lastName`, `instanceId`. --- ## Ports | Port | Adapter | Purpose | |------|---------|---------| | `AuthNotificationsPort` | `AuthNotificationsAdapter` | Wraps `CloudNotificationsService.removeTokensOfSessionIds`. Exists to avoid a direct hexagonal-layer violation — `business/` cannot import infrastructure push notification services. | | `AuthTokenManagerPort` | `AuthTokenManageAdapter` | Implements 2FA token creation/verification (HMAC-SHA256, NOT JWT — see gotchas). | | `ServiceProvidersRepositoryPort` | `AuthServiceProvidersRepositoryAdapter` | SAML service provider CRUD (find, list, create). | | `SessionCachePort` | `SessionCacheAdapter` at `src/api/dbAdapters/sessionCacheAdapter.ts` | Extends `CachePort` — connects to the dedicated Janus-owned sessions ElastiCache cluster via username/password auth. Shared adapter, not in the auth module. | | `JanusSessionsPort` | `JanusSessionsAdapter` | Read-only gRPC client for Janus `GetSessionById` / `GetSessionsByUserId`. Gated per-instance via the `SESSIONS_VIA_JANUS_ENABLED` feature flag. NOT_FOUND maps to `null` / `[]`; any other gRPC error throws `JanusUnavailableError`, increments the `monolith.auth.janus_lookup_failure` Datadog metric, and the caller in `AuthService` falls back to the existing DB path. | --- ## JWT Middleware (`presentation/middlewares/jwtAuthenticator.ts`) This is the cross-cutting auth middleware wired into routers across the entire monolith. Fragile — changes here affect every authenticated endpoint. ### Exported middleware | Export | Purpose | |--------|---------| | `validateAccessToken` | Standard access token validation for users and bots | | `validateRefreshToken` | Refresh token validation (used only on refresh endpoints) | | `validateBotAppAccessToken` | Bot-only access token (rejects user tokens) | | `validateBotAppRefreshToken` | Bot-only refresh token | | `validateAppScope` | Asserts `ClientScopes.APP` in token scopes | | `validateBackofficeScope` | Asserts `ClientScopes.BO` in token scopes | | `validateBotAppScope` | Asserts `ClientScopes.BOT_APP` in token scopes | | `validateBotAppPermissions(capabilities?)` | Validates bot capabilities and, if `X-Humand-User-Id` is set, the impersonated user's capabilities too | ### `validateToken` internals 1. Extracts `Bearer` token from `Authorization` header via `JwtService.getTokenFromHeader`. 2. Calls `JwtService.determineTokenOwner` to detect user vs. bot token. 3. For users: `jwtService.verifyAndGetDataFromTokenString` → calls `AuthService.ensureValidSessionId` → sets `res.locals.loggedUser`, `instanceOfLoggedUser`, `scope`, `iat`, `sessionId`, `refreshId`. 4. For bots: `jwtService.verifyAndGetBotAppDataFromTokenString` → calls `AuthBotAppAuthService.ensureValidSessionId` → reads `X-Humand-User-Id` header → loads impersonated user → sets `res.locals.loggedBotApp`, `instanceOfLoggedBotApp`, `botAppImpersonateUserId`, plus logged user if found. 5. Calls `addContextMetadata(...)` to attach structured metadata to the async context for downstream logging. 6. Checks `decodedToken.type` matches expected type (access vs. refresh). ### `disableLogging` / `enableLogging` pattern The middleware wraps its body in `disableLogging()` ... `enableLogging(logger)` to suppress Sequelize query noise during session validation. **Every exit path (success, error) must call `enableLogging` before returning.** This is currently maintained via try/catch/finally-style structure — do not remove it. ### `res.locals` contract (`AuthLocals`) Defined in `business/interfaces/locals.ts`. All authenticated controllers rely on these fields: ```typescript interface AuthLocals extends HumandLocals { loggedUser: UserInterface; // set for user tokens and bot-impersonating-user instanceOfLoggedUser: Instance; sessionId: string; refreshId: string; // JTI of the refresh token (undefined for access tokens) scope: string; // space-separated scopes iat: number; // issued-at timestamp apiKeyId?: string; // set only for API key authenticated requests loggedBotApp?: UserBotApp; // set for bot tokens instanceOfLoggedBotApp?: Instance; botAppImpersonateUserId?: number; // set when bot uses X-Humand-User-Id header } ``` --- ## API Key Middleware (`presentation/middlewares/apiKeyAuthenticator.ts`) Unlike the JWT middleware (plain exported functions), this is a **factory**: `createValidateApiKeyMiddleware(apiKeyService)` returns the middleware function. Used for the Public API context. Also enforces IP allowlist if configured on the key. --- ## Token and Session Flow ### Regular login ``` POST /login → AuthController.login → AuthService.login → usersService.findByUsernames → instance forceOTP/otpAfterRegularLogin check (reject if enabled for non-bots) → verifyPasswordOfUser (bcrypt; accepts MASTER_PASSWORD) → generateTokens → { accessToken, refreshToken } → createSession → SessionDAO.create → removeOldSessions if maxConcurrentSessions > 0 ← LoginResult { accessToken, refreshToken, user, instance, validSessionIds, ... } → setJwtCookieWithStringExpiration (httpOnly cookie) → post-transaction: auditsService.create, queuePort.sendMessage(USER_LOGIN) → realTimeNotificationsService.emitCurrentValidSessionsEvent if validSessionIds present ``` ### Token contents Tokens are signed by `JwtService` (at `src/api/services/jwt.ts`). Fields embedded in access tokens: ``` instanceId, userId (or botAppId), scopes[], sessionId (UUID), loginSso?, parentSessionId? ``` Refresh tokens add: `jti` (UUID, the `refreshId`), `type: "REFRESH"`. ### Session storage `Sessions` table (compound PK `id` + `userId`): | Column | Purpose | |--------|---------| | `id` | UUID, the `sessionId` embedded in tokens | | `userId` | Owner | | `scope` | First scope value (e.g. `"app"`) | | `lastRefreshIds` | Array of recent refresh JTIs (size = `MAX_CONCURRENT_REFRESH_TOKENS_ON_SESSION`) | | `lastAccessExpires` | Expiry of the most recent access token | | `lastRefreshExpires` | Expiry of the most recent refresh token | | `parentId` | Set for child sessions; these are excluded from concurrent session eviction | | `sessionOrigin` | `REGULAR`, `SSO`, `SAML`, `OTP`, `INTERNAL` | | `agent` | User-agent string (truncated to 255 chars) | `BotAppSessions` table is structurally similar but uses `botAppId` + `instanceId` instead of `userId`. It IS paranoid (soft-delete). ### Session cache Dedicated Janus-owned ElastiCache cluster, accessed via `SessionCachePort` / `SessionCacheAdapter` (username/password auth). Username is `sessions-rw-password` (from `SESSIONS_CACHE_DB_USERNAME` env var); password comes from SSM `/be/sessions-redis/rw-password` (injected via `SESSIONS_CACHE_DB_PASSWORD`). TLS is controlled by `SESSIONS_CACHE_DB_USE_TLS` (enabled in production). In tests: uses `default` user, test-container password, TLS disabled. Key format: `SESSIONS:v1:{instanceId}:{userId}:{sessionId}`. TTL is tied to `lastRefreshExpires` — set eagerly on `createSession`, updated on every `refresh`, deleted on logout/eviction. The cache is a fast existence check. The DB is the source of truth. On cache miss, `ensureValidSessionId` falls back to DB and re-hydrates the key with the remaining TTL from `lastRefreshExpires`. On logout, `cachePort.del` MUST be called or the session stays valid via cache hit until expiry. Bot sessions (`AuthBotAppAuthService`, `BotAppSessions`) are NOT on this cluster — they use the existing `CachePort` (shared Redis db 0). Only user sessions use `SessionCachePort`. ### Janus gRPC fallback When the `SESSIONS_VIA_JANUS_ENABLED` instance flag is on, four read paths in `AuthService` consult Janus over gRPC after a Redis miss (or unconditionally for paths that don't have a Redis layer yet): `ensureValidSessionId`, `findSession` (refresh), `removeOldSessions`, and `logoutSessionsOfScope`. The `JanusSessionsAdapter` translates the proto `Session` into a `JanusSessionSnapshot` carrying the fields these paths read (`id`, `userId`, `parentId`, `scope`, `sessionOrigin`, `lastRefreshIds`, `lastAccessExpires`, `lastRefreshExpires`, `isActive`). gRPC errors are caught and translated into a DB fallback; the `monolith.auth.janus_lookup_failure` Datadog metric is incremented per failed call (tag `method`). The DB fallback is a safety net for rollout — it will be removed in a follow-up once the metric stays at zero. The monolith does NOT hydrate Redis from Janus hits; the cluster is Janus-owned and Janus is the sole writer in the end state. Bot sessions are out of scope (not replicated to Janus). ### Token refresh `GET /refresh` → `validateRefreshToken` + scope middleware → `AuthController.appRefresh` → `AuthService.refresh` → `findSession` by `refreshId` (JTI) in `lastRefreshIds` array → `generateTokens` (same `sessionId`, new `refreshId`) → `updateSession` (rotate `lastRefreshIds`, update expiry timestamps) The `lastRefreshIds` array accepts the last N refresh JTIs to tolerate racing clients. Do not reduce the window to 1. --- ## OTP Flow Two distinct modes controlled by instance flags: ### `forceOTP` mode (OTP-only — no password) ``` GET /otp/options?... → AuthService.getOtps POST /otp/send → AuthService.sendOtp POST /otp/login → AuthService.loginWithOtp (no init2faToken expected) ``` ### `otpAfterRegularLogin` mode (password + OTP) ``` POST /login (init2fa=true) → AuthService.init2fa → returns { init2faToken } (init2faToken is an HMAC token, NOT a JWT — see gotchas) POST /otp/send → AuthService.sendOtp POST /otp/login → AuthService.loginWithOtp (init2faToken required and verified) ``` --- ## SSO Flow All SSO providers extend the `SsoController` abstract class (`presentation/controllers/sso/sso.ts`). Providers implement `getUserDataBySsoToken({ accessToken, nonce? })` to resolve `{ email, firstName?, lastName? }`. Supported providers: Google (`@googleapis/people`), Azure (`@microsoft/microsoft-graph-client`), Apple (`apple-signin-auth`, requires `nonce`), Okta (`@okta/jwt-verifier`). Login path: `SsoController.loginWithSso → AuthService.loginWithSSO → AuthService.loginOrJIT`. `loginOrJIT` handles JIT (just-in-time) user creation: - Uses `email` as `employeeInternalId` for SSO lookups (SSO always uses email). - If the user does not exist and `instance.usersCreationBySSOEnabled` is true and the email domain matches an instance SSO domain, the user is auto-created with a random password. - If `instance.useEmailForSso` is true, lookup is by email across users (must be unique — throws if >1 match). --- ## SAML Flow Humand acts as the **Identity Provider** (IdP). External services (Service Providers) redirect users to Humand for authentication and receive a SAML assertion back. - IdP metadata endpoint: `GET /auth/idp/metadata/{instanceId}` - SP-initiated SSO: SP redirects browser to `POST /auth/idp/{instanceId}/login` - IdP-initiated SSO: `GET /auth/idp/init/{spId}` (Humand initiates the auth) - The response is a signed SAML assertion (POST binding) containing user attributes. SAML login from external IdP (Humand as SP): Handled separately in `presentation/controllers/sso/saml.ts`. The flow: 1. External IdP redirects back to Humand with a SAML assertion. 2. The SAML controller calls `AuthService.loginWithSamlSSO`. 3. A SAML OTC (one-time code, via `oneTimeCodesService`) is created and returned to the frontend. 4. Frontend calls `POST /auth/saml-otc` to exchange the OTC for tokens. `employeeInternalId` used for SAML user lookup depends on `instanceSamlConfig.employeeInternalIdField` (either `EMAIL` or the SAML `nameId`). --- ## Gotchas and Invariants ### 1. Uniform error responses are deliberate Almost every failure path in `AuthService` throws `UnauthorizedError(ErrorMessages.invalidCredentials, ErrorCodes.INVALID_CREDENTIALS)` regardless of whether the user, instance, or password was wrong. This prevents credential enumeration. Do NOT add more specific error codes to distinguish these cases. ### 2. The 2FA `init2faToken` is NOT a JWT `AuthTokenManageAdapter.create2faToken` produces a custom format: ``` base64(expiresAt).hex(HMAC-SHA256(secretKey, base64(instanceId|userId|scope|expiresAt))) ``` Verification: re-derive the HMAC and compare signatures; check expiry. It is NOT verifiable by `jwtService`. Do not treat it as a JWT. ### 3. Master password backdoor `AuthService.verifyPasswordOfUser` accepts `MASTER_PASSWORD` env var as a valid password for any account. When used, `ClientScopes.INTERNAL` is added to the token scopes. This is an intentional backdoor for internal tooling, not a bug. ### 4. Session invalidation requires both DB and Redis When a session is destroyed (logout, eviction), both `SessionDAO.destroy` and `cachePort.del(getSessionCacheKey(instanceId, userId, sessionId))` must be called. Skipping the Redis delete leaves the session valid until the key expires via cache hit. The key lives on the dedicated sessions ElastiCache cluster accessed via `SessionCachePort`. ### 5. Two parallel pipelines — keep them in sync `AuthService` (users) and `AuthBotAppAuthService` (bots) are structurally identical but use separate session tables and token builders. Fixes or changes to session logic (refresh rotation, cache invalidation, concurrent session eviction) usually need to be applied to both. ### 6. `validateBotAppPermissions` does a dual permission check When a bot uses the `X-Humand-User-Id` header to impersonate a user, this middleware checks: 1. The bot's own capabilities (`permissionsService.validateAnyBotAppCapability`). 2. The impersonated user's capabilities (`permissionsService.validateAnyCapability`). Both checks are required. Omitting either is a privilege escalation vulnerability. ### 7. `disableLogging` / `enableLogging` in JWT middleware The JWT validation path suppresses Sequelize query logging. Every exit path (including error paths) must call `enableLogging(logger)` before calling `next()` or `next(error)`. The current implementation maintains this via structured try/catch — do not break it. ### 8. Concurrent session eviction returns session IDs for client notification `removeOldSessions` returns the IDs of the sessions that remain valid after eviction. These IDs are propagated to `AuthController.login`, which then calls `realTimeNotificationsService.emitCurrentValidSessionsEvent` so the frontend can log out the evicted sessions. Do not drop `validSessionIds` from the `LoginResult` type. ### 9. Child sessions are excluded from concurrent session eviction `removeOldSessions` filters `WHERE parentId IS NULL`. Child sessions (short-lived, used for download tokens) do not count toward the `maxConcurrentSessions` limit and are never evicted by this mechanism. ### 10. API key old-format LRU cache entries must be invalidated on revoke When an old-format API key is revoked, `revokeApiKey` searches the LRU cache by value and deletes the entry. Failing to do so means the revoked key remains valid until the cache TTL expires (1 minute). ### 11. `JwtService` is in the legacy services directory Token signing, verification, and decoding live at `src/api/services/jwt.ts` — outside this module. When changing token shape or claims, consult `jwt.ts` too. The types (`DecodedAccessToken`, `DecodedRefreshToken`, `BotAppDecodedRefreshToken`, etc.) are at `src/api/types/jwtToken.ts`. ### 12. SAML `IdentityProviderInstance` objects are cached per `instanceId` `SamlIdentityProviderService` holds a `Map` in memory for the lifetime of the process. This means SAML config changes for an instance require a process restart to take effect. ### 13. `addContextMetadata` in JWT middleware affects log structure globally `jwtAuthenticator.ts` writes `{ user: { id, instanceId, employeeInternalId }, session: { iat, id, tokenType, scope, ... } }` or `{ botApp: { id, instanceId, ... }, session: ... }` to the async context. This metadata appears in all downstream log lines. Adding or removing fields has a fan-out effect on log queries and alerting. --- ## Testing ### Integration tests Located at `test-integration/api/auth/` — ~22 test files covering: login, logout, refresh, OTP flows, SAML, SSO, API keys, bot app auth, impersonation, password reset, Zendesk token, external apps (SAML + OAUTH). External apps tests: `listExternalApps.test.ts` (GET /auth/idp/service-providers) and `createExternalApp.test.ts` (POST /auth/idp/service-providers). Commands: `test-integration/commands/auth/listExternalApps.ts`, `test-integration/commands/auth/createExternalApp.ts`. Reusable commands at `test-integration/commands/auth/`. For login in tests, use the canonical commands from `test-integration/commands/users/loginTo.ts`: ```typescript import { LoginToApi, LoginToBackoffice, LoginToDevops } from '../../commands/users/loginTo'; ``` These wrap the login endpoint with proper assertions and return the `LoggedSession`. ### Mocked ports in integration tests `OtpTwilioPort` is mocked (`OtpTwilioAdapterMock`) — OTP codes sent via Twilio are not actually sent in tests. Access the mock via `getMock(OtpTwilioPort.name)` to assert what code was "sent". SAML test fixtures live at `test-integration/fixtures/`: - `example-customer-idp-metadata.xml` — external IdP metadata - `example-idp-signingCert.cer` / `.key` — signing credentials ### Running auth tests ```bash pnpm nx run monolith:test-integration-all -- --testPathPattern="test-integration/api/auth" ```