From eb42c644cd9689f886611139a34a6249ebb60879 Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Mon, 11 May 2026 15:46:27 -0400 Subject: [PATCH] feat: integrate FingerprintJS Pro visitor header for bot detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loads FingerprintJS Pro at app startup and attaches the resolved visitorId as X-Fp-Visitor-Id on all first-party ADS API requests. Enforcement is left to the backend. The integration is fully best-effort — any failure at any point resolves silently to a no-op with no impact on request flow or app bootstrap. --- src/config/discovery.vars.js.default | 3 + src/js/mixins/discovery_bootstrap.js | 22 ++- src/js/utils/fingerprint_core.js | 81 ++++++++++ test/mocha/js/utils/fingerprint_core.spec.js | 161 +++++++++++++++++++ 4 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 src/js/utils/fingerprint_core.js create mode 100644 test/mocha/js/utils/fingerprint_core.spec.js diff --git a/src/config/discovery.vars.js.default b/src/config/discovery.vars.js.default index 882ca81d0..3364007e2 100644 --- a/src/config/discovery.vars.js.default +++ b/src/config/discovery.vars.js.default @@ -47,6 +47,9 @@ define([], function() { /** ORCID API proxy base URL */ orcidApiEndpoint: '//ecs-staging-elb-2044121877.us-east-1.elb.amazonaws.com/v1/orcid', + /** FingerprintJS Pro public API key */ + fingerprintApiKey: '', + /** reCAPTCHA site key */ recaptchaKey: '6LfE6AITAAAAALdSy2qxVW4TBqy5RdmREiv-AwlJ', diff --git a/src/js/mixins/discovery_bootstrap.js b/src/js/mixins/discovery_bootstrap.js index b3816eb80..131750f46 100644 --- a/src/js/mixins/discovery_bootstrap.js +++ b/src/js/mixins/discovery_bootstrap.js @@ -9,6 +9,7 @@ define([ 'js/components/pubsub_events', 'hbs', 'js/components/api_targets', + 'js/utils/fingerprint_core', ], function( _, Backbone, @@ -16,7 +17,8 @@ define([ ApiRequest, PubSubEvents, HandleBars, - ApiTargets + ApiTargets, + fingerprintCore ) { var startGlobalHandler = function() { var routes = [ @@ -123,6 +125,14 @@ define([ api.clientVersion = conf.version; } + try { + if (conf.fingerprintApiKey) { + fingerprintCore.load(conf.fingerprintApiKey); + } + } catch (e) { + // fingerprinting must never halt bootstrap + } + // ApiTargets has a _needsCredentials array that contains all endpoints // that require cookies api.modifyRequestOptions = function(opts, request) { @@ -136,6 +146,16 @@ define([ withCredentials: true, }; } + + try { + var fpId = fingerprintCore.getVisitorId(); + // only attach to first-party ADS API requests + if (fpId && opts.url && opts.url.indexOf(api.url) === 0) { + opts.headers['X-Fp-Visitor-Id'] = fpId; + } + } catch (e) { + // header is best-effort; never disrupt request + } }; var orcidApi = beehive.getService('OrcidApi'); diff --git a/src/js/utils/fingerprint_core.js b/src/js/utils/fingerprint_core.js new file mode 100644 index 000000000..3584d9245 --- /dev/null +++ b/src/js/utils/fingerprint_core.js @@ -0,0 +1,81 @@ +/* UMD-style module that works with RequireJS and CommonJS */ +(function(root, factory) { + if (typeof define === 'function' && define.amd) { + define('js/utils/fingerprint_core', [], factory); + } else if (typeof module === 'object' && module.exports) { + module.exports = factory(); + } else { + root.FingerprintCore = factory(); + } +})(this, function() { + var SCRIPT_ID = 'ads-fingerprint-script'; + var visitorId = null; + var loadingPromise = null; + + var injectScript = function(apiKey) { + return new Promise(function(resolve, reject) { + var existing = document.getElementById(SCRIPT_ID); + if (existing && existing.parentNode) { + existing.parentNode.removeChild(existing); + } + + var script = document.createElement('script'); + script.id = SCRIPT_ID; + script.async = true; + script.src = + 'https://fpjscdn.net/v3/' + + encodeURIComponent(apiKey) + + '/iife.min.js'; + script.onload = resolve; + script.onerror = function() { + reject(new Error('FingerprintJS Pro script failed to load')); + }; + document.head.appendChild(script); + }); + }; + + var load = function(apiKey) { + if (!apiKey) { + return Promise.resolve(); + } + if (loadingPromise) { + return loadingPromise; + } + loadingPromise = injectScript(apiKey) + .then(function() { + return window.FingerprintJS.load(); + }) + .then(function(fp) { + return fp.get(); + }) + .then(function(result) { + visitorId = (result && result.visitorId) || null; + }) + .catch(function() { + visitorId = null; + loadingPromise = null; // allow retry on transient failure + }); + return loadingPromise; + }; + + var getVisitorId = function() { + return visitorId; + }; + + // Reset module state — used by tests only + var _reset = function() { + visitorId = null; + loadingPromise = null; + var existing = document.getElementById(SCRIPT_ID); + if (existing && existing.parentNode) { + existing.parentNode.removeChild(existing); + } + }; + + return { + load: load, + getVisitorId: getVisitorId, + SCRIPT_ID: SCRIPT_ID, + _reset: _reset, + }; +}); diff --git a/test/mocha/js/utils/fingerprint_core.spec.js b/test/mocha/js/utils/fingerprint_core.spec.js new file mode 100644 index 000000000..2e87ebd80 --- /dev/null +++ b/test/mocha/js/utils/fingerprint_core.spec.js @@ -0,0 +1,161 @@ +define(['js/utils/fingerprint_core'], function(fingerprintCore) { + describe('FingerprintCore (fingerprint_core.spec.js)', function() { + var sb; + + beforeEach(function() { + sb = sinon.sandbox.create(); + fingerprintCore._reset(); + }); + + afterEach(function() { + sb.restore(); + fingerprintCore._reset(); + delete window.FingerprintJS; + }); + + // Stub appendChild to trigger onload with a given FingerprintJS mock. + // Uses sinon 1.x 3-arg form: stub(obj, method, fn). + function stubAppendWithLoad(fpMock) { + sb.stub(document.head, 'appendChild', function(el) { + window.FingerprintJS = fpMock; + el.onload(); + }); + } + + function stubAppendWithError() { + sb.stub(document.head, 'appendChild', function(el) { + el.onerror(new Error('network error')); + }); + } + + describe('load()', function() { + it('is a no-op and returns a resolved promise when apiKey is falsy', function(done) { + var spy = sb.spy(document.head, 'appendChild'); + fingerprintCore.load('').then(function() { + expect(spy.called).to.equal(false); + expect(fingerprintCore.getVisitorId()).to.equal(null); + done(); + }); + }); + + it('injects a script tag with the FingerprintJS Pro CDN src', function(done) { + var getStub = sb.stub(); + getStub.returns(Promise.resolve({ visitorId: 'test-visitor-id-123' })); + var loadStub = sb.stub(); + loadStub.returns(Promise.resolve({ get: getStub })); + stubAppendWithLoad({ load: loadStub }); + + fingerprintCore.load('MY-API-KEY').then(function() { + var appendedEl = document.head.appendChild.args[0][0]; + expect(appendedEl.src).to.contain('fpjscdn.net/v3/MY-API-KEY'); + expect(appendedEl.id).to.equal(fingerprintCore.SCRIPT_ID); + done(); + }); + }); + + it('caches the visitorId after successful resolution', function(done) { + var getStub = sb.stub(); + getStub.returns(Promise.resolve({ visitorId: 'visitor-abc' })); + var loadStub = sb.stub(); + loadStub.returns(Promise.resolve({ get: getStub })); + stubAppendWithLoad({ load: loadStub }); + + fingerprintCore.load('KEY').then(function() { + expect(fingerprintCore.getVisitorId()).to.equal('visitor-abc'); + done(); + }); + }); + + it('returns the same promise if called multiple times', function() { + sb.stub(document.head, 'appendChild'); // pending — never fires onload + var p1 = fingerprintCore.load('KEY'); + var p2 = fingerprintCore.load('KEY'); + expect(p1).to.equal(p2); + expect(document.head.appendChild.callCount).to.equal(1); + }); + + it('keeps visitorId null and clears loadingPromise on script load failure', function(done) { + stubAppendWithError(); + + fingerprintCore.load('KEY').then(function() { + expect(fingerprintCore.getVisitorId()).to.equal(null); + done(); + }); + }); + + it('keeps visitorId null and clears loadingPromise when FingerprintJS.load() rejects', function(done) { + var loadStub = sb.stub(); + loadStub.returns(Promise.reject(new Error('init failure'))); + stubAppendWithLoad({ load: loadStub }); + + fingerprintCore.load('KEY').then(function() { + expect(fingerprintCore.getVisitorId()).to.equal(null); + done(); + }); + }); + + it('keeps visitorId null and clears loadingPromise when fp.get() rejects', function(done) { + var getStub = sb.stub(); + getStub.returns(Promise.reject(new Error('get failure'))); + var loadStub = sb.stub(); + loadStub.returns(Promise.resolve({ get: getStub })); + stubAppendWithLoad({ load: loadStub }); + + fingerprintCore.load('KEY').then(function() { + expect(fingerprintCore.getVisitorId()).to.equal(null); + done(); + }); + }); + + it('allows retry after a transient failure', function(done) { + stubAppendWithError(); + + fingerprintCore.load('KEY').then(function() { + expect(fingerprintCore.getVisitorId()).to.equal(null); + + // restore and set up a successful second attempt + document.head.appendChild.restore(); + var getStub = sb.stub(); + getStub.returns(Promise.resolve({ visitorId: 'retry-visitor' })); + var loadStub = sb.stub(); + loadStub.returns(Promise.resolve({ get: getStub })); + stubAppendWithLoad({ load: loadStub }); + + return fingerprintCore.load('KEY'); + }).then(function() { + expect(fingerprintCore.getVisitorId()).to.equal('retry-visitor'); + done(); + }); + }); + + it('keeps visitorId null when result has no visitorId field', function(done) { + var getStub = sb.stub(); + getStub.returns(Promise.resolve({})); + var loadStub = sb.stub(); + loadStub.returns(Promise.resolve({ get: getStub })); + stubAppendWithLoad({ load: loadStub }); + + fingerprintCore.load('KEY').then(function() { + expect(fingerprintCore.getVisitorId()).to.equal(null); + done(); + }); + }); + }); + + describe('getVisitorId()', function() { + it('returns null before load() is called', function() { + expect(fingerprintCore.getVisitorId()).to.equal(null); + }); + + it('returns null while load() is still pending', function() { + sb.stub(document.head, 'appendChild'); // never fires onload + fingerprintCore.load('KEY'); + expect(fingerprintCore.getVisitorId()).to.equal(null); + }); + + it('never throws', function() { + expect(function() { fingerprintCore.getVisitorId(); }).to.not.throw(); + }); + }); + }); +});