diff --git a/.env.example b/.env.example index fe8c231..c29a56c 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,17 @@ -PATH_SSL_PRIVATE_KEY=./infrastructure/host/test.key -PATH_SSL_CERTIFICATE=./infrastructure/host/test.cert - -PORT=3000 -NODE_ENV=development - +AUTH_SIGN_IN_URL=https://cola.service.cabinetoffice.gov.uk/v2//login BASE_URL=http://localhost:3000 CDN_HOST=test -NODE_SSL_ENABLED=false - +COOKIE_ID_NAME=app-name +COOKIE_PARSER_SECRET=test +COOKIE_SESSION_SECRET=test +HUMAN=true LOG_LEVEL=info -HUMAN=true \ No newline at end of file +NODE_ENV=development +NODE_SSL_ENABLED=false +PATH_SSL_PRIVATE_KEY=./infrastructure/host/test.key +PATH_SSL_CERTIFICATE=./infrastructure/host/test.cert +PORT=3000 +SESSION_APP_KEY=test +SESSION_ID_NAME=test +USER_POOL_CLIENT_ID=test +USER_POOL_ID=test \ No newline at end of file diff --git a/README.md b/README.md index 0e6db09..e31bb56 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,6 @@ Directory Path | Description `./.husky` | Add pre check script, includes `pre-commit` and `pre-push` checks `./src` | Contains all TypeScript code `./src/app.ts` | Application entry point -`./src/bin/www.ts` | Server configuration `./src/config/index.ts` | Contains all the application's configurations `./src/controller` | Business logic and handlers `./src/middleware` | Middleware functions (Authentication, validation ...) @@ -42,6 +41,31 @@ Directory Path | Description `./docs` | Contains documentation files Others files | Other files related to modules dependency, CI/CD, *git, dockerization, lint, test/typescript configs … + +### Config variables + +Some config variables relate to authentication, refer to [node-login](https://github.com/cabinetoffice/node-login) for more details about how these variables are used. + +| Key | Description | Example Value | +|-----------------------------|---------------------------------------------------------------------|--------------------------------------------------------------------| +| AUTH_SIGN_IN_URL | Authentication sign in URL | `https://cola.service.cabinetoffice.gov.uk/v2//login`| +| BASE_URL | Base application URL | `http://localhost:3000` (dev mode) | +| CDN_HOST | CDN host | `cdn_domain` | +| COOKIE_ID_NAME | The name of the COLA authentication cookie | `github-requests` | +| COOKIE_PARSER_SECRET | Secret used in validating/calculating the cookie signature | `secret` | +| COOKIE_SESSION_SECRET | Secret key for signing the session cookie | `secret` | +| HUMAN | Formatting messages form (default JSON) | `true` (Enable human formatting for log messages) | +| LOG_LEVEL | Logging levels | `info` | +| NODE_ENV | Node environment | `development` or `production` | +| NODE_SSL_ENABLED | Node SSL | `true` or `false` | +| PATH_SSL_CERTIFICATE | Path to SSL certificate | `./infrastructure/host/test.cert` | +| PATH_SSL_PRIVATE_KEY | Path to SSL private key | `./infrastructure/host/test.key` | +| PORT | Server port number | `3000` | +| SESSION_APP_KEY | Session application key | `git` | +| SESSION_ID_NAME | Session ID name | `connect.sid` | +| USER_POOL_CLIENT_ID | Client ID of an app registered with the user pool in Amazon Cognito | `secret` | +| USER_POOL_ID | ID of the user pool in Amazon Cognito | `secret` | + ## ESlint We use ESlint as both a formatter and code quality assurance. Eslint can also be setup to format on save using a VScode extension: diff --git a/docker-compose.yml b/docker-compose.yml index c4c5401..8d2aa52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,11 +8,19 @@ services: ports: - "${PORT}:3000" environment: - - NODE_ENV=${NODE_ENV} - - PATH_SSL_PRIVATE_KEY=${PATH_SSL_PRIVATE_KEY} - - PATH_SSL_CERTIFICATE=${PATH_SSL_CERTIFICATE} - - CDN_HOST=${CDN_HOST} - - NODE_SSL_ENABLED=${NODE_SSL_ENABLED} + - AUTH_SIGN_IN_URL=${AUTH_SIGN_IN_URL} - BASE_URL=${BASE_URL} + - CDN_HOST=${CDN_HOST} + - COOKIE_ID_NAME=${COOKIE_ID_NAME} + - COOKIE_PARSER_SECRET=${COOKIE_PARSER_SECRET} + - COOKIE_SESSION_SECRET=${COOKIE_SESSION_SECRET} - HUMAN=${HUMAN} - - LOG_LEVEL=${LOG_LEVEL} \ No newline at end of file + - LOG_LEVEL=${LOG_LEVEL} + - NODE_ENV=${NODE_ENV} + - NODE_SSL_ENABLED=${NODE_SSL_ENABLED} + - PATH_SSL_CERTIFICATE=${PATH_SSL_CERTIFICATE} + - PATH_SSL_PRIVATE_KEY=${PATH_SSL_PRIVATE_KEY} + - SESSION_APP_KEY=${SESSION_APP_KEY} + - SESSION_ID_NAME=${SESSION_ID_NAME} + - USER_POOL_CLIENT_ID=${USER_POOL_CLIENT_ID} + - USER_POOL_ID=${USER_POOL_ID} diff --git a/package-lock.json b/package-lock.json index 9391d4d..88b97f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "license": "MIT", "dependencies": { "@co-digital/logging": "^1.0.1", + "@co-digital/login": "^1.0.4", "cookie-parser": "^1.4.6", + "cookie-session": "^2.1.0", "cors": "^2.8.5", "crypto": "^1.0.1", "express": "^4.18.2", @@ -22,6 +24,7 @@ }, "devDependencies": { "@types/cookie-parser": "^1.4.4", + "@types/cookie-session": "^2.0.49", "@types/cors": "^2.8.14", "@types/express": "^4.17.17", "@types/jest": "^29.5.4", @@ -737,9 +740,9 @@ "dev": true }, "node_modules/@co-digital/logging": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@co-digital/logging/-/logging-1.0.1.tgz", - "integrity": "sha512-lHJb7MRAPiZ4GaJzJXqsgEnpxm2rDE6ZMbMqtc+ZymD7BDswobZ6/BOBQCZOajD1zoZOm02fecKvnH7d2gONxw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@co-digital/logging/-/logging-1.0.2.tgz", + "integrity": "sha512-/9e9uG3vPntblBac3x9jpSbbMujsnDBNu3unx1YJ2dBsyFRVEnZOVIfuJcmEwlnxXqpmwA+eB05W/7Kz7jdDGw==", "dependencies": { "luxon": "^3.4.3", "winston": "^3.11.0" @@ -749,6 +752,20 @@ "npm": ">=10.0.0" } }, + "node_modules/@co-digital/login": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@co-digital/login/-/login-1.0.4.tgz", + "integrity": "sha512-w57cntkUq5BxcPfVWY5gOuYa+s+2Qy+crUMhTQaxL9oIiAOKnZdoIMkPXtLAy8JYs8KV90eFrTWQvIAWg1Kpfw==", + "dependencies": { + "@co-digital/logging": "^1.0.2", + "cookie-parser": "^1.4.6", + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=20.8.0", + "npm": ">=10.0.0" + } + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -1464,6 +1481,16 @@ "@types/express": "*" } }, + "node_modules/@types/cookie-session": { + "version": "2.0.49", + "resolved": "https://registry.npmjs.org/@types/cookie-session/-/cookie-session-2.0.49.tgz", + "integrity": "sha512-4E/bBjlqLhU5l4iGPR+NkVJH593hpNsT4dC3DJDr+ODm6Qpe13kZQVkezRIb+TYDXaBMemS3yLQ+0leba3jlkQ==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/keygrip": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", @@ -1558,6 +1585,12 @@ "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", "dev": true }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", + "dev": true + }, "node_modules/@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -2587,6 +2620,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-session": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.0.tgz", + "integrity": "sha512-u73BDmR8QLGcs+Lprs0cfbcAPKl2HnPcjpwRXT41sEV4DRJ2+W0vJEEZkG31ofkx+HZflA70siRIjiTdIodmOQ==", + "dependencies": { + "cookies": "0.9.1", + "debug": "3.2.7", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cookie-session/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -2598,6 +2653,18 @@ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -4524,6 +4591,25 @@ "node": ">=6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/keyv": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", @@ -4940,6 +5026,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6069,6 +6163,14 @@ } } }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 39c3872..6a6e090 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "license": "MIT", "devDependencies": { "@types/cookie-parser": "^1.4.4", + "@types/cookie-session": "^2.0.49", "@types/cors": "^2.8.14", "@types/express": "^4.17.17", "@types/jest": "^29.5.4", @@ -40,7 +41,9 @@ }, "dependencies": { "@co-digital/logging": "^1.0.1", + "@co-digital/login": "^1.0.4", "cookie-parser": "^1.4.6", + "cookie-session": "^2.1.0", "cors": "^2.8.5", "crypto": "^1.0.1", "express": "^4.18.2", diff --git a/src/app.ts b/src/app.ts index 6d58666..423bb39 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,5 +1,6 @@ import express from 'express'; import cookieParser from 'cookie-parser'; +import cookieSession from 'cookie-session'; import path from 'path'; import router from './routes/index'; @@ -11,6 +12,7 @@ import { errorHandler, errorNotFound } from './controller/error.controller'; import { setNonce } from './middleware/nonce.middleware'; import { configureRateLimit } from './config/rate-limit'; +import { COOKIE_PARSER_SECRET, COOKIE_SESSION_SECRET } from './config/index'; const app = express(); @@ -18,7 +20,9 @@ app.disable('x-powered-by'); app.use(express.json()); app.use(express.urlencoded({ extended: false })); -app.use(cookieParser()); + +app.use(cookieParser(COOKIE_PARSER_SECRET)); +app.use(cookieSession({ secret: COOKIE_SESSION_SECRET })); app.use(setNonce); configureHelmet(app); diff --git a/src/config/index.ts b/src/config/index.ts index 0f6c743..c82ef1c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -4,6 +4,10 @@ export const PORT = getEnvironmentValue('PORT', '3000'); export const BASE_URL = getEnvironmentValue('BASE_URL', `http://localhost:${PORT}`); export const CDN_HOST = getEnvironmentValue('CDN_HOST'); export const NODE_SSL_ENABLED = getEnvironmentValue('NODE_SSL_ENABLED', 'false'); +export const NODE_ENV = getEnvironmentValue('NODE_ENV'); + +export const COOKIE_PARSER_SECRET = getEnvironmentValue('COOKIE_PARSER_SECRET'); +export const COOKIE_SESSION_SECRET = getEnvironmentValue('COOKIE_SESSION_SECRET'); export const PATH_SSL_PRIVATE_KEY = getEnvironmentValue('PATH_SSL_PRIVATE_KEY', 'false'); export const PATH_SSL_CERTIFICATE = getEnvironmentValue('PATH_SSL_CERTIFICATE', 'false'); diff --git a/src/middleware/authentication.middleware.ts b/src/middleware/authentication.middleware.ts new file mode 100644 index 0000000..fea4f6d --- /dev/null +++ b/src/middleware/authentication.middleware.ts @@ -0,0 +1,25 @@ +import { NextFunction, Request, Response } from 'express'; +import { log } from '../utils/logger'; +import * as config from '../config'; +import { colaAuthenticationMiddleware } from '@co-digital/login'; + +export const authentication = ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + + if (config.NODE_ENV === 'production') { + log.infoRequest(req, 'Authenticating through COLA...'); + return colaAuthenticationMiddleware(req, res, next); + } + + log.infoRequest(req, 'Skipping authentication...'); + next(); + + } catch (err: any) { + log.errorRequest(req, err.message); + next(err); + } +}; diff --git a/src/routes/info.ts b/src/routes/info.ts index 17a802a..1160700 100644 --- a/src/routes/info.ts +++ b/src/routes/info.ts @@ -2,10 +2,11 @@ import { Router } from 'express'; import { get, post } from '../controller/info.controller'; import * as config from '../config'; +import { authentication } from '../middleware/authentication.middleware'; const infoRouter = Router(); -infoRouter.get(config.INFO_URL, get); +infoRouter.get(config.INFO_URL, authentication, get); infoRouter.post(config.INFO_URL, post); export default infoRouter; diff --git a/test/setup.ts b/test/setup.ts index 484c925..df0553c 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,7 +1,16 @@ export default () => { - process.env.LOG_LEVEL = 'info'; + process.env.CDN_HOST = 'test'; process.env.HUMAN = 'true'; + process.env.LOG_LEVEL = 'info'; + process.env.COOKIE_PARSER_SECRET = 'secret'; + process.env.COOKIE_SESSION_SECRET = 'secret'; + process.env.COOKIE_ID_NAME = 'secret'; + process.env.USER_POOL_ID = 'secret'; + process.env.USER_POOL_CLIENT_ID = 'secret'; + process.env.COOKIE_ID_NAME = 'test'; + process.env.AUTH_SIGN_IN_URL = 'test'; + process.env.SESSION_ID_NAME = 'test'; + process.env.SESSION_APP_KEY = 'test'; process.env.TEST_KEY = 'test'; - process.env.CDN_HOST = 'test'; process.env.UNSANITISED_TEST_KEY = ' test '; }; diff --git a/test/unit/middleware/authentication.middleware.spec.ts b/test/unit/middleware/authentication.middleware.spec.ts new file mode 100644 index 0000000..ea1c8a5 --- /dev/null +++ b/test/unit/middleware/authentication.middleware.spec.ts @@ -0,0 +1,75 @@ +jest.mock('../../../src/utils/logger', () => ({ + log: { + infoRequest: jest.fn(), + errorRequest: jest.fn() + } +})); +jest.mock('../../../src/config/index.ts', () => ({ + __esModule: true, + NODE_ENV: null +})); +jest.mock('@co-digital/login'); + +import { describe, expect, test, jest, afterEach } from '@jest/globals'; +import { Request, Response, NextFunction } from 'express'; + +import { authentication } from '../../../src/middleware/authentication.middleware'; +import { log } from '../../../src/utils/logger'; +import * as config from '../../../src/config/index'; + +import { mockRequest, mockResponse, mockNext } from '../../mock/express.mock'; +import { colaAuthenticationMiddleware } from '@co-digital/login'; +import { MOCK_ERROR } from '../../mock/data'; + +const configMock = config as { NODE_ENV: string }; +const logInfoRequestMock = log.infoRequest as jest.Mock; +const logErrorRequestMock = log.errorRequest as jest.Mock; +const colaAuthenticationMiddlewareMock = colaAuthenticationMiddleware as jest.Mock; + +describe('Authentication Middleware test suites', () => { + + let req: Request; + let res: Response; + let next: NextFunction; + + beforeEach(() => { + req = mockRequest(); + res = mockResponse(); + next = mockNext; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('should skip authentication and call next if NODE_ENV is not production', () => { + + configMock.NODE_ENV = 'development'; + + authentication(req, res, next); + + expect(logInfoRequestMock).toHaveBeenCalledWith(req, 'Skipping authentication...'); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('should call ColaAuthenticationMiddleware if NODE_ENV set to production', () => { + configMock.NODE_ENV = 'production'; + + authentication(req, res, next); + + expect(logInfoRequestMock).toHaveBeenCalledWith(req, 'Authenticating through COLA...'); + expect(colaAuthenticationMiddlewareMock).toHaveBeenCalledTimes(1); + }); + + test('should call next with error object if error is thrown', () => { + + configMock.NODE_ENV = 'production'; + colaAuthenticationMiddlewareMock.mockImplementationOnce(() => { throw new Error(MOCK_ERROR.message); }); + + authentication(req, res, next); + + expect(logErrorRequestMock).toHaveBeenCalledTimes(1); + expect(logErrorRequestMock).toHaveBeenCalledWith(req, MOCK_ERROR.message); + expect(next).toHaveBeenCalledTimes(1); + }); +});