All documents

MERIDIAN · DOC 06 / 25

Authentication

Sessions, cookies, Argon2id, enumeration defenses, role and tenant authorization, CSRF/CORS posture.

IMPLEMENTED IN PHASE 3. This document defines how identity works on the platform: who can authenticate, how sessions work, and how the backend decides what an authenticated user may do. The frontend is never consulted for those decisions.


1 · Who can authenticate — and who cannot

ActorAccount?Authenticates?Notes
Platform Adminyes (exactly one in V1)yes — /api/v1/auth/*tenantId = null
Tenant Adminyes (exactly one per tenant)yes — /api/v1/auth/*tenantId binding required
End CustomerNONEVERanonymous public tracking only

There is no CUSTOMER/STAFF/DRIVER/MANAGER/SUPER_ADMIN/OPERATOR role, and no customer authentication surface anywhere — no register endpoint exists.


2 · The flows (locked, implemented exactly so)

Login

User → Login Form → POST /api/v1/auth/login
  → rate limit (10 attempts / IP / 10 min)
  → validate input (zod loginSchema)
  → find user by email
  → verify Argon2id hash  (unknown email → one dummy-hash verify, same cost)
  → verify user.status = ACTIVE
  → if TENANT_ADMIN: verify tenant exists and tenant.status = ACTIVE
  → create session (random 256-bit token; SHA-256 hash stored)
  → Set-Cookie: meridian_session (HTTP-only)
  → 200 { success: true, data: { user: SafeUser } }

Any credential failure → 401 { code: "INVALID_CREDENTIALS",
                               message: "Invalid email or password." }  ← always identical

Authenticated request

Request (cookie: meridian_session)
  → withHandler pipeline (rate limit → origin check → security headers last)
  → withAuth: read cookie → session lookup by token hash
       → load FRESH user → reject if user suspended  (403 ACCOUNT_SUSPENDED)
       → if TENANT_ADMIN: load tenant → reject suspended/archived
                           (403 TENANT_SUSPENDED / TENANT_ARCHIVED)
  → authorize(context, roles?) — role allow-list gate (403 FORBIDDEN)
  → controller receives AuthContext { user: SafeUser, sessionId }

3 · Passwords

DecisionValue
AlgorithmArgon2id (memory-hard), via the argon2 package
Stored fieldpasswordHashselect: false, never returned by queries
Policy (V1)10–128 chars, non-blank. No arbitrary complexity rules
Hashing locationsrc/server/utils/password.ts — the only hashing site
Timing parityunknown email → dummy-hash verify before failing identically

Plaintext passwords exist only inside the login/change-password request, are never logged, never stored, never returned.


4 · Sessions

Server-side sessions in the MongoDB sessions collection — an infrastructure collection (the six locked business collections are unchanged). No Redis, no JWT, no tokens in browser-readable storage.

PropertyBehavior
Tokenrandom 256-bit, base64url — opaque bearer (not a JWT)
At restSHA-256 hash only (unique index) — a DB leak yields no live tokens
Lifetime7 days absolute from creation (no sliding renewal in V1)
CleanupTTL index on expiresAt — MongoDB physically deletes dead sessions
Snapshot fieldsuserId / role / tenantId — re-validated fresh per request
Invalidationlogout destroys the session; password change destroys all OTHER sessions of the user and preserves the current one

Cookie configuration

AttributeDevelopmentProduction
Namemeridian_sessionmeridian_session
HttpOnlyyes (JS cannot read it)yes
Secureno (http://localhost)yes (HTTPS only)
SameSiteLaxLax
Path//
Expirysession expiry (7d)session expiry (7d)

5 · Endpoints (full contract: docs/api.md)

Method & routeAuthBehavior
POST /api/v1/auth/loginissues session + cookie; hard rate limit
POST /api/v1/auth/logoutdestroys session, clears cookie; idempotent
GET /api/v1/auth/meanyreturns SafeUser; 401 without a valid session
POST /api/v1/auth/change-passwordanyverify current → policy → rehash → invalidate others

6 · Enumeration & brute-force defenses

  1. One message for every login failure — unknown email, wrong password, inactive user, suspended/archived tenant all return the identical 401 INVALID_CREDENTIALS / "Invalid email or password." (the real reason is logged server-side only).
  2. Timing parity — the dummy-hash verify makes unknown emails cost the same Argon2id work as real ones.
  3. Login rate limit — 10 attempts/IP/10 minutes in addition to the global 120/min/IP limit; excess → 429 RATE_LIMITED.
  4. Specific codes exist only AFTER authenticationACCOUNT_SUSPENDED, TENANT_SUSPENDED, TENANT_ARCHIVED can only be observed by someone already holding a valid session.

7 · Authorization model

Authentication answers who are you; authorization answers may you.

Platform Admin is privileged — not invisible

Platform Admin passes the same gates; there is no if admin → skip checks bypass. Cross-tenant reads/writes will always flow through explicitly authorized platform endpoints, and every mutation is logged.

Tenant scope is session-authoritative

AuthContext.user.tenantId is the ONLY tenant identity the backend accepts. Phase 5 services take tenantId from the context and inject it into every query filter — a client-sent tenantId is ignored as plain untrusted input (cf. docs/security.md §1, tests/authorization.test.ts).


8 · Tenant status restrictions (implemented)

Tenant statePublic site/trackingTenant Admin loginTenant Admin APIPlatform Admin
ACTIVEworksworksworksworks
SUSPENDEDdisabled (Phase 4/5 gate on same rule)401 generic403 TENANT_SUSPENDEDworks
ARCHIVEDdisabled401 generic403 TENANT_ARCHIVEDworks (data retained)

Enforcement point: resolveSessionByToken re-checks user + tenant status on every request, so suspension takes effect immediately — not at next login and not at session expiry.


9 · CSRF and CORS

CSRF (all four layers active for state-changing requests):

  1. SameSite=Lax cookies — cross-site POSTs don't carry the session.
  2. Origin allow-list on every non-GET (Phase 2 middleware) — requests with a foreign Origin get 403 FORBIDDEN.
  3. JSON-only bodies — readJsonBody rejects anything but Content-Type: application/json; simple HTML-form CSRF cannot send that.
  4. Same-origin deployment — the UI and API are one origin, so no ambient cross-origin surface exists for credential flows.

CORS: the frontend is served by the same application; no credentialed cross-origin access is offered to third parties and Access-Control-Allow-Origin: * is never combined with credentials.


10 · Logging rules

Logged (no credentials, ever): login success, login failure reason (category only), blocked logins (inactive user / suspended tenant), logout, password change (+ count of invalidated sessions), rate-limit hits, security rejections.

NEVER logged: passwords, password hashes, session tokens, raw cookies, session secrets. The logger redaction list (src/server/utils/logger.ts) covers *.password, *.token, *.secret, auth headers, cookies.


11 · Verification assets