Anomaly detection
WIPScoring whether a request with a valid session looks like the person it was issued to, with the device and impossible-travel detectors
A valid session can still arrive from somewhere it should not. auth.anomaly runs detectors over each request, combines what they notice into one score, and recommends allow, step-up or deny. It recommends only; the server adapters refuse a deny, and the rest is yours to act on.
Nothing is registered by default, and with no detectors the step is skipped entirely.
Registering detectors
import { authMemoryDeviceFingerprintStore, deviceFingerprintDetector, sha256 } from '@gentleduck/auth/core'
auth.anomaly.register(deviceFingerprintDetector({ store: authMemoryDeviceFingerprintStore(), authSha256: sha256 }))
| Detector | Signal | Needs |
|---|---|---|
deviceFingerprintDetector | new-device, scoring 0.7, the first time an identity is seen on a device (sha256 of the User-Agent and the IP's /24 or /48 network) | A store. The memory one is per process; implement AuthDeviceFingerprint.IStore over shared storage for a fleet. |
authImpossibleTravelDetector | impossible-travel, when the distance from the last known position implies more than 900 km/h | getLastSeen(identityId), answering a position you record, and geo on the request. |
A detector is any { id, evaluate(ctx) } answering signals { kind, score, evidence }, with score from 0 to 1.
Running them
The detectors need the request, so they run only when resolveSession gets a requestSnapshot. The server adapters' actor wrappers pass one once you give them getCaller:
app.use(
expressActorContext(auth, {
getCaller: (req) => ({ ...expressCaller(req), geo: lookupGeo(req.ip) }),
onAnomaly: async (result) => {
// called for every decision but 'allow', in place of the default refusal
if (result.decision === 'deny') throw new AuthError('AUTH_ANOMALY_DENIED', { score: result.score })
throw new AuthError('AUTH_STEP_UP_REQUIRED', { challenge: result.signals })
},
}),
)
- Without
onAnomaly, adenyis refused withAUTH_ANOMALY_DENIED(403) and astep-upis let through, since a new device alone reaches the step-up score and refusing it would demand MFA on every first visit. - With
onAnomaly, the decision is yours: throw to refuse, including for adeny. - What a detector learns, such as a new device, is recorded only once the request is let through.
Calling resolveSession({ headers }, { requestSnapshot }) yourself, branch on result.anomaly?.decision and call result.anomaly?.admit() once you serve the request.
Scoring
Signals combine as 1 - (1 - a)(1 - b)..., so more evidence always raises the score and it never passes 1.
| Setting | Default | Effect |
|---|---|---|
stepUpAt | 0.7 | At or above: step-up. |
denyAt | 0.95 | At or above: deny. |
threshold | 0.7 | Emits suspicious for an allow scoring this much; every other decision always emits. |
detectorTimeoutMs | 1000 | A detector slower than this is dropped for the request. |
reactions | none | Per detector id, then signal kind ('*' for any detector): 'allow' mutes the signal, 'step-up' or 'deny' forces at least that. |
createAuth({
// ...
anomaly: { denyAt: 0.9, reactions: { '*': { 'impossible-travel': 'deny' } } },
})
A detector that throws, times out or answers garbage is logged and skipped, so a broken plugin never locks users out, and a broken detector looks like one that found nothing. Watch the logs.