audit-device-tracker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +280 -0
  3. package/dist/adapters/geo/maxmind.d.ts +31 -0
  4. package/dist/adapters/geo/maxmind.js +25 -0
  5. package/dist/adapters/geo/static.d.ts +7 -0
  6. package/dist/adapters/geo/static.js +10 -0
  7. package/dist/adapters/storage/memory.d.ts +14 -0
  8. package/dist/adapters/storage/memory.js +44 -0
  9. package/dist/adapters/storage/sqlite.d.ts +30 -0
  10. package/dist/adapters/storage/sqlite.js +120 -0
  11. package/dist/client/fingerprint.d.ts +36 -0
  12. package/dist/client/fingerprint.js +37 -0
  13. package/dist/constants.d.ts +2 -0
  14. package/dist/constants.js +2 -0
  15. package/dist/core/geo-distance.d.ts +5 -0
  16. package/dist/core/geo-distance.js +10 -0
  17. package/dist/core/parser.d.ts +7 -0
  18. package/dist/core/parser.js +78 -0
  19. package/dist/core/rules/impossible-travel.d.ts +12 -0
  20. package/dist/core/rules/impossible-travel.js +30 -0
  21. package/dist/core/rules/index.d.ts +11 -0
  22. package/dist/core/rules/index.js +25 -0
  23. package/dist/core/rules/new-device.d.ts +3 -0
  24. package/dist/core/rules/new-device.js +16 -0
  25. package/dist/core/rules/new-ip.d.ts +3 -0
  26. package/dist/core/rules/new-ip.js +9 -0
  27. package/dist/core/rules/shared-device.d.ts +6 -0
  28. package/dist/core/rules/shared-device.js +16 -0
  29. package/dist/core/rules/simultaneous-login.d.ts +7 -0
  30. package/dist/core/rules/simultaneous-login.js +22 -0
  31. package/dist/core/scoring.d.ts +10 -0
  32. package/dist/core/scoring.js +14 -0
  33. package/dist/index.d.ts +13 -0
  34. package/dist/index.js +9 -0
  35. package/dist/middleware/express.d.ts +12 -0
  36. package/dist/middleware/express.js +11 -0
  37. package/dist/tracker.d.ts +23 -0
  38. package/dist/tracker.js +157 -0
  39. package/dist/types.d.ts +141 -0
  40. package/dist/types.js +1 -0
  41. package/package.json +42 -0
@@ -0,0 +1,10 @@
1
+ const EARTH_RADIUS_KM = 6371;
2
+ const toRadians = (degrees) => (degrees * Math.PI) / 180;
3
+ /** Great-circle distance between two points on Earth (Haversine formula), in kilometres. */
4
+ export function distanceKm(a, b) {
5
+ const dLat = toRadians(b.latitude - a.latitude);
6
+ const dLon = toRadians(b.longitude - a.longitude);
7
+ const h = Math.sin(dLat / 2) ** 2 +
8
+ Math.cos(toRadians(a.latitude)) * Math.cos(toRadians(b.latitude)) * Math.sin(dLon / 2) ** 2;
9
+ return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
10
+ }
@@ -0,0 +1,7 @@
1
+ import type { RequestContext, RequestLike } from '../types.js';
2
+ export declare function extractIp(req: RequestLike): string;
3
+ export declare function anonymizeIp(ip: string): string;
4
+ export declare function readCookie(header: string, name: string): string | undefined;
5
+ export declare function sign(value: string, secret: string): string;
6
+ export declare function verify(signed: string | undefined, secret: string): string | null;
7
+ export declare function buildContext(req: RequestLike, cookieName: string, secret: string, fingerprintHeader?: string): RequestContext;
@@ -0,0 +1,78 @@
1
+ import Bowser from 'bowser';
2
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
3
+ import { DEFAULT_FINGERPRINT_HEADER } from '../constants.js';
4
+ /** Node may give a header as string, string[] or undefined: normalise to string. */
5
+ const first = (v) => Array.isArray(v) ? (v[0] ?? '') : (v ?? '');
6
+ const CLIENT_FINGERPRINT_PATTERN = /^[a-f0-9]{16,64}$/i;
7
+ export function extractIp(req) {
8
+ const forwarded = first(req.headers['x-forwarded-for']).split(',')[0]?.trim();
9
+ return req.ip || forwarded || req.socket?.remoteAddress || 'unknown';
10
+ }
11
+ export function anonymizeIp(ip) {
12
+ if (ip.includes(':'))
13
+ return ip.split(':').slice(0, 3).join(':') + '::';
14
+ const parts = ip.split('.');
15
+ return parts.length === 4 ? `${parts[0]}.${parts[1]}.${parts[2]}.0` : ip;
16
+ }
17
+ export function readCookie(header, name) {
18
+ for (const part of header.split(';')) {
19
+ const [key, ...value] = part.trim().split('=');
20
+ if (key === name)
21
+ return decodeURIComponent(value.join('='));
22
+ }
23
+ return undefined;
24
+ }
25
+ export function sign(value, secret) {
26
+ const signature = createHmac('sha256', secret).update(value).digest('base64url');
27
+ return `${value}.${signature}`;
28
+ }
29
+ export function verify(signed, secret) {
30
+ if (!signed)
31
+ return null;
32
+ const dot = signed.lastIndexOf('.');
33
+ if (dot < 1)
34
+ return null;
35
+ const value = signed.slice(0, dot);
36
+ const expected = Buffer.from(sign(value, secret));
37
+ const given = Buffer.from(signed);
38
+ return expected.length === given.length && timingSafeEqual(expected, given)
39
+ ? value
40
+ : null;
41
+ }
42
+ function toDeviceType(type) {
43
+ if (type === 'desktop' || type === 'mobile' || type === 'tablet' || type === 'bot') {
44
+ return type;
45
+ }
46
+ return 'unknown';
47
+ }
48
+ export function buildContext(req, cookieName, secret, fingerprintHeader = DEFAULT_FINGERPRINT_HEADER) {
49
+ const userAgent = first(req.headers['user-agent']);
50
+ const language = first(req.headers['accept-language']).split(',')[0]?.trim() || null;
51
+ // Bowser throws on an empty string, so we only parse when there is something to parse.
52
+ const ua = userAgent ? Bowser.parse(userAgent) : null;
53
+ const browser = ua?.browser.name ?? 'unknown';
54
+ const os = ua?.os.name ?? 'unknown';
55
+ const deviceType = toDeviceType(ua?.platform.type);
56
+ // The client fingerprint is only a hint (anyone can send anything): we accept a
57
+ // well-formed hex string and mix it in, we never trust it for identity.
58
+ const rawClient = first(req.headers[fingerprintHeader.toLowerCase()]);
59
+ const clientFingerprint = CLIENT_FINGERPRINT_PATTERN.test(rawClient) ? rawClient.toLowerCase() : null;
60
+ const traits = [browser, os, deviceType, language].join('|');
61
+ const fingerprint = createHash('sha256')
62
+ .update(clientFingerprint ? `${traits}|${clientFingerprint}` : traits)
63
+ .digest('hex')
64
+ .slice(0, 32);
65
+ return {
66
+ ip: extractIp(req),
67
+ userAgent,
68
+ language,
69
+ browser,
70
+ browserVersion: ua?.browser.version ?? '',
71
+ os,
72
+ osVersion: ua?.os.version ?? '',
73
+ deviceType,
74
+ cookieDeviceId: verify(readCookie(first(req.headers.cookie), cookieName), secret),
75
+ clientFingerprint,
76
+ fingerprint,
77
+ };
78
+ }
@@ -0,0 +1,12 @@
1
+ import type { Rule } from '../../types.js';
2
+ export interface ImpossibleTravelOptions {
3
+ /** Faster than this is considered impossible. Default: 900 km/h (an airliner). */
4
+ maxSpeedKmh?: number;
5
+ /** Ignore short hops, where IP geolocation is too imprecise. Default: 300 km. */
6
+ minDistanceKm?: number;
7
+ }
8
+ /**
9
+ * Two logins too far apart for the time elapsed between them.
10
+ * A known device scores less than a new one: a VPN can explain the first, hardly the second.
11
+ */
12
+ export declare function impossibleTravelRule(options?: ImpossibleTravelOptions): Rule;
@@ -0,0 +1,30 @@
1
+ import { distanceKm } from '../geo-distance.js';
2
+ const label = (l) => l.city ?? l.country ?? 'unknown place';
3
+ /**
4
+ * Two logins too far apart for the time elapsed between them.
5
+ * A known device scores less than a new one: a VPN can explain the first, hardly the second.
6
+ */
7
+ export function impossibleTravelRule(options = {}) {
8
+ const maxSpeedKmh = options.maxSpeedKmh ?? 900;
9
+ const minDistanceKm = options.minDistanceKm ?? 300;
10
+ return {
11
+ name: 'impossible-travel',
12
+ evaluate(ctx) {
13
+ const previous = ctx.previousLogin;
14
+ if (!ctx.location || !previous?.location)
15
+ return null;
16
+ const km = distanceKm(previous.location, ctx.location);
17
+ if (km < minDistanceKm)
18
+ return null;
19
+ const hours = Math.max((ctx.now.getTime() - previous.at.getTime()) / 3_600_000, 1 / 3600);
20
+ const speed = km / hours;
21
+ if (speed <= maxSpeedKmh)
22
+ return null;
23
+ return {
24
+ reason: 'impossible_travel',
25
+ points: ctx.isNewDevice ? 60 : 40,
26
+ detail: `${label(previous.location)} -> ${label(ctx.location)}: ${Math.round(km)} km in ${Math.round(hours * 60)} min`,
27
+ };
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,11 @@
1
+ import type { Finding, Rule, RuleContext } from '../../types.js';
2
+ import { impossibleTravelRule } from './impossible-travel.js';
3
+ import { newDeviceRule } from './new-device.js';
4
+ import { newIpRule } from './new-ip.js';
5
+ import { sharedDeviceRule } from './shared-device.js';
6
+ import { simultaneousLoginRule } from './simultaneous-login.js';
7
+ export { impossibleTravelRule, newDeviceRule, newIpRule, sharedDeviceRule, simultaneousLoginRule };
8
+ /** The built-in rules, in the order their reasons are reported. */
9
+ export declare function defaultRules(): Rule[];
10
+ /** Runs every rule. A rule that throws is reported and skipped: it never blocks a login. */
11
+ export declare function runRules(rules: readonly Rule[], ctx: RuleContext, onError?: (error: unknown) => void): Finding[];
@@ -0,0 +1,25 @@
1
+ import { impossibleTravelRule } from './impossible-travel.js';
2
+ import { newDeviceRule } from './new-device.js';
3
+ import { newIpRule } from './new-ip.js';
4
+ import { sharedDeviceRule } from './shared-device.js';
5
+ import { simultaneousLoginRule } from './simultaneous-login.js';
6
+ export { impossibleTravelRule, newDeviceRule, newIpRule, sharedDeviceRule, simultaneousLoginRule };
7
+ /** The built-in rules, in the order their reasons are reported. */
8
+ export function defaultRules() {
9
+ return [newDeviceRule, sharedDeviceRule, newIpRule, impossibleTravelRule(), simultaneousLoginRule()];
10
+ }
11
+ /** Runs every rule. A rule that throws is reported and skipped: it never blocks a login. */
12
+ export function runRules(rules, ctx, onError) {
13
+ const findings = [];
14
+ for (const rule of rules) {
15
+ try {
16
+ const finding = rule.evaluate(ctx);
17
+ if (finding)
18
+ findings.push(finding);
19
+ }
20
+ catch (error) {
21
+ onError?.(error);
22
+ }
23
+ }
24
+ return findings;
25
+ }
@@ -0,0 +1,3 @@
1
+ import type { Rule } from '../../types.js';
2
+ /** A user's very first device is a baseline (0 points); any later new device is suspicious. */
3
+ export declare const newDeviceRule: Rule;
@@ -0,0 +1,16 @@
1
+ /** A user's very first device is a baseline (0 points); any later new device is suspicious. */
2
+ export const newDeviceRule = {
3
+ name: 'new-device',
4
+ evaluate(ctx) {
5
+ if (!ctx.isNewDevice)
6
+ return null;
7
+ if (!ctx.userHadDevices) {
8
+ return { reason: 'first_device', points: 0, detail: 'First device seen for this user (baseline)' };
9
+ }
10
+ return {
11
+ reason: 'new_device',
12
+ points: 35,
13
+ detail: `New device: ${ctx.device.browser} on ${ctx.device.os}`,
14
+ };
15
+ },
16
+ };
@@ -0,0 +1,3 @@
1
+ import type { Rule } from '../../types.js';
2
+ /** A known device seen from an address it never used before (a new device is already flagged). */
3
+ export declare const newIpRule: Rule;
@@ -0,0 +1,9 @@
1
+ /** A known device seen from an address it never used before (a new device is already flagged). */
2
+ export const newIpRule = {
3
+ name: 'new-ip',
4
+ evaluate(ctx) {
5
+ if (ctx.isNewDevice || !ctx.isNewIp)
6
+ return null;
7
+ return { reason: 'new_ip', points: 10, detail: `Known device seen from a new IP (${ctx.ip})` };
8
+ },
9
+ };
@@ -0,0 +1,6 @@
1
+ import type { Rule } from '../../types.js';
2
+ /**
3
+ * The same device already logged in as another user: the signature of shared credentials.
4
+ * Marking a device as trusted (tracker.trustDevice) silences this rule for that user.
5
+ */
6
+ export declare const sharedDeviceRule: Rule;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The same device already logged in as another user: the signature of shared credentials.
3
+ * Marking a device as trusted (tracker.trustDevice) silences this rule for that user.
4
+ */
5
+ export const sharedDeviceRule = {
6
+ name: 'shared-device',
7
+ evaluate(ctx) {
8
+ if (ctx.sharedWithUsers.length === 0 || ctx.device.trusted)
9
+ return null;
10
+ return {
11
+ reason: 'device_shared_with_other_users',
12
+ points: 60,
13
+ detail: `Device also used by: ${ctx.sharedWithUsers.join(', ')}`,
14
+ };
15
+ },
16
+ };
@@ -0,0 +1,7 @@
1
+ import type { Rule } from '../../types.js';
2
+ export interface SimultaneousLoginOptions {
3
+ /** Two logins closer than this, from different devices and IPs, are "simultaneous". Default: 5. */
4
+ windowMinutes?: number;
5
+ }
6
+ /** The same account active on two different devices, on two different networks, at once. */
7
+ export declare function simultaneousLoginRule(options?: SimultaneousLoginOptions): Rule;
@@ -0,0 +1,22 @@
1
+ /** The same account active on two different devices, on two different networks, at once. */
2
+ export function simultaneousLoginRule(options = {}) {
3
+ const windowMs = (options.windowMinutes ?? 5) * 60_000;
4
+ return {
5
+ name: 'simultaneous-login',
6
+ evaluate(ctx) {
7
+ const previous = ctx.previousLogin;
8
+ if (!previous)
9
+ return null;
10
+ const elapsed = ctx.now.getTime() - previous.at.getTime();
11
+ if (elapsed > windowMs)
12
+ return null;
13
+ if (previous.deviceId === ctx.device.id || previous.ip === ctx.ip)
14
+ return null;
15
+ return {
16
+ reason: 'simultaneous_login',
17
+ points: 20,
18
+ detail: `Another device logged in ${Math.round(elapsed / 1000)} s ago from a different IP`,
19
+ };
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,10 @@
1
+ import type { Finding, RiskLevel, RiskReason } from '../types.js';
2
+ export interface ScoreResult {
3
+ riskScore: number;
4
+ riskLevel: RiskLevel;
5
+ reasons: RiskReason[];
6
+ findings: Finding[];
7
+ }
8
+ export declare function riskLevelFor(score: number): RiskLevel;
9
+ /** Adds up the findings of all rules into one score between 0 and 100. */
10
+ export declare function combine(findings: Finding[]): ScoreResult;
@@ -0,0 +1,14 @@
1
+ export function riskLevelFor(score) {
2
+ return score >= 60 ? 'high' : score >= 30 ? 'medium' : 'low';
3
+ }
4
+ /** Adds up the findings of all rules into one score between 0 and 100. */
5
+ export function combine(findings) {
6
+ const total = findings.reduce((sum, finding) => sum + finding.points, 0);
7
+ const riskScore = Math.min(100, Math.max(0, total));
8
+ return {
9
+ riskScore,
10
+ riskLevel: riskLevelFor(riskScore),
11
+ reasons: findings.map((finding) => finding.reason),
12
+ findings,
13
+ };
14
+ }
@@ -0,0 +1,13 @@
1
+ export { DeviceTracker } from './tracker.js';
2
+ export { MemoryStorage } from './adapters/storage/memory.js';
3
+ export { SqliteStorage } from './adapters/storage/sqlite.js';
4
+ export type { SqliteDatabase, SqliteStatement } from './adapters/storage/sqlite.js';
5
+ export { StaticGeo } from './adapters/geo/static.js';
6
+ export { MaxMindGeo } from './adapters/geo/maxmind.js';
7
+ export type { MaxMindCityRecord, MaxMindReader } from './adapters/geo/maxmind.js';
8
+ export { distanceKm } from './core/geo-distance.js';
9
+ export { defaultRules, impossibleTravelRule, newDeviceRule, newIpRule, sharedDeviceRule, simultaneousLoginRule, } from './core/rules/index.js';
10
+ export type { ImpossibleTravelOptions } from './core/rules/impossible-travel.js';
11
+ export type { SimultaneousLoginOptions } from './core/rules/simultaneous-login.js';
12
+ export { trackLogin } from './middleware/express.js';
13
+ export * from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export { DeviceTracker } from './tracker.js';
2
+ export { MemoryStorage } from './adapters/storage/memory.js';
3
+ export { SqliteStorage } from './adapters/storage/sqlite.js';
4
+ export { StaticGeo } from './adapters/geo/static.js';
5
+ export { MaxMindGeo } from './adapters/geo/maxmind.js';
6
+ export { distanceKm } from './core/geo-distance.js';
7
+ export { defaultRules, impossibleTravelRule, newDeviceRule, newIpRule, sharedDeviceRule, simultaneousLoginRule, } from './core/rules/index.js';
8
+ export { trackLogin } from './middleware/express.js';
9
+ export * from './types.js';
@@ -0,0 +1,12 @@
1
+ import type { DeviceTracker } from '../tracker.js';
2
+ import type { RequestLike, TrackResult } from '../types.js';
3
+ /** The two response methods we need: Express and Node's http both provide them. */
4
+ export interface ResponseLike {
5
+ getHeader(name: string): number | string | string[] | undefined;
6
+ setHeader(name: string, value: string | string[]): unknown;
7
+ }
8
+ /**
9
+ * Track a successful login AND hand the device cookie to the browser.
10
+ * Existing Set-Cookie headers (e.g. a session cookie) are preserved.
11
+ */
12
+ export declare function trackLogin(tracker: DeviceTracker, req: RequestLike, res: ResponseLike, userId: string): Promise<TrackResult>;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Track a successful login AND hand the device cookie to the browser.
3
+ * Existing Set-Cookie headers (e.g. a session cookie) are preserved.
4
+ */
5
+ export async function trackLogin(tracker, req, res, userId) {
6
+ const result = await tracker.track(req, userId);
7
+ const current = res.getHeader('Set-Cookie');
8
+ const existing = current === undefined ? [] : Array.isArray(current) ? current : [String(current)];
9
+ res.setHeader('Set-Cookie', [...existing, result.deviceCookie]);
10
+ return result;
11
+ }
@@ -0,0 +1,23 @@
1
+ import type { DeviceRecord, DeviceTrackerOptions, RequestLike, TrackResult } from './types.js';
2
+ export declare class DeviceTracker {
3
+ readonly cookieName: string;
4
+ private readonly options;
5
+ private readonly rules;
6
+ constructor(options: DeviceTrackerOptions);
7
+ /** A failing alert must never break a login: report the error and carry on. */
8
+ private runHook;
9
+ /** A failing geo provider must never break a login either: no location, that's all. */
10
+ private locate;
11
+ private buildCookie;
12
+ /** Call once a user has authenticated successfully. */
13
+ track(req: RequestLike, userId: string): Promise<TrackResult>;
14
+ getDevices(userId: string): Promise<DeviceRecord[]>;
15
+ getLoginHistory(userId: string, limit?: number): Promise<import("./types.js").LoginEvent[]>;
16
+ revokeDevice(userId: string, deviceId: string): Promise<void>;
17
+ /**
18
+ * Mark a device as trusted (or not) for one user, e.g. a reception desk computer.
19
+ * A trusted device no longer triggers the shared-device rule for that user.
20
+ * Returns false when the user has no such device.
21
+ */
22
+ trustDevice(userId: string, deviceId: string, trusted?: boolean): Promise<boolean>;
23
+ }
@@ -0,0 +1,157 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { DEFAULT_FINGERPRINT_HEADER } from './constants.js';
3
+ import { anonymizeIp, buildContext, sign } from './core/parser.js';
4
+ import { defaultRules, runRules } from './core/rules/index.js';
5
+ import { combine } from './core/scoring.js';
6
+ export class DeviceTracker {
7
+ cookieName;
8
+ options;
9
+ rules;
10
+ constructor(options) {
11
+ if (!options.secret || options.secret.length < 16) {
12
+ throw new Error('audit-device-tracker: "secret" must be at least 16 characters');
13
+ }
14
+ this.options = options;
15
+ this.cookieName = options.cookieName ?? 'adt_did';
16
+ this.rules = options.rules ?? defaultRules();
17
+ }
18
+ /** A failing alert must never break a login: report the error and carry on. */
19
+ async runHook(hook) {
20
+ try {
21
+ await hook();
22
+ }
23
+ catch (error) {
24
+ this.options.onError?.(error);
25
+ }
26
+ }
27
+ /** A failing geo provider must never break a login either: no location, that's all. */
28
+ async locate(ip) {
29
+ if (!this.options.geo)
30
+ return null;
31
+ try {
32
+ return (await this.options.geo.lookup(ip)) ?? null;
33
+ }
34
+ catch (error) {
35
+ this.options.onError?.(error);
36
+ return null;
37
+ }
38
+ }
39
+ buildCookie(deviceId) {
40
+ const value = encodeURIComponent(sign(deviceId, this.options.secret));
41
+ const secure = this.options.cookieSecure === false ? '' : '; Secure';
42
+ return `${this.cookieName}=${value}; Max-Age=31536000; Path=/; HttpOnly; SameSite=Lax${secure}`;
43
+ }
44
+ /** Call once a user has authenticated successfully. */
45
+ async track(req, userId) {
46
+ const { storage } = this.options;
47
+ const ctx = buildContext(req, this.cookieName, this.options.secret, this.options.clientFingerprintHeader ?? DEFAULT_FINGERPRINT_HEADER);
48
+ const ip = this.options.ipAnonymization === 'partial' ? anonymizeIp(ctx.ip) : ctx.ip;
49
+ const now = new Date();
50
+ // 1. Which device is this? A valid cookie is authoritative: the fingerprint
51
+ // is only a fallback when there is no cookie at all.
52
+ const previousDevices = await storage.listDevices(userId);
53
+ const existing = ctx.cookieDeviceId
54
+ ? await storage.getDevice(userId, ctx.cookieDeviceId)
55
+ : await storage.findDeviceByFingerprint(userId, ctx.fingerprint);
56
+ const isNewDevice = existing === null;
57
+ const isNewIp = existing !== null && !existing.knownIps.includes(ip);
58
+ // 2. Build the updated (or brand new) device record.
59
+ const device = existing
60
+ ? {
61
+ ...existing,
62
+ browserVersion: ctx.browserVersion,
63
+ osVersion: ctx.osVersion,
64
+ lastSeen: now,
65
+ loginCount: existing.loginCount + 1,
66
+ lastIp: ip,
67
+ knownIps: isNewIp ? [...existing.knownIps, ip].slice(-20) : existing.knownIps,
68
+ }
69
+ : {
70
+ id: ctx.cookieDeviceId ?? randomUUID(),
71
+ userId,
72
+ fingerprint: ctx.fingerprint,
73
+ idSource: ctx.cookieDeviceId ? 'cookie' : 'fingerprint',
74
+ browser: ctx.browser,
75
+ browserVersion: ctx.browserVersion,
76
+ os: ctx.os,
77
+ osVersion: ctx.osVersion,
78
+ deviceType: ctx.deviceType,
79
+ language: ctx.language,
80
+ firstSeen: now,
81
+ lastSeen: now,
82
+ loginCount: 1,
83
+ lastIp: ip,
84
+ knownIps: [ip],
85
+ trusted: false,
86
+ };
87
+ // 3. Gather what the rules need: other users of this device, location, previous login.
88
+ const sharedWithUsers = (await storage.listUsersByDevice(device.id)).filter((id) => id !== userId);
89
+ const location = await this.locate(ctx.ip);
90
+ const [previousLogin = null] = await storage.listLogins(userId, 1);
91
+ // 4. Let every rule judge the login, then add the points up.
92
+ const findings = runRules(this.rules, {
93
+ userId,
94
+ now,
95
+ ip,
96
+ device,
97
+ isNewDevice,
98
+ userHadDevices: previousDevices.length > 0,
99
+ isNewIp,
100
+ sharedWithUsers,
101
+ location,
102
+ previousLogin,
103
+ }, this.options.onError);
104
+ const { riskScore, riskLevel, reasons } = combine(findings);
105
+ // 5. Persist, then notify.
106
+ await storage.saveDevice(device);
107
+ await storage.addLogin({
108
+ userId,
109
+ deviceId: device.id,
110
+ ip,
111
+ userAgent: ctx.userAgent,
112
+ at: now,
113
+ riskScore,
114
+ reasons,
115
+ location,
116
+ });
117
+ const result = {
118
+ device,
119
+ isNewDevice,
120
+ riskScore,
121
+ riskLevel,
122
+ reasons,
123
+ findings,
124
+ location,
125
+ sharedWithUsers,
126
+ deviceCookie: this.buildCookie(device.id),
127
+ };
128
+ if (isNewDevice && previousDevices.length > 0) {
129
+ await this.runHook(() => this.options.onNewDevice?.(result, userId));
130
+ }
131
+ if (riskScore >= (this.options.suspiciousThreshold ?? 60)) {
132
+ await this.runHook(() => this.options.onSuspicious?.(result, userId));
133
+ }
134
+ return result;
135
+ }
136
+ getDevices(userId) {
137
+ return this.options.storage.listDevices(userId);
138
+ }
139
+ getLoginHistory(userId, limit) {
140
+ return this.options.storage.listLogins(userId, limit);
141
+ }
142
+ revokeDevice(userId, deviceId) {
143
+ return this.options.storage.removeDevice(userId, deviceId);
144
+ }
145
+ /**
146
+ * Mark a device as trusted (or not) for one user, e.g. a reception desk computer.
147
+ * A trusted device no longer triggers the shared-device rule for that user.
148
+ * Returns false when the user has no such device.
149
+ */
150
+ async trustDevice(userId, deviceId, trusted = true) {
151
+ const device = await this.options.storage.getDevice(userId, deviceId);
152
+ if (!device)
153
+ return false;
154
+ await this.options.storage.saveDevice({ ...device, trusted });
155
+ return true;
156
+ }
157
+ }