UNPKG

@hmcts/rpx-xui-node-lib

Version:

Common nodejs library components for XUI

665 lines 32.2 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Strategy = void 0; const events = __importStar(require("events")); const passport_1 = __importDefault(require("passport")); const auth_constants_1 = require("../auth.constants"); const common_1 = require("../../common"); const joi_1 = __importDefault(require("joi")); const URL = __importStar(require("url")); const openid_client_1 = require("openid-client"); const csurf_1 = __importDefault(require("@dr.pogodin/csurf")); const jwt_decode_1 = __importDefault(require("jwt-decode")); class Strategy extends events.EventEmitter { constructor(strategyName, router, logger = (0, common_1.getLogger)('auth:strategy')) { super(); this.options = { authorizationURL: '', tokenURL: '', clientID: '', clientSecret: '', callbackURL: '', scope: '', logoutURL: '', useRoutes: true, sessionKey: '', //openID options discoveryEndpoint: '', issuerURL: '', responseTypes: [''], tokenEndpointAuthMethod: '', allowRolesRegex: '.', useCSRF: true, routeCredential: undefined, serviceOverride: false, }; this.redactingLogReplacer = (key, value) => { if (key && Strategy.SENSITIVE_LOG_KEY_PATTERN.test(key)) { return Strategy.REDACTED_LOG_VALUE; } return value; }; /* istanbul ignore next */ this.initialiseStrategy = (options) => __awaiter(this, void 0, void 0, function* () { this.options = options; this.logger.log('initialising strategy, options:'); this.logger.log(JSON.stringify(options, this.redactingLogReplacer)); }); /** * The login route handler will attempt to setup security state param and redirect user if not authenticated * @param req Request * @param res Response * @param next NextFunction */ /* istanbul ignore next */ this.loginHandler = (req, res, next) => __awaiter(this, void 0, void 0, function* () { this.logger.log('Base loginHandler Hit'); const reqSession = req.session; const { promise, state } = this.saveStateInSession(reqSession); /* istanbul ignore next */ try { /* istanbul ignore next */ yield promise; /* istanbul ignore next */ this.logger.log('calling passport authenticate with state ' + state); /* istanbul ignore next */ return passport_1.default.authenticate(this.strategyName, { redirect_uri: reqSession === null || reqSession === void 0 ? void 0 : reqSession.callbackURL, state, keepSessionInfo: false, }, (error, user, info) => { var _a; /* istanbul ignore next */ if (error) { this.logger.error('passport authenticate error ', JSON.stringify(error, this.redactingLogReplacer)); } /* istanbul ignore next */ if (info) { this.logger.info('passport authenticate info', JSON.stringify(info, this.redactingLogReplacer)); } /* istanbul ignore next */ if (!user) { const message = 'No user details returned by the authentication service, redirecting to login'; this.logger.log(message); } else { this.logger.log('User details from passport.authenticate ' + ((_a = user.userInfo) === null || _a === void 0 ? void 0 : _a.email)); } })(req, res, next); /* istanbul ignore next */ } catch (error) { this.logger.error('Exception in passport.authenticate', error, this.strategyName); next(error); return Promise.reject(error); } }); /* istanbul ignore next */ this.setCallbackURL = (req, _res, next) => { var _a; const reqSession = req.session; // Always ensure callbackURL is set to a non-empty string const hasValidSessionCallback = typeof reqSession.callbackURL === 'string' && reqSession.callbackURL.trim().length > 0; const hasValidOptionCallback = typeof this.options.callbackURL === 'string' && this.options.callbackURL.trim().length > 0; if (!hasValidSessionCallback) { req.app.set('trust proxy', true); const pathname = hasValidOptionCallback ? this.options.callbackURL.trim() : req.originalUrl; reqSession.callbackURL = URL.format({ protocol: req.protocol, host: req.get('host'), pathname, }); } // 🔍 Log current config and session key status this.logger.log(`setCallbackURL, options.callbackurl: ${this.options.callbackURL}`); if (this.options.sessionKey) { const sessionKey = this.options.sessionKey; this.logger.log(`sessionKey: ${sessionKey}`); this.logger.log(`state from session = ${(_a = reqSession[sessionKey]) === null || _a === void 0 ? void 0 : _a.state}`); } else { this.logger.log('sessionKey not set'); } next(); }; /* istanbul ignore next */ this.logout = (req, res, next) => __awaiter(this, void 0, void 0, function* () { const reqSession = req.session; try { this.logger.log('logout start'); const { accessToken, refreshToken } = (reqSession === null || reqSession === void 0 ? void 0 : reqSession.passport.user.tokenset) || null; const auth = this.getAuthorization(this.options.clientID, this.options.clientSecret); yield common_1.http.delete(this.urlFromToken(this.options.logoutURL, accessToken), { headers: { Authorization: auth, }, }); yield common_1.http.delete(this.urlFromToken(this.options.logoutURL, refreshToken), { headers: { Authorization: auth, }, }); //passport provides this method on request object req.logout({ keepSessionInfo: false }, (err) => __awaiter(this, void 0, void 0, function* () { if (err) { console.error(err); return next(err); } yield this.destroySession(req); /* istanbul ignore next */ if (req.query.noredirect) { res.status(200).send({ message: 'You have been logged out!' }); return Promise.resolve(); } const redirectUrl = URL.format({ protocol: req.protocol, host: req.get('host'), }); const params = new URLSearchParams({ post_logout_redirect_uri: redirectUrl }); const finalSSOLogoutUrl = `${this.options.ssoLogoutURL}?${params.toString()}`; const redirect = finalSSOLogoutUrl ? finalSSOLogoutUrl : auth_constants_1.AUTH.ROUTE.LOGIN; this.logger.log('redirecting to => ', redirect); // 401 is when no accessToken res.redirect(redirect); /* istanbul ignore next */ })); } catch (e) { this.logger.error('error => ', e); res.status(401).redirect(auth_constants_1.AUTH.ROUTE.DEFAULT_REDIRECT); } this.logger.log('logout end'); }); /* istanbul ignore next */ this.authRouteHandler = (req, res) => { return res.send(req.isAuthenticated()); }; /* istanbul ignore next */ this.destroySession = (req) => __awaiter(this, void 0, void 0, function* () { return new Promise((resolve, reject) => { var _a; (_a = req.session) === null || _a === void 0 ? void 0 : _a.destroy((err) => { if (err) { reject(err); } this.logger.log('session destroyed'); resolve(true); }); }); }); /* istanbul ignore next */ this.keepAliveHandler = (_req, _res, next) => { next(); }; /* istanbul ignore next */ this.configure = (options) => { const configuredOptions = Object.assign(Object.assign({}, this.options), options); this.validateOptions(configuredOptions); this.options = configuredOptions; this.serializeUser(); this.deserializeUser(); (() => __awaiter(this, void 0, void 0, function* () { yield this.initialiseStrategy(this.options); }))(); // ensure trust proxy is always enabled before any other middleware executes this.initializeTrustProxy(); this.initializePassport(); this.initializeSession(); this.initializeKeepAlive(); this.initialiseCSRF(); if (options.useRoutes) { this.router.get(auth_constants_1.AUTH.ROUTE.DEFAULT_AUTH_ROUTE, this.authRouteHandler); this.router.get(auth_constants_1.AUTH.ROUTE.KEEPALIVE_ROUTE, this.authRouteHandler); this.router.get(auth_constants_1.AUTH.ROUTE.LOGIN, this.setCallbackURL, this.loginHandler); this.router.get(auth_constants_1.AUTH.ROUTE.OAUTH_CALLBACK, this.callbackHandler); this.router.get(auth_constants_1.AUTH.ROUTE.LOGOUT, this.logout); } this.addHeaders(); this.emit(`${this.strategyName}.bootstrap.success`); return this.router; }; /* istanbul ignore next */ this.callbackHandler = (req, res, next) => __awaiter(this, void 0, void 0, function* () { var _a; this.logger.log('in callbackHandler for url ' + req.url); const reqSession = req.session; const qstate = typeof req.query.state == 'string' ? req.query.state : undefined; const INVALID_STATE_ERROR = 'Invalid authorization request state.'; if (!qstate) { this.logger.log('Missing callback state in authorization response, rejecting request'); res.locals = res.locals || {}; res.locals.message = INVALID_STATE_ERROR; this.emit(auth_constants_1.AUTH.EVENT.AUTHENTICATE_FAILURE, req, res, next); res.redirect(auth_constants_1.AUTH.ROUTE.EXPIRED_LOGIN_LINK); return; } if (this.options.sessionKey) { const sessionKey = this.options.sessionKey; this.logger.log(`sessionKey: ${sessionKey}`); this.logger.log(`state from session = ${(_a = reqSession[sessionKey]) === null || _a === void 0 ? void 0 : _a.state}`); } else { this.logger.log('sessionKey not set'); } const LOGIN_BOOKMARK_ERROR = 'LoginBookmarkUsed :'; const emitAuthenticationFailure = (logMessages) => { this.logger.log(`inside emitAuthenticationFailure, message count ${logMessages.length}`); if (!logMessages.length) return; this.logger.log(`emitAuthenticationFailure logMessages ${logMessages.join('\n')}`); res.locals.message = logMessages.join('\n'); this.emit(auth_constants_1.AUTH.EVENT.AUTHENTICATE_FAILURE, req, res, next); }; const redirectWithFailure = (errorMessages, INVALID_STATE_ERROR, uri) => { errorMessages.push(INVALID_STATE_ERROR); emitAuthenticationFailure(errorMessages); return res.redirect(uri); }; this.logger.log(`calling passport authenticate with ${this.strategyName} strategy`); passport_1.default.authenticate(this.strategyName, { redirect_uri: reqSession === null || reqSession === void 0 ? void 0 : reqSession.callbackURL, keepSessionInfo: false, failureMessage: true, }, (error, user, info) => { var _a, _b, _c; if (info) { this.logger.log(`in passport authenticate callback info: ${info}`); } let errorMessages = []; if ((_a = this.options) === null || _a === void 0 ? void 0 : _a.sessionKey) { const sessState = (_b = reqSession[this.options.sessionKey]) === null || _b === void 0 ? void 0 : _b.state; this.logger.log(`in passport authenticate callback, strategy ${this.strategyName}, state ${sessState}`); } if (error) { this.logger.log(`in passport authenticate error: ${error}`); switch (error.name) { case 'TimeoutError': const timeoutErrorMessage = `${error.name}: timeout awaiting ${error.url} for ${error.gotOptions.gotTimeout.request}ms`; errorMessages.push(timeoutErrorMessage); this.logger.error(error); break; default: errorMessages.push(error); this.logger.error(error); break; } } if (!user) { const MISMATCH_NONCE = 'nonce mismatch'; const MISMATCH_STATE = 'state mismatch'; if ((info === null || info === void 0 ? void 0 : info.message) === INVALID_STATE_ERROR) { if (!qstate) { // if state is not in query, then we can ignore this.logger.log('Invalid state error, redirecting to default'); return res.redirect(auth_constants_1.AUTH.ROUTE.DEFAULT_REDIRECT); } else { errorMessages.push(LOGIN_BOOKMARK_ERROR); return redirectWithFailure(errorMessages, INVALID_STATE_ERROR, auth_constants_1.AUTH.ROUTE.EXPIRED_LOGIN_LINK); } } else if ((info === null || info === void 0 ? void 0 : info.message.includes(MISMATCH_NONCE)) || (info === null || info === void 0 ? void 0 : info.message.includes(MISMATCH_STATE))) { errorMessages.push(LOGIN_BOOKMARK_ERROR); return redirectWithFailure(errorMessages, info.message, auth_constants_1.AUTH.ROUTE.EXPIRED_LOGIN_LINK); } else if (error === null || error === void 0 ? void 0 : error.message.includes('did not find expected authorization request details in session')) { errorMessages = [LOGIN_BOOKMARK_ERROR]; return redirectWithFailure(errorMessages, error.message, auth_constants_1.AUTH.ROUTE.EXPIRED_LOGIN_LINK); } else { const message = 'No user details returned by the authentication service, redirecting to login'; this.logger.log(message); return redirectWithFailure(errorMessages, message, auth_constants_1.AUTH.ROUTE.LOGIN); } } else { this.logger.log('User id from passport.authenticate ' + ((_c = user === null || user === void 0 ? void 0 : user.userInfo) === null || _c === void 0 ? void 0 : _c.id)); } emitAuthenticationFailure(errorMessages); this.verifyLogin(req, user, next, res); })(req, res, next); }); /* istanbul ignore next */ this.isTokenExpired = (token) => { const jwtData = (0, jwt_decode_1.default)(token); return this.jwTokenExpired(jwtData); }; /* istanbul ignore next */ this.authenticate = (req, _res, next) => { var _a, _b, _c; if (req.isUnauthenticated() || !((_c = (_b = (_a = req === null || req === void 0 ? void 0 : req.session) === null || _a === void 0 ? void 0 : _a.passport) === null || _b === void 0 ? void 0 : _b.user) === null || _c === void 0 ? void 0 : _c.userinfo)) { this.logger.log('unauthenticated'); return _res.status(401).send({ message: 'Unauthorized' }); } next(); }; /* istanbul ignore next */ this.makeAuthorization = (passport) => `Bearer ${passport.user.tokenset.accessToken}`; /* istanbul ignore next */ this.setHeaders = (req, _res, next) => __awaiter(this, void 0, void 0, function* () { var _a; const reqSession = req.session; if ((_a = reqSession === null || reqSession === void 0 ? void 0 : reqSession.passport) === null || _a === void 0 ? void 0 : _a.user) { if (this.isRouteCredentialNeeded(req.path, this.options)) { yield this.setCredentialToken(req); } else { req.headers['user-roles'] = reqSession.passport.user.userinfo.roles.join(); req.headers.Authorization = this.makeAuthorization(reqSession.passport); } } else if (this.isRouteCredentialNeeded(req.path, this.options)) { yield this.setCredentialToken(req); } next(); }); /* istanbul ignore next */ this.isRouteCredentialNeeded = (url, options) => { return options.routeCredential && options.routeCredential.routes && options.routeCredential.routes.includes(url); }; /* istanbul ignore next */ this.setCredentialToken = (req) => __awaiter(this, void 0, void 0, function* () { let routeCredentialToken; const cachedToken = req.app.get('routeCredentialToken'); if (cachedToken && cachedToken.access_token && !this.isTokenExpired(cachedToken.access_token)) { routeCredentialToken = cachedToken; } else { routeCredentialToken = yield this.generateToken(); req.app.set('routeCredentialToken', routeCredentialToken); } if (routeCredentialToken && routeCredentialToken.access_token) { req.headers.Authorization = `Bearer ${routeCredentialToken.access_token}`; } }); /* istanbul ignore next */ this.generateToken = () => __awaiter(this, void 0, void 0, function* () { const url = this.getUrlFromOptions(this.options); try { const axiosConfig = { headers: { 'content-type': 'application/x-www-form-urlencoded' }, }; const body = this.getRequestBody(this.options); const response = yield common_1.http.post(url, body, axiosConfig); return response.data; } catch (error) { this.logger.error('error generating authentication token => ', error); } }); /* istanbul ignore next */ this.verifyLogin = (req, user, next, res) => { req.logIn(user, (err) => { const roles = user.userinfo.roles; if (err) { this.logger.error('verifyLogin error', err); return next(err); } if (this.options.allowRolesRegex && !(0, common_1.arrayPatternMatch)(roles, this.options.allowRolesRegex)) { this.logger.info(JSON.stringify(user.userInfo)); this.logger.error(`User has no application access, as they do not have a role that matches ${this.options.allowRolesRegex}.`); return this.logout(req, res, next); } if (!this.listenerCount(auth_constants_1.AUTH.EVENT.AUTHENTICATE_SUCCESS)) { this.logger.log(`redirecting, no listener count: ${auth_constants_1.AUTH.EVENT.AUTHENTICATE_SUCCESS}, user: ${user.email}`); res.redirect(auth_constants_1.AUTH.ROUTE.DEFAULT_REDIRECT); } else { req.isRefresh = false; this.emit(auth_constants_1.AUTH.EVENT.AUTHENTICATE_SUCCESS, req, res, next); } }); }; /* istanbul ignore next */ this.initializePassport = () => { this.router.use(passport_1.default.initialize()); }; /* istanbul ignore next */ this.initializeSession = () => { this.router.use(passport_1.default.session()); }; /* istanbul ignore next */ this.initializeKeepAlive = () => { this.router.use(this.keepAliveHandler); }; /* istanbul ignore next */ this.initializeTrustProxy = () => { this.router.use((req, _res, next) => { if (req.app.get('trust proxy') !== true) { req.app.set('trust proxy', true); this.logger.log('trust proxy enabled'); } next(); }); }; /** * helper method to store csrf token into session */ /* istanbul ignore next */ this.initialiseCSRF = () => { if (this.options.useCSRF) { this.logger.log('initialising CSRF middleware'); const csrfProtection = (0, csurf_1.default)({ value: this.getCSRFValue, }); // cookie options added via EXUI-986, fortify issues const cookieOptions = { sameSite: 'strict', secure: true, }; /* istanbul ignore next */ this.router.use(csrfProtection, (req, res, next) => { res.cookie('XSRF-TOKEN', req.csrfToken(), cookieOptions); next(); }); } }; /** * retrieve the csrf token value, lastly from sent cookies * @param req * @return string */ /* istanbul ignore next */ this.getCSRFValue = (req) => { return ((req.body && req.body._csrf) || (req.query && req.query._csrf) || req.headers['csrf-token'] || req.headers['xsrf-token'] || req.headers['x-csrf-token'] || req.headers['x-xsrf-token'] || req.cookies['XSRF-TOKEN']); }; /* istanbul ignore next */ this.addHeaders = () => { this.router.use(this.setHeaders); }; /* istanbul ignore next */ this.serializeUser = () => { passport_1.default.serializeUser((user, done) => { this.logger.log(`${this.strategyName} serializeUser`); this.emitIfListenersExist(auth_constants_1.AUTH.EVENT.SERIALIZE_USER, user, done); }); }; /* istanbul ignore next */ this.deserializeUser = () => { passport_1.default.deserializeUser((id, done) => { this.emitIfListenersExist(auth_constants_1.AUTH.EVENT.DESERIALIZE_USER, id, done); }); }; /* istanbul ignore next */ this.jwTokenExpired = (jwtData) => { const expires = new Date(jwtData.exp * 1000).getTime(); const now = new Date().getTime(); return expires < now; }; /** * Get session URL * @return {string} */ /* istanbul ignore next */ this.urlFromToken = (url, token) => { return `${url}/session/${token}`; }; /** * Get authorization from ClientID and secret * @return {string} */ /* istanbul ignore next */ this.getAuthorization = (clientID, clientSecret, encoding = 'base64') => { return `Basic ${Buffer.from(`${clientID}:${clientSecret}`).toString(encoding)}`; }; /** * Get all the events that this strategy emits * @return {string[]} - ['auth.authenticate.success'] */ /* istanbul ignore next */ this.getEvents = () => { return Object.values(auth_constants_1.AUTH.EVENT); }; /** * emit Events if any subscriptions available */ /* istanbul ignore next */ this.emitIfListenersExist = (eventName, eventObject, done) => { if (!this.listenerCount(eventName)) { done(null, eventObject); } else { this.emit(eventName, eventObject, done); } }; /* istanbul ignore next */ this.getRequestBody = (options) => { var _a; if (options.routeCredential) { const userName = options.routeCredential.userName; const userPassword = encodeURIComponent(options.routeCredential.password); const scope = (_a = options.routeCredential) === null || _a === void 0 ? void 0 : _a.scope; const clientSecret = options.clientSecret; const idamClient = options.clientID; return `grant_type=password&password=${userPassword}&username=${userName}&scope=${scope}&client_id=${idamClient}&client_secret=${clientSecret}`; } const msg = 'options.routeCredential missing values'; throw new Error(msg); }; /* istanbul ignore next */ this.getUrlFromOptions = (options) => { if (options.routeCredential) { return `${options.logoutURL}/o/token`; } const msg = 'missing routeCredential in options'; this.logger.error('msg'); throw new Error(msg); }; this.strategyName = strategyName; this.router = router; this.logger = logger; } validateOptions(options) { const schema = joi_1.default.object({ authorizationURL: joi_1.default.string().required(), tokenURL: joi_1.default.string().required(), clientID: joi_1.default.string().required(), clientSecret: joi_1.default.string().required(), callbackURL: joi_1.default.string().required(), discoveryEndpoint: joi_1.default.string(), issuerURL: joi_1.default.string(), logoutURL: joi_1.default.string().required(), scope: joi_1.default.string().required(), scopeSeparator: joi_1.default.any(), sessionKey: joi_1.default.any(), useRoutes: joi_1.default.bool(), skipUserProfile: joi_1.default.any(), responseTypes: joi_1.default.array(), tokenEndpointAuthMethod: joi_1.default.string(), pkce: joi_1.default.any(), proxy: joi_1.default.any(), store: joi_1.default.any(), state: joi_1.default.any(), customHeaders: joi_1.default.any(), allowRolesRegex: joi_1.default.string(), useCSRF: joi_1.default.bool(), serviceOverride: joi_1.default.bool(), routeCredential: joi_1.default.any(), ssoLogoutURL: joi_1.default.string(), }); const { error } = schema.validate(options); if (error) { throw error; } return true; } saveStateInSession(reqSession, state) { if (!state) { state = openid_client_1.generators.state(); this.logger.log(`state not found, generating new state ${state}`); } const p = new Promise((resolve) => { var _a, _b; if (reqSession && ((_a = this.options) === null || _a === void 0 ? void 0 : _a.sessionKey)) { // add the state to the current session object, this will then contain both nonce and state reqSession[(_b = this.options) === null || _b === void 0 ? void 0 : _b.sessionKey] = Object.assign(Object.assign({}, reqSession[this.options.sessionKey]), { state }); this.logger.log(`saving state ${state} in session`); reqSession.save(() => { this.logger.log(`state ${state} saved in session`); resolve(true); }); } else { this.logger.log('sessionKey not available state not saved'); resolve(false); } }); return { promise: p, state: state }; } } exports.Strategy = Strategy; Strategy.REDACTED_LOG_VALUE = '[REDACTED]'; Strategy.SENSITIVE_LOG_KEY_PATTERN = /(clientSecret|client_secret|password|token|authorization)/i; //# sourceMappingURL=strategy.class.js.map