AbsoluteJS

Adaptive Auth

Score every login by risk — new device, new country, impossible travel, attempt velocity — and react: allow, force a step-up, or deny. An opinionated engine that builds on the step-up and MFA gates, with geo you supply (no bundled GeoIP).

#Engine & Rules

Bind the known-device and login-history stores once with createRiskEngine. Four rules ship in-box — new_device, new_country, impossible_travel, velocity — and every rule's action and threshold is overridable.

The verdict is the most severe rule that fired (allow < step_up < deny).
TS
import {
  createNeonKnownDeviceStore,
  createNeonLoginHistoryStore,
  createRiskEngine
} from '@absolutejs/auth';

// Bind the two stores (and any overrides) once, then reuse the engine.
export const risk = createRiskEngine({
  knownDeviceStore: createNeonKnownDeviceStore(process.env.DATABASE_URL),
  loginHistoryStore: createNeonLoginHistoryStore(process.env.DATABASE_URL),
  // Every rule's action + threshold is overridable:
  rules: { new_country: 'allow' }, // e.g. don't step up just for a new country
  maxTravelKmh: 1000, // impossible_travel threshold (default 900)
  velocityMaxAttempts: 5 // deny after N attempts within velocityWindowMs
});

// Built-in rules (defaults): new_device -> step_up, new_country -> step_up,
// impossible_travel -> deny, velocity -> deny. The overall action is the most
// severe rule that fires (allow < step_up < deny).

#Assess & Act

Call assessRisk in your login or OAuth-callback handler, where the request context (device, IP, geo) is available — the credentials MFA gate only sees the user, so adaptive auth is consumer-invoked by design. Record the attempt, then act on the verdict:

denyBlocks the login.
step_upRoutes into the MFA gate.
allowProceeds as a normal login.
TS
// In your login / OAuth-callback handler — where the request context exists —
// assess the attempt, record it, then act on the verdict.
const context = {
  deviceId, // your device cookie / fingerprint
  geo: await lookupGeo(ipAddress), // you own geo resolution (no bundled GeoIP)
  ipAddress,
  userId: user.sub
};

const { action, reasons } = await risk.assessRisk(context);
await risk.recordAttempt({ ...context, outcome: action });

if (action === 'deny') {
  return status('Forbidden', { reasons }); // block outright
}
if (action === 'step_up') {
  return status('OK', { status: 'mfa_required' }); // route into the MFA gate
}
// action === 'allow' -> promote the session as usual

#Trusting Devices

Once the user clears the step-up, call trustDevice — the "remember this device" action — so the new_device rule stops firing for it. Device first/last-seen and the full attempt history persist in the two stores (in-memory for dev, Postgres/Neon for production).

TS
// After the user clears the step-up (MFA verified), remember this device so the
// new_device rule stops firing for it next time:
await risk.trustDevice(user.sub, deviceId, 'Work laptop');

#Weighted scoring & fingerprint

scoreRisk is an Auth0-style alternative to the per-rule actions: each fired signal adds its weight and the summed score maps to an action via thresholds. It adds two consumer-fed signals — proxy and off_hours (from isProxy / localHour). fingerprintDevice hashes client signals into a stable deviceId — a stronger default than a User-Agent string alone.

TS
import { fingerprintDevice, scoreRisk } from '@absolutejs/auth';

// A stable device id from client signals (a better default than a UA string
// alone) — use it as the adaptive deviceId.
const deviceId = await fingerprintDevice({
  userAgent, language, timezone, platform, screen, canvasHash
});

// Weighted scoring, an alternative to per-rule actions: each fired signal adds
// its weight; the summed score maps to an action via thresholds. proxy/off_hours
// fire only when you pass isProxy/localHour.
const result = await scoreRisk(
  {
    knownDeviceStore,
    loginHistoryStore,
    weights: { new_country: 30, velocity: 70 },
    thresholds: { stepUp: 40, deny: 80 }
  },
  { deviceId, geo, isProxy, localHour, userId: user.sub }
);
// result -> { action: 'step_up', score: 65, reasons: [...] }

#Browser fingerprint client

@absolutejs/auth/fingerprint-client ships a browser-only collectDeviceFingerprint() that hashes canvas + AudioContext + WebGL + font enumeration + screen geometry into a stable base64url id — the FingerprintJS algorithm set, self-hostable, no SDK and no third-party request. Pair it with the server-side fingerprintDevice as a fallback for old clients.

TS
// In the browser — the strong fingerprint client. Same shape as the server-side
// fingerprintDevice (returns a stable base64url deviceId), but the input is
// canvas + audio + WebGL + font enumeration + screen geometry instead of a UA
// string. Sub-100ms, runs on demand, no SDK / no third-party request.
import { collectDeviceFingerprint } from '@absolutejs/auth/fingerprint-client';

// On your signin page:
const deviceId = await collectDeviceFingerprint();
await fetch('/auth/login', {
  method: 'POST',
  body: JSON.stringify({ email, password }),
  headers: {
    'content-type': 'application/json',
    'x-client-fingerprint': deviceId
  }
});

// On the server, prefer the client-provided id; fall back to fingerprintDevice
// (User-Agent only) for old clients:
const deviceId =
  request.headers.get('x-client-fingerprint') ??
  await fingerprintDevice({ userAgent: request.headers.get('user-agent') });