Repository: govuk-one-login/govuk-one-login-frontend
Pull request #167: feat(DFC-811): setting fingerprint as a cookie
Author: login: arwatGDS
Created: 2025-03-26T16:38:42Z | Merged: 2025-03-28T15:48:11Z

## Description and Context

Fingerprint is set as a cookie and then testing in the alpha app package. The script from the node modules has been imported into the alpha app and this is how it is tested.

As well as this, when the fingerprint is set as a cookie in the browser, it is unset so that it expires when the web browser is closed. The fingerprint provided in the cookie is base64 encoded.

### Tickets ###
Related Jira ticket - [DFC-809](https://govukverify.atlassian.net/browse/DFC-809)

- [DFC-811](https://govukverify.atlassian.net/browse/DFC-811)

### Steps to reproduce ###
To test and reproduce the fingerprint as a cookie:
1. Build the alpha app
2. Run NPM start (opens on port 3000)
3. Inspect the console - You should see a log with the fingerprint.
4. Inspect the application tab and in there will be the cookies. You will be able to find the device_intelligence_fingerprint as the cookie stored in the browser.

## Checklist

- [x] **Code Changes**
  - [x] Tests added/updated
  
- [x] **Dependencies**
  - [x] Dependency versions updated, if necessary
  
- [x] **Testing**
  - [x] Local testing done
  - [x] Tests run for affected packages
  
- [x] **Documentation**
  - [x] Confluence Documentation updated, if applicable
  - [x] README updated, if applicable
  - [x] Ticket updated, if applicable

### Environment variables or secrets
- [x] No environment variables or secrets were added or changed

### Additional Information ###


[DFC-809]: https://govukverify.atlassian.net/browse/DFC-809?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
[DFC-811]: https://govukverify.atlassian.net/browse/DFC-811?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/packages/alpha-app/public/stylesheets/application-frontendUi.css b/packages/alpha-app/public/stylesheets/application-frontendUi.css
index 676e7c53..1ff49c82 100644
--- a/packages/alpha-app/public/stylesheets/application-frontendUi.css
+++ b/packages/alpha-app/public/stylesheets/application-frontendUi.css
@@ -1 +1 @@
-@media(max-width: 640px){.govuk-header__navigation-item{border-left:none !important}}.frontendUi_header_signOut-item{padding:5px 0px 5px 30px;border-left:1px solid #b1b4b6;margin-left:auto}.frontendUi_header__signOut{display:flex}.frontendUi-header__content{margin-left:auto}.language-select{margin:15px 0 15px 0}.language-select__list{margin-top:1em;text-align:right}.language-select__list-item{display:inline-block}.language-select__list-item:first-child::after{content:"";display:inline-block;position:relative;top:.1875em;height:1em;border-right:.09375em solid black}.language-select__list-item a,.language-select__list-item [aria-current]{padding:.3125em}@media screen and (max-width: 641px){.language-select__list{float:none;text-align:left;padding-bottom:10px;border-bottom:1px solid #b1b4b6}}
+@media(max-width: 640px){.govuk-header__navigation-item{border-left:none !important}}.frontendUi_header_signOut-item{padding:5px 0px 5px 30px;border-left:1px solid #b1b4b6;margin-left:auto}.frontendUi_header__signOut{display:flex}.frontendUi-header__content{margin-left:auto}
diff --git a/packages/alpha-app/src/app.js b/packages/alpha-app/src/app.js
index 117585d8..05ddd0bd 100644
--- a/packages/alpha-app/src/app.js
+++ b/packages/alpha-app/src/app.js
@@ -92,9 +92,9 @@ app.use(
 );
 /** ThumbmarkJS fingerprint **/
 app.use(
-  "/thumbmarkJS/fingerprint",
+  "/fingerprint",
   express.static(
-    nodeModules("@govuk-one-login/frontend-device-intelligence/build/esm"),
+    nodeModules("@govuk-one-login/frontend-device-intelligence/build/iife"),
   ),
 );
 app.use(
diff --git a/packages/alpha-app/src/views/common/base.njk b/packages/alpha-app/src/views/common/base.njk
index 21ac5a99..fbfe546b 100644
--- a/packages/alpha-app/src/views/common/base.njk
+++ b/packages/alpha-app/src/views/common/base.njk
@@ -91,13 +91,13 @@
     });
   </script>
 
+  <script src="/fingerprint/index.js"></script>
   <script>
-    import('/thumbmarkJS/fingerprint/index.js')
-    .then((ThumbmarkJS) => {
-        ThumbmarkJS.getFingerprint().then((fp) => { console.log(fp)})
-        ThumbmarkJS.getFingerprintData().then((fp) => { console.log(fp)})
-    })
+    console.log(window.ThumbmarkJS.getFingerprint())
+    console.log(window.ThumbmarkJS.getFingerprintData())
+    console.log(window.ThumbmarkJS.setFingerprintCookie())
   </script>
+
 {% endblock %}
 
 {% block footer %}
diff --git a/packages/frontend-device-intelligence/README.md b/packages/frontend-device-intelligence/README.md
index 4b126ae8..764f6c51 100644
--- a/packages/frontend-device-intelligence/README.md
+++ b/packages/frontend-device-intelligence/README.md
@@ -70,18 +70,28 @@ To test the Device Intelligence package in the One Login Frontend monorepo, foll
   <ol>
     <li> Complete the prerequisites and installation steps. </li> 
     </br>
-    <li> Add the /thumbmarkJS/fingerprint path to the app.js so that the application utilises the device intelligence package from the govuk-one-login node modules. </li>
+    <li> Add the /fingerprint path to the app.js so that the application utilises the device intelligence package from the govuk-one-login node modules. </li>
     <br/>
     <li> Add the usage of the fingerprint into the web page by adding in the script into the base nunjucks file. Declare alongside the rest of the scripts in nunjucks context block. </li>
   
   ```
+    <script src="/fingerprint/index.js"></script>
     <script>
-      import('/thumbmarkJS/fingerprint/index.js')
-      .then((ThumbmarkJS) => {
-          ThumbmarkJS.getFingerprint().then((fp) => { console.log(fp)})
-      })
+      console.log(window.ThumbmarkJS.getFingerprint())
+      console.log(window.ThumbmarkJS.getFingerprintData())
+      console.log(window.ThumbmarkJS.setFingerprintCookie())
     </script>
   ```
+  <li> Update the rollup.config.js file to support 'iife' format for browsers. </li>
+
+  ```
+    {
+      file: "build/iife/index.js",
+      format: "iife",
+      name: "ThumbmarkJS"
+    }
+  ```
+
   <li> Run npm start in the alpha app directory and application should be available at http://localhost:3000. Once loaded, inspect the console to view the result of printing out the fingerprint ID. </li>
   </ol>
 
diff --git a/packages/frontend-device-intelligence/rollup.config.js b/packages/frontend-device-intelligence/rollup.config.js
index 7c5a6625..865e108c 100644
--- a/packages/frontend-device-intelligence/rollup.config.js
+++ b/packages/frontend-device-intelligence/rollup.config.js
@@ -12,6 +12,11 @@ export default {
       file: "build/esm/index.js",
       format: "es",
     },
+    {
+      file: "build/iife/index.js",
+      format: "iife",
+      name: "ThumbmarkJS"
+    }
   ],
   plugins: [
     typescript(),
diff --git a/packages/frontend-device-intelligence/src/fingerprint/functions.test.ts b/packages/frontend-device-intelligence/src/fingerprint/functions.test.ts
index 8bffc70b..23445a2d 100644
--- a/packages/frontend-device-intelligence/src/fingerprint/functions.test.ts
+++ b/packages/frontend-device-intelligence/src/fingerprint/functions.test.ts
@@ -1,8 +1,11 @@
 /* eslint-disable */
+import * as functions from "./functions";
+import * as components from "../components/index";
 import {
   getFingerprintData,
   getFingerprint,
   filterFingerprintData,
+  setFingerprintCookie,
 } from "./functions";
 import { ComponentInterface } from "../components/index";
 
@@ -17,10 +20,17 @@ jest.mock("../utils/hash", () => ({
   hash: jest.fn((data) => `hashed_${data}`),
 }));
 
-// Mock fetch for logging function
 global.fetch = jest.fn(() => Promise.resolve({ ok: true })) as jest.Mock;
 
 describe("getFingerprintData()", () => {
+  beforeEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
   test("returns filtered component data correctly", async () => {
     const data = await getFingerprintData();
     expect(data).toEqual({
@@ -30,22 +40,32 @@ describe("getFingerprintData()", () => {
   });
 
   test("handles empty data gracefully", async () => {
-    const { getComponentPromises } = require("../components/index");
-    getComponentPromises.mockReturnValue({});
+    jest.spyOn(components, "getComponentPromises").mockReturnValueOnce({});
+
     const data = await getFingerprintData();
     expect(data).toEqual({});
   });
 
   test("throws an error if something goes wrong", async () => {
-    const { getComponentPromises } = require("../components/index");
-    getComponentPromises.mockImplementation(() => {
-      throw new Error("Test Error");
-    });
+    jest
+      .spyOn(components, "getComponentPromises")
+      .mockImplementationOnce(() => {
+        throw new Error("Test Error");
+      });
+
     await expect(getFingerprintData()).rejects.toThrow("Test Error");
   });
 });
 
 describe("filterFingerprintData()", () => {
+  beforeEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
   const sampleData: ComponentInterface = {
     included: { path: { key: "value" } },
     excluded: { path: { key: "excludedValue" } },
@@ -71,11 +91,84 @@ describe("filterFingerprintData()", () => {
 });
 
 describe("getFingerprint()", () => {
+  beforeEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
   test("throws an error when data fetching fails", async () => {
-    const { getFingerprintData } = require("./functions");
     jest
-      .spyOn(require("./functions"), "getFingerprintData")
-      .mockRejectedValue(new Error("Test Error"));
-    await expect(getFingerprint()).rejects.toThrow("Test Error");
+      .spyOn(functions, "getFingerprintData")
+      .mockRejectedValueOnce(new Error("Test Error"));
+
+    await expect(getFingerprintData()).rejects.toThrow("Test Error");
+  });
+});
+
+describe("setFingerprintCookie()", () => {
+  beforeEach(() => {
+    jest.restoreAllMocks();
+    Object.defineProperty(document, "cookie", {
+      writable: true,
+      value: "",
+    });
+    jest.spyOn(global, "btoa").mockImplementation((data) => `encoded_${data}`);
+
+    jest.spyOn(console, "warn").mockImplementation(() => {});
+    jest.spyOn(console, "error").mockImplementation(() => {});
+    jest
+      .spyOn(console, "log")
+      .mockClear()
+      .mockImplementation(() => {});
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  it("should set the fingerprint cookie correctly", async () => {
+    const mockData = "mockFingerprint";
+
+    jest
+      .spyOn(functions, "getFingerprintData")
+      .mockResolvedValue(mockData as unknown as ComponentInterface);
+    jest.spyOn(global, "btoa").mockImplementation((data) => `encoded_${data}`);
+
+    await setFingerprintCookie();
+
+    console.log("document.cookie value:", document.cookie);
+    expect(document.cookie).toBe(
+      'device_intelligence_fingerprint=encoded_{"componentA":{"key":"valueA"},"componentB":{"key":"valueB"}}; path=/; secure; SameSite=Strict',
+    );
+  });
+
+  it("should log a warning if run on the server side", async () => {
+    const originalWindow = global.window;
+    delete (global as any).window;
+
+    await setFingerprintCookie();
+
+    expect(console.warn).toHaveBeenCalledWith(
+      "fingerprint cookie logic should only run on the client side",
+    );
+
+    global.window = originalWindow;
+  });
+
+  it("should log an error if fingerprint generation fails", async () => {
+    jest.spyOn(console, "error").mockImplementation(() => {});
+    jest.spyOn(global, "btoa").mockImplementation(() => {
+      throw new Error("Encoding error");
+    });
+
+    await setFingerprintCookie();
+
+    expect(console.error).toHaveBeenCalledWith(
+      "Error setting fingerprint cookie:",
+      expect.any(Error),
+    );
   });
 });
diff --git a/packages/frontend-device-intelligence/src/fingerprint/functions.ts b/packages/frontend-device-intelligence/src/fingerprint/functions.ts
index 0174659c..3990cedc 100644
--- a/packages/frontend-device-intelligence/src/fingerprint/functions.ts
+++ b/packages/frontend-device-intelligence/src/fingerprint/functions.ts
@@ -81,6 +81,7 @@ export async function getFingerprint(includeData?: false): Promise<string>;
 export async function getFingerprint(
   includeData: true,
 ): Promise<{ hash: string; data: ComponentInterface }>;
+
 export async function getFingerprint(
   includeData?: boolean,
 ): Promise<string | { hash: string; data: ComponentInterface }> {
@@ -92,3 +93,19 @@ export async function getFingerprint(
     return thisHash.toString();
   }
 }
+
+export async function setFingerprintCookie(): Promise<void> {
+  if (typeof window === "undefined") {
+    console.warn("fingerprint cookie logic should only run on the client side");
+    return;
+  }
+
+  try {
+    const fingerprint = await getFingerprintData();
+    const encodedFingerprint = btoa(JSON.stringify(fingerprint));
+    document.cookie = `device_intelligence_fingerprint=${encodedFingerprint}; path=/; secure; SameSite=Strict`;
+    console.log("Fingerprint cookie set:", encodedFingerprint);
+  } catch (error) {
+    console.error("Error setting fingerprint cookie:", error);
+  }
+}
diff --git a/packages/frontend-device-intelligence/src/index.ts b/packages/frontend-device-intelligence/src/index.ts
index 402420ba..f11c755e 100644
--- a/packages/frontend-device-intelligence/src/index.ts
+++ b/packages/frontend-device-intelligence/src/index.ts
@@ -1,4 +1,4 @@
-import { getFingerprint, getFingerprintData } from "./fingerprint/functions";
+import { getFingerprint, getFingerprintData, setFingerprintCookie } from "./fingerprint/functions";
 import "./components";
 
-export { getFingerprint, getFingerprintData };
+export { getFingerprint, getFingerprintData, setFingerprintCookie };
diff --git a/packages/frontend-device-intelligence/src/utils/commonPixels.ts b/packages/frontend-device-intelligence/src/utils/commonPixels.ts
index 5e3cd2e9..fb85dd36 100644
--- a/packages/frontend-device-intelligence/src/utils/commonPixels.ts
+++ b/packages/frontend-device-intelligence/src/utils/commonPixels.ts
@@ -39,4 +39,4 @@ export function getMostFrequent(arr: number[]): number {
   }
 
   return mostFrequent;
-}
\ No newline at end of file
+}


Reviews:
user: login: nickhealGDS
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-27T16:36:33Z
user: login: nickhealGDS
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-27T16:38:48Z
user: login: nickhealGDS
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-27T16:41:27Z
user: login: arwatGDS
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T09:29:58Z
user: login: arwatGDS
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T13:31:34Z
user: login: arwatGDS
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T13:31:40Z
user: login: nickhealGDS
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-28T15:48:01Z

Review comments:
diff_hunk: @@ -92,10 +92,11 @@
   </script>
 
   <script>
-    import('/thumbmarkJS/fingerprint/index.js')
+    import('/fingerprint/index.js')
path: packages/alpha-app/src/views/common/base.njk
user: login: nickhealGDS
body: Sorry I've just remembered that esmodules aren't supported in IE11 https://caniuse.com/?search=es%20module

In theory the service manual doesn't require us to to support IE11 any more (for JS), but in this case it feels like it might be easy enough to support it and get some more data.

I think adding an `iife` output format to the rollup config will allow teams to implement this the same way analytics is used which should support older browsers.
created_at: 2025-03-27T16:36:33Z
author_association: COLLABORATOR
diff_hunk: @@ -92,3 +93,19 @@ export async function getFingerprint(
     return thisHash.toString();
   }
 }
+
+export async function setFingerprintCookie(): Promise<void> {
+  if (typeof window === "undefined") {
+    console.warn("fingerprint cookie logic should only run on the client side");
+    return;
+  }
+
+  try {
+    const fingerprint = await getFingerprint();
+    const encodedFingerprint = btoa(fingerprint);
+    document.cookie = `device_intelligence_fingerprint=${encodedFingerprint}; path=/; secure; SameSite=Lax`;
path: packages/frontend-device-intelligence/src/fingerprint/functions.ts
user: login: nickhealGDS
body: I think we should be able to use `SameSite=Strict` here. All our services are subdomains of the `account.gov.uk` site, so it should transfer internally between services.

Oh that's reminded me as well, we want the cookie to be set at the `account.gov.uk` domain level, otherwise we'll end up with multiple being set for all the different subdomains. With the analytics package we allow the user of our package to provide their own cookie domain which is a good idea to support all the lower environments.
created_at: 2025-03-27T16:38:48Z
author_association: COLLABORATOR
diff_hunk: @@ -92,3 +93,19 @@ export async function getFingerprint(
     return thisHash.toString();
   }
 }
+
+export async function setFingerprintCookie(): Promise<void> {
+  if (typeof window === "undefined") {
+    console.warn("fingerprint cookie logic should only run on the client side");
+    return;
+  }
+
+  try {
+    const fingerprint = await getFingerprint();
path: packages/frontend-device-intelligence/src/fingerprint/functions.ts
user: login: nickhealGDS
body: Ah sorry it's the `getFingerprintData` function that we need to fetch and send to the backend.

It will be an object as well, so we'll need to `JSON.stringify` it before it's `btoa` encoded.
created_at: 2025-03-27T16:41:27Z
author_association: COLLABORATOR
diff_hunk: @@ -92,3 +93,19 @@ export async function getFingerprint(
     return thisHash.toString();
   }
 }
+
+export async function setFingerprintCookie(): Promise<void> {
+  if (typeof window === "undefined") {
+    console.warn("fingerprint cookie logic should only run on the client side");
+    return;
+  }
+
+  try {
+    const fingerprint = await getFingerprint();
+    const encodedFingerprint = btoa(fingerprint);
+    document.cookie = `device_intelligence_fingerprint=${encodedFingerprint}; path=/; secure; SameSite=Lax`;
path: packages/frontend-device-intelligence/src/fingerprint/functions.ts
user: login: arwatGDS
body: Changed it
created_at: 2025-03-28T09:29:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -92,10 +92,11 @@
   </script>
 
   <script>
-    import('/thumbmarkJS/fingerprint/index.js')
+    import('/fingerprint/index.js')
path: packages/alpha-app/src/views/common/base.njk
user: login: arwatGDS
body: Done
created_at: 2025-03-28T13:31:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -92,3 +93,19 @@ export async function getFingerprint(
     return thisHash.toString();
   }
 }
+
+export async function setFingerprintCookie(): Promise<void> {
+  if (typeof window === "undefined") {
+    console.warn("fingerprint cookie logic should only run on the client side");
+    return;
+  }
+
+  try {
+    const fingerprint = await getFingerprint();
path: packages/frontend-device-intelligence/src/fingerprint/functions.ts
user: login: arwatGDS
body: Done
created_at: 2025-03-28T13:31:40Z
author_association: CONTRIBUTOR

PR comments:
user: login: arwatGDS
created_at: 2025-03-26T16:47:08Z
body: Documentation for this can also be found here: https://govukverify.atlassian.net/wiki/spaces/DIFC/pages/5200805971/Device+Intelligence+Implementation+Guide?force_transition=5d49b611-7fb6-4438-a620-5f48035c07ff 
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-03-28T15:47:45Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=govuk-one-login_frontend-device-intelligence&pullRequest=167) **Quality Gate passed for 'govuk-one-login_frontend-device-intelligence'**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=govuk-one-login_frontend-device-intelligence&pullRequest=167&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=govuk-one-login_frontend-device-intelligence&pullRequest=167&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=govuk-one-login_frontend-device-intelligence&pullRequest=167&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=govuk-one-login_frontend-device-intelligence&pullRequest=167&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=govuk-one-login_frontend-device-intelligence&pullRequest=167&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=govuk-one-login_frontend-device-intelligence&pullRequest=167)


author_association: NONE