2. Authentication
2.1 User sessions (session cookie)
- Authentication is by Better Auth session cookie, with
basePath/api/v1/auth. - Cookie prefix is
dffms. In production secure cookies are enabled; the cookie name is__Secure-dffms.session_token. Cookies are host-only (cross-subdomain cookies are disabled). - The Better Auth plugins configured are
twoFactorandpasskey. No bearer-token plugin is configured, so the API reads only the session cookie. (Inferred from the plugin list; the code does not state this explicitly.) - Sign-in methods:
- Email and password.
requireEmailVerificationistrue, so unverified accounts cannot sign in. Minimum password length 6, maximum 128. - Google sign-in, when enabled for the platform.
disableImplicitSignUpistrue, so Google sign-in does not create accounts. - Passkeys (WebAuthn), through
@better-auth/passkey.
- Email and password.
- Session lifetime is 4 hours. The session is refreshed every 15 minutes of activity.
freshAgeis 5 minutes (Better Auth default in this configuration). - Staff accounts are invite-only (accepted invitation converts to a membership). Public accounts have
accessTypePUBLIC_USER.
Example of the cookie header sent on every authenticated request:
Cookie: __Secure-dffms.session_token=<session-cookie>
2.2 Multi-factor authentication and step-up
Two different MFA concepts appear in the code and must not be confused:
| Concept | Where enforced | Rule |
|---|---|---|
| Tenant MFA enrolment | resolveActorFromRequest, requireAuthorization, requireReportingAccess, requirePlatformSuperAdmin | If the tenant's security policy sets requireMfaEnrollment, the account must have 2FA enrolled, otherwise MFA_REQUIRED. Default policy value is false. |
| Step-up for high-risk actions | authorize(); finance service requireStepUp | Actions approve, reverse, refund, payout, configure, export require an MFA-enrolled account and a session younger than the action's freshness window. |
Freshness windows as coded:
| Path | Window | Failure |
|---|---|---|
Domain authorize() step-up actions | 15 minutes since session.createdAt | 403 with reason fresh_auth_required (mapped to FRESH_AUTH_REQUIRED or FORBIDDEN depending on route) |
Finance service (requireStepUp) and finance wrapper (requireFreshSession) | 5 minutes since session.createdAt | 401 FRESH_AUTH_REQUIRED from the service; the wrapper returns 401 UNAUTHENTICATED |
| Platform Super Administrator access management and marketplace decision | 5 minutes (requireFreshSession) | 401 FRESH_AUTH_REQUIRED |
Passkeys: when a tenant enforces MFA enrolment, requirePlatformSuperAdmin also requires at least one registered passkey for the Platform Super Administrator (MFA_REQUIRED, 401).
To satisfy a fresh-authentication requirement, the client signs in again (POST /api/v1/auth/sign-in/email, or the passkey or Google flow) and then calls the endpoint within the window.
2.3 Tenant selection
X-DFFMS-Tenant-Id: <tenantId>is required on: generic resources (/api/v1/{resource}), finance applications, financing cases, finance transactions, reconciliation sync.- The header must be a UUID. Missing or invalid values return 400
VALIDATION_ERRORon generic and finance-application routes, and 422VALIDATION_ERRORon the finance-transaction and reconciliation routes (see Section 4). - The actor must hold an active role assignment in that tenant. Otherwise 403
FORBIDDEN.
- The header must be a UUID. Missing or invalid values return 400
- Reporting, marketplace decision and push-subscription routes ignore the header. They resolve the tenant from the account:
- Staff: the oldest
ACTIVEmembership of the account (getPortalAccess). - Public users (push subscriptions): the public marketplace tenant
00000000-0000-0000-0000-000000000000. - Platform Super Administrator decision: the portal access tenant as above.
- Staff: the oldest
- Tenant identifiers are never inferred from client-supplied record identifiers. Record lookups are constrained by
tenantIdin every query.
2.4 Unauthenticated endpoints
GET /api/v1/health,GET /api/v1/ready,GET /api/v1/openapi.POST /api/v1/contact: no session, but a mutation Origin check applies.POST /api/v1/webhooks/{provider}: no session; authenticated by provider HMAC signature./api/v1/auth/*: Better Auth endpoints, each with its own rules.
2.5 Origin checks (CSRF defence for mutations)
Every state-changing request that uses a session cookie must send an Origin header that is in the allowed-origin set.
- Requests from an origin that is not allowed are rejected: 400
VALIDATION_ERRORon the generic-resource and finance-application routes, 403ORIGIN_DENIEDelsewhere. - CORS preflight (
OPTIONS) on/api/*from an origin that is not allowed returns 403.
Example:
Origin: https://dffms.org