diff --git a/CHANGELOG.md b/CHANGELOG.md index a18e9fc..06dbacb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [2.30.1](https://github.com/hmcts/rpx-xui-node-lib/compare/v2.30.0...v2.30.1) (2025-01-07) + + +### Bug Fixes + +* #exui-112: rediect to link expired login link page of state or nonce is stale ([#258](https://github.com/hmcts/rpx-xui-node-lib/issues/258)) ([f366fd2](https://github.com/hmcts/rpx-xui-node-lib/commit/f366fd262eea9a6ef9af8fd8f8051cdeb3383434)), closes [#exui-112](https://github.com/hmcts/rpx-xui-node-lib/issues/exui-112) [#exui-112](https://github.com/hmcts/rpx-xui-node-lib/issues/exui-112) + +# [2.30.0](https://github.com/hmcts/rpx-xui-node-lib/compare/v2.29.7...v2.30.0) (2025-01-06) + + +### Features + +* Tech/ex UI 2249 content security ([#256](https://github.com/hmcts/rpx-xui-node-lib/issues/256)) ([1dd3a8a](https://github.com/hmcts/rpx-xui-node-lib/commit/1dd3a8a30342d981bf4a1036ec7ad6c1881625ca)) + ## [2.29.7](https://github.com/hmcts/rpx-xui-node-lib/compare/v2.29.6...v2.29.7) (2024-12-18) diff --git a/package.json b/package.json index 1b55d65..81c0178 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hmcts/rpx-xui-node-lib", - "version": "2.29.5-exui-2079-rc15", + "version": "2.29.5-exui-2079-rc16", "description": "Common nodejs library components for XUI", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/src/auth/auth.constants.ts b/src/auth/auth.constants.ts index f887350..309e40b 100644 --- a/src/auth/auth.constants.ts +++ b/src/auth/auth.constants.ts @@ -12,5 +12,6 @@ export const AUTH = { KEEPALIVE_ROUTE: '/auth/keepalive', OAUTH_CALLBACK: '/oauth2/callback', LOGOUT: '/auth/logout', + EXPIRED_LOGIN_LINK: '/expired-login-link', }, } diff --git a/src/auth/models/strategy.class.ts b/src/auth/models/strategy.class.ts index de082a5..abeb209 100644 --- a/src/auth/models/strategy.class.ts +++ b/src/auth/models/strategy.class.ts @@ -285,6 +285,12 @@ export abstract class Strategy extends events.EventEmitter { this.emit(AUTH.EVENT.AUTHENTICATE_FAILURE, req, res, next) } + const redirectWithFailure = (errorMessages: string[], INVALID_STATE_ERROR: string, uri: string) => { + errorMessages.push(INVALID_STATE_ERROR) + emitAuthenticationFailure(errorMessages) + return res.redirect(uri) + } + passport.authenticate( this.strategyName, { @@ -310,19 +316,21 @@ export abstract class Strategy extends events.EventEmitter { } if (info) { - if (info.message === INVALID_STATE_ERROR) { - errorMessages.push(INVALID_STATE_ERROR) - } this.logger.info('Authenticate callback info', info) } if (!user) { - const message = 'No user details returned by the authentication service, redirecting to login' - errorMessages.push(message) - this.logger.log(message) - - emitAuthenticationFailure(errorMessages) - return res.redirect(AUTH.ROUTE.LOGIN) + const MISMATCH_NONCE = 'nonce mismatch' + const MISMATCH_STATE = 'state mismatch' + if (info?.message === INVALID_STATE_ERROR) { + return redirectWithFailure(errorMessages, INVALID_STATE_ERROR, AUTH.ROUTE.EXPIRED_LOGIN_LINK) + } else if (info?.message.includes(MISMATCH_NONCE) || info?.message.includes(MISMATCH_STATE)) { + return redirectWithFailure(errorMessages, info.message, AUTH.ROUTE.EXPIRED_LOGIN_LINK) + } else { + const message = 'No user details returned by the authentication service, redirecting to login' + this.logger.log(message) + return redirectWithFailure(errorMessages, message, AUTH.ROUTE.LOGIN) + } } emitAuthenticationFailure(errorMessages) this.verifyLogin(req, user, next, res) diff --git a/src/auth/oauth2/models/oauth2.class.spec.ts b/src/auth/oauth2/models/oauth2.class.spec.ts index b66fdea..7845ad8 100644 --- a/src/auth/oauth2/models/oauth2.class.spec.ts +++ b/src/auth/oauth2/models/oauth2.class.spec.ts @@ -4,6 +4,7 @@ import { createMock } from 'ts-auto-mock' import { Request, Response, Router } from 'express' import { AuthOptions } from '../../models' import { XuiLogger } from '../../../common' +import { AUTH } from '../../auth.constants' describe('OAUTH2 Auth', () => { const mockRequestRequired = { @@ -207,4 +208,82 @@ describe('OAUTH2 Auth', () => { expect(mockRequest.headers.Authorization).toEqual(authToken) expect(next).toHaveBeenCalled() }) + + test('should handle INVALID_STATE_ERROR in info', () => { + const info = { message: 'Invalid authorization request state.' } + jest.spyOn(passport, 'authenticate').mockImplementation((strategy, options, callback) => { + if (callback) { + callback(null, null, info) + } + return jest.fn() + }) + + const mockRouter = createMock() + const logger = { + log: jest.fn(), + error: jest.fn(), + info: jest.fn(), + } as unknown as XuiLogger + const oAuth2 = new OAuth2(mockRouter, logger) + const mockRequest = createMock() + const mockResponse = createMock() + const next = jest.fn() + + oAuth2.callbackHandler(mockRequest, mockResponse, next) + + expect(mockResponse.redirect).toHaveBeenCalledWith(AUTH.ROUTE.EXPIRED_LOGIN_LINK) + }) + + test('should handle MISMATCH_NONCE or MISMATCH_STATE in info', () => { + const info = { message: 'nonce mismatch' } + jest.spyOn(passport, 'authenticate').mockImplementation((strategy, options, callback) => { + if (callback) { + callback(null, null, info) + } + return jest.fn() + }) + + const mockRouter = createMock() + const logger = { + log: jest.fn(), + error: jest.fn(), + info: jest.fn(), + } as unknown as XuiLogger + const oAuth2 = new OAuth2(mockRouter, logger) + const mockRequest = createMock() + const mockResponse = createMock() + const next = jest.fn() + + oAuth2.callbackHandler(mockRequest, mockResponse, next) + + expect(mockResponse.redirect).toHaveBeenCalledWith(AUTH.ROUTE.EXPIRED_LOGIN_LINK) + }) + + test('should handle no user returned', () => { + const info = { message: 'Some other error' } + jest.spyOn(passport, 'authenticate').mockImplementation((strategy, options, callback) => { + if (callback) { + callback(null, null, info) + } + return jest.fn() + }) + + const mockRouter = createMock() + const logger = { + log: jest.fn(), + error: jest.fn(), + info: jest.fn(), + } as unknown as XuiLogger + const oAuth2 = new OAuth2(mockRouter, logger) + const mockRequest = createMock() + const mockResponse = createMock() + const next = jest.fn() + + oAuth2.callbackHandler(mockRequest, mockResponse, next) + + expect(logger.log).toHaveBeenCalledWith( + 'No user details returned by the authentication service, redirecting to login', + ) + expect(mockResponse.redirect).toHaveBeenCalledWith(AUTH.ROUTE.LOGIN) + }) }) diff --git a/src/common/util/contentSecurityPolicy.spec.ts b/src/common/util/contentSecurityPolicy.spec.ts new file mode 100644 index 0000000..acc7f5d --- /dev/null +++ b/src/common/util/contentSecurityPolicy.spec.ts @@ -0,0 +1,13 @@ +import { getContentSecurityPolicy } from './' +import { SECURITY_POLICY } from './contentSecurityPolicy' + +describe('getContentSecurityPolicy(helmet)', () => { + it('should correctly call the content security policy', () => { + const helmet: any = { + contentSecurityPolicy: (policy: any) => { + return policy + }, + } + expect(getContentSecurityPolicy(helmet)).toBe(SECURITY_POLICY) + }) +}) diff --git a/src/common/util/contentSecurityPolicy.ts b/src/common/util/contentSecurityPolicy.ts new file mode 100644 index 0000000..1261189 --- /dev/null +++ b/src/common/util/contentSecurityPolicy.ts @@ -0,0 +1,53 @@ +export const SECURITY_POLICY = { + directives: { + connectSrc: [ + "'self' blob: data:", + '*.gov.uk', + 'dc.services.visualstudio.com', + '*.launchdarkly.com', + 'https://*.google-analytics.com', + 'https://*.googletagmanager.com', + 'https://*.analytics.google.com', + '*.hmcts.net', + 'wss://*.webpubsub.azure.com', + 'https://*.in.applicationinsights.azure.com', + 'https://*.monitor.azure.com', + ], + defaultSrc: ["'self'"], + fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'], + formAction: ["'none'"], + frameAncestors: ["'self'"], + frameSrc: ["'self'"], + imgSrc: [ + "'self'", + 'data:', + 'https://*.google-analytics.com', + 'https://*.googletagmanager.com', + 'https://raw.githubusercontent.com/hmcts/', + 'https://stats.g.doubleclick.net/', + 'https://ssl.gstatic.com/', + 'https://www.gstatic.com/', + 'https://fonts.gstatic.com', + ], + mediaSrc: ["'self'"], + scriptSrc: [ + "'self'", + "'unsafe-inline'", + "'unsafe-eval'", + 'https://*.google-analytics.com', + 'https://*.googletagmanager.com', + 'az416426.vo.msecnd.net', + ], + styleSrc: [ + "'self'", + "'unsafe-inline'", + 'https://fonts.googleapis.com', + 'https://fonts.gstatic.com', + 'https://www.googletagmanager.com', + ], + }, +} + +export const getContentSecurityPolicy = (helmet: any) => { + return helmet.contentSecurityPolicy(SECURITY_POLICY) +} diff --git a/src/common/util/index.ts b/src/common/util/index.ts index daf221b..4f172e7 100644 --- a/src/common/util/index.ts +++ b/src/common/util/index.ts @@ -1,5 +1,6 @@ export { hasKey } from './hasKey' export { getLogger, XuiLogger } from './debug.logger' +export { getContentSecurityPolicy } from './contentSecurityPolicy' export { sortArray } from './sortArray' export { isStringPatternMatch } from './stringPatternMatch' export { arrayPatternMatch } from './arrayPatternMatch'