Repository: DEFRA/btms-portal-frontend
Pull request #11: CDMS 366: integration with Defra ID
Author: login: TrinhChuongEE
Created: 2025-02-27T14:53:00Z | Merged: 2025-03-03T13:29:09Z

Adds integration with Defra ID to handle authentication and restricts access to the search pages to authenticated users.


Diff:
diff --git a/package-lock.json b/package-lock.json
index b3afa844..640982a1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,10 +13,13 @@
         "@babel/runtime": "^7.26.0",
         "@defra/hapi-tracing": "^1.0.0",
         "@elastic/ecs-pino-format": "^1.5.0",
+        "@hapi/bell": "^13.0.2",
         "@hapi/catbox-memory": "^6.0.2",
         "@hapi/catbox-redis": "^7.0.2",
+        "@hapi/cookie": "^12.0.1",
         "@hapi/hapi": "^21.3.12",
         "@hapi/inert": "^7.1.0",
+        "@hapi/jwt": "^3.2.0",
         "@hapi/vision": "^7.0.3",
         "@hapi/yar": "^11.0.2",
         "aws-embedded-metrics": "^4.2.0",
@@ -2620,6 +2623,23 @@
         "@hapi/hoek": "^11.0.2"
       }
     },
+    "node_modules/@hapi/bell": {
+      "version": "13.0.2",
+      "resolved": "https://registry.npmjs.org/@hapi/bell/-/bell-13.0.2.tgz",
+      "integrity": "sha512-C4Rn23URNinjVCeGkQxVXIs24RRVxkt6OhklIMa3P1TA7UCdq3yJKKDpjiYfWZ/clievNK4NdxCo+ffWJfTQ7w==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@hapi/boom": "^10.0.1",
+        "@hapi/bounce": "^3.0.1",
+        "@hapi/cryptiles": "^6.0.1",
+        "@hapi/hoek": "^11.0.2",
+        "@hapi/wreck": "^18.0.1",
+        "joi": "^17.7.1"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
     "node_modules/@hapi/boom": {
       "version": "10.0.1",
       "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz",
@@ -2677,6 +2697,16 @@
         "@hapi/hoek": "^11.0.2"
       }
     },
+    "node_modules/@hapi/catbox-object": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@hapi/catbox-object/-/catbox-object-3.0.1.tgz",
+      "integrity": "sha512-3w6E2DXtjWbmLYi4WcFUOor5jgrXN4PWhDrMrXKP/cEsFSfVSRJ0FhY2PXrhrUHwcllfKezYafWU3tQ5+8RO1w==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@hapi/boom": "^10.0.1",
+        "@hapi/hoek": "^11.0.2"
+      }
+    },
     "node_modules/@hapi/catbox-redis": {
       "version": "7.0.2",
       "resolved": "https://registry.npmjs.org/@hapi/catbox-redis/-/catbox-redis-7.0.2.tgz",
@@ -2701,6 +2731,18 @@
         "@hapi/boom": "^10.0.0"
       }
     },
+    "node_modules/@hapi/cookie": {
+      "version": "12.0.1",
+      "resolved": "https://registry.npmjs.org/@hapi/cookie/-/cookie-12.0.1.tgz",
+      "integrity": "sha512-TpykARUIgTBvgbsgtYe5CrM/7XBGms2/22JD3N6dzct6bG1vcOC6pH1JWIos1O5zvQnyDSJ2pnaFk+DToHkAng==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@hapi/boom": "^10.0.1",
+        "@hapi/bounce": "^3.0.1",
+        "@hapi/hoek": "^11.0.2",
+        "@hapi/validate": "^2.0.1"
+      }
+    },
     "node_modules/@hapi/cryptiles": {
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/@hapi/cryptiles/-/cryptiles-6.0.1.tgz",
@@ -2801,6 +2843,30 @@
         "@hapi/hoek": "^11.0.2"
       }
     },
+    "node_modules/@hapi/jwt": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/@hapi/jwt/-/jwt-3.2.0.tgz",
+      "integrity": "sha512-2EU5zdr0petG63J2RHiagGByA1Qr6qzxMmrJJ3/0cm78be1Lq4vi038YgKJvbBSxSboIp5SWGZdYB6+CJlqEoQ==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@hapi/b64": "^6.0.0",
+        "@hapi/boom": "^10.0.0",
+        "@hapi/bounce": "^3.0.0",
+        "@hapi/bourne": "^3.0.0",
+        "@hapi/catbox-object": "^3.0.0",
+        "@hapi/cryptiles": "^6.0.0",
+        "@hapi/hoek": "^10.0.0",
+        "@hapi/wreck": "^18.0.0",
+        "ecdsa-sig-formatter": "^1.0.0",
+        "joi": "^17.2.1"
+      }
+    },
+    "node_modules/@hapi/jwt/node_modules/@hapi/hoek": {
+      "version": "10.0.1",
+      "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-10.0.1.tgz",
+      "integrity": "sha512-CvlW7jmOhWzuqOqiJQ3rQVLMcREh0eel4IBnxDx2FAcK8g7qoJRQK4L1CPBASoCY6y8e6zuCy3f2g+HWdkzcMw==",
+      "license": "BSD-3-Clause"
+    },
     "node_modules/@hapi/mimos": {
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/@hapi/mimos/-/mimos-7.0.1.tgz",
@@ -6239,6 +6305,15 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/ecdsa-sig-formatter": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+      "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
+      }
+    },
     "node_modules/electron-to-chromium": {
       "version": "1.5.56",
       "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.56.tgz",
diff --git a/package.json b/package.json
index e3b514f7..12da60b6 100644
--- a/package.json
+++ b/package.json
@@ -35,10 +35,13 @@
     "@babel/runtime": "^7.26.0",
     "@defra/hapi-tracing": "^1.0.0",
     "@elastic/ecs-pino-format": "^1.5.0",
+    "@hapi/bell": "^13.0.2",
     "@hapi/catbox-memory": "^6.0.2",
     "@hapi/catbox-redis": "^7.0.2",
+    "@hapi/cookie": "^12.0.1",
     "@hapi/hapi": "^21.3.12",
     "@hapi/inert": "^7.1.0",
+    "@hapi/jwt": "^3.2.0",
     "@hapi/vision": "^7.0.3",
     "@hapi/yar": "^11.0.2",
     "aws-embedded-metrics": "^4.2.0",
diff --git a/src/auth/refesh-token.js b/src/auth/refesh-token.js
new file mode 100644
index 00000000..0c47b165
--- /dev/null
+++ b/src/auth/refesh-token.js
@@ -0,0 +1,36 @@
+import Wreck from '@hapi/wreck'
+import Querystring from 'querystring'
+import { createLogger } from '../utils/logger.js'
+
+import { config } from '../config/config.js'
+import { getUserSession } from './user-session.js'
+
+const authConfig = config.get('auth')
+const logger = createLogger()
+
+async function refreshAccessToken (request) {
+  const authedUser = await getUserSession(request)
+  const refreshToken = authedUser?.refreshToken ?? null
+  const clientId = authConfig.defraId.clientId
+  const clientSecret = authConfig.defraId.clientSecret
+
+  const params = {
+    client_id: clientId,
+    client_secret: clientSecret,
+    grant_type: 'refresh_token',
+    refresh_token: refreshToken,
+    scope: `${clientId} openid`
+  }
+
+  logger.info('Access token expired, refreshing...')
+
+  return Wreck.post(authedUser.tokenUrl, {
+    headers: {
+      'Content-Type': 'application/x-www-form-urlencoded',
+      'Cache-Control': 'no-cache'
+    },
+    payload: Querystring.stringify(params)
+  })
+}
+
+export { refreshAccessToken }
diff --git a/src/auth/user-session.js b/src/auth/user-session.js
new file mode 100644
index 00000000..3cd56be8
--- /dev/null
+++ b/src/auth/user-session.js
@@ -0,0 +1,123 @@
+import jwt from '@hapi/jwt'
+import { addSeconds, isPast, parseISO, subMinutes } from 'date-fns'
+import { refreshAccessToken } from './refesh-token.js'
+
+async function setUserSession (request, sessionId) {
+  const { profile } = request.auth.credentials
+  const expiresInSeconds = request.auth.credentials.expiresIn
+  const expiresInMilliSeconds = expiresInSeconds * 1000
+  const expiresAt = addSeconds(new Date(), expiresInSeconds)
+
+  await request.server.app.cache.set(sessionId, {
+    ...profile,
+    isAuthenticated: request.auth.isAuthenticated,
+    token: request.auth.credentials.token,
+    refreshToken: request.auth.credentials.refreshToken,
+    expiresIn: expiresInMilliSeconds,
+    expiresAt: expiresAt.toISOString()
+  })
+}
+
+function removeUserSession (request) {
+  dropUserSession(request)
+  request.cookieAuth.clear()
+}
+
+async function updateUserSession (request, refreshedSession) {
+  const payload = jwt.token.decode(refreshedSession.access_token).decoded
+    .payload
+
+  // Update userSession with new access token and new expiry details
+  const expiresInSeconds = refreshedSession.expires_in
+  const expiresInMilliSeconds = expiresInSeconds * 1000
+  const expiresAt = addSeconds(new Date(), expiresInSeconds)
+  const authedUser = await getUserSession(request)
+  const displayName = [payload.firstName, payload.lastName]
+    .filter((part) => part)
+    .join(' ')
+
+  await request.server.app.cache.set(request.state.userSession.sessionId, {
+    ...authedUser,
+    id: payload.sub,
+    correlationId: payload.correlationId,
+    sessionId: payload.sessionId,
+    contactId: payload.contactId,
+    serviceId: payload.serviceId,
+    firstName: payload.firstName,
+    lastName: payload.lastName,
+    displayName,
+    email: payload.email,
+    uniqueReference: payload.uniqueReference,
+    loa: payload.loa,
+    aal: payload.aal,
+    enrolmentCount: payload.enrolmentCount,
+    enrolmentRequestCount: payload.enrolmentRequestCount,
+    currentRelationshipId: payload.currentRelationshipId,
+    relationships: payload.relationships,
+    roles: payload.roles,
+    isAuthenticated: true,
+    idToken: refreshedSession.id_token,
+    token: refreshedSession.access_token,
+    refreshToken: refreshedSession.refresh_token,
+    expiresIn: expiresInMilliSeconds,
+    expiresAt: expiresAt.toISOString()
+  })
+
+  return getUserSession(request)
+}
+
+async function validateUserSession (server, request, session) {
+  const authedUser = await getUserSession(request)
+
+  if (!authedUser) {
+    return { isValid: false }
+  }
+
+  const tokenHasExpired = isPast(
+    subMinutes(parseISO(authedUser.expiresAt), 1)
+  )
+
+  if (tokenHasExpired) {
+    const response = await refreshAccessToken(request)
+
+    if (!response.ok) {
+      removeUserSession(request)
+
+      return { isValid: false }
+    }
+
+    const refreshAccessTokenJson = await response.json()
+    const updatedSession = await updateUserSession(
+      request,
+      refreshAccessTokenJson
+    )
+
+    return {
+      isValid: true,
+      credentials: updatedSession
+    }
+  }
+
+  const userSession = await server.app.cache.get(session.sessionId)
+
+  if (userSession) {
+    return {
+      isValid: true,
+      credentials: userSession
+    }
+  }
+
+  return { isValid: false }
+}
+
+async function getUserSession (request) {
+  return request.state?.userSession?.sessionId
+    ? request.server.app.cache.get(request.state.userSession.sessionId)
+    : null
+}
+
+function dropUserSession (request) {
+  return request.server.app.cache.drop(request.state.userSession.sessionId)
+}
+
+export { setUserSession, removeUserSession, updateUserSession, validateUserSession, getUserSession, dropUserSession }
diff --git a/src/config/config.js b/src/config/config.js
index ec33fadb..944e3a8b 100644
--- a/src/config/config.js
+++ b/src/config/config.js
@@ -12,7 +12,8 @@ const isTest = process.env.NODE_ENV === 'test'
 const isDevelopment = process.env.NODE_ENV === 'development'
 
 const configKeys = {
-  SERVICE_NAME: 'serviceName'
+  SERVICE_NAME: 'serviceName',
+  APP_BASE_URL: 'appBaseUrl'
 }
 
 const config = convict({
@@ -29,6 +30,12 @@ const config = convict({
     default: 'development',
     env: 'NODE_ENV'
   },
+  appBaseUrl: {
+    doc: 'Application base URL for after we signIn',
+    format: String,
+    default: 'http://localhost:3000',
+    env: 'APP_BASE_URL'
+  },
   port: {
     doc: 'The port to bind.',
     format: 'port',
@@ -246,6 +253,36 @@ const config = convict({
       default: '',
       env: 'BTMS_API_PASSWORD'
     }
+  },
+  auth: {
+    defraId: {
+      oidcConfigurationUrl: {
+        doc: 'DEFRA ID OIDC Configuration URL',
+        format: String,
+        env: 'AUTH_DEFRA_ID_OIDC_CONFIGURATION_URL',
+        default:
+          'https://dcidmtest.b2clogin.com/dcidmtest.onmicrosoft.com/b2c_1a_cui_signin_stub/.well-known/openid-configuration'
+      },
+      serviceId: {
+        doc: 'DEFRA ID Service ID',
+        format: String,
+        env: 'AUTH_DEFRA_ID_SERVICE_ID',
+        default: 'd7d72b79-9c62-ee11-8df0-000d3adf7047'
+      },
+      clientId: {
+        doc: 'DEFRA ID Client ID',
+        format: String,
+        env: 'AUTH_DEFRA_ID_CLIENT_ID',
+        default: '2fb0d715-affa-4bf1-836e-44a464e3fbea'
+      },
+      clientSecret: {
+        doc: 'DEFRA ID Client Secret',
+        format: String,
+        sensitive: true,
+        env: 'AUTH_DEFRA_ID_CLIENT_SECRET',
+        default: ''
+      }
+    }
   }
 })
 
diff --git a/src/plugins/auth/defra-id.js b/src/plugins/auth/defra-id.js
new file mode 100644
index 00000000..81fcfb6e
--- /dev/null
+++ b/src/plugins/auth/defra-id.js
@@ -0,0 +1,90 @@
+import Wreck from '@hapi/wreck'
+import jwt from '@hapi/jwt'
+import bell from '@hapi/bell'
+import { paths } from '../../routes/route-constants.js'
+
+import { config, configKeys } from '../../config/config.js'
+
+const authConfig = config.get('auth.defraId')
+const sessionConfig = config.get('session')
+
+const getDefraIdAuthConfig = async (oidcConfigurationUrl) => {
+  const { payload } = await Wreck.get(oidcConfigurationUrl, {
+    json: 'strict'
+  })
+
+  return payload
+}
+
+const defraId = {
+  plugin: {
+    name: 'defra-id',
+    register: async (server) => {
+      const oidcConfigurationUrl = authConfig.oidcConfigurationUrl
+      const serviceId = authConfig.serviceId
+      const clientId = authConfig.clientId
+      const clientSecret = authConfig.clientSecret
+      const authCallbackUrl = config.get(configKeys.APP_BASE_URL) + paths.AUTH_DEFRA_ID_CALLBACK
+
+      await server.register(bell)
+
+      const oidcConf = await getDefraIdAuthConfig(oidcConfigurationUrl)
+
+      server.auth.strategy('defra-id', 'bell', {
+        location: (request) => {
+          request.yar.flash('referrer', paths.SEARCH)
+
+          return authCallbackUrl
+        },
+        provider: {
+          name: 'defra-id',
+          protocol: 'oauth2',
+          useParamsAuth: true,
+          auth: oidcConf.authorization_endpoint,
+          pkce: 'S256',
+          token: oidcConf.token_endpoint,
+          scope: ['openid'],
+          profile: async function (credentials, params, _get) {
+            const payload = jwt.token.decode(credentials.token).decoded.payload
+            const displayName = [payload.firstName, payload.lastName]
+              .filter((part) => part)
+              .join(' ')
+
+            credentials.profile = {
+              id: payload.sub,
+              correlationId: payload.correlationId,
+              sessionId: payload.sessionId,
+              contactId: payload.contactId,
+              serviceId: payload.serviceId,
+              firstName: payload.firstName,
+              lastName: payload.lastName,
+              displayName,
+              email: payload.email,
+              uniqueReference: payload.uniqueReference,
+              loa: payload.loa,
+              aal: payload.aal,
+              enrolmentCount: payload.enrolmentCount,
+              enrolmentRequestCount: payload.enrolmentRequestCount,
+              currentRelationshipId: payload.currentRelationshipId,
+              relationships: payload.relationships,
+              roles: payload.roles,
+              idToken: params.id_token,
+              tokenUrl: oidcConf.token_endpoint,
+              logoutUrl: oidcConf.end_session_endpoint
+            }
+          }
+        },
+        password: sessionConfig.cookie.password,
+        clientId,
+        clientSecret,
+        cookie: 'bell-defra-id',
+        isSecure: sessionConfig.cookie.secure,
+        providerParams: {
+          serviceId
+        }
+      })
+    }
+  }
+}
+
+export { defraId }
diff --git a/src/plugins/auth/session-cookie.js b/src/plugins/auth/session-cookie.js
new file mode 100644
index 00000000..de8ec553
--- /dev/null
+++ b/src/plugins/auth/session-cookie.js
@@ -0,0 +1,35 @@
+import authCookie from '@hapi/cookie'
+
+import { config } from '../../config/config.js'
+import { validateUserSession } from '../../auth/user-session.js'
+
+const sessionConfig = config.get('session')
+
+const sessionCookie = {
+  plugin: {
+    name: 'user-session',
+    register: async (server) => {
+      await server.register(authCookie)
+
+      server.auth.strategy('session', 'cookie', {
+        cookie: {
+          name: 'userSession',
+          path: '/',
+          password: sessionConfig.cookie.password,
+          isSecure: sessionConfig.cookie.secure,
+          ttl: sessionConfig.cookie.ttl
+        },
+        keepAlive: true,
+        validate: async (request, session) => {
+          return validateUserSession(server, request, session)
+        }
+      })
+
+      server.auth.default('session')
+    }
+  }
+}
+
+export {
+  sessionCookie
+}
diff --git a/src/plugins/index.js b/src/plugins/index.js
index d7aa15af..d9263cb1 100644
--- a/src/plugins/index.js
+++ b/src/plugins/index.js
@@ -2,6 +2,8 @@ import { vision } from './template-renderer/vision.js'
 import { requestLogger } from './request-logger.js'
 import { secureContext } from './secure-context/secure-context.js'
 import { sessionManager } from './session-manager.js'
+import { defraId } from './auth/defra-id.js'
+import { sessionCookie } from './auth/session-cookie.js'
 import { requestTracing } from './request-tracing.js'
 import { router } from './router.js'
 import { pulse } from './pulse.js'
@@ -13,6 +15,8 @@ export default [
   secureContext,
   pulse,
   sessionManager,
+  defraId,
+  sessionCookie,
   vision,
   router,
   errorPage
diff --git a/src/plugins/template-renderer/context.js b/src/plugins/template-renderer/context.js
index ba7df679..af796dfb 100644
--- a/src/plugins/template-renderer/context.js
+++ b/src/plugins/template-renderer/context.js
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'
 import { config } from '../../config/config.js'
 import { createLogger } from '../../utils/logger.js'
 import { getNavigationItems } from '../../config/navigation-items.js'
+import { getUserSession } from '../../auth/user-session.js'
 
 const logger = createLogger()
 const assetPath = config.get('assetPath')
@@ -18,7 +19,7 @@ let webpackManifest
 /**
  * @param {Request | null} request
  */
-export function context (request) {
+export async function context (request) {
   if (!webpackManifest) {
     try {
       webpackManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'))
@@ -27,7 +28,10 @@ export function context (request) {
     }
   }
 
+  const authedUser = await getUserSession(request)
+
   return {
+    authedUser,
     assetPath: `${assetPath}/assets`,
     serviceName: config.get('serviceName'),
     serviceUrl: '/',
diff --git a/src/routes/home.js b/src/routes/home.js
index 3ddcbec4..cd779dfd 100644
--- a/src/routes/home.js
+++ b/src/routes/home.js
@@ -1,12 +1,9 @@
-/**
- * Sets up the route used for the home page.
- * These routes are registered in src/plugins/router.js.
- */
+import { paths } from './route-constants.js'
 
 export const home = {
   method: 'GET',
-  path: '/',
+  path: paths.LANDING,
   handler: (_request, h) => {
-    return h.view('home')
+    return h.view('home', { signInUrl: paths.SIGN_IN, signOutUrl: paths.SIGN_OUT })
   }
 }
diff --git a/src/routes/index.js b/src/routes/index.js
index cae8ca55..c8c9048d 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -1,6 +1,9 @@
 import { health } from './health.js'
 import { staticAssetRoutes } from './static-assets.js'
 import { home } from './home.js'
+import { signinOidc } from './signin-oidc.js'
+import { signIn } from './sign-in.js'
+import { signOut } from './sign-out.js'
 import { search } from './search.js'
 import { searchResult } from './search-result.js'
 
@@ -11,6 +14,9 @@ const defaultRoutes = [
 ]
 
 const appSpecificRoutes = [
+  signinOidc,
+  signIn,
+  signOut,
   ...search,
   searchResult
 ]
diff --git a/src/routes/route-constants.js b/src/routes/route-constants.js
index a348e0e8..0b711261 100644
--- a/src/routes/route-constants.js
+++ b/src/routes/route-constants.js
@@ -1,4 +1,8 @@
 const paths = {
+  LANDING: '/',
+  AUTH_DEFRA_ID_CALLBACK: '/signin-oidc',
+  SIGN_IN: '/sign-in',
+  SIGN_OUT: '/sign-out',
   SEARCH: '/search',
   SEARCH_RESULT: '/search-result'
 }
diff --git a/src/routes/search-result.js b/src/routes/search-result.js
index 4bdbdc7c..1ff1a28c 100644
--- a/src/routes/search-result.js
+++ b/src/routes/search-result.js
@@ -4,6 +4,9 @@ import { paths, queryStringParams } from './route-constants.js'
 export const searchResult = {
   method: 'GET',
   path: paths.SEARCH_RESULT,
+  options: {
+    auth: 'session'
+  },
   handler: async (_request, h) => {
     const searchResults = await performSearch(_request.query[queryStringParams.SEARCH_TERM])
     const model = createSearchResultsModel(searchResults)
diff --git a/src/routes/search.js b/src/routes/search.js
index dcb08bcd..de883e49 100644
--- a/src/routes/search.js
+++ b/src/routes/search.js
@@ -18,6 +18,9 @@ const validSearchTermSchema = Joi.object({
 export const search = [{
   method: 'GET',
   path: paths.SEARCH,
+  options: {
+    auth: 'session'
+  },
   handler: (_request, h) => {
     return h.view(viewTemplate)
   }
@@ -26,7 +29,7 @@ export const search = [{
   method: 'POST',
   path: paths.SEARCH,
   options: {
-    auth: false,
+    auth: 'session',
     validate: {
       payload: validSearchTermSchema,
       failAction: async (_request, h, _err) => {
diff --git a/src/routes/sign-in.js b/src/routes/sign-in.js
new file mode 100644
index 00000000..4dc4e3fc
--- /dev/null
+++ b/src/routes/sign-in.js
@@ -0,0 +1,12 @@
+import { paths } from './route-constants.js'
+
+export const signIn = {
+  method: 'GET',
+  path: paths.SIGN_IN,
+  options: {
+    auth: 'defra-id'
+  },
+  handler: async (_request, h) => {
+    return h.redirect(paths.SEARCH)
+  }
+}
diff --git a/src/routes/sign-out.js b/src/routes/sign-out.js
new file mode 100644
index 00000000..270848c4
--- /dev/null
+++ b/src/routes/sign-out.js
@@ -0,0 +1,27 @@
+import { paths } from './route-constants.js'
+import { config, configKeys } from '../config/config.js'
+import { dropUserSession, getUserSession } from '../auth/user-session.js'
+
+export const signOut = {
+  method: 'GET',
+  path: paths.SIGN_OUT,
+  handler: async (_request, h) => {
+    const authedUser = await getUserSession(_request)
+
+    if (!authedUser) {
+      return h.redirect(paths.LANDING)
+    }
+
+    const referrer = config.get(configKeys.APP_BASE_URL)
+    const idTokenHint = authedUser.idToken
+
+    const logoutUrl = encodeURI(
+      `${authedUser.logoutUrl}?id_token_hint=${idTokenHint}&post_logout_redirect_uri=${referrer}`
+    )
+
+    dropUserSession(_request)
+    _request.cookieAuth.clear()
+
+    return h.redirect(logoutUrl)
+  }
+}
diff --git a/src/routes/signin-oidc.js b/src/routes/signin-oidc.js
new file mode 100644
index 00000000..4c206a61
--- /dev/null
+++ b/src/routes/signin-oidc.js
@@ -0,0 +1,25 @@
+import { paths } from './route-constants.js'
+import { setUserSession } from '../auth/user-session.js'
+
+export const signinOidc = {
+  method: ['GET', 'POST'],
+  path: paths.AUTH_DEFRA_ID_CALLBACK,
+  options: {
+    auth: 'defra-id'
+  },
+  handler: async (_request, h) => {
+    if (_request.auth?.isAuthenticated) {
+      const sessionId = crypto.randomUUID()
+
+      await setUserSession(_request, sessionId)
+
+      _request.cookieAuth.set({ sessionId })
+
+      _request.logger.info('User has been successfully authenticated')
+    }
+
+    const redirect = _request.yar.flash('referrer')?.at(0) ?? paths.SEARCH
+
+    return h.redirect(redirect)
+  }
+}
diff --git a/src/server.js b/src/server.js
index a352127f..d951e2fd 100644
--- a/src/server.js
+++ b/src/server.js
@@ -9,6 +9,9 @@ export async function createServer () {
   const server = hapi.server({
     port: config.get('port'),
     routes: {
+      auth: {
+        mode: 'try'
+      },
       validate: {
         options: {
           abortEarly: false
@@ -43,6 +46,13 @@ export async function createServer () {
       strictHeader: false
     }
   })
+
+  server.app.cache = server.cache({
+    cache: 'session',
+    expiresIn: config.get('session.cache.ttl'),
+    segment: 'session'
+  })
+
   await server.register(plugins)
 
   return server
diff --git a/src/templates/home.njk b/src/templates/home.njk
index 923eece0..bed45265 100644
--- a/src/templates/home.njk
+++ b/src/templates/home.njk
@@ -5,50 +5,27 @@
 
 {% block content %}
   {{ appHeading({
-    text: heading,
-    caption: "Core delivery platform Node.js Frontend Template."
+    text: heading
   }) }}
 
   <div class="govuk-grid-row">
     <div class="govuk-grid-column-two-thirds">
       <div class="govuk-body" data-testid="app-page-body">
-        <p>
-          Welcome to the Frontend Template, a starter kit for Core Delivery Platform Node.js Frontends 🚀
-        </p>
-        <p>
-          Based on <a href="https://defra.github.io/software-development-standards">DEFRA</a> and <a
-            href="https://design-system.service.gov.uk/">GOV.UK</a> best practices this starter kit provides you with
-          a deployable, production ready template 🙌🏻 to start your new service with.
-        </p>
-        <p>This template comes with:</p>
-        <ul>
-          <li>
-            <a href="https://frontend.design-system.service.gov.uk/">GOV.UK Frontend</a>
-          </li>
-          <li>
-            <a href="https://hapi.dev">Hapi.js</a>
-          </li>
-          <li>
-            <a href="https://mozilla.github.io/nunjucks/">Nunjucks</a>
-          </li>
-          <li>
-            <a href="https://github.com/mozilla/node-convict">Node Convict</a>
-          </li>
-          <li>
-            <a href="https://jestjs.io/">Jest testing</a>
-          </li>
-          <li>GitHub Actions Pull Request and Publish jobs</li>
-          <li>
-            <a href="https://github.com/pinojs/pino">PinoJs logging</a>
-          </li>
-          <li>
-            <a href="https://standardjs.com">Standard JS</a>
-          </li>
-          <li>
-            <a href="https://stylelint.io">StyleLint</a>
-          </li>
-          <li>and lots more... 😎</li>
-        </ul>
+        {% if authedUser.displayName %}
+          <p>
+            User <strong>{{ authedUser.displayName }}</strong> is authenticated
+          </p>
+          <p>
+            <a href="https://your-account.cpdev.cui.defra.gov.uk/management">Manage account</a> - <a href={{ signOutUrl }}>Sign out</a>
+          </p>
+        {% else %}
+          <p>
+            User is not authenticated!
+          </p>
+          <p>
+            <a id="login-link" href={{ signInUrl }}>LOGIN</a>
+          </p>
+        {% endif %}
       </div>
     </div>
   </div>
diff --git a/src/utils/caching/cache-engine.js b/src/utils/caching/cache-engine.js
index abb2249c..d0cc0b7e 100644
--- a/src/utils/caching/cache-engine.js
+++ b/src/utils/caching/cache-engine.js
@@ -19,7 +19,10 @@ export function getCacheEngine (engine) {
   if (engine === 'redis') {
     logger.info('Using Redis session cache')
     const redisClient = buildRedisClient(config.get('redis'))
-    return new CatboxRedis({ client: redisClient })
+    return new CatboxRedis({
+      client: redisClient,
+      segment: 'session'
+    })
   }
 
   if (config.get('isProduction')) {
@@ -29,5 +32,7 @@ export function getCacheEngine (engine) {
   }
 
   logger.info('Using Catbox Memory session cache')
-  return new CatboxMemory()
+  return new CatboxMemory({
+    segment: 'session'
+  })
 }
diff --git a/test/unit/auth/refresh-token.test.js b/test/unit/auth/refresh-token.test.js
new file mode 100644
index 00000000..dfad14d1
--- /dev/null
+++ b/test/unit/auth/refresh-token.test.js
@@ -0,0 +1,67 @@
+import { refreshAccessToken } from '../../../src/auth/refesh-token.js'
+import { createAuthedUser } from '../utils/session-helper.js'
+import { createLogger } from '../../../src/utils/logger.js'
+import Querystring from 'querystring'
+import { config } from '../../../src/config/config.js'
+import { getUserSession } from '../../../src/auth/user-session.js'
+
+const mockPost = jest.fn()
+const mockLoggerInfo = jest.fn()
+
+const authConfig = config.get('auth')
+
+jest.mock('@hapi/wreck', () => ({
+  post: (...args) => mockPost(...args)
+}))
+
+jest.mock('../../../src/utils/logger.js', () => ({
+  createLogger: () => ({
+    info: (...args) => mockLoggerInfo(...args)
+  })
+}))
+
+jest.mock('../../../src/auth/user-session.js', () => ({
+  getUserSession: jest.fn()
+}))
+
+describe('#refreshToken', () => {
+  describe('When a request to refresh a token is received', () => {
+    let logger
+
+    beforeEach(async () => {
+      jest.clearAllMocks()
+      logger = createLogger()
+    })
+
+    test('Should call refresh endpoint for a new token', async () => {
+      const authedUser = createAuthedUser()
+      const clientId = authConfig.defraId.clientId
+      const clientSecret = authConfig.defraId.clientSecret
+
+      getUserSession.mockReturnValue(authedUser)
+
+      const request = {
+        logger: logger
+      }
+
+      const expectedPayload = Querystring.stringify({
+        client_id: clientId,
+        client_secret: clientSecret,
+        grant_type: 'refresh_token',
+        refresh_token: authedUser.refreshToken,
+        scope: `${clientId} openid`
+      })
+
+      await refreshAccessToken(request)
+
+      expect(mockPost).toHaveBeenCalledTimes(1)
+      expect(mockPost).toHaveBeenCalledWith(authedUser.tokenUrl, expect.objectContaining({
+        headers: {
+          'Content-Type': 'application/x-www-form-urlencoded',
+          'Cache-Control': 'no-cache'
+        },
+        payload: expectedPayload
+      }))
+    })
+  })
+})
diff --git a/test/unit/auth/user-session.test.js b/test/unit/auth/user-session.test.js
new file mode 100644
index 00000000..87b8b930
--- /dev/null
+++ b/test/unit/auth/user-session.test.js
@@ -0,0 +1,403 @@
+import { removeUserSession, setUserSession, updateUserSession, validateUserSession, getUserSession, dropUserSession } from '../../../src/auth/user-session.js'
+import { startServer } from '../../../src/utils/start-server.js'
+import { createAuthedUser, createRefreshedToken, setupAuthedUserSession } from '../utils/session-helper.js'
+import { config } from '../../../src/config/config.js'
+import { refreshAccessToken } from '../../../src/auth/refesh-token.js'
+
+const mockCookieAuthClear = jest.fn()
+
+jest.mock('../../../src/auth/refesh-token.js', () => ({
+  refreshAccessToken: jest.fn()
+}))
+
+const sessionConfig = config.get('session')
+
+describe('#userSession', () => {
+  describe('When setting user session', () => {
+    let server, authedUser
+
+    beforeEach(async () => {
+      jest.clearAllMocks()
+      server = await startServer()
+      authedUser = createAuthedUser()
+    })
+
+    afterEach(async () => {
+      await server.stop({ timeout: 0 })
+    })
+
+    test('Should set session in cache', async () => {
+      const request = {
+        server: server,
+        auth: {
+          isAuthenticated: true,
+          credentials: {
+            ...authedUser,
+            profile: {
+              ...authedUser
+            }
+          }
+        }
+      }
+
+      await setUserSession(request, authedUser.sessionId)
+
+      const cachedSession = await server.app.cache.get(authedUser.sessionId)
+
+      expect(cachedSession).not.toBeNull()
+      expect(cachedSession.isAuthenticated).toBeTruthy()
+    })
+  })
+
+  describe('When removing user session', () => {
+    let server, authedUser
+
+    beforeEach(async () => {
+      jest.clearAllMocks()
+      server = await startServer()
+      authedUser = createAuthedUser()
+    })
+
+    afterEach(async () => {
+      await server.stop({ timeout: 0 })
+    })
+
+    test('Should drop and clear the session and cookie', async () => {
+      const request = {
+        server: server,
+        state: {
+          userSession: {
+            sessionId: authedUser.sessionId
+          }
+        },
+        cookieAuth: {
+          clear: mockCookieAuthClear
+        }
+      }
+
+      removeUserSession(request)
+
+      expect(mockCookieAuthClear).toHaveBeenCalledTimes(1)
+    })
+  })
+
+  describe('When updating user session', () => {
+    let server, originalCachedSession
+
+    beforeEach(async () => {
+      jest.clearAllMocks()
+      server = await startServer()
+      originalCachedSession = await setupAuthedUserSession(server)
+    })
+
+    afterEach(async () => {
+      await server.stop({ timeout: 0 })
+    })
+
+    test('Should replace session in cache', async () => {
+      const refreshedToken = createRefreshedToken()
+
+      const request = {
+        server: server,
+        state: {
+          userSession: {
+            sessionId: originalCachedSession.sessionId
+          }
+        }
+      }
+
+      const refeshedSession = {
+        id_token: refreshedToken,
+        access_token: refreshedToken,
+        refresh_token: refreshedToken,
+        expires_in: (sessionConfig.cache.ttl / 1000) + 1 // Make sure the new session doesn't have the same expiry time when the test runs quick
+      }
+
+      await updateUserSession(request, refeshedSession)
+
+      const newCachedSession = await server.app.cache.get(originalCachedSession.sessionId)
+
+      expect(newCachedSession).not.toBeNull()
+      expect(newCachedSession.expiresAt).not.toEqual(originalCachedSession.expiresAt)
+    })
+  })
+
+  describe('When validating user session', () => {
+    let server, userSession
+
+    beforeEach(async () => {
+      jest.clearAllMocks()
+      server = await startServer()
+    })
+
+    afterEach(async () => {
+      await server.stop({ timeout: 0 })
+    })
+
+    test('Should return not valid if session does not exist', async () => {
+      const request = { }
+
+      const session = {
+        sessionId: 'a-test-session-id'
+      }
+
+      const result = await validateUserSession(server, request, session)
+
+      expect(result.isValid).toBeFalsy()
+    })
+
+    test('Should return valid if active session exists', async () => {
+      userSession = await setupAuthedUserSession(server)
+
+      const request = {
+        server: server,
+        state: {
+          userSession: {
+            sessionId: userSession.sessionId
+          }
+        }
+      }
+
+      const session = {
+        sessionId: userSession.sessionId
+      }
+
+      const result = await validateUserSession(server, request, session)
+
+      expect(result.isValid).toBeTruthy()
+      expect(result.credentials).toEqual(userSession)
+    })
+
+    test('Should return not valid if unable to refresh token', async () => {
+      refreshAccessToken.mockReturnValue({
+        ok: false
+      })
+      userSession = await setupAuthedUserSession(server, new Date().toISOString())
+
+      const request = {
+        server: server,
+        state: {
+          userSession: {
+            sessionId: userSession.sessionId
+          }
+        },
+        cookieAuth: {
+          clear: mockCookieAuthClear
+        }
+      }
+
+      const session = {
+        sessionId: userSession.sessionId
+      }
+
+      const result = await validateUserSession(server, request, session)
+
+      expect(refreshAccessToken).toHaveBeenCalledTimes(1)
+      expect(mockCookieAuthClear).toHaveBeenCalledTimes(1)
+      expect(result.isValid).toBeFalsy()
+    })
+
+    test('Should return valid if session token successfully refreshed', async () => {
+      const refreshedToken = createRefreshedToken()
+
+      refreshAccessToken.mockReturnValue({
+        ok: true,
+        json: () => {
+          return {
+            id_token: refreshedToken,
+            access_token: refreshedToken,
+            refresh_token: refreshedToken,
+            expires_in: sessionConfig.cache.ttl / 1000
+          }
+        }
+      })
+      userSession = await setupAuthedUserSession(server, new Date().toISOString())
+
+      const request = {
+        server: server,
+        state: {
+          userSession: {
+            sessionId: userSession.sessionId
+          }
+        }
+      }
+
+      const session = {
+        sessionId: userSession.sessionId
+      }
+
+      const result = await validateUserSession(server, request, session)
+
+      expect(refreshAccessToken).toHaveBeenCalledTimes(1)
+      expect(result.isValid).toBeTruthy()
+      expect(result.credentials).not.toEqual(userSession)
+      expect(result.credentials.expiresAt).not.toEqual(userSession.expiresAt)
+    })
+  })
+
+  describe('When getting user session', () => {
+    let server, userSession
+
+    describe('When a session exists', () => {
+      beforeEach(async () => {
+        server = await startServer()
+        userSession = await setupAuthedUserSession(server)
+      })
+
+      afterEach(async () => {
+        await server.stop({ timeout: 0 })
+      })
+
+      test('Should return the cached session', async () => {
+        const cachedSession = await server.app.cache.get(userSession.sessionId)
+        expect(cachedSession).toEqual(userSession)
+
+        const request = {
+          server,
+          state: {
+            userSession: {
+              sessionId: userSession.sessionId
+            }
+          }
+        }
+
+        const retrievedSession = await getUserSession(request)
+
+        expect(retrievedSession).toEqual(userSession)
+      })
+    })
+
+    describe('When request state is not present', () => {
+      beforeEach(async () => {
+        server = await startServer()
+        userSession = await setupAuthedUserSession(server)
+      })
+
+      afterEach(async () => {
+        await server.stop({ timeout: 0 })
+      })
+
+      test('Should return empty session', async () => {
+        const cachedSession = await server.app.cache.get(userSession.sessionId)
+        expect(cachedSession).toEqual(userSession)
+
+        const request = {
+          server
+        }
+
+        const retrievedSession = await getUserSession(request)
+
+        expect(retrievedSession).toBeFalsy()
+      })
+    })
+
+    describe('When a user session is not present', () => {
+      beforeEach(async () => {
+        server = await startServer()
+        userSession = await setupAuthedUserSession(server)
+      })
+
+      afterEach(async () => {
+        await server.stop({ timeout: 0 })
+      })
+
+      test('Should return empty session', async () => {
+        const cachedSession = await server.app.cache.get(userSession.sessionId)
+        expect(cachedSession).toEqual(userSession)
+
+        const request = {
+          server,
+          state: {}
+        }
+
+        const retrievedSession = await getUserSession(request)
+
+        expect(retrievedSession).toBeFalsy()
+      })
+    })
+
+    describe('When a session does not exist in cache', () => {
+      beforeEach(async () => {
+        server = await startServer()
+      })
+
+      afterEach(async () => {
+        await server.stop({ timeout: 0 })
+      })
+
+      test('Should return null', async () => {
+        const request = {
+          server,
+          state: {
+            userSession: {
+              sessionId: crypto.randomUUID()
+            }
+          }
+        }
+
+        const retrievedSession = await getUserSession(request)
+
+        expect(retrievedSession).toBeNull()
+      })
+    })
+  })
+
+  describe('When dropping user session', () => {
+    let server, userSession
+
+    describe('When a session exists in cache', () => {
+      beforeEach(async () => {
+        server = await startServer()
+        userSession = await setupAuthedUserSession(server)
+      })
+
+      afterEach(async () => {
+        await server.stop({ timeout: 0 })
+      })
+
+      test('Should remove the session from cache', async () => {
+        let cachedSession
+
+        cachedSession = await server.app.cache.get(userSession.sessionId)
+        expect(cachedSession).toEqual(userSession)
+
+        const request = {
+          server,
+          state: {
+            userSession: {
+              sessionId: userSession.sessionId
+            }
+          }
+        }
+
+        dropUserSession(request)
+
+        cachedSession = await server.app.cache.get(userSession.sessionId)
+        expect(cachedSession).toBeNull()
+      })
+    })
+
+    describe('When a session does not exist in cache', () => {
+      beforeEach(async () => {
+        server = await startServer()
+      })
+
+      afterEach(async () => {
+        await server.stop({ timeout: 0 })
+      })
+
+      test('Should not throw error', async () => {
+        const request = {
+          server,
+          state: {
+            userSession: {
+              sessionId: crypto.randomUUID()
+            }
+          }
+        }
+
+        await expect(dropUserSession(request)).resolves.not.toThrowError()
+      })
+    })
+  })
+})
diff --git a/test/unit/plugins/template-rendering/context.test.js b/test/unit/plugins/template-rendering/context.test.js
index 78b000ee..8850a76b 100644
--- a/test/unit/plugins/template-rendering/context.test.js
+++ b/test/unit/plugins/template-rendering/context.test.js
@@ -1,3 +1,6 @@
+import { createAuthedUser } from '../../utils/session-helper.js'
+import { getUserSession } from '../../../../src/auth/user-session.js'
+
 const mockReadFileSync = jest.fn()
 const mockLoggerError = jest.fn()
 const expectedServiceName = 'Border Trade Matching Service'
@@ -10,29 +13,45 @@ jest.mock('../../../../src/utils/logger.js', () => ({
   createLogger: () => ({ error: (...args) => mockLoggerError(...args) })
 }))
 
+jest.mock('../../../../src/auth/user-session.js', () => ({
+  getUserSession: jest.fn()
+}))
+
 describe('#context', () => {
   const mockRequest = { path: '/' }
   let contextResult
 
   describe('When webpack manifest file read succeeds', () => {
-    let contextImport
+    let contextImport, authedUser
 
     beforeAll(async () => {
       contextImport = await import('../../../../src/plugins/template-renderer/context.js')
+
+      authedUser = createAuthedUser()
+      getUserSession.mockReturnValue(authedUser)
     })
 
-    beforeEach(() => {
+    beforeEach(async () => {
       // Return JSON string
       mockReadFileSync.mockReturnValue(`{
         "application.js": "javascripts/application.js",
         "stylesheets/application.scss": "stylesheets/application.css"
       }`)
 
-      contextResult = contextImport.context(mockRequest)
+      contextResult = await contextImport.context(mockRequest)
+    })
+
+    test('Should read file', () => {
+      expect(mockReadFileSync).toHaveBeenCalled()
+    })
+
+    test('Should use cache', () => {
+      expect(mockReadFileSync).not.toHaveBeenCalled()
     })
 
     test('Should provide expected context', () => {
       expect(contextResult).toEqual({
+        authedUser,
         assetPath: '/public/assets',
         breadcrumbs: [],
         getAssetPath: expect.any(Function),
@@ -85,51 +104,3 @@ describe('#context', () => {
     })
   })
 })
-
-describe('#context cache', () => {
-  const mockRequest = { path: '/' }
-  let contextResult
-
-  describe('Webpack manifest file cache', () => {
-    let contextImport
-
-    beforeAll(async () => {
-      contextImport = await import('../../../../src/plugins/template-renderer/context.js')
-    })
-
-    beforeEach(() => {
-      // Return JSON string
-      mockReadFileSync.mockReturnValue(`{
-        "application.js": "javascripts/application.js",
-        "stylesheets/application.scss": "stylesheets/application.css"
-      }`)
-
-      contextResult = contextImport.context(mockRequest)
-    })
-
-    test('Should read file', () => {
-      expect(mockReadFileSync).toHaveBeenCalled()
-    })
-
-    test('Should use cache', () => {
-      expect(mockReadFileSync).not.toHaveBeenCalled()
-    })
-
-    test('Should provide expected context', () => {
-      expect(contextResult).toEqual({
-        assetPath: '/public/assets',
-        breadcrumbs: [],
-        getAssetPath: expect.any(Function),
-        navigation: [
-          {
-            isActive: true,
-            text: 'Home',
-            url: '/'
-          }
-        ],
-        serviceName: expectedServiceName,
-        serviceUrl: '/'
-      })
-    })
-  })
-})
diff --git a/test/unit/routes/search-result.test.js b/test/unit/routes/search-result.test.js
index e122f09a..97f37284 100644
--- a/test/unit/routes/search-result.test.js
+++ b/test/unit/routes/search-result.test.js
@@ -4,6 +4,7 @@ import { config, configKeys } from '../../../src/config/config.js'
 import { paths, queryStringParams } from '../../../src/routes/route-constants.js'
 import { performSearch } from '../../../src/services/index.js'
 import { createSearchResultsModel } from '../../../src/models/index.js'
+import { setupAuthedUserSession } from '../utils/session-helper.js'
 
 jest.mock('../../../src/services/index.js', () => ({
   performSearch: jest.fn()
@@ -13,11 +14,12 @@ jest.mock('../../../src/models/index.js', () => ({
 }))
 
 describe('#serveSearchResultsPage', () => {
-  let server
+  let server, userSession
 
-  describe('When secure context is disabled', () => {
+  describe('When authenticated', () => {
     beforeEach(async () => {
       server = await startServer()
+      userSession = await setupAuthedUserSession(server)
     })
 
     afterEach(async () => {
@@ -37,7 +39,11 @@ describe('#serveSearchResultsPage', () => {
 
       const { statusCode, payload } = await server.inject({
         method: 'GET',
-        url: `${paths.SEARCH_RESULT}?${queryStringParams.SEARCH_TERM}=${testSearchTerm}`
+        url: `${paths.SEARCH_RESULT}?${queryStringParams.SEARCH_TERM}=${testSearchTerm}`,
+        auth: {
+          strategy: 'defra-id',
+          credentials: userSession
+        }
       })
 
       expect(performSearch).toHaveBeenCalledWith(testSearchTerm)
@@ -46,4 +52,24 @@ describe('#serveSearchResultsPage', () => {
       expect(payload).toEqual(expect.stringContaining(`Search result - ${config.get(configKeys.SERVICE_NAME)}`))
     })
   })
+
+  describe('When not authenticated', () => {
+    beforeEach(async () => {
+      server = await startServer()
+    })
+
+    afterEach(async () => {
+      await server.stop({ timeout: 0 })
+    })
+
+    test('Should respond with unauthorized', async () => {
+      const { statusCode, payload } = await server.inject({
+        method: 'GET',
+        url: paths.SEARCH_RESULT
+      })
+
+      expect(statusCode).toBe(httpConstants.HTTP_STATUS_UNAUTHORIZED)
+      expect(payload).toEqual(expect.stringContaining(`Unauthorized - ${config.get(configKeys.SERVICE_NAME)}`))
+    })
+  })
 })
diff --git a/test/unit/routes/search.test.js b/test/unit/routes/search.test.js
index f616c131..dfc4ef7e 100644
--- a/test/unit/routes/search.test.js
+++ b/test/unit/routes/search.test.js
@@ -3,16 +3,18 @@ import { isValidSearchTerm, hasSearchResult } from '../../../src/services/search
 import { constants as httpConstants } from 'http2'
 import { config, configKeys } from '../../../src/config/config.js'
 import { paths, queryStringParams } from '../../../src/routes/route-constants.js'
+import { setupAuthedUserSession } from '../utils/session-helper.js'
 
 jest.mock('../../../src/services/search-service.js')
 
 describe('#serveSearchPage', () => {
-  let server
+  let server, userSession
 
-  describe('When secure context is disabled', () => {
+  describe('When authenticated', () => {
     beforeEach(async () => {
       jest.clearAllMocks()
       server = await startServer()
+      userSession = await setupAuthedUserSession(server)
     })
 
     afterEach(async () => {
@@ -22,7 +24,11 @@ describe('#serveSearchPage', () => {
     test('Should respond with search page', async () => {
       const { statusCode, payload } = await server.inject({
         method: 'GET',
-        url: paths.SEARCH
+        url: paths.SEARCH,
+        auth: {
+          strategy: 'defra-id',
+          credentials: userSession
+        }
       })
 
       expect(statusCode).toBe(httpConstants.HTTP_STATUS_OK)
@@ -38,7 +44,11 @@ describe('#serveSearchPage', () => {
         method: 'POST',
         url: paths.SEARCH,
         headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-        payload: `searchTerm=${testChedRef}`
+        payload: `searchTerm=${testChedRef}`,
+        auth: {
+          strategy: 'defra-id',
+          credentials: userSession
+        }
       })
 
       expect(statusCode).toBe(httpConstants.HTTP_STATUS_FOUND)
@@ -57,7 +67,11 @@ describe('#serveSearchPage', () => {
         method: 'POST',
         url: paths.SEARCH,
         headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-        payload: `searchTerm=${searchTerm}`
+        payload: `searchTerm=${searchTerm}`,
+        auth: {
+          strategy: 'defra-id',
+          credentials: userSession
+        }
       })
 
       expectedValidSearchTermCall ? expect(isValidSearchTerm).toHaveBeenCalledWith(searchTerm) : expect(isValidSearchTerm).not.toHaveBeenCalled()
@@ -69,4 +83,25 @@ describe('#serveSearchPage', () => {
       expect(request.response.source.context.errorCode).toBe(expectedErrorCode)
     })
   })
+
+  describe('When not authenticated', () => {
+    beforeEach(async () => {
+      jest.clearAllMocks()
+      server = await startServer()
+    })
+
+    afterEach(async () => {
+      await server.stop({ timeout: 0 })
+    })
+
+    test('Should respond with unauthorized', async () => {
+      const { statusCode, payload } = await server.inject({
+        method: 'GET',
+        url: paths.SEARCH
+      })
+
+      expect(statusCode).toBe(httpConstants.HTTP_STATUS_UNAUTHORIZED)
+      expect(payload).toEqual(expect.stringContaining(`Unauthorized - ${config.get(configKeys.SERVICE_NAME)}`))
+    })
+  })
 })
diff --git a/test/unit/routes/sign-in.test.js b/test/unit/routes/sign-in.test.js
new file mode 100644
index 00000000..abda27df
--- /dev/null
+++ b/test/unit/routes/sign-in.test.js
@@ -0,0 +1,33 @@
+import { startServer } from '../../../src/utils/start-server.js'
+import { createAuthedUser } from '../utils/session-helper.js'
+import { paths } from '../../../src/routes/route-constants.js'
+import { constants as httpConstants } from 'http2'
+
+describe('#signIn', () => {
+  describe('When accessed following successful signin', () => {
+    let server, userSession
+
+    beforeEach(async () => {
+      server = await startServer()
+      userSession = createAuthedUser()
+    })
+
+    afterEach(async () => {
+      await server.stop({ timeout: 0 })
+    })
+
+    test('Should redirect to search page', async () => {
+      const { statusCode, headers } = await server.inject({
+        method: 'GET',
+        url: paths.SIGN_IN,
+        auth: {
+          strategy: 'defra-id',
+          credentials: userSession
+        }
+      })
+
+      expect(statusCode).toBe(httpConstants.HTTP_STATUS_FOUND)
+      expect(headers.location).toContain(paths.SEARCH)
+    })
+  })
+})
diff --git a/test/unit/routes/sign-out.test.js b/test/unit/routes/sign-out.test.js
new file mode 100644
index 00000000..ff4cac91
--- /dev/null
+++ b/test/unit/routes/sign-out.test.js
@@ -0,0 +1,66 @@
+import { startServer } from '../../../src/utils/start-server.js'
+import { setupAuthedUserSession } from '../utils/session-helper.js'
+import { paths } from '../../../src/routes/route-constants.js'
+import { constants as httpConstants } from 'http2'
+import { getUserSession, dropUserSession } from '../../../src/auth/user-session.js'
+
+jest.mock('../../../src/auth/user-session.js', () => ({
+  getUserSession: jest.fn(),
+  dropUserSession: jest.fn()
+}))
+
+describe('#signOut', () => {
+  describe('When accessed with no active session', () => {
+    let server
+
+    beforeEach(async () => {
+      jest.clearAllMocks()
+      server = await startServer()
+      getUserSession.mockReturnValue(null)
+    })
+
+    afterEach(async () => {
+      await server.stop({ timeout: 0 })
+    })
+
+    test('Should redirect to landing page', async () => {
+      const { statusCode, headers } = await server.inject({
+        method: 'GET',
+        url: paths.SIGN_OUT
+      })
+
+      expect(statusCode).toBe(httpConstants.HTTP_STATUS_FOUND)
+      expect(headers.location).toContain(paths.LANDING)
+    })
+  })
+
+  describe('When accessed with an active session', () => {
+    let server, userSession
+
+    beforeEach(async () => {
+      jest.clearAllMocks()
+      server = await startServer()
+      userSession = await setupAuthedUserSession(server)
+      getUserSession.mockReturnValue(userSession)
+      dropUserSession.mockImplementation(() => {
+        server.app.cache.drop(userSession.sessionId)
+      })
+    })
+
+    afterEach(async () => {
+      await server.stop({ timeout: 0 })
+    })
+
+    test('Should redirect to signin provider signOut flow', async () => {
+      const { statusCode, headers } = await server.inject({
+        method: 'GET',
+        url: paths.SIGN_OUT
+      })
+
+      expect(statusCode).toBe(httpConstants.HTTP_STATUS_FOUND)
+      expect(headers.location).toContain(userSession.logoutUrl)
+      expect(headers.location).toContain('id_token_hint')
+      expect(headers.location).toContain('post_logout_redirect_uri')
+    })
+  })
+})
diff --git a/test/unit/routes/signin-oidc.test.js b/test/unit/routes/signin-oidc.test.js
new file mode 100644
index 00000000..b9be2643
--- /dev/null
+++ b/test/unit/routes/signin-oidc.test.js
@@ -0,0 +1,86 @@
+import { startServer } from '../../../src/utils/start-server.js'
+import { createAuthedUser } from '../utils/session-helper.js'
+import { paths } from '../../../src/routes/route-constants.js'
+import { constants as httpConstants } from 'http2'
+import { signinOidc } from '../../../src/routes/signin-oidc.js'
+
+crypto.randomUUID = () => 'a-test-session-id'
+
+describe('#signinOidc', () => {
+  describe('When accessed following successful signin', () => {
+    let server, userSession
+
+    beforeEach(async () => {
+      jest.clearAllMocks()
+      server = await startServer()
+      userSession = createAuthedUser()
+    })
+
+    afterEach(async () => {
+      await server.stop({ timeout: 0 })
+    })
+
+    test('Should redirect to search page', async () => {
+      const { statusCode, headers, request } = await server.inject({
+        method: 'GET',
+        url: paths.AUTH_DEFRA_ID_CALLBACK,
+        auth: {
+          strategy: 'defra-id',
+          credentials: userSession
+        }
+      })
+
+      const cachedSession = await request.server.app.cache.get('a-test-session-id')
+
+      expect(cachedSession).not.toBeNull()
+      expect(cachedSession.isAuthenticated).toBeTruthy()
+      expect(statusCode).toBe(httpConstants.HTTP_STATUS_FOUND)
+      expect(headers.location).toContain(paths.SEARCH)
+      expect(headers['set-cookie']).toEqual(
+        expect.arrayContaining([
+          expect.stringContaining('userSession')
+        ])
+      )
+    })
+  })
+
+  describe('When accessed following unsuccessful signin', () => {
+    let server
+
+    beforeEach(async () => {
+      jest.clearAllMocks()
+      server = await startServer()
+    })
+
+    afterEach(async () => {
+      await server.stop({ timeout: 0 })
+    })
+
+    test.each([
+      { mockAuth: { isAuthenticated: false } },
+      { mockAuth: null }
+    ])('Should not set authed session and redirect to page', async ({ mockAuth }) => {
+      const mockRedirect = jest.fn()
+
+      const mockRequest = {
+        server: server,
+        yar: {
+          flash: (referrer) => {
+            return paths.SEARCH
+          }
+        },
+        auth: mockAuth
+      }
+      const mockResponse = {
+        redirect: mockRedirect
+      }
+
+      await signinOidc.handler(mockRequest, mockResponse)
+
+      const cachedSession = await server.app.cache.get('a-test-session-id')
+
+      expect(cachedSession).toBeNull()
+      expect(mockRedirect).toHaveBeenCalledTimes(1)
+    })
+  })
+})
diff --git a/test/unit/templates/template.test.helper.js b/test/unit/templates/template.test.helper.js
index 0209a371..cccaae71 100644
--- a/test/unit/templates/template.test.helper.js
+++ b/test/unit/templates/template.test.helper.js
@@ -2,7 +2,6 @@ import { fileURLToPath } from 'node:url'
 import path from 'path'
 import nunjucks from 'nunjucks'
 import { load } from 'cheerio'
-import { context } from '../../../src/plugins/template-renderer/context.js'
 import * as filters from '../../../src/plugins/template-renderer/filters/filters.js'
 
 const dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -17,7 +16,7 @@ const nunjucksTestEnv = nunjucks.configure(
   }
 )
 
-nunjucksTestEnv.addGlobal('getAssetPath', context({}).getAssetPath)
+nunjucksTestEnv.addGlobal('getAssetPath', (asset) => { return asset })
 
 Object.entries(filters).forEach(([name, filter]) => {
   nunjucksTestEnv.addFilter(name, filter)
diff --git a/test/unit/utils/session-helper.js b/test/unit/utils/session-helper.js
new file mode 100644
index 00000000..b7eba04c
--- /dev/null
+++ b/test/unit/utils/session-helper.js
@@ -0,0 +1,85 @@
+import { addSeconds } from 'date-fns'
+import jwt from '@hapi/jwt'
+import { config } from '../../../src/config/config.js'
+
+const authConfig = config.get('auth')
+const sessionConfig = config.get('session')
+
+export const setupAuthedUserSession = async (server, expiresAt) => {
+  const authedUser = createAuthedUser(expiresAt)
+
+  await server.app.cache.set(authedUser.sessionId, authedUser)
+
+  return authedUser
+}
+
+export const createAuthedUser = (expiresAt) => {
+  const expiresInSeconds = sessionConfig.cache.ttl / 1000
+  const authedUserProfile = createUserProfile()
+  authedUserProfile.expiresAt = expiresAt ?? authedUserProfile.expiresAt
+
+  const dummyToken = createDummyToken(authedUserProfile, expiresInSeconds)
+
+  return {
+    ...authedUserProfile,
+    idToken: dummyToken,
+    token: dummyToken,
+    refreshToken: dummyToken
+  }
+}
+
+export const createRefreshedToken = (sessionId) => {
+  const authedUserProfile = createUserProfile(sessionId)
+
+  return createDummyToken(authedUserProfile, sessionConfig.cache.ttl / 1000)
+}
+
+function createDummyToken (authedUserProfile, ttl) {
+  return jwt.token.generate(
+    {
+      ...authedUserProfile,
+      aud: 'test',
+      sub: 'test',
+      iss: 'test',
+      user: 'Test User'
+    },
+    {
+      key: 'test',
+      algorithm: 'HS256'
+    },
+    {
+      ttlSec: ttl
+    }
+  )
+}
+
+function createUserProfile (sessionId) {
+  const expiresInSeconds = sessionConfig.cache.ttl / 1000
+  const expiresInMilliSeconds = sessionConfig.cache.ttl
+  const expiresAt = addSeconds(new Date(), expiresInSeconds)
+
+  return {
+    id: crypto.randomUUID(),
+    correlationId: crypto.randomUUID(),
+    sessionId: sessionId ?? crypto.randomUUID(),
+    contactId: crypto.randomUUID(),
+    serviceId: authConfig.defraId.serviceId,
+    firstName: 'Test',
+    lastName: 'User',
+    displayName: 'Test User',
+    email: 'test.user@btms-portal-frontend-unit-test.com',
+    uniqueReference: crypto.randomUUID(),
+    loa: 1,
+    aal: 1,
+    enrolmentCount: 1,
+    enrolmentRequestCount: 1,
+    currentRelationshipId: 1,
+    relationships: '1:1:Defra:0:undefined:0',
+    roles: '',
+    isAuthenticated: true,
+    expiresIn: expiresInMilliSeconds,
+    expiresAt: expiresAt.toISOString(),
+    tokenUrl: 'https://foo',
+    logoutUrl: 'https://bar'
+  }
+}


Reviews:
user: login: tal-nagra
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-28T12:36:34Z
user: login: tal-nagra
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-28T14:46:34Z
user: login: tal-nagra
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-28T15:14:35Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:39:06Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:39:13Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:39:34Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:39:44Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:39:54Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:40:02Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:41:40Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:41:47Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:41:55Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:42:00Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:42:06Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:42:11Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:43:07Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:43:22Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:43:31Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:43:35Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:43:39Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:43:44Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:43:49Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:43:53Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:44:03Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:44:08Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:44:13Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:44:29Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:44:38Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:45:03Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:55:34Z
user: login: tal-nagra
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T09:21:36Z
user: login: TrinhChuongEE
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T13:12:44Z
user: login: tal-nagra
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T13:23:56Z

Review comments:
diff_hunk: @@ -54,10 +57,12 @@
     "ioredis": "^5.4.1",
     "joi": "^17.4.2",
     "kitsu-core": "^10.2.0",
+    "lodash": "^4.17.21",
path: package.json
user: login: tal-nagra
body: let's remove `lodash` as it is a fairly heavy dependency and we are only using one function from it,
I previously removed lodash from the code too
created_at: 2025-02-28T10:37:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -54,10 +57,12 @@
     "ioredis": "^5.4.1",
     "joi": "^17.4.2",
     "kitsu-core": "^10.2.0",
+    "lodash": "^4.17.21",
     "nunjucks": "^3.2.4",
     "pino": "^9.5.0",
     "pino-pretty": "^13.0.0",
-    "undici": "^6.20.1"
+    "undici": "^6.20.1",
+    "uuid": "^11.1.0"
path: package.json
user: login: tal-nagra
body: Let's remove the uuid package and we can just use the built-in randomUUID function
https://nodejs.org/docs/latest-v22.x/api/crypto.html#cryptorandomuuidoptions
created_at: 2025-02-28T10:37:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,6 +29,12 @@ const config = convict({
     default: 'development',
     env: 'NODE_ENV'
   },
+  appBaseUrl: {
path: src/config/config.js
user: login: tal-nagra
body: no need to change now but going forward we want to retrieve this from the current active request
Let's take a look in a future PR to see if this is possible
In prod, it looks like we may have more than one hostname for the service
created_at: 2025-02-28T10:50:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+import Wreck from '@hapi/wreck'
+import jwt from '@hapi/jwt'
+import bell from '@hapi/bell'
+import { deserialise } from 'kitsu-core'
+
+import { config } from '../../config/config.js'
+
+const authConfig = config.get('auth')
+const sessionConfig = config.get('session')
+
+const getDefraIdAuthConfig = async (oidcConfigurationUrl) => {
+  const { payload } = await Wreck.get(oidcConfigurationUrl, {
+    json: 'strict'
+  })
+
+  return deserialise(payload)
path: src/plugins/auth/defra-id.js
user: login: tal-nagra
body: the deserialise function invocation can be removed here because it is only intended to be used with JSON API responses. The `payload` can simply be returned 
created_at: 2025-02-28T10:51:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+import Wreck from '@hapi/wreck'
+import jwt from '@hapi/jwt'
+import bell from '@hapi/bell'
+import { deserialise } from 'kitsu-core'
+
+import { config } from '../../config/config.js'
+
+const authConfig = config.get('auth')
+const sessionConfig = config.get('session')
+
+const getDefraIdAuthConfig = async (oidcConfigurationUrl) => {
+  const { payload } = await Wreck.get(oidcConfigurationUrl, {
+    json: 'strict'
+  })
+
+  return deserialise(payload)
+}
+
+const defraId = {
+  plugin: {
+    name: 'defra-id',
+    register: async (server) => {
+      const oidcConfigurationUrl = authConfig.defraId.oidcConfigurationUrl
+      const serviceId = authConfig.defraId.serviceId
+      const clientId = authConfig.defraId.clientId
+      const clientSecret = authConfig.defraId.clientSecret
+      const authCallbackUrl = config.get('appBaseUrl') + '/signin-oidc'
path: src/plugins/auth/defra-id.js
user: login: tal-nagra
body: Let's refer to routeConstants.paths.AUTH_DEFRA_ID_CALLBACK here rather than hard-coding
created_at: 2025-02-28T10:54:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+import Wreck from '@hapi/wreck'
+import jwt from '@hapi/jwt'
+import bell from '@hapi/bell'
+import { deserialise } from 'kitsu-core'
+
+import { config } from '../../config/config.js'
+
+const authConfig = config.get('auth')
path: src/plugins/auth/defra-id.js
user: login: tal-nagra
body: can we change this to get('auth.defraId') ?
Then further down we don't have to do authConfig.defraId.clientId and we can just do authConfig.clientId
created_at: 2025-02-28T10:56:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,4 +1,8 @@
 const paths = {
+  LANDING: '/',
+  AUTH: '/signin-oidc',
path: src/routes/route-constants.js
user: login: tal-nagra
body: rename to: `AUTH_DEFRA_ID_CALLBACK`
we are going to need to define a call back path for the azure AD stuff too
created_at: 2025-02-28T10:58:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+import Wreck from '@hapi/wreck'
+import jwt from '@hapi/jwt'
+import bell from '@hapi/bell'
+import { deserialise } from 'kitsu-core'
+
+import { config } from '../../config/config.js'
+
+const authConfig = config.get('auth')
+const sessionConfig = config.get('session')
+
+const getDefraIdAuthConfig = async (oidcConfigurationUrl) => {
+  const { payload } = await Wreck.get(oidcConfigurationUrl, {
+    json: 'strict'
+  })
+
+  return deserialise(payload)
+}
+
+const defraId = {
+  plugin: {
+    name: 'defra-id',
+    register: async (server) => {
+      const oidcConfigurationUrl = authConfig.defraId.oidcConfigurationUrl
+      const serviceId = authConfig.defraId.serviceId
+      const clientId = authConfig.defraId.clientId
+      const clientSecret = authConfig.defraId.clientSecret
+      const authCallbackUrl = config.get('appBaseUrl') + '/signin-oidc'
+
+      await server.register(bell)
+
+      const oidcConf = await getDefraIdAuthConfig(oidcConfigurationUrl)
+
+      server.auth.strategy('defra-id', 'bell', {
+        location: (request) => {
+          request.yar.flash('referrer', '/search')
+
+          return authCallbackUrl
+        },
+        provider: {
+          name: 'defra-id',
+          protocol: 'oauth2',
+          useParamsAuth: true,
+          auth: oidcConf.authorization_endpoint,
+          token: oidcConf.token_endpoint,
+          scope: ['openid', 'offline_access'],
path: src/plugins/auth/defra-id.js
user: login: tal-nagra
body: perhaps remove `offline_access` from the scopes array for now as I am unsure if this is a requirement
created_at: 2025-02-28T11:00:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,5 @@
+function dropUserSession () {
path: src/plugins/auth/drop-user-session.js
user: login: tal-nagra
body: remove this file and add the `dropUserSession` function to `auth/user-session.js`
created_at: 2025-02-28T12:04:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,7 @@
+async function getUserSession () {
path: src/plugins/auth/get-user-session.js
user: login: tal-nagra
body: remove this file and add the `getyUserSession` function to `auth/user-session.js`
created_at: 2025-02-28T12:05:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,33 @@
+import Wreck from '@hapi/wreck'
+import Querystring from 'querystring'
+
+import { config } from '../../config/config.js'
+
+const authConfig = config.get('auth')
+
+async function refreshAccessToken (request) {
+  const authedUser = await request.getUserSession()
+  const refreshToken = authedUser?.refreshToken ?? null
+  const clientId = authConfig.defraId.clientId
+  const clientSecret = authConfig.defraId.clientSecret
+
+  const params = {
+    client_id: clientId,
+    client_secret: clientSecret,
+    grant_type: 'refresh_token',
+    refresh_token: refreshToken,
+    scope: `${clientId} openid offline_access`
+  }
+
+  request.logger.info('Access token expired, refreshing...')
path: src/plugins/auth/refesh-token.js
user: login: tal-nagra
body: let's change this to use createLogger() to write the log entry
created_at: 2025-02-28T12:08:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+import Wreck from '@hapi/wreck'
+import jwt from '@hapi/jwt'
+import bell from '@hapi/bell'
+import { deserialise } from 'kitsu-core'
+
+import { config } from '../../config/config.js'
+
+const authConfig = config.get('auth')
+const sessionConfig = config.get('session')
+
+const getDefraIdAuthConfig = async (oidcConfigurationUrl) => {
+  const { payload } = await Wreck.get(oidcConfigurationUrl, {
+    json: 'strict'
+  })
+
+  return deserialise(payload)
+}
+
+const defraId = {
+  plugin: {
+    name: 'defra-id',
+    register: async (server) => {
+      const oidcConfigurationUrl = authConfig.defraId.oidcConfigurationUrl
+      const serviceId = authConfig.defraId.serviceId
+      const clientId = authConfig.defraId.clientId
+      const clientSecret = authConfig.defraId.clientSecret
+      const authCallbackUrl = config.get('appBaseUrl') + '/signin-oidc'
+
+      await server.register(bell)
+
+      const oidcConf = await getDefraIdAuthConfig(oidcConfigurationUrl)
+
+      server.auth.strategy('defra-id', 'bell', {
+        location: (request) => {
+          request.yar.flash('referrer', '/search')
path: src/plugins/auth/defra-id.js
user: login: tal-nagra
body: use the route constants here instead of hard-coding
created_at: 2025-02-28T12:09:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,82 @@
+import authCookie from '@hapi/cookie'
+import { isPast, parseISO, subMinutes } from 'date-fns'
+
+import { config } from '../../config/config.js'
+import { refreshAccessToken } from './refesh-token.js'
+import { removeUserSession, updateUserSession } from './user-session.js'
+
+const sessionConfig = config.get('session')
+
+const validateSession = async (server, request, session) => {
+  const authedUser = await request.getUserSession()
+
+  if (!authedUser) {
+    return { isValid: false }
+  }
+
+  const tokenHasExpired = isPast(
+    subMinutes(parseISO(authedUser.expiresAt), 1)
+  )
+
+  if (tokenHasExpired) {
+    const response = await refreshAccessToken(request)
+
+    if (!response.ok) {
+      removeUserSession(request)
+
+      return { isValid: false }
+    }
+
+    const refreshAccessTokenJson = await response.json()
+    const updatedSession = await updateUserSession(
+      request,
+      refreshAccessTokenJson
+    )
+
+    return {
+      isValid: true,
+      credentials: updatedSession
+    }
+  }
+
+  const userSession = await server.app.cache.get(session.sessionId)
+
+  if (userSession) {
+    return {
+      isValid: true,
+      credentials: userSession
+    }
+  }
+
+  return { isValid: false }
+}
+
+const sessionCookie = {
+  plugin: {
+    name: 'user-session',
+    register: async (server) => {
+      await server.register(authCookie)
+
+      server.auth.strategy('session', 'cookie', {
+        cookie: {
+          name: 'userSession',
+          path: '/',
+          password: sessionConfig.cookie.password,
+          isSecure: sessionConfig.cookie.secure,
+          ttl: sessionConfig.cookie.ttl
+        },
+        keepAlive: true,
+        validate: async (request, session) => {
+          return validateSession(server, request, session)
+        }
+      })
+
+      server.auth.default('session')
+    }
+  }
+}
+
+export {
+  validateSession,
path: src/plugins/auth/session-cookie.js
user: login: tal-nagra
body: validateSession can also be moved to `auth/user-session.js` and renamed to `validateUserSession`
created_at: 2025-02-28T12:13:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,7 @@
+async function getUserSession () {
+  return this.state?.userSession?.sessionId
+    ? this.server.app.cache.get(this.state.userSession.sessionId)
path: src/plugins/auth/get-user-session.js
user: login: tal-nagra
body: Check if `this` can be removed here ( perhaps change in a future PR) 
created_at: 2025-02-28T12:19:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,82 @@
+import authCookie from '@hapi/cookie'
+import { isPast, parseISO, subMinutes } from 'date-fns'
+
+import { config } from '../../config/config.js'
+import { refreshAccessToken } from './refesh-token.js'
+import { removeUserSession, updateUserSession } from './user-session.js'
+
+const sessionConfig = config.get('session')
+
+const validateSession = async (server, request, session) => {
+  const authedUser = await request.getUserSession()
+
+  if (!authedUser) {
+    return { isValid: false }
+  }
+
+  const tokenHasExpired = isPast(
+    subMinutes(parseISO(authedUser.expiresAt), 1)
+  )
+
+  if (tokenHasExpired) {
+    const response = await refreshAccessToken(request)
+
+    if (!response.ok) {
+      removeUserSession(request)
+
+      return { isValid: false }
+    }
+
+    const refreshAccessTokenJson = await response.json()
+    const updatedSession = await updateUserSession(
+      request,
+      refreshAccessTokenJson
+    )
+
+    return {
+      isValid: true,
+      credentials: updatedSession
+    }
+  }
+
+  const userSession = await server.app.cache.get(session.sessionId)
+
+  if (userSession) {
+    return {
+      isValid: true,
+      credentials: userSession
+    }
+  }
+
+  return { isValid: false }
+}
+
+const sessionCookie = {
+  plugin: {
+    name: 'user-session',
+    register: async (server) => {
+      await server.register(authCookie)
+
+      server.auth.strategy('session', 'cookie', {
+        cookie: {
+          name: 'userSession',
path: src/plugins/auth/session-cookie.js
user: login: tal-nagra
body: this name may be subject to change ( we can tackle this in a future PR )
created_at: 2025-02-28T12:20:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,32 @@
+import _ from 'lodash'
+
+import { paths } from './route-constants.js'
+import { provideAuthedUser } from './prerequisites/provide-authed-user.js'
+import { config } from '../config/config.js'
+
+export const logout = {
+  method: 'GET',
+  path: paths.LOGOUT,
+  options: {
+    pre: [provideAuthedUser]
+  },
+  handler: async (_request, h) => {
+    const authedUser = _request.pre.authedUser
+
+    if (_.isEmpty(authedUser)) {
path: src/routes/logout.js
user: login: tal-nagra
body: remove the use of lodash here
created_at: 2025-02-28T12:23:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,6 @@
+const provideAuthedUser = {
path: src/routes/prerequisites/provide-authed-user.js
user: login: tal-nagra
body: remove this file and move this function into routes/logout.js
created_at: 2025-02-28T12:25:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,4 +1,8 @@
 const paths = {
+  LANDING: '/',
+  AUTH: '/signin-oidc',
+  LOGIN: '/login',
path: src/routes/route-constants.js
user: login: tal-nagra
body: change /login to /sign-in
created_at: 2025-02-28T12:26:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,4 +1,8 @@
 const paths = {
+  LANDING: '/',
+  AUTH: '/signin-oidc',
+  LOGIN: '/login',
+  LOGOUT: '/logout',
path: src/routes/route-constants.js
user: login: tal-nagra
body: change /logout to /sign-out
created_at: 2025-02-28T12:27:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+import { v4 as uuidv4 } from 'uuid'
+import { addSeconds } from 'date-fns'
+import { paths } from './route-constants.js'
+
+export const signinOidc = {
+  method: ['GET', 'POST'],
+  path: paths.AUTH,
+  options: {
+    auth: 'defra-id'
+  },
+  handler: async (_request, h) => {
+    if (_request.auth.isAuthenticated) {
+      const { profile } = _request.auth.credentials
+      const expiresInSeconds = _request.auth.credentials.expiresIn
+      const expiresInMilliSeconds = expiresInSeconds * 1000
+      const expiresAt = addSeconds(new Date(), expiresInSeconds)
+
+      const sessionId = uuidv4()
path: src/routes/signin-oidc.js
user: login: tal-nagra
body: use the out-of-the-box crypto randomUUID function here
created_at: 2025-02-28T12:28:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+import { v4 as uuidv4 } from 'uuid'
+import { addSeconds } from 'date-fns'
+import { paths } from './route-constants.js'
+
+export const signinOidc = {
+  method: ['GET', 'POST'],
+  path: paths.AUTH,
+  options: {
+    auth: 'defra-id'
+  },
+  handler: async (_request, h) => {
+    if (_request.auth.isAuthenticated) {
+      const { profile } = _request.auth.credentials
+      const expiresInSeconds = _request.auth.credentials.expiresIn
+      const expiresInMilliSeconds = expiresInSeconds * 1000
+      const expiresAt = addSeconds(new Date(), expiresInSeconds)
+
+      const sessionId = uuidv4()
+      await _request.server.app.cache.set(sessionId, {
path: src/routes/signin-oidc.js
user: login: tal-nagra
body: perform the server.app.cache.set in an appropriate function in user-session.js
created_at: 2025-02-28T12:30:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,52 @@
+import jwt from '@hapi/jwt'
path: src/plugins/auth/user-session.js
user: login: tal-nagra
body: move this file to src/auth
created_at: 2025-02-28T12:32:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,33 @@
+import Wreck from '@hapi/wreck'
path: src/plugins/auth/refesh-token.js
user: login: tal-nagra
body: move this file to `src/auth`
created_at: 2025-02-28T12:34:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,49 +6,27 @@
 {% block content %}
   {{ appHeading({
     text: heading,
-    caption: "Core delivery platform Node.js Frontend Template."
+    caption: "Lorem ipsum..."
   }) }}
 
   <div class="govuk-grid-row">
     <div class="govuk-grid-column-two-thirds">
       <div class="govuk-body" data-testid="app-page-body">
-        <p>
-          Welcome to the Frontend Template, a starter kit for Core Delivery Platform Node.js Frontends 🚀
-        </p>
-        <p>
-          Based on <a href="https://defra.github.io/software-development-standards">DEFRA</a> and <a
-            href="https://design-system.service.gov.uk/">GOV.UK</a> best practices this starter kit provides you with
-          a deployable, production ready template 🙌🏻 to start your new service with.
-        </p>
-        <p>This template comes with:</p>
-        <ul>
-          <li>
-            <a href="https://frontend.design-system.service.gov.uk/">GOV.UK Frontend</a>
-          </li>
-          <li>
-            <a href="https://hapi.dev">Hapi.js</a>
-          </li>
-          <li>
-            <a href="https://mozilla.github.io/nunjucks/">Nunjucks</a>
-          </li>
-          <li>
-            <a href="https://github.com/mozilla/node-convict">Node Convict</a>
-          </li>
-          <li>
-            <a href="https://jestjs.io/">Jest testing</a>
-          </li>
-          <li>GitHub Actions Pull Request and Publish jobs</li>
-          <li>
-            <a href="https://github.com/pinojs/pino">PinoJs logging</a>
-          </li>
-          <li>
-            <a href="https://standardjs.com">Standard JS</a>
-          </li>
-          <li>
-            <a href="https://stylelint.io">StyleLint</a>
-          </li>
-          <li>and lots more... 😎</li>
-        </ul>
+        {% if authedUser.displayName %}
+          <p>
+            User <strong>{{ authedUser.displayName }}</strong> is authenticated
+          </p>
+          <p>
+            <a href="https://your-account.cpdev.cui.defra.gov.uk/management">Manage account</a> - <a href="/logout">Sign out</a>
+          </p>
+        {% else %}
+          <p>
+            User is not authenticated!
+          </p>
+          <p>
+            <a id="login-link" href="/login">LOGIN</a>
path: src/templates/home.njk
user: login: tal-nagra
body: pass a model to this view to provide the login link URL from route constants
created_at: 2025-02-28T12:36:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,49 +6,27 @@
 {% block content %}
   {{ appHeading({
     text: heading,
-    caption: "Core delivery platform Node.js Frontend Template."
+    caption: "Lorem ipsum..."
path: src/templates/home.njk
user: login: tal-nagra
body: Remove the caption for now
created_at: 2025-02-28T12:36:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+import Wreck from '@hapi/wreck'
+import jwt from '@hapi/jwt'
+import bell from '@hapi/bell'
+import { deserialise } from 'kitsu-core'
+
+import { config } from '../../config/config.js'
+
+const authConfig = config.get('auth')
+const sessionConfig = config.get('session')
+
+const getDefraIdAuthConfig = async (oidcConfigurationUrl) => {
+  const { payload } = await Wreck.get(oidcConfigurationUrl, {
+    json: 'strict'
+  })
+
+  return deserialise(payload)
+}
+
+const defraId = {
+  plugin: {
+    name: 'defra-id',
+    register: async (server) => {
+      const oidcConfigurationUrl = authConfig.defraId.oidcConfigurationUrl
+      const serviceId = authConfig.defraId.serviceId
+      const clientId = authConfig.defraId.clientId
+      const clientSecret = authConfig.defraId.clientSecret
+      const authCallbackUrl = config.get('appBaseUrl') + '/signin-oidc'
+
+      await server.register(bell)
+
+      const oidcConf = await getDefraIdAuthConfig(oidcConfigurationUrl)
+
+      server.auth.strategy('defra-id', 'bell', {
+        location: (request) => {
+          request.yar.flash('referrer', '/search')
+
+          return authCallbackUrl
+        },
+        provider: {
+          name: 'defra-id',
+          protocol: 'oauth2',
+          useParamsAuth: true,
+          auth: oidcConf.authorization_endpoint,
path: src/plugins/auth/defra-id.js
user: login: tal-nagra
body: add the following field to the provider object:
`pkce: 'S256'` 
& test if it works with the defra stub
We might only be able to switch on the PKCE when interacting with the real defra ID
created_at: 2025-02-28T14:46:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,32 @@
+import _ from 'lodash'
+
+import { paths } from './route-constants.js'
+import { provideAuthedUser } from './prerequisites/provide-authed-user.js'
+import { config } from '../config/config.js'
+
+export const logout = {
+  method: 'GET',
+  path: paths.LOGOUT,
+  options: {
+    pre: [provideAuthedUser]
+  },
+  handler: async (_request, h) => {
+    const authedUser = _request.pre.authedUser
+
+    if (_.isEmpty(authedUser)) {
+      return h.redirect('/')
+    }
+
+    const referrer = config.get('appBaseUrl')
path: src/routes/logout.js
user: login: tal-nagra
body: add `appBaseUrl` and any other config keys accessed in more than one place to `configKeys` in src/config/config.js
created_at: 2025-02-28T15:14:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -54,10 +57,12 @@
     "ioredis": "^5.4.1",
     "joi": "^17.4.2",
     "kitsu-core": "^10.2.0",
+    "lodash": "^4.17.21",
path: package.json
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:39:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -54,10 +57,12 @@
     "ioredis": "^5.4.1",
     "joi": "^17.4.2",
     "kitsu-core": "^10.2.0",
+    "lodash": "^4.17.21",
     "nunjucks": "^3.2.4",
     "pino": "^9.5.0",
     "pino-pretty": "^13.0.0",
-    "undici": "^6.20.1"
+    "undici": "^6.20.1",
+    "uuid": "^11.1.0"
path: package.json
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:39:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+import Wreck from '@hapi/wreck'
+import jwt from '@hapi/jwt'
+import bell from '@hapi/bell'
+import { deserialise } from 'kitsu-core'
+
+import { config } from '../../config/config.js'
+
+const authConfig = config.get('auth')
+const sessionConfig = config.get('session')
+
+const getDefraIdAuthConfig = async (oidcConfigurationUrl) => {
+  const { payload } = await Wreck.get(oidcConfigurationUrl, {
+    json: 'strict'
+  })
+
+  return deserialise(payload)
path: src/plugins/auth/defra-id.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:39:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+import Wreck from '@hapi/wreck'
+import jwt from '@hapi/jwt'
+import bell from '@hapi/bell'
+import { deserialise } from 'kitsu-core'
+
+import { config } from '../../config/config.js'
+
+const authConfig = config.get('auth')
+const sessionConfig = config.get('session')
+
+const getDefraIdAuthConfig = async (oidcConfigurationUrl) => {
+  const { payload } = await Wreck.get(oidcConfigurationUrl, {
+    json: 'strict'
+  })
+
+  return deserialise(payload)
+}
+
+const defraId = {
+  plugin: {
+    name: 'defra-id',
+    register: async (server) => {
+      const oidcConfigurationUrl = authConfig.defraId.oidcConfigurationUrl
+      const serviceId = authConfig.defraId.serviceId
+      const clientId = authConfig.defraId.clientId
+      const clientSecret = authConfig.defraId.clientSecret
+      const authCallbackUrl = config.get('appBaseUrl') + '/signin-oidc'
path: src/plugins/auth/defra-id.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:39:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+import Wreck from '@hapi/wreck'
+import jwt from '@hapi/jwt'
+import bell from '@hapi/bell'
+import { deserialise } from 'kitsu-core'
+
+import { config } from '../../config/config.js'
+
+const authConfig = config.get('auth')
path: src/plugins/auth/defra-id.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:39:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,4 +1,8 @@
 const paths = {
+  LANDING: '/',
+  AUTH: '/signin-oidc',
path: src/routes/route-constants.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:40:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+import Wreck from '@hapi/wreck'
+import jwt from '@hapi/jwt'
+import bell from '@hapi/bell'
+import { deserialise } from 'kitsu-core'
+
+import { config } from '../../config/config.js'
+
+const authConfig = config.get('auth')
+const sessionConfig = config.get('session')
+
+const getDefraIdAuthConfig = async (oidcConfigurationUrl) => {
+  const { payload } = await Wreck.get(oidcConfigurationUrl, {
+    json: 'strict'
+  })
+
+  return deserialise(payload)
+}
+
+const defraId = {
+  plugin: {
+    name: 'defra-id',
+    register: async (server) => {
+      const oidcConfigurationUrl = authConfig.defraId.oidcConfigurationUrl
+      const serviceId = authConfig.defraId.serviceId
+      const clientId = authConfig.defraId.clientId
+      const clientSecret = authConfig.defraId.clientSecret
+      const authCallbackUrl = config.get('appBaseUrl') + '/signin-oidc'
+
+      await server.register(bell)
+
+      const oidcConf = await getDefraIdAuthConfig(oidcConfigurationUrl)
+
+      server.auth.strategy('defra-id', 'bell', {
+        location: (request) => {
+          request.yar.flash('referrer', '/search')
+
+          return authCallbackUrl
+        },
+        provider: {
+          name: 'defra-id',
+          protocol: 'oauth2',
+          useParamsAuth: true,
+          auth: oidcConf.authorization_endpoint,
+          token: oidcConf.token_endpoint,
+          scope: ['openid', 'offline_access'],
path: src/plugins/auth/defra-id.js
user: login: TrinhChuongEE
body: Done. Looking at the Defra ID configuration for the Test environment, this scope may not even be supported. It may need to be configurable per environment depending on whether its needed in future.
created_at: 2025-03-03T08:41:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,5 @@
+function dropUserSession () {
path: src/plugins/auth/drop-user-session.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:41:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,7 @@
+async function getUserSession () {
path: src/plugins/auth/get-user-session.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:41:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,33 @@
+import Wreck from '@hapi/wreck'
+import Querystring from 'querystring'
+
+import { config } from '../../config/config.js'
+
+const authConfig = config.get('auth')
+
+async function refreshAccessToken (request) {
+  const authedUser = await request.getUserSession()
+  const refreshToken = authedUser?.refreshToken ?? null
+  const clientId = authConfig.defraId.clientId
+  const clientSecret = authConfig.defraId.clientSecret
+
+  const params = {
+    client_id: clientId,
+    client_secret: clientSecret,
+    grant_type: 'refresh_token',
+    refresh_token: refreshToken,
+    scope: `${clientId} openid offline_access`
+  }
+
+  request.logger.info('Access token expired, refreshing...')
path: src/plugins/auth/refesh-token.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:41:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+import Wreck from '@hapi/wreck'
+import jwt from '@hapi/jwt'
+import bell from '@hapi/bell'
+import { deserialise } from 'kitsu-core'
+
+import { config } from '../../config/config.js'
+
+const authConfig = config.get('auth')
+const sessionConfig = config.get('session')
+
+const getDefraIdAuthConfig = async (oidcConfigurationUrl) => {
+  const { payload } = await Wreck.get(oidcConfigurationUrl, {
+    json: 'strict'
+  })
+
+  return deserialise(payload)
+}
+
+const defraId = {
+  plugin: {
+    name: 'defra-id',
+    register: async (server) => {
+      const oidcConfigurationUrl = authConfig.defraId.oidcConfigurationUrl
+      const serviceId = authConfig.defraId.serviceId
+      const clientId = authConfig.defraId.clientId
+      const clientSecret = authConfig.defraId.clientSecret
+      const authCallbackUrl = config.get('appBaseUrl') + '/signin-oidc'
+
+      await server.register(bell)
+
+      const oidcConf = await getDefraIdAuthConfig(oidcConfigurationUrl)
+
+      server.auth.strategy('defra-id', 'bell', {
+        location: (request) => {
+          request.yar.flash('referrer', '/search')
path: src/plugins/auth/defra-id.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:42:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,82 @@
+import authCookie from '@hapi/cookie'
+import { isPast, parseISO, subMinutes } from 'date-fns'
+
+import { config } from '../../config/config.js'
+import { refreshAccessToken } from './refesh-token.js'
+import { removeUserSession, updateUserSession } from './user-session.js'
+
+const sessionConfig = config.get('session')
+
+const validateSession = async (server, request, session) => {
+  const authedUser = await request.getUserSession()
+
+  if (!authedUser) {
+    return { isValid: false }
+  }
+
+  const tokenHasExpired = isPast(
+    subMinutes(parseISO(authedUser.expiresAt), 1)
+  )
+
+  if (tokenHasExpired) {
+    const response = await refreshAccessToken(request)
+
+    if (!response.ok) {
+      removeUserSession(request)
+
+      return { isValid: false }
+    }
+
+    const refreshAccessTokenJson = await response.json()
+    const updatedSession = await updateUserSession(
+      request,
+      refreshAccessTokenJson
+    )
+
+    return {
+      isValid: true,
+      credentials: updatedSession
+    }
+  }
+
+  const userSession = await server.app.cache.get(session.sessionId)
+
+  if (userSession) {
+    return {
+      isValid: true,
+      credentials: userSession
+    }
+  }
+
+  return { isValid: false }
+}
+
+const sessionCookie = {
+  plugin: {
+    name: 'user-session',
+    register: async (server) => {
+      await server.register(authCookie)
+
+      server.auth.strategy('session', 'cookie', {
+        cookie: {
+          name: 'userSession',
+          path: '/',
+          password: sessionConfig.cookie.password,
+          isSecure: sessionConfig.cookie.secure,
+          ttl: sessionConfig.cookie.ttl
+        },
+        keepAlive: true,
+        validate: async (request, session) => {
+          return validateSession(server, request, session)
+        }
+      })
+
+      server.auth.default('session')
+    }
+  }
+}
+
+export {
+  validateSession,
path: src/plugins/auth/session-cookie.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:42:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,7 @@
+async function getUserSession () {
+  return this.state?.userSession?.sessionId
+    ? this.server.app.cache.get(this.state.userSession.sessionId)
path: src/plugins/auth/get-user-session.js
user: login: TrinhChuongEE
body: This function has been moved to user-session.js as a regular function rather than a decorated function, so "this" is no longer required and removed.
created_at: 2025-03-03T08:43:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,32 @@
+import _ from 'lodash'
+
+import { paths } from './route-constants.js'
+import { provideAuthedUser } from './prerequisites/provide-authed-user.js'
+import { config } from '../config/config.js'
+
+export const logout = {
+  method: 'GET',
+  path: paths.LOGOUT,
+  options: {
+    pre: [provideAuthedUser]
+  },
+  handler: async (_request, h) => {
+    const authedUser = _request.pre.authedUser
+
+    if (_.isEmpty(authedUser)) {
path: src/routes/logout.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:43:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,6 @@
+const provideAuthedUser = {
path: src/routes/prerequisites/provide-authed-user.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:43:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,4 +1,8 @@
 const paths = {
+  LANDING: '/',
+  AUTH: '/signin-oidc',
+  LOGIN: '/login',
path: src/routes/route-constants.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:43:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,4 +1,8 @@
 const paths = {
+  LANDING: '/',
+  AUTH: '/signin-oidc',
+  LOGIN: '/login',
+  LOGOUT: '/logout',
path: src/routes/route-constants.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:43:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+import { v4 as uuidv4 } from 'uuid'
+import { addSeconds } from 'date-fns'
+import { paths } from './route-constants.js'
+
+export const signinOidc = {
+  method: ['GET', 'POST'],
+  path: paths.AUTH,
+  options: {
+    auth: 'defra-id'
+  },
+  handler: async (_request, h) => {
+    if (_request.auth.isAuthenticated) {
+      const { profile } = _request.auth.credentials
+      const expiresInSeconds = _request.auth.credentials.expiresIn
+      const expiresInMilliSeconds = expiresInSeconds * 1000
+      const expiresAt = addSeconds(new Date(), expiresInSeconds)
+
+      const sessionId = uuidv4()
path: src/routes/signin-oidc.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:43:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+import { v4 as uuidv4 } from 'uuid'
+import { addSeconds } from 'date-fns'
+import { paths } from './route-constants.js'
+
+export const signinOidc = {
+  method: ['GET', 'POST'],
+  path: paths.AUTH,
+  options: {
+    auth: 'defra-id'
+  },
+  handler: async (_request, h) => {
+    if (_request.auth.isAuthenticated) {
+      const { profile } = _request.auth.credentials
+      const expiresInSeconds = _request.auth.credentials.expiresIn
+      const expiresInMilliSeconds = expiresInSeconds * 1000
+      const expiresAt = addSeconds(new Date(), expiresInSeconds)
+
+      const sessionId = uuidv4()
+      await _request.server.app.cache.set(sessionId, {
path: src/routes/signin-oidc.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:43:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,52 @@
+import jwt from '@hapi/jwt'
path: src/plugins/auth/user-session.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:43:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,33 @@
+import Wreck from '@hapi/wreck'
path: src/plugins/auth/refesh-token.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:44:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,49 +6,27 @@
 {% block content %}
   {{ appHeading({
     text: heading,
-    caption: "Core delivery platform Node.js Frontend Template."
+    caption: "Lorem ipsum..."
   }) }}
 
   <div class="govuk-grid-row">
     <div class="govuk-grid-column-two-thirds">
       <div class="govuk-body" data-testid="app-page-body">
-        <p>
-          Welcome to the Frontend Template, a starter kit for Core Delivery Platform Node.js Frontends 🚀
-        </p>
-        <p>
-          Based on <a href="https://defra.github.io/software-development-standards">DEFRA</a> and <a
-            href="https://design-system.service.gov.uk/">GOV.UK</a> best practices this starter kit provides you with
-          a deployable, production ready template 🙌🏻 to start your new service with.
-        </p>
-        <p>This template comes with:</p>
-        <ul>
-          <li>
-            <a href="https://frontend.design-system.service.gov.uk/">GOV.UK Frontend</a>
-          </li>
-          <li>
-            <a href="https://hapi.dev">Hapi.js</a>
-          </li>
-          <li>
-            <a href="https://mozilla.github.io/nunjucks/">Nunjucks</a>
-          </li>
-          <li>
-            <a href="https://github.com/mozilla/node-convict">Node Convict</a>
-          </li>
-          <li>
-            <a href="https://jestjs.io/">Jest testing</a>
-          </li>
-          <li>GitHub Actions Pull Request and Publish jobs</li>
-          <li>
-            <a href="https://github.com/pinojs/pino">PinoJs logging</a>
-          </li>
-          <li>
-            <a href="https://standardjs.com">Standard JS</a>
-          </li>
-          <li>
-            <a href="https://stylelint.io">StyleLint</a>
-          </li>
-          <li>and lots more... 😎</li>
-        </ul>
+        {% if authedUser.displayName %}
+          <p>
+            User <strong>{{ authedUser.displayName }}</strong> is authenticated
+          </p>
+          <p>
+            <a href="https://your-account.cpdev.cui.defra.gov.uk/management">Manage account</a> - <a href="/logout">Sign out</a>
+          </p>
+        {% else %}
+          <p>
+            User is not authenticated!
+          </p>
+          <p>
+            <a id="login-link" href="/login">LOGIN</a>
path: src/templates/home.njk
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:44:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,49 +6,27 @@
 {% block content %}
   {{ appHeading({
     text: heading,
-    caption: "Core delivery platform Node.js Frontend Template."
+    caption: "Lorem ipsum..."
path: src/templates/home.njk
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:44:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+import Wreck from '@hapi/wreck'
+import jwt from '@hapi/jwt'
+import bell from '@hapi/bell'
+import { deserialise } from 'kitsu-core'
+
+import { config } from '../../config/config.js'
+
+const authConfig = config.get('auth')
+const sessionConfig = config.get('session')
+
+const getDefraIdAuthConfig = async (oidcConfigurationUrl) => {
+  const { payload } = await Wreck.get(oidcConfigurationUrl, {
+    json: 'strict'
+  })
+
+  return deserialise(payload)
+}
+
+const defraId = {
+  plugin: {
+    name: 'defra-id',
+    register: async (server) => {
+      const oidcConfigurationUrl = authConfig.defraId.oidcConfigurationUrl
+      const serviceId = authConfig.defraId.serviceId
+      const clientId = authConfig.defraId.clientId
+      const clientSecret = authConfig.defraId.clientSecret
+      const authCallbackUrl = config.get('appBaseUrl') + '/signin-oidc'
+
+      await server.register(bell)
+
+      const oidcConf = await getDefraIdAuthConfig(oidcConfigurationUrl)
+
+      server.auth.strategy('defra-id', 'bell', {
+        location: (request) => {
+          request.yar.flash('referrer', '/search')
+
+          return authCallbackUrl
+        },
+        provider: {
+          name: 'defra-id',
+          protocol: 'oauth2',
+          useParamsAuth: true,
+          auth: oidcConf.authorization_endpoint,
path: src/plugins/auth/defra-id.js
user: login: TrinhChuongEE
body: Done and works.
created_at: 2025-03-03T08:44:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,32 @@
+import _ from 'lodash'
+
+import { paths } from './route-constants.js'
+import { provideAuthedUser } from './prerequisites/provide-authed-user.js'
+import { config } from '../config/config.js'
+
+export const logout = {
+  method: 'GET',
+  path: paths.LOGOUT,
+  options: {
+    pre: [provideAuthedUser]
+  },
+  handler: async (_request, h) => {
+    const authedUser = _request.pre.authedUser
+
+    if (_.isEmpty(authedUser)) {
+      return h.redirect('/')
+    }
+
+    const referrer = config.get('appBaseUrl')
path: src/routes/logout.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T08:44:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,6 +29,12 @@ const config = convict({
     default: 'development',
     env: 'NODE_ENV'
   },
+  appBaseUrl: {
path: src/config/config.js
user: login: TrinhChuongEE
body: Noted
created_at: 2025-03-03T08:45:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,82 @@
+import authCookie from '@hapi/cookie'
+import { isPast, parseISO, subMinutes } from 'date-fns'
+
+import { config } from '../../config/config.js'
+import { refreshAccessToken } from './refesh-token.js'
+import { removeUserSession, updateUserSession } from './user-session.js'
+
+const sessionConfig = config.get('session')
+
+const validateSession = async (server, request, session) => {
+  const authedUser = await request.getUserSession()
+
+  if (!authedUser) {
+    return { isValid: false }
+  }
+
+  const tokenHasExpired = isPast(
+    subMinutes(parseISO(authedUser.expiresAt), 1)
+  )
+
+  if (tokenHasExpired) {
+    const response = await refreshAccessToken(request)
+
+    if (!response.ok) {
+      removeUserSession(request)
+
+      return { isValid: false }
+    }
+
+    const refreshAccessTokenJson = await response.json()
+    const updatedSession = await updateUserSession(
+      request,
+      refreshAccessTokenJson
+    )
+
+    return {
+      isValid: true,
+      credentials: updatedSession
+    }
+  }
+
+  const userSession = await server.app.cache.get(session.sessionId)
+
+  if (userSession) {
+    return {
+      isValid: true,
+      credentials: userSession
+    }
+  }
+
+  return { isValid: false }
+}
+
+const sessionCookie = {
+  plugin: {
+    name: 'user-session',
+    register: async (server) => {
+      await server.register(authCookie)
+
+      server.auth.strategy('session', 'cookie', {
+        cookie: {
+          name: 'userSession',
path: src/plugins/auth/session-cookie.js
user: login: TrinhChuongEE
body: Noted
created_at: 2025-03-03T08:55:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,25 @@
+import { paths } from './route-constants.js'
+import { setUserSession } from '../auth/user-session.js'
+
+export const signinOidc = {
+  method: ['GET', 'POST'],
+  path: paths.AUTH_DEFRA_ID_CALLBACK,
+  options: {
+    auth: 'defra-id'
+  },
+  handler: async (_request, h) => {
+    if (_request.auth.isAuthenticated) {
path: src/routes/signin-oidc.js
user: login: tal-nagra
body: It's worth making this line a little more defensive and changing to:
`_request.auth?.isAuthenticated`
created_at: 2025-03-03T09:21:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,25 @@
+import { paths } from './route-constants.js'
+import { setUserSession } from '../auth/user-session.js'
+
+export const signinOidc = {
+  method: ['GET', 'POST'],
+  path: paths.AUTH_DEFRA_ID_CALLBACK,
+  options: {
+    auth: 'defra-id'
+  },
+  handler: async (_request, h) => {
+    if (_request.auth.isAuthenticated) {
path: src/routes/signin-oidc.js
user: login: TrinhChuongEE
body: Done
created_at: 2025-03-03T13:12:44Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-03-03T13:14:35Z
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=DEFRA_btms-portal-frontend&pullRequest=11) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=DEFRA_btms-portal-frontend&pullRequest=11&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=DEFRA_btms-portal-frontend&pullRequest=11&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=DEFRA_btms-portal-frontend&pullRequest=11&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [91.1% Coverage on New Code](https://sonarcloud.io/component_measures?id=DEFRA_btms-portal-frontend&pullRequest=11&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=DEFRA_btms-portal-frontend&pullRequest=11&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=DEFRA_btms-portal-frontend&pullRequest=11)


author_association: NONE