feat(auth): trusted devices, recovery codes, and admin MFA management
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / client-build (pull_request) Successful in 9m24s

Add opt-in "Trust this device" so a browser/app skips the TOTP step (never
the password) for 30 days, single-use bcrypt recovery codes as a 2FA-lockout
fallback, and admin trusted-device/MFA-reset management — backend, web UI,
OpenAPI spec, and tests.

- Schema: trusted_devices (sha256 token hash, looked up by unique index) and
  recovery_codes (bcrypt, single-use). Both additive/idempotent.
- Session service: trust-token mint/hash/resolve + cap helpers; new rg_trust
  httpOnly cookie (survives logout, revoked on untrust/password change/reset/
  TOTP disable). JWTs stay stateless — trust is a server-side row, not a claim.
- Web + mobile login accept a trusted-device token / recovery code; login/totp
  gains trustDevice + recoveryCode. Cap of 10/user with NO silent pruning — an
  over-cap trust returns 409/trustLimitReached and the client prompts to revoke.
- Self-service /auth/me/trusted-devices* + recovery-codes*; admin
  /admin/users/:id/trusted-devices* + /mfa/reset. All actions audit-logged.
- Client: "Trust this device" + recovery-code login options, one-time recovery
  code display, Trusted Devices + Recovery Codes account panels, a TOTP-styled
  revoke-to-continue cap modal, and admin per-user security controls.
- OpenAPI regenerated; 33 new server tests (all suites green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 23:38:48 -05:00
parent 8d5bdc0d6e
commit 60ebacff2c
38 changed files with 3542 additions and 90 deletions

View File

@@ -1,8 +1,10 @@
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const settings = require('../../../model/settings/settings.model')
const { setAuthCookie, clearAuthCookie } = require('../../../auth/token')
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const { setAuthCookie, clearAuthCookie, setTrustCookie } = require('../../../auth/token')
const sessionService = require('../../../auth/session.service')
const { establishTrust } = require('./trustDevice.helper')
const totp = require('../../../utils/totp')
const botScore = require('../../../middleware/botScore')
const loginProtection = require('../../../middleware/loginProtection')
@@ -27,14 +29,14 @@ function needsTotp(user) {
// the cookie, clear the IP's failure backoff, and record the login. authMethod
// records how this session was authenticated ('local' password, or 'totp' after
// the second factor) — carried in the session token for downstream visibility.
async function issueSession(req, res, user, authMethod = 'local') {
async function issueSession(req, res, user, authMethod = 'local', extra = undefined) {
loginProtection.recordSuccess(req.ip)
await users.recordLogin(user.id, req.ip)
const { token } = sessionService.createSession(user, authMethod)
setAuthCookie(req, res, token)
await activity.log({ req, userId: user.id, action: 'auth.login' })
log.info('login success', { username: user.username, id: user.id, ip: req.ip, authMethod })
return res.json({ user: { id: user.id, username: user.username, role: user.role } })
return res.json({ user: { id: user.id, username: user.username, role: user.role }, ...(extra || {}) })
}
async function login(req, res) {
@@ -68,9 +70,23 @@ async function login(req, res) {
}
// Password is correct. If this user has TOTP on, do NOT issue a session yet —
// hand back a short-lived, signed "password verified" challenge and require
// the code. If TOTP is off, log them straight in.
// unless this browser is a trusted device, in which case the second factor is
// skipped (the password was still required above). Otherwise hand back a
// short-lived, signed "password verified" challenge and require the code.
if (needsTotp(user)) {
// Trusted-device skip: honor a valid trust token bound to THIS user. Any DB
// hiccup falls through to the normal TOTP challenge (fail closed to TOTP).
try {
const device = await sessionService.resolveTrustedDevice(req)
if (device && device.user_id === user.id) {
await sessionService.honorTrustedDevice(device.id)
await activity.log({ req, userId: user.id, action: 'auth.login.trusted_device' })
log.info('login via trusted device (TOTP skipped)', { username: user.username, id: user.id, ip: req.ip })
return issueSession(req, res, user, 'totp')
}
} catch (err) {
log.error('trusted-device check failed; falling back to TOTP', err)
}
const challenge = sessionService.createPartialSession(user)
log.info('password ok, awaiting TOTP', { username: user.username, id: user.id, ip: req.ip })
return res.json({ totpRequired: true, challenge })
@@ -134,23 +150,56 @@ async function register(req, res) {
}
}
// Second step for TOTP users: verify the challenge token + code, then issue the
// session. A wrong code counts as a failed attempt (backoff + bot score).
// Second step for TOTP users: verify the challenge token + a second factor, then
// issue the session. The second factor is either the current authenticator `code`
// OR a single-use `recoveryCode` (for users who lost their authenticator). A wrong
// factor counts as a failed attempt (backoff + bot score). If `trustDevice` is set,
// this browser is remembered so future logins skip the TOTP step — unless the user
// is at the trusted-device cap, in which case the session is still issued and the
// response carries a { trustLimitReached, devices } prompt to revoke one first.
async function loginTotp(req, res) {
const { challenge, code } = req.body
const { challenge, code, recoveryCode, trustDevice, deviceName } = req.body
const decoded = sessionService.upgradeSessionAfterTotp(challenge)
if (!decoded) {
return res.status(401).json({ message: 'Your verification session expired. Please sign in again.' })
}
try {
const user = await users.getRawById(decoded.id)
if (!user || !user.totp_enabled || !totp.verifyCode(user.totp_secret, code)) {
if (!user || !user.totp_enabled) {
botScore.recordLoginFailure(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('TOTP verify failed', { id: decoded.id, ip: req.ip })
return res.status(401).json({ message: 'Invalid verification code.' })
}
return issueSession(req, res, user, 'totp')
// Accept a TOTP code, or fall back to consuming a single-use recovery code.
let verified = Boolean(code) && totp.verifyCode(user.totp_secret, code)
let viaRecovery = false
if (!verified && recoveryCode) {
verified = await recoveryCodes.consumeForUser(user.id, recoveryCode)
viaRecovery = verified
}
if (!verified) {
botScore.recordLoginFailure(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('TOTP verify failed', { id: user.id, ip: req.ip, recovery: Boolean(recoveryCode) })
return res.status(401).json({ message: 'Invalid verification code.' })
}
if (viaRecovery) {
await activity.log({ req, userId: user.id, action: 'account.recovery_code.consume' })
log.info('login via recovery code', { id: user.id, ip: req.ip })
}
// Optionally remember this browser as a trusted device.
let trustLimit = null
if (trustDevice) {
const result = await establishTrust(req, user, { platform: 'web', deviceName: deviceName || null })
if (result.ok) setTrustCookie(req, res, result.trustToken)
else if (result.capReached) trustLimit = result.devices
}
const extra = trustLimit ? { trustLimitReached: true, devices: trustLimit } : undefined
return issueSession(req, res, user, 'totp', extra)
} catch (err) {
log.error('loginTotp error', err)
return res.status(500).json({ message: 'Internal Server Error' })

View File

@@ -94,16 +94,21 @@ authRouter.post(
authRouter.post(
'/login/totp',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Complete login with a TOTP code'
// #swagger.description = 'Second step for 2FA accounts. Exchange the challenge from /login plus the current authenticator code for a session cookie.'
// #swagger.summary = 'Complete login with a TOTP or recovery code'
// #swagger.description = 'Second step for 2FA accounts. Exchange the challenge from /login plus either the current authenticator code OR a single-use recovery code for a session cookie. Set trustDevice to remember this browser and skip TOTP on future logins (30 days); if the trusted-device limit is reached the session is still issued and the response carries { trustLimitReached, devices } so the user can revoke one first.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpLoginRequest" } } } } */
/* #swagger.responses[200] = { description: 'Session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
/* #swagger.responses[200] = { description: 'Session issued (optionally with a trusted-device-limit prompt)', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Invalid code or expired challenge', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
...loginGuards,
body('challenge').isString().notEmpty(),
body('code').isString().trim().isLength({ min: 6, max: 8 }),
// Either a TOTP code or a recovery code satisfies the second factor; the
// controller rejects the request when neither verifies.
body('code').optional({ values: 'falsy' }).isString().trim().isLength({ min: 6, max: 8 }),
body('recoveryCode').optional({ values: 'falsy' }).isString().trim().isLength({ min: 8, max: 32 }),
body('trustDevice').optional().isBoolean(),
body('deviceName').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }),
validate,
loginTotp,
)

View File

@@ -22,6 +22,7 @@ const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
const { accountChangeLimiter } = require('../../../middleware/rateLimit')
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
const meRouter = express.Router()
@@ -164,4 +165,83 @@ meRouter.delete(
account.revokeSession,
)
// ── Trusted devices (self-service, MFA "Trust this device") ────────────────
// Distinct from /sessions (mobile login sessions): these are the devices allowed
// to SKIP the TOTP step at login. List, trust-current, revoke one, untrust all.
meRouter.get(
'/trusted-devices',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'List trusted devices (self)'
// #swagger.description = 'Active (unrevoked, unexpired) trusted devices — the browsers/apps allowed to skip the TOTP step at login. Never returns tokens.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Active trusted devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/TrustedDevice" } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.listTrustedDevices,
)
meRouter.post(
'/trusted-devices',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Trust the current device (self)'
// #swagger.description = 'Marks the current browser/app as trusted so future logins skip the TOTP step (30 days). Web receives an httpOnly trust cookie; native (bearer) sessions receive { trustToken } to store. Returns 409 { error: "trusted_device_limit", devices } when the per-user cap is reached — revoke one first, then retry.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { deviceName: { type: "string" } } } } } } */
/* #swagger.responses[200] = { description: 'Device trusted', content: { "application/json": { schema: { $ref: "#/components/schemas/TrustDeviceResult" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Trusted-device limit reached', content: { "application/json": { schema: { $ref: "#/components/schemas/TrustedDeviceLimit" } } } } */
accountChangeLimiter,
body('deviceName').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }),
validate,
account.trustThisDevice,
)
meRouter.delete(
'/trusted-devices',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Revoke all trusted devices (self)'
// #swagger.description = 'Untrust every device; future logins on all of them require the full TOTP step again. Also clears this browsers trust cookie.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Revoked count', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "integer" } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.revokeAllTrustedDevices,
)
meRouter.delete(
'/trusted-devices/:id',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Revoke one trusted device (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Trusted-device id from GET /auth/me/trusted-devices.' }
/* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }),
validate,
account.revokeTrustedDevice,
)
// ── Recovery (backup) codes (self-service) ─────────────────────────────────
meRouter.get(
'/account/recovery-codes/status',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Remaining recovery-code count (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Remaining unused codes', content: { "application/json": { schema: { type: "object", properties: { remaining: { type: "integer" } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.recoveryCodesStatus,
)
meRouter.post(
'/account/recovery-codes/generate',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Regenerate recovery codes (self, password step-up)'
// #swagger.description = 'Generates a fresh set of single-use recovery codes, invalidating any prior set, and returns them ONCE. Requires the current password (accounts that have one); refuses when two-factor is off. Behind the login backoff/bot guards since a wrong password is credential-guessing.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { currentPassword: { type: "string" } } } } } } */
/* #swagger.responses[200] = { description: 'New recovery codes (shown once)', content: { "application/json": { schema: { $ref: "#/components/schemas/RecoveryCodes" } } } } */
/* #swagger.responses[400] = { description: 'Wrong password, or two-factor not enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
backoffGuard,
slowLogin,
accountChangeLimiter,
body('currentPassword').optional({ values: 'falsy' }).isString(),
validate,
account.generateRecoveryCodes,
)
module.exports = meRouter

View File

@@ -14,7 +14,9 @@
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const sessionService = require('../../../auth/session.service')
const { establishTrust } = require('./trustDevice.helper')
const totp = require('../../../utils/totp')
const botScore = require('../../../middleware/botScore')
const loginProtection = require('../../../middleware/loginProtection')
@@ -52,9 +54,10 @@ async function persistAndFinish(req, user, out, action, deviceName = null) {
await activity.log({ req, userId: user.id, action })
}
// POST /auth/mobile/login { username, password, code? }
// POST /auth/mobile/login { username, password, code?, recoveryCode?, trustDevice? }
async function login(req, res) {
const { username, password, code } = req.body
const { username, password, code, recoveryCode, trustDevice } = req.body
const deviceName = req.body.device_name || null
try {
const user = await users.getRawByUsername(username)
const ok = user && (await users.validatePassword(user, password))
@@ -65,26 +68,54 @@ async function login(req, res) {
return res.status(401).json(GENERIC_FAIL)
}
// Second factor, single-request style: if 2FA is enabled, a valid code must
// accompany this request. Missing or wrong → tell the app to prompt + retry.
// A wrong code is a real failed attempt (scored + backed off like web).
// Second factor, single-request style: if 2FA is enabled it must be satisfied
// by (a) a trusted-device token (X-Trust-Token) bound to this user, (b) a valid
// TOTP code, or (c) a single-use recovery code. Otherwise tell the app to prompt
// + retry. A wrong code/recovery code is a real failed attempt (scored + backed
// off like web); a missing factor is not (it's the expected first round-trip).
let viaRecovery = false
if (user.totp_enabled) {
if (!code || !totp.verifyCode(user.totp_secret, code)) {
if (code) {
botScore.recordLoginFailure(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('mobile TOTP verify failed', { id: user.id, ip: req.ip })
const device = await sessionService.resolveTrustedDevice(req)
const trusted = Boolean(device && device.user_id === user.id)
if (trusted) {
await sessionService.honorTrustedDevice(device.id)
await activity.log({ req, userId: user.id, action: 'auth.login.trusted_device' })
} else {
let verified = Boolean(code) && totp.verifyCode(user.totp_secret, code)
if (!verified && recoveryCode) {
verified = await recoveryCodes.consumeForUser(user.id, recoveryCode)
viaRecovery = verified
}
if (!verified) {
if (code || recoveryCode) {
botScore.recordLoginFailure(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('mobile TOTP verify failed', { id: user.id, ip: req.ip, recovery: Boolean(recoveryCode) })
}
return res.status(401).json({ totpRequired: true, message: 'A verification code is required.' })
}
return res.status(401).json({ totpRequired: true, message: 'A verification code is required.' })
}
}
loginProtection.recordSuccess(req.ip)
const meta = sessionService.sessionMeta(req)
const out = sessionService.createMobileSession(user, meta)
await persistAndFinish(req, user, out, 'auth.mobile.login', req.body.device_name || null)
await persistAndFinish(req, user, out, 'auth.mobile.login', deviceName)
if (viaRecovery) {
await activity.log({ req, userId: user.id, action: 'account.recovery_code.consume' })
log.info('mobile login via recovery code', { id: user.id, ip: req.ip })
}
// Optionally remember this device so future logins skip the second factor.
const body = tokenResponse(out, user)
if (trustDevice) {
const result = await establishTrust(req, user, { platform: 'mobile', deviceName })
if (result.ok) body.trustToken = result.trustToken
else if (result.capReached) { body.trustLimitReached = true; body.devices = result.devices }
}
log.info('mobile login success', { username: user.username, id: user.id, ip: req.ip })
return res.json(tokenResponse(out, user))
return res.json(body)
} catch (err) {
log.error('mobile login error', err)
return res.status(500).json({ message: 'Internal Server Error' })

View File

@@ -25,9 +25,9 @@ mobileRouter.post(
'/login',
// #swagger.tags = ['Auth · Mobile']
// #swagger.summary = 'Native login → access + refresh tokens'
// #swagger.description = 'Bearer-token login for native clients. Single-request 2FA: if the account has TOTP on and no/invalid code is supplied, returns 401 { totpRequired: true } and the client retries with a code.'
// #swagger.description = 'Bearer-token login for native clients. Single-request 2FA: if the account has TOTP on and no/invalid code is supplied, returns 401 { totpRequired: true } and the client retries with a code (or a single-use recoveryCode). A previously trusted device may present the X-Trust-Token header to skip the code entirely. Set trustDevice to remember this device (the response then carries trustToken to store); if the trusted-device limit is reached the tokens are still issued and the response carries { trustLimitReached, devices }.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/MobileLoginRequest" } } } } */
/* #swagger.responses[200] = { description: 'Access + refresh tokens', content: { "application/json": { schema: { $ref: "#/components/schemas/MobileTokenResponse" } } } } */
/* #swagger.responses[200] = { description: 'Access + refresh tokens (optionally with trustToken / a trusted-device-limit prompt)', content: { "application/json": { schema: { $ref: "#/components/schemas/MobileTokenResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Invalid credentials, or a TOTP code is required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -36,7 +36,11 @@ mobileRouter.post(
body('password').isString().notEmpty(),
// Optional TOTP code (single-request 2FA); only checked when the account has 2FA on.
body('code').optional().isString().trim().isLength({ min: 6, max: 8 }),
// Optional friendly device label for the Active Devices list.
// Optional single-use recovery code, an alternative second factor.
body('recoveryCode').optional({ values: 'falsy' }).isString().trim().isLength({ min: 8, max: 32 }),
// Optional opt-in to remember this device (skip TOTP on future logins).
body('trustDevice').optional().isBoolean(),
// Optional friendly device label for the Active Devices / Trusted Devices lists.
body('device_name').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }),
validate,
login,

View File

@@ -18,6 +18,8 @@
const passwordResets = require('../../../model/passwordResets/passwordResets.model')
const users = require('../../../model/users/users.model')
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const activity = require('../../../model/activity/activity.model')
const mailer = require('../../../utils/mailer')
@@ -103,6 +105,10 @@ async function confirmReset(req, res) {
// Web sessions are covered by the cutoff bump; mobile bearer sessions live in
// their own table and must be revoked explicitly.
await mobileSessions.revokeAllForUser(row.user_id)
// A reset is a security event (often "I lost access"): drop every trusted
// device and recovery code so the second-factor bypass can't survive it.
await trustedDevices.revokeAllForUser(row.user_id)
await recoveryCodes.clearForUser(row.user_id)
// Retire any other outstanding links for this user (e.g. duplicate requests).
await passwordResets.invalidatePendingForUser(row.user_id)

View File

@@ -0,0 +1,46 @@
// ── Shared trusted-device establishment ────────────────────────────────────
//
// One place that mints + persists a trusted device, enforces the per-user cap
// (no silent pruning), and audit-logs it. Reused by every path that can create a
// trust: web /auth/login/totp, mobile /auth/mobile/login, and the authenticated
// self-service POST /auth/me/trusted-devices (the "revoke one, then retry" path
// after a cap-reached prompt).
//
// The caller decides how the returned trust token reaches the client: the web
// paths set the httpOnly rg_trust cookie (setTrustCookie); native paths return the
// token in the JSON body for EncryptedSharedPreferences. This helper never touches
// res, so it stays surface-agnostic.
const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
const activity = require('../../../model/activity/activity.model')
const sessionService = require('../../../auth/session.service')
const log = require('../../../utils/logger')('trusted-device')
// Attempt to trust the current device for `user`. Returns:
// { ok: true, trustToken } — trusted; caller delivers the token
// { ok: false, capReached: true, devices } — at the cap; caller prompts to revoke
// `platform` is 'web' | 'mobile'; `deviceName` is the optional friendly label.
async function establishTrust(req, user, { platform = 'web', deviceName = null } = {}) {
if (await sessionService.trustDeviceCapReached(user.id)) {
const devices = await trustedDevices.listActiveForUser(user.id)
log.info('trust refused — device cap reached', { userId: user.id, platform })
return { ok: false, capReached: true, devices }
}
const meta = sessionService.sessionMeta(req)
const out = sessionService.mintTrustToken(meta)
await trustedDevices.store({
userId: user.id,
tokenHash: out.trustHash,
platform,
deviceName,
deviceHash: out.deviceHash,
userAgent: out.userAgent,
expiresAt: out.expiresAt,
})
await activity.log({ req, userId: user.id, action: 'account.trusted_device.add', detail: { platform } })
log.info('device trusted', { userId: user.id, platform })
return { ok: true, trustToken: out.trustToken }
}
module.exports = { establishTrust }