Appearance
Auth & sessions
Credentials + mandatory OTP. No OIDC/SAML. No demo bypass (LOCUS_DEMO_MODE removed; there are no demo accounts).
text
browser cookie locus_session (signed ticket)
→ Edge middleware (presence + HMAC signature + expiry)
→ BFF requireBffSession + enforceRbacGuard (loads SoR session)
→ C# InstitutionalAuthMiddleware (AuthSessions → ClaimsPrincipal)
→ TenantRlsMiddleware (GUCs for FORCE RLS)
→ controllers: InstitutionalAuthorization.RequirePermissionBank (locus-web)
text
Register → Organization + INSTITUTION_ADMIN → OTP challenge
Login → always mfaRequired → OTP → AuthSessions + HMAC locus_session cookie
Logout → revoke AuthSessions + clear cookie- No OIDC/SAML. No demo bypass.
- Edge middleware verifies ticket signature + expiry (
SESSION_SIGNING_KEY). - BFF forwards
Authorization: Bearer <sessionId>+X-Organization-Idfrom session claims.
Sequence
mermaid
sequenceDiagram
autonumber
actor U as User
participant W as locus-web /api/auth
participant S as SoR /api/auth/*
participant E as Edge middleware
U->>W: POST {action: "register", email, password, fullName, institutionName, country, institutionType}
W->>S: POST /api/auth/register
S->>S: guards, Organization + User + Membership(INSTITUTION_ADMIN), audit ORG_REGISTERED
S-->>W: {mfaRequired: true, organizationId, otpCode? (Development only)}
U->>W: POST {action: "login", email, password, rememberDevice}
W->>S: POST /api/auth/login
S-->>W: {mfaRequired: true, portal: "institution"}
U->>W: POST {action: "otp", email, code}
W->>S: POST /api/auth/otp
S->>S: AuthSessions row sess_<guid>, TokenHash = SHA-256(sessionId), audit SIGN_IN
S-->>W: {session: {sessionId, tenantId, role, expiresAt, …}}
W->>W: mintSessionTicket(sessionId, expiresAt)
W-->>U: Set-Cookie locus_session=v1.<payload>.<sig>; HttpOnly; SameSite=Lax; Secure(prod)
U->>E: GET /workspace/overview
E->>E: verifySessionTicket (signature + exp)
E-->>U: allow or 302 /login?redirect=…/api/auth actions (web BFF)
| Method + action | Behaviour |
|---|---|
GET | Resolve cookie → fetchSorSession; 401 { authenticated: false } if no session |
POST register | registerAgainstSor; requires email, password, fullName, institutionName; returns mfaRequired: true |
POST login | loginAgainstSor; always mfaRequired (a response without it is a 500) |
POST otp | verifyOtpAgainstSor → mint ticket → cookie. Recovery codes are not supported (400) |
POST logout | Revokes AuthSessions in the SoR; cookie deleted even if the SoR is unreachable |
POST switch_tenant | audited tenant switch (domain mirror) |
otpCode is returned in API responses only outside production (and logged by the SoR in Development).
Session ticket v1.<payload>.<sig>
Format (session-ticket.ts, Edge-safe WebCrypto):
text
v1.<base64url(JSON {"sid": "<sessionId>", "exp": <unix seconds>})>.<base64url(HMAC-SHA256(key, "v1." + payload))>| Part | Detail |
|---|---|
sid | Opaque SoR session id (sess_<guid>); the SoR stores only SHA-256(sessionId) as TokenHash |
exp | Unix seconds; exp*1000 <= now → EXPIRED |
| Key | SESSION_SIGNING_KEY; required in production (throws otherwise, verify returns MISSING_KEY); dev fallback key only outside production |
| Verification | constant-time compare; failure reasons MALFORMED, BAD_SIGNATURE, EXPIRED, MISSING_KEY |
| Lifetime | SoR session 8 h, or 72 h with rememberDevice |
| Cookie | locus_session (also __Secure-locus-session read), httpOnly, secure in production, sameSite=lax, path=/, maxAge = exp − now |
| Legacy | unsigned sess_* cookies are always rejected |
The same format is used by locus-ops with cookie locus_ops_session (separate cookie; same key recommended).
Public routes (bank middleware)
| Class | Paths |
|---|---|
| Pages | /, /landing, /login, /register, /auth/login, /auth/forgot-password, /auth/otp, /auth/reset-password, /pricing, /request-access, /request-access/verify, /onboarding, /auth/accept-invite, /legal, /legal/{mentions-legales,confidentialite,conditions,securite} |
| API prefixes | /api/onboarding, /api/regulatory/search, /api/auth, /api/locus/members/accept-invite, /api/locus/access-requests |
Everything else under /api/** | 401 UNAUTHENTICATED without a valid signed ticket |
/workspace/** | redirect /login?redirect=… |
Error codes: 401 UNAUTHENTICATED, 401 SESSION_EXPIRED, 403 TENANT_FORBIDDEN.
SoR endpoints
| Endpoint | Purpose |
|---|---|
POST /api/auth/register | Org + user + INSTITUTION_ADMIN membership; OTP e-mail |
POST /api/auth/login | Password step; always OTP |
POST /api/auth/platform-login | Ops password step |
POST /api/auth/otp | Issues AuthSession |
POST /api/auth/logout | IsRevoked = true, audit SIGN_OUT |
GET /api/auth/session | Session details from claims (sub, tenant_id, …) |
Registration guards (SoR AuthController.Register): all of email, password, fullName, institutionName required; consumer e-mail rejected; password ≥ 12 characters; 409 EMAIL_TAKEN; 409 ORG_EXISTS when an active org already owns the corporate domain (ask an admin for an invitation). New org defaults: edition securities, primary country CG unless provided.
Login guards: invalid credentials → generic Identifiants invalides.; Invited membership must accept the invite first; Suspended → access suspended. Fresh tenant = empty workspace until the first dossier.
Platform (locus-ops)
text
POST /api/auth/platform-login → rejects non-PLATFORM_OPERATOR before OTP
OTP → locus_ops_session cookie| Rule | Behaviour |
|---|---|
| platform-login reject | SoR returns 401 NOT_PLATFORM_OPERATOR ("Ce portail est réservé aux opérateurs plateforme Locus.") before an OTP is generated |
| Bank login by an operator | SoR returns 401 USE_PLATFORM_LOGIN |
| OTP step | ops BFF additionally returns 403 NOT_PLATFORM_OPERATOR if the issued session role is not PLATFORM_OPERATOR |
| Session read | GET /api/auth returns 401 unless role is PLATFORM_OPERATOR |
| Public | /login, /login/otp, /api/auth only |
Seeded (seeder only): ops@locus.platform / Locus@2026!Seed — only when Seed:RunOnStartup=true; never in production.
Access requests
Public submit/verify on bank BFF → SoR state machine. Approve / provision UI on ops /access-requests (system:manage).
text
SUBMITTED ─(24 h magic token)─► EMAIL_VERIFIED ─► COMPLIANCE_REVIEW ─► APPROVED ─► PROVISIONED
└────────────► REJECTED| Rule | Value |
|---|---|
| Free e-mail domains | rejected → CONSUMER_EMAIL_REJECTED |
| Terms | termsAccepted + advisoryDisclaimerAccepted required → TERMS_NOT_ACCEPTED |
| Rate limit | 5 requests / 15 min per address → 429 RATE_LIMIT_EXCEEDED |
| Domain dedupe | active org → TENANT_ALREADY_PROVISIONED; pending request → PENDING_REQUEST_EXISTS |
| Verify token | 24 h TTL; SHA-256(token) stored; link {App:PublicWebBaseUrl}/request-access/verify?token=… |
| Review / provision | system:manage (PLATFORM_OPERATOR); provision creates Organization + edition modules + invited INSTITUTION_ADMIN |
Routes: BFF /api/locus/access-requests (submit public; list = system:manage), /verify (public), /[id]/review, /[id]/provision; SoR api/access-requests (GET, POST, verify, {id}/review, {id}/provision). See Access requests (ops).
Roles (RBAC)
| Role | Scope |
|---|---|
PLATFORM_OPERATOR | Global platform (system:manage): health, tenant provisioning, vault, cross-org market ingest. Cannot be invited as an in-tenant role via Members API |
INSTITUTION_ADMIN | Tenant admin: users, seats, edition, module toggles, audit |
ARRANGER_LEAD | Dossier creation, term sheets, memo sign-off, external sharing |
CREDIT_ANALYST | SYSCOHADA reconciliation, covenant stress, cell overrides with rationale; no memo sign-off (403) |
COMPLIANCE_OFFICER | COSUMAF pre-visa, legal cross-checks; no financial override (403) |
SYNDICATE_GUEST | Read-only deal room; no memo generation (403) |
InstitutionalRole also carries expanded desk roles (ORIGINATION_BANKER, RISK_MANAGER, LEGAL_COUNSEL, RESEARCH_ANALYST, PORTFOLIO_MANAGER, COMMITTEE_VIEWER). Matrix: locus-backend/Services/InstitutionalAuthorization.cs + TS mirrors in src/domains/auth/.
Fail-closed behaviours
- BFF prefers SoR session authority; alerts / reconciliation / overrides fail closed (no
DOMAIN_MIRRORfallback). - C# deal / org / member / export / document / market / portfolio / search / precedent / blocker / analysis-job paths require an authenticated tenant + permission (no seed-org fallback).
- Cross-tenant dossier access →
403 TENANT_FORBIDDEN/ not found under the caller'sOrganizationId. Auth:AllowDevHeadersmust befalsein production (Development only:X-Organization-Id/X-User-*).
Tests: auth_otp_and_register, session_ticket_middleware, auth_gate_and_api_rbac, fail_closed_integration, onboarding_lifecycle, access_request_production_api.