/*********************************************************************************************************************************************************************** * File Name: trustedDevice.service.js * Type of Program: Service * Description: Lets a login from an already-verified device skip the OTP * gate. A device is trusted the first time its user clears an * OTP; trust rolls forward 30 days on each trusted login and is * tied to both an opaque cookie token (device_trust) and a * User-Agent fingerprint, so a stolen cookie alone isn't enough * once the fingerprint no longer matches. Ordinary logout does * NOT revoke trust or clear the device_trust cookie — expires_at * is the only thing that ends the OTP-skip window in the normal * case, so logging out and back in on the same device still * skips OTP until the 30-day window actually lapses. Trust is * only force-revoked by password change/reset, admin ban/ * deactivate/force-logout, or a single session being explicitly * terminated. * Author: Kenneth Obsequio * Date Created: Jul. 5, 2026 *********************************************************************************************************************************************************************** * HOW TO USE: * const trustedDevice = require('../services/trustedDevice.service'); * const fingerprintHash = trustedDevice.getFingerprintHash(req); * const trusted = await trustedDevice.findValid(user.user_id, req.cookies.device_trust, fingerprintHash); * if (trusted) { ...skip OTP... } * await trustedDevice.issueOrRefresh(res, user.user_id, fingerprintHash, session.session_id); ***********************************************************************************************************************************************************************/ const crypto = require('crypto'); const mdl_TrustedDevices = require('../models/users/trusted_devices.mdl'); const { parseUA } = require('../utils/session_info.util'); const { hashToken } = require('../utils/token.util'); const TRUST_DAYS = 30; const COOKIE_NAME = 'device_trust'; const getFingerprintHash = (req) => { const { browser, os, device } = parseUA(req.headers['user-agent']); return crypto.createHash('sha256').update(`${browser}|${os}|${device}`).digest('hex'); }; const cookieOptions = (maxAge) => ({ httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', maxAge, }); /** * Looks up a non-revoked, non-expired trusted device matching both the * cookie token and the current request's fingerprint. * * Fails safe: any lookup error (e.g. table not migrated yet) is treated as * "not trusted" rather than propagating — a broken trust check should never * take down the login/OTP path itself, it should just fall back to OTP. * @returns {Promise} */ const findValid = async (userId, rawToken, fingerprintHash) => { if (!rawToken) return null; try { const row = await mdl_TrustedDevices.findOne({ where: { user_id: userId, device_token_hash: hashToken(rawToken), fingerprint_hash: fingerprintHash, revoked_at: null, }, }); return (row && new Date(row.expires_at) > new Date()) ? row : null; } catch (err) { console.error('[TRUSTED DEVICE] findValid failed, falling back to OTP:', err.message); return null; } }; /** * Marks the current device as trusted for TRUST_DAYS, rolling the window * forward on repeat use, and sets the device_trust cookie. * * Fails safe: called after tokens/session are already minted, so a failure * here (e.g. table not migrated yet) must not break an otherwise-successful * login — it just means this device won't skip OTP next time. */ const issueOrRefresh = async (res, userId, fingerprintHash, sessionId) => { try { const rawToken = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + TRUST_DAYS * 24 * 60 * 60 * 1000); const fields = { device_token_hash: hashToken(rawToken), expires_at: expiresAt, revoked_at: null, last_session_id: sessionId, }; // Plain find-then-create/update rather than findOrCreate() — Sequelize's // postgres findOrCreate() relies on a temp PL/pgSQL function for atomicity // that CockroachDB doesn't support ("cannot create user-defined functions // under a temporary schema"). const row = await mdl_TrustedDevices.findOne({ where: { user_id: userId, fingerprint_hash: fingerprintHash } }); if (row) { await row.update(fields); } else { await mdl_TrustedDevices.create({ user_id: userId, fingerprint_hash: fingerprintHash, ...fields }); } res.cookie(COOKIE_NAME, rawToken, cookieOptions(TRUST_DAYS * 24 * 60 * 60 * 1000)); } catch (err) { console.error('[TRUSTED DEVICE] issueOrRefresh failed:', err.message); } }; /** * Revokes trust for one specific device by its cookie token. Not called by * the normal logout flow (see auth.controller.js exports.logout) — ordinary * logout intentionally leaves trust intact. Kept as a primitive for a * future explicit "forget this device" action, should one be added. */ const revokeByToken = async (userId, rawToken) => { if (!rawToken) return; try { await mdl_TrustedDevices.update( { revoked_at: new Date() }, { where: { user_id: userId, device_token_hash: hashToken(rawToken), revoked_at: null } } ); } catch (err) { console.error('[TRUSTED DEVICE] revokeByToken failed:', err.message); } }; const revokeAllForUser = async (userId) => { try { await mdl_TrustedDevices.update( { revoked_at: new Date() }, { where: { user_id: userId, revoked_at: null } } ); } catch (err) { console.error('[TRUSTED DEVICE] revokeAllForUser failed:', err.message); } }; const revokeBySessionId = async (sessionId) => { if (!sessionId) return; try { await mdl_TrustedDevices.update( { revoked_at: new Date() }, { where: { last_session_id: sessionId, revoked_at: null } } ); } catch (err) { console.error('[TRUSTED DEVICE] revokeBySessionId failed:', err.message); } }; module.exports = { COOKIE_NAME, getFingerprintHash, findValid, issueOrRefresh, revokeByToken, revokeAllForUser, revokeBySessionId, };