Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

## [6.118.0] - 2026-03-02

### Changed

- Sign user out of all synchronized tabs

## [6.117.0] - 2026-03-02

## [6.117.0] - 2026-03-02

### Changed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hmrc-frontend",
"version": "6.117.0",
"version": "6.118.0",
"description": "Design patterns for HMRC frontends",
"scripts": {
"start": "gulp dev",
Expand Down
7 changes: 7 additions & 0 deletions src/components/timeout-dialog/session-activity-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ export default class SessionActivityService {
}
}

logSignedOut() {
if (this.activityChannel) {
const event = { signedOut: true };
this.activityChannel.postMessage(event);
}
}

onActivity(callback) {
if (this.activityChannel) {
this.activityChannel.onmessage = (event) => {
Expand Down
15 changes: 15 additions & 0 deletions src/components/timeout-dialog/session-activity-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ describe('/components/timeout-dialog/session-activity-service', () => {
});
});

describe('logSignedOut', () => {
it('should post an event containing signedOut status to the activity channel', () => {
const sut = new SessionActivityService(mockBroadcastChannelFactory);
sut.logSignedOut();
expect(mockBroadcastChannel.postMessage)
.toHaveBeenCalledWith({ signedOut: true });
});
describe('when BroadcastChannel is undefined', () => {
it('should do nothing', () => {
const sut = new SessionActivityService(undefined);
expect(() => sut.logSignedOut).not.toThrow();
});
});
});

describe('onActivity', () => {
it('should register the passed callback fn with the broadcast channel', () => {
const sut = new SessionActivityService(mockBroadcastChannelFactory);
Expand Down
1 change: 1 addition & 0 deletions src/components/timeout-dialog/template.njk
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@
data-keep-alive-button-text="{{ params.keepAliveButtonText }}"
data-sign-out-button-text="{{ params.signOutButtonText }}"
data-synchronise-tabs="{{ params.synchroniseTabs }}"
data-synchronise-tabs-signout="{{ params.synchroniseTabsSignout }}"
data-hide-sign-out-button="{{ params.hideSignOutButton }}"/>
12 changes: 12 additions & 0 deletions src/components/timeout-dialog/timeout-dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ function TimeoutDialog($module, $sessionActivityService) {
const cleanupFunctions = [];
let currentTimer;
const sessionActivityService = $sessionActivityService;
let signedOut = false;

function init() {
const validate = ValidateInput;
Expand Down Expand Up @@ -61,6 +62,9 @@ function TimeoutDialog($module, $sessionActivityService) {
synchroniseTabs: validate.boolean(
lookupData('data-synchronise-tabs') || false,
),
synchroniseTabsSignout: validate.boolean(
lookupData('data-synchronise-tabs-signout') || false,
),
hideSignOutButton: validate.boolean(
lookupData('data-hide-sign-out-button') || false,
),
Expand All @@ -82,6 +86,12 @@ function TimeoutDialog($module, $sessionActivityService) {
const listenForSessionActivityAndResetDialogTimer = () => {
if (settings.synchroniseTabs) {
sessionActivityService.onActivity((event) => {
if (event.signedOut) {
if (!signedOut && settings.synchroniseTabsSignout) {
signOut();
return;
}
}
Comment on lines +89 to +94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a lot of hmrc services share a domain tax.service.gov.uk so if we implemented this change there would be some services with the new js and some on the old - I think the way that sessionActivityService is implemented at the moment - broadcasting signOut event through the same channel would look make it look like session activity to other tabs because it doesn't discriminate type of event at the moment - not sure what that would result in (though somewhat might be same as current - in that once user has signed out, and they hit another page of a tax service the session activity might be broadcast from that new page - though lack of timestamp that it expects from session activity might cause an error maybe)

some other thoughts

  • I wonder if there's some risk of background tabs being paused or off loaded in someway by browsers and then when reopened old sign out messages being broadcast and it destroying a new session the user has unintentionally
  • on hmrc side, I wonder if the auth service will handle several sign out requests at the same time gracefully (where I think for a lot of services the timeout and sign out urls will be the same - because all requests to the kind of hmrc services we support on tax.service.gov.uk extend session - so if you displayed a timeout error page then it could end up extending session you have said is timed out)

I think I'll need to think about it a bit more thoroughly - it makes sense to me - but might need to be extra defensive in how it's implemented / make it so you need to opt-in somehow such that we don't turn it on by default for stuff on tax.service.gov.uk so we didn't break some unknowingly delicately balanced thing

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough. Happy to add another option

synchroniseTabsSignout
data-synchronise-tabs-signout

Would that work for you?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup that would make it easier to add for us I think - when we first implemented the syncing - it was just to stop a background tab being able to take out the session - and we flagged up there were still gaps / not great behaviours (like if data was entered in form on background tab, without this the data could sit in there filled in indefinitely- and as you say have it seem like session was still active) but we weren't confident we wouldn't pull wrong jenga piece and cause a live incident of some kind

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed an updated version.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apologies! Taken me longer than I thought to come back, I am still a bit unsure about scenario where someone signs out after updating to a version of hmrc-frontend with this - it seems because of the reuse of session activity channel - and because events in that channel aren't discriminated in any way - like at the moment any message to it for someone on current version would be presumed to be session activity (that should keep session alive)

so if message { signedOut: true } is sent then any services using old version will run:

const timeOfActivity = event.timestamp;
cleanup();
setupDialogTimer(timeOfActivity);

I'm not actually sure how default params work for js functions - if passing undefined here will cause the default to be used, or if it will cause it to be undefined

const setupDialogTimer = (timeOfLastActivity = getDateNow()) => {

it might be I guess that it won't break anything but will cause background tabs timeout timers to reset - where they might get reset anyway, if the foreground tab which was explicitly signed out lands on a page with timeout dialog on it and some session activity from new session is broadcast


then one other thing I notice with this, background tabs will rebroadcast the sign out event - so if you have 4 tabs, and you sign out in 1, so there's the chance for some kind of sign out loop or oddness

like if someone signs out in one window, they get redirected immediately to sign out endpoint and may end up after that on a page with timeout dialog on it again - if that was case and then there is the chance that sign out events broadcast from background pages would hit pages from the new session and could theoretically create some kind of sign out loop

I guess the bits that are making stuff difficult:

  • lack of some kind of discriminator of event types making it hard to extend session activity channel with support for new event types
  • lack of some kind of discriminator of session within which event occurred making it hard to not discard events that maybe shouldn't be handled if they were for a different session

this is kind of hitting on the past stuff we had with this I think where it feels really complicated and like there are quite a lot of risks in terms of architecture of frontend services in HMRC

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timeout dialog code just seems to be quite a precarious jenga tower to make changes too - it might MIGHT be easier to fork it and modify it for your purposes - than try to change it upstream - and then for the future maybe it's the kind of thing that would be better reimplemented with with the bits making stuff difficult considered upfront as part of the design -where we kind of patched in this session awareness stuff

const timeOfActivity = event.timestamp;
cleanup();
setupDialogTimer(timeOfActivity);
Expand Down Expand Up @@ -285,6 +295,8 @@ function TimeoutDialog($module, $sessionActivityService) {
const getDateNow = () => Date.now();

const signOut = () => {
signedOut = true;
sessionActivityService.logSignedOut();
RedirectHelper.redirectToUrl(settings.signOutUrl);
};

Expand Down
8 changes: 8 additions & 0 deletions src/components/timeout-dialog/timeout-dialog.jsdom.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ describe('/components/timeout-dialog', () => {

SessionActivityService.mockImplementation(() => ({
logActivity: jest.fn(),
logSignedOut: jest.fn(),
onActivity: jest.fn(),
}));
const mockSessionActivityService = new SessionActivityService();
Expand Down Expand Up @@ -122,6 +123,7 @@ describe('/components/timeout-dialog', () => {
utils.ajaxGet.mockReset();
redirectHelper.redirectToUrl.mockReset();
mockSessionActivityService.onActivity.mockReset();
mockSessionActivityService.logSignedOut.mockReset();
jest.clearAllTimers();
});

Expand Down Expand Up @@ -196,6 +198,12 @@ describe('/components/timeout-dialog', () => {
expect(redirectHelper.redirectToUrl).toHaveBeenCalledWith('/sign-out');
});

it('should broadcast the signed out status when sign out is clicked', () => {
assume(mockSessionActivityService.logSignedOut).not.toHaveBeenCalled();
clickElem(testScope.latestDialog$element.querySelector('#hmrc-timeout-sign-out-link'));
expect(mockSessionActivityService.logSignedOut).toHaveBeenCalled();
});

it('should use the sign out url on the sign out link', () => {
const $signoutLink = testScope.latestDialog$element.querySelector('a#hmrc-timeout-sign-out-link');
expect($signoutLink.attributes.getNamedItem('href').value).toEqual('/sign-out');
Expand Down
8 changes: 8 additions & 0 deletions src/components/timeout-dialog/timeout-dialog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ examples:
keepAliveUrl: "?abc=def"
signOutUrl: "?ghi=jkl"
synchroniseTabs: true
- name: synchronise-tabs-signout
data:
timeout: 75
countdown: 73
keepAliveUrl: "?abc=def"
signOutUrl: "?ghi=jkl"
synchroniseTabs: true
synchroniseTabsSignout: true
- name: timeout-warnings-not-synchronised
data:
timeout: 75
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import 'expect-puppeteer';

import {
clockTickSeconds,
useFakeTimers,
} from '../../../lib/browser-tests/puppeteer-helpers';

import { examplePreview } from '../../../lib/url-helpers';

async function navigateToPage(session, url, advance = 10) {
const page = await session.newPage();
await useFakeTimers(page);
await page.goto(examplePreview(url));
await clockTickSeconds(page, advance);
return page;
}

describe('multiple tabs open with synchronise tabs feature switch enabled', () => {
it('should keep other synchronised tabs alive when the user chooses to extend their session', async () => {
const session = await browser.createBrowserContext();
Expand Down Expand Up @@ -51,4 +64,35 @@ describe('multiple tabs open with synchronise tabs feature switch enabled', () =

await session.close();
});

it('should sign out of all synchronised tabs when the user chooses to sign out early', async () => {
const synchronisedTabsSignoutUrl = 'timeout-dialog/synchronise-tabs-signout';
const synchronisedTabsUrl = 'timeout-dialog/synchronise-tabs';
const unsynchronisedTabsSignoutUrl = 'timeout-dialog/timeout-warnings-not-synchronised';
const signoutUrl = '?ghi=jkl';

const session = await browser.createBrowserContext();

const nonSynchronisedPage = await navigateToPage(session, unsynchronisedTabsSignoutUrl);
const synchronisedNoSignoutPage = await navigateToPage(
session,
synchronisedTabsUrl,
);
const backgroundPage = await navigateToPage(session, synchronisedTabsSignoutUrl);
const foregroundPage = await navigateToPage(session, synchronisedTabsSignoutUrl);

await expect(foregroundPage).toClick('a', { text: 'Sign out' });

// foregorund should be signed out
await foregroundPage.waitForNavigation({ timeout: 2000 });
await expect(foregroundPage.url()).toMatch(signoutUrl);
// background should be signed out
await backgroundPage.waitForNavigation({ timeout: 5000 });
await expect(backgroundPage.url()).toMatch(signoutUrl);
// but non-synchronised and synchronised without signout pages should still be signed in
await expect(nonSynchronisedPage.url()).toMatch(unsynchronisedTabsSignoutUrl);
await expect(synchronisedNoSignoutPage.url()).toMatch(synchronisedTabsUrl);

await session.close();
});
});