From d31f252739ba1235b8c22ff1e8fb3a5b30675b4a Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Tue, 7 Jan 2025 16:49:07 +0200 Subject: [PATCH 01/19] node-saml init --- api/api.js | 154 +++++++++++++++----------------- mod/user/saml.js | 223 ++++++++++++++++++++++------------------------- package.json | 5 +- 3 files changed, 179 insertions(+), 203 deletions(-) diff --git a/api/api.js b/api/api.js index 2b3191217..e7129d569 100644 --- a/api/api.js +++ b/api/api.js @@ -82,15 +82,15 @@ The process.ENV object holds configuration provided to the node process from the @property {String} [SAML_IDP_CRT] Required authentication via [SAML]{@link module:/user/saml}. */ -const login = require('../mod/user/login') +const login = require('../mod/user/login'); -const auth = require('../mod/user/auth') +const auth = require('../mod/user/auth'); -const saml = require('../mod/user/saml') +const saml = require('../mod/user/saml'); -const register = require('../mod/user/register') +const register = require('../mod/user/register'); -const logger = require('../mod/utils/logger') +const logger = require('../mod/utils/logger'); const routes = { fetch: require('../mod/fetch'), @@ -100,13 +100,13 @@ const routes = { sign: require('../mod/sign/_sign'), user: require('../mod/user/_user'), workspace: require('../mod/workspace/_workspace'), -} +}; -process.env.COOKIE_TTL ??= '36000' +process.env.COOKIE_TTL ??= '36000'; -process.env.TITLE ??= 'GEOLYTIX | XYZ' +process.env.TITLE ??= 'GEOLYTIX | XYZ'; -process.env.DIR ??= '' +process.env.DIR ??= ''; /** @function api @@ -138,55 +138,54 @@ All other requests will passed to the async validateRequestAuth method. @property {Boolean} params.register The request should redirect to user/register. */ module.exports = function api(req, res) { - // redirect if dir is missing in url path. if (process.env.DIR && req.url.length === 1) { - res.setHeader('location', `${process.env.DIR}`) - return res.status(302).send() + res.setHeader('location', `${process.env.DIR}`); + return res.status(302).send(); } - logger(req, 'req') + logger(req, 'req'); - logger(req.url, 'req_url') + logger(req.url, 'req_url'); // SAML request. if (req.url.match(/\/saml/)) { - - return saml(req, res) + return saml(req, res); } - req.params = validateRequestParams(req) + req.params = validateRequestParams(req); if (req.params instanceof Error) { - - return res.status(400).send(req.params.message) + return res.status(400).send(req.params.message); } if (req.params.logout) { - // Remove cookie. - res.setHeader('Set-Cookie', `${process.env.TITLE}=null;HttpOnly;Max-Age=0;Path=${process.env.DIR || '/'}`) + res.setHeader( + 'Set-Cookie', + `${process.env.TITLE}=null;HttpOnly;Max-Age=0;Path=${process.env.DIR || '/'}`, + ); const msg = req.params.msg ? `?msg=${req.params.msg}` : ''; // Set location to the domain path. - res.setHeader('location', `${process.env.DIR || '/'}${msg}`) + res.setHeader('location', `${process.env.DIR || '/'}${msg}`); - return res.status(302).send() + return res.status(302).send(); } // Short circuit to user/login. if (req.params.login || req.body?.login) { - return login(req, res) + return login(req, res); } // Short circuit to user/register if (req.params.register || req.body?.register) { - return register(req, res) + return register(req, res); } - validateRequestAuth(req, res) -} + validateRequestAuth(req, res); +}; /** @function validateRequestAuth @@ -209,56 +208,54 @@ PRIVATE processes require user auth for all requests and will shortcircuit to th @property {string} req.url The request url. */ async function validateRequestAuth(req, res) { - // Validate signature of either request token, authorization header, or cookie. - const user = await auth(req, res) + const user = await auth(req, res); // Remove token from params object. - delete req.params.token + delete req.params.token; // The authentication method returns an error. if (user && user instanceof Error) { - if (req.headers.authorization) { - // Request with failed authorization headers are not passed to login. - return res.status(401).send(user.message) + return res.status(401).send(user.message); } // Remove cookie. - res.setHeader('Set-Cookie', `${process.env.TITLE}=null;HttpOnly;Max-Age=0;Path=${process.env.DIR || '/'};SameSite=Strict${!req.headers.host.includes('localhost') && ';Secure' || ''}`) + res.setHeader( + 'Set-Cookie', + `${process.env.TITLE}=null;HttpOnly;Max-Age=0;Path=${process.env.DIR || '/'};SameSite=Strict${(!req.headers.host.includes('localhost') && ';Secure') || ''}`, + ); // Set msg parameter for the login view. // The msg provides information in regards to failed logins. - req.params.msg = user.msg || user.message + req.params.msg = user.msg || user.message; // Return login view with error message. - return login(req, res) + return login(req, res); } // Set user as request parameter. - req.params.user = user + req.params.user = user; // User route if (req.url.match(/(?<=\/api\/user)/)) { - //Requests to the User API maybe for login or registration and must be routed before the check for PRIVATE processes. - return routes.user(req, res) + return routes.user(req, res); } // PRIVATE instances require user auth for all requests. if (!req.params.user && process.env.PRIVATE) { - // Redirect to the SAML login. if (process.env.SAML_LOGIN) { - res.setHeader('location', `${process.env.DIR}/saml/login`) - return res.status(302).send() + res.setHeader('location', `${process.env.DIR}/saml/login`); + return res.status(302).send(); } - return login(req, res) + return login(req, res); } - requestRouter(req, res) + requestRouter(req, res); } /** @@ -274,46 +271,41 @@ By default requests will be passed to the [View API]{@link module:/view} module. @property {string} req.url The request url. */ function requestRouter(req, res) { - switch (true) { - // Provider API case /(?<=\/api\/provider)/.test(req.url): - routes.provider(req, res) + routes.provider(req, res); break; // Signer API case /(?<=\/api\/sign)/.test(req.url): - routes.sign(req, res) + routes.sign(req, res); break; // Location API [deprecated] case /(?<=\/api\/location)/.test(req.url): - // Route to Query API with location template - req.params.template = `location_${req.params.method}` - routes.query(req, res) + req.params.template = `location_${req.params.method}`; + routes.query(req, res); break; // Query API case /(?<=\/api\/query)/.test(req.url): - - routes.query(req, res) + routes.query(req, res); break; // Fetch API case /(?<=\/api\/fetch)/.test(req.url): - - routes.fetch(req, res) + routes.fetch(req, res); break; case /(?<=\/api\/workspace)/.test(req.url): - - routes.workspace(req, res) + routes.workspace(req, res); break; // View API is the default route. - default: routes.view(req, res) + default: + routes.view(req, res); } } @@ -340,68 +332,64 @@ The params object properties will be iterated through to parse Object values [eg @returns {Object} Returns a validated params object. */ function validateRequestParams(req) { - // Merge request params and query params. - const params = Object.assign(req.params || {}, req.query || {}) + const params = Object.assign(req.params || {}, req.query || {}); // User is a restricted parameter. - delete params.user + delete params.user; // URL parameter keys must match white listed letters and numbers only. - if (Object.keys(params).some(key => !/^[A-Za-z0-9_-]*$/.exec(key))) { - - return new Error('URL parameter key validation failed.') + if (Object.keys(params).some((key) => !/^[A-Za-z0-9_-]*$/.exec(key))) { + return new Error('URL parameter key validation failed.'); } // URL parameter keys must match white listed letters and numbers only. - if (Object.keys(params).some(key => key === 'user')) { - - return new Error('user is a restricted request parameter.') + if (Object.keys(params).some((key) => key === 'user')) { + return new Error('user is a restricted request parameter.'); } // Language param will default to english [en] is not explicitly set. - params.language ??= 'en' + params.language ??= 'en'; // Assign from _template if provided as path param. - params.template ??= params._template + params.template ??= params._template; for (const key in params) { - // Delete param keys with undefined values. if (params[key] === undefined) { - delete params[key] + delete params[key]; continue; } // Delete param keys with empty string value. if (params[key] === '') { - delete params[key] + delete params[key]; continue; } // Parse lowerCase object value. switch (params[key].toLowerCase()) { - - case ('null'): - params[key] = null + case 'null': + params[key] = null; continue; - case ('false'): - params[key] = false + case 'false': + params[key] = false; continue; - case ('true'): - params[key] = true + case 'true': + params[key] = true; continue; } // Check whether the params value begins and ends with square braces. if (params[key].match(/^\[.*\]$/)) { - // Match the string between square brackets and split into an array with undefined array values filtered out. - params[key] = match(/^\[(.*)\]$/)[1].split(',').filter(Boolean) + params[key] = match(/^\[(.*)\]$/)[1] + .split(',') + .filter(Boolean); } } - return params -} \ No newline at end of file + return params; +} diff --git a/mod/user/saml.js b/mod/user/saml.js index 469bb7b69..585024b9e 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -24,62 +24,52 @@ The idp requires a certificate `${process.env.SAML_IDP_CRT}.crt`, single sign-on @module /user/saml */ -let acl, sp, idp; +let strategy, samlConfig; try { - const saml2 = require('saml2-js'); - - const logger = require('../utils/logger'); - - const jwt = require('jsonwebtoken'); + const { SAML } = require('@node-saml/node-saml'); const { join } = require('path'); const { readFileSync } = require('fs'); - acl = require('./acl'); - - sp = new saml2.ServiceProvider({ - entity_id: process.env.SAML_ENTITY_ID, - private_key: - process.env.SAML_SP_CRT && + samlConfig = { + callbackUrl: process.env.SAML_ACS, + entryPoint: process.env.SAML_SSO, + issuer: process.env.SAML_ENTITY_ID, + idpCert: + process.env.SAML_IDP_CRT && String( - readFileSync(join(__dirname, `../../${process.env.SAML_SP_CRT}.pem`)) + readFileSync(join(__dirname, `../../${process.env.SAML_IDP_CRT}.crt`)), ), - certificate: + privateKey: process.env.SAML_SP_CRT && String( - readFileSync(join(__dirname, `../../${process.env.SAML_SP_CRT}.crt`)) + readFileSync(join(__dirname, `../../${process.env.SAML_SP_CRT}.pem`)), ), - assert_endpoint: process.env.SAML_ACS, - allow_unencrypted_assertion: true, - }); - - idp = new saml2.IdentityProvider({ - sso_login_url: process.env.SAML_SSO, - sso_logout_url: process.env.SAML_SLO, - certificates: process.env.SAML_IDP_CRT && [ + publicKey: + process.env.SAML_SP_CRT && String( - readFileSync(join(__dirname, `../../${process.env.SAML_IDP_CRT}.crt`)) + readFileSync(join(__dirname, `../../${process.env.SAML_SP_CRT}.crt`)), ), - ], - sign_get_request: true, - }); + logoutUrl: process.env.SAML_SLO, + }; - module.exports = saml; + strategy = new SAML(samlConfig); + module.exports = saml; } catch { - //Check if there are any SAML keys in the process. - const samlKeys = Object.keys(process.env).filter(key => key.startsWith('SAML')); + const samlKeys = Object.keys(process.env).filter((key) => + key.startsWith('SAML'), + ); //If we have keys then log we that the module is not present if (samlKeys.length > 0) { - console.log('SAML2 module is not available.') + console.log('SAML2 module is not available.'); } module.exports = null; - } /** @@ -108,87 +98,83 @@ The user object is signed as a JSON Web Token and set as a cookie to the HTTP re */ function saml(req, res) { - - if (!sp || !idp) { - console.warn(`SAML SP or IDP are not available in XYZ instance.`) + if (!strategy) { + console.warn(`SAML is not available in XYZ instance.`); return; } // Return metadata. if (/\/saml\/metadata/.exec(req.url)) { res.setHeader('Content-Type', 'application/xml'); - res.send(sp.create_metadata()); - } - - // Create Service Provider login request url. - if (req.params?.login || /\/saml\/login/.exec(req.url)) { - sp.create_login_request_url(idp, {}, - (err, login_url, request_id) => { - if (err != null) return res.send(500); - - res.setHeader('location', login_url); - res.status(301).send(); - }); - } - - if (/\/saml\/acs/.exec(req.url)) { - - sp.post_assert( - idp, - { - request_body: req.body, - }, - async (err, saml_response) => { - - if (err != null) { - console.error(err); - return res.send(500); - } - - logger(saml_response, 'saml_response') - - const user = { - email: saml_response.user.name_id, - session_index: saml_response.user.session_index, - } - - if (process.env.SAML_ACL) { - - const acl_response = await acl_lookup(saml_response.user.name_id) - - if (!acl_response) { - return res.status(401).send('User account not found') - } - - if (acl_response instanceof Error) { - return res.status(401).send(acl_response.message) - } - - Object.assign(user, acl_response) - } - - // Create token with 8 hour expiry. - const token = jwt.sign( - user, - process.env.SECRET, - { - expiresIn: parseInt(process.env.COOKIE_TTL), - }); - - const cookie = - `${process.env.TITLE}=${token};HttpOnly;` + - `Max-Age=${process.env.COOKIE_TTL};` + - `Path=${process.env.DIR || '/'};`; - - res.setHeader('Set-Cookie', cookie); - - res.setHeader('location', `${process.env.DIR || '/'}`); - - return res.status(302).send(); - } + const metadata = strategy.generateServiceProviderMetadata( + null, + samlConfig.idpCert, ); + res.send(metadata); } -}; + + // // Create Service Provider login request url. + // if (req.params?.login || /\/saml\/login/.exec(req.url)) { + // sp.create_login_request_url(idp, {}, (err, login_url, request_id) => { + // if (err != null) return res.send(500); + // + // res.setHeader('location', login_url); + // res.status(301).send(); + // }); + // } + // + // if (/\/saml\/acs/.exec(req.url)) { + // sp.post_assert( + // idp, + // { + // request_body: req.body, + // }, + // async (err, saml_response) => { + // if (err != null) { + // console.error(err); + // return res.send(500); + // } + // + // logger(saml_response, 'saml_response'); + // + // const user = { + // email: saml_response.user.name_id, + // session_index: saml_response.user.session_index, + // }; + // + // if (process.env.SAML_ACL) { + // const acl_response = await acl_lookup(saml_response.user.name_id); + // + // if (!acl_response) { + // return res.status(401).send('User account not found'); + // } + // + // if (acl_response instanceof Error) { + // return res.status(401).send(acl_response.message); + // } + // + // Object.assign(user, acl_response); + // } + // + // // Create token with 8 hour expiry. + // const token = jwt.sign(user, process.env.SECRET, { + // expiresIn: parseInt(process.env.COOKIE_TTL), + // }); + // + // const cookie = + // `${process.env.TITLE}=${token};HttpOnly;` + + // `Max-Age=${process.env.COOKIE_TTL};` + + // `Path=${process.env.DIR || '/'};`; + // + // res.setHeader('Set-Cookie', cookie); + // + // res.setHeader('location', `${process.env.DIR || '/'}`); + // + // return res.status(302).send(); + // }, + // ); + // } +} /** @function acl_lookup @@ -205,45 +191,46 @@ User object or Error. */ async function acl_lookup(email) { - if (acl === null) { - return new Error('ACL unavailable.') + return new Error('ACL unavailable.'); } - const date = new Date() + const date = new Date(); // Update access_log and return user record matched by email. - const rows = await acl(` + const rows = await acl( + ` UPDATE acl_schema.acl_table SET access_log = array_append(access_log, '${date.toISOString().replace(/\..*/, '')}') WHERE lower(email) = lower($1) RETURNING email, roles, language, blocked, approved, approved_by, verified, admin, password;`, - [email]) + [email], + ); - if (rows instanceof Error) return new Error('Failed to query to ACL.') + if (rows instanceof Error) return new Error('Failed to query to ACL.'); // Get user record from first row. - const user = rows[0] + const user = rows[0]; if (!user) return null; // Blocked user cannot login. if (user.blocked) { - return new Error('User blocked in ACL.') + return new Error('User blocked in ACL.'); } // Accounts must be verified and approved for login if (!user.verified) { - return new Error('User not verified in ACL') + return new Error('User not verified in ACL'); } if (!user.approved) { - return new Error('User not approved in ACL') + return new Error('User not approved in ACL'); } return { roles: user.roles, language: user.language, - admin: user.admin - } -} \ No newline at end of file + admin: user.admin, + }; +} diff --git a/package.json b/package.json index 40d6cadbe..360481546 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,9 @@ "license": "MIT", "optionalDependencies": { "@aws-sdk/client-s3": "^3.691.0", + "@aws-sdk/cloudfront-signer": "^3.621.0", "@aws-sdk/s3-request-presigner": "^3.691.0", - "@aws-sdk/cloudfront-signer": "^3.621.0" + "@node-saml/node-saml": "^5.0.0" }, "dependencies": { "jsonwebtoken": "^9.0.2", @@ -45,4 +46,4 @@ "nodemon": "^3.1.7", "uhtml": "^3.1.0" } -} \ No newline at end of file +} From 376b4bc62977de9103d0590c05d38d24b5549744 Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Tue, 7 Jan 2025 17:52:46 +0200 Subject: [PATCH 02/19] Update login --- mod/user/saml.js | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/mod/user/saml.js b/mod/user/saml.js index 585024b9e..e22a7dc87 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -55,6 +55,7 @@ try { logoutUrl: process.env.SAML_SLO, }; + /** @type {SAML} */ strategy = new SAML(samlConfig); module.exports = saml; @@ -97,7 +98,7 @@ The user object is signed as a JSON Web Token and set as a cookie to the HTTP re @param {Object} res HTTP response. */ -function saml(req, res) { +async function saml(req, res) { if (!strategy) { console.warn(`SAML is not available in XYZ instance.`); return; @@ -113,15 +114,29 @@ function saml(req, res) { res.send(metadata); } - // // Create Service Provider login request url. - // if (req.params?.login || /\/saml\/login/.exec(req.url)) { - // sp.create_login_request_url(idp, {}, (err, login_url, request_id) => { - // if (err != null) return res.send(500); - // - // res.setHeader('location', login_url); - // res.status(301).send(); - // }); - // } + // Create Service Provider login request url. + if (req.params?.login || /\/saml\/login/.exec(req.url)) { + try { + // RelayState can be used to store the URL to redirect to after login + const relayState = req.query.returnTo || process.env.DIR; + + const url = await strategy.getAuthorizeUrlAsync( + relayState, + req.get('host'), // Get host from request + { + additionalParams: { + // Add any additional params needed + }, + }, + ); + + res.redirect(url); + } catch (error) { + console.error('SAML authorization error:', error); + res.status(500).send('Authentication failed'); + } + } + // // if (/\/saml\/acs/.exec(req.url)) { // sp.post_assert( From 2e8c31c2de245bfb98f2aa8a1a3beb7f47c98354 Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Wed, 8 Jan 2025 18:25:14 +0200 Subject: [PATCH 03/19] Update saml login/acs/logout --- mod/user/saml.js | 149 ++++++++++++++++++++++++++++------------------- 1 file changed, 89 insertions(+), 60 deletions(-) diff --git a/mod/user/saml.js b/mod/user/saml.js index e22a7dc87..2ec7f0e92 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -24,7 +24,7 @@ The idp requires a certificate `${process.env.SAML_IDP_CRT}.crt`, single sign-on @module /user/saml */ -let strategy, samlConfig; +let strategy, samlConfig, logger, jwt, acl; try { const { SAML } = require('@node-saml/node-saml'); @@ -33,6 +33,12 @@ try { const { readFileSync } = require('fs'); + logger = require('../../mod/utils/logger'); + + jwt = require('jsonwebtoken'); + + acl = require('../user/acl.js'); + samlConfig = { callbackUrl: process.env.SAML_ACS, entryPoint: process.env.SAML_SSO, @@ -53,6 +59,8 @@ try { readFileSync(join(__dirname, `../../${process.env.SAML_SP_CRT}.crt`)), ), logoutUrl: process.env.SAML_SLO, + wantAuthnResponseSigned: false, + acceptedClockSkewMs: -1, // Set to -1 to disable time validation }; /** @type {SAML} */ @@ -114,8 +122,32 @@ async function saml(req, res) { res.send(metadata); } - // Create Service Provider login request url. + if (/\/saml\/logout/.exec(req.url)) { + try { + const user = await jwt.decode(req.cookies[`${process.env.TITLE}_SAML`]); + const url = await strategy.getLogoutUrlAsync(user); + + res.cookie(`${process.env.TITLE}_SAML`, '', { + httpOnly: true, + expires: new Date(0), + path: process.env.DIR || '/', + }); + + res.cookie(process.env.TITLE, '', { + httpOnly: true, + expires: new Date(0), + path: process.env.DIR || '/', + }); + + res.redirect(url); + } catch (error) { + console.error('Logout process failed:', error); + return res.redirect('/'); + } + } + if (req.params?.login || /\/saml\/login/.exec(req.url)) { + // Create Service Provider login request url. try { // RelayState can be used to store the URL to redirect to after login const relayState = req.query.returnTo || process.env.DIR; @@ -124,9 +156,7 @@ async function saml(req, res) { relayState, req.get('host'), // Get host from request { - additionalParams: { - // Add any additional params needed - }, + additionalParams: {}, }, ); @@ -137,65 +167,64 @@ async function saml(req, res) { } } - // - // if (/\/saml\/acs/.exec(req.url)) { - // sp.post_assert( - // idp, - // { - // request_body: req.body, - // }, - // async (err, saml_response) => { - // if (err != null) { - // console.error(err); - // return res.send(500); - // } - // - // logger(saml_response, 'saml_response'); - // - // const user = { - // email: saml_response.user.name_id, - // session_index: saml_response.user.session_index, - // }; - // - // if (process.env.SAML_ACL) { - // const acl_response = await acl_lookup(saml_response.user.name_id); - // - // if (!acl_response) { - // return res.status(401).send('User account not found'); - // } - // - // if (acl_response instanceof Error) { - // return res.status(401).send(acl_response.message); - // } - // - // Object.assign(user, acl_response); - // } - // - // // Create token with 8 hour expiry. - // const token = jwt.sign(user, process.env.SECRET, { - // expiresIn: parseInt(process.env.COOKIE_TTL), - // }); - // - // const cookie = - // `${process.env.TITLE}=${token};HttpOnly;` + - // `Max-Age=${process.env.COOKIE_TTL};` + - // `Path=${process.env.DIR || '/'};`; - // - // res.setHeader('Set-Cookie', cookie); - // - // res.setHeader('location', `${process.env.DIR || '/'}`); - // - // return res.status(302).send(); - // }, - // ); - // } + if (/\/saml\/acs/.exec(req.url)) { + try { + const samlResponse = await strategy.validatePostResponseAsync(req.body); + + logger(samlResponse, 'saml_response'); + + const user = { + email: samlResponse.profile.nameID, + nameID: samlResponse.profile.nameID, + sessionIndex: samlResponse.profile.sessionIndex, + nameIDFormat: samlResponse.profile.nameIDFormat, + nameQualifier: samlResponse.profile.nameQualifier, + spNameQualifier: samlResponse.profile.spNameQualifier, + }; + + if (process.env.SAML_ACL) { + const aclResponse = await aclLookUp(user.email); + + if (!aclResponse) { + return res.status(401).send('User account not found'); + } + + if (aclResponse instanceof Error) { + return res.status(401).send(aclResponse.message); + } + + Object.assign(user, aclResponse); + } + + // Create token with 8 hour expiry. + const token = jwt.sign(user, process.env.SECRET, { + expiresIn: parseInt(process.env.COOKIE_TTL), + }); + + const samlCookie = + `${process.env.TITLE}_SAML=${token};HttpOnly;` + + `Max-Age=${process.env.COOKIE_TTL};` + + `Path=${process.env.DIR || '/'};`; + + const cookie = + `${process.env.TITLE}=${token};HttpOnly;` + + `Max-Age=${process.env.COOKIE_TTL};` + + `Path=${process.env.DIR || '/'};`; + + res.setHeader('Set-Cookie', [cookie, samlCookie]); + + res.redirect(`${process.env.DIR || '/'}`); + } catch (error) { + console.log(error); + } + } } /** -@function acl_lookup +@function aclLookUp @description -The acl_lookup attempts to find a user record by it's email in the ACL. +The aclLookUp attempts to find a user record by it's email in the ACL. The user record will be validated and returned to the requesting saml Assertion Consumer Service [ACS]. @@ -205,7 +234,7 @@ The user record will be validated and returned to the requesting saml Assertion User object or Error. */ -async function acl_lookup(email) { +async function aclLookUp(email) { if (acl === null) { return new Error('ACL unavailable.'); } From 81525ff61598737ac48f4883e6d9c96192880ae7 Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Thu, 9 Jan 2025 11:37:10 +0200 Subject: [PATCH 04/19] Update SLO and SAML Cookie assign --- api/api.js | 4 ++ express.js | 93 +++++++++++++++++++++++++++---------------- mod/user/cookie.js | 98 ++++++++++++++++++++++++++-------------------- mod/user/saml.js | 40 ++++++++++++------- 4 files changed, 144 insertions(+), 91 deletions(-) diff --git a/api/api.js b/api/api.js index e7129d569..4d2b38059 100644 --- a/api/api.js +++ b/api/api.js @@ -160,6 +160,10 @@ module.exports = function api(req, res) { } if (req.params.logout) { + if (process.env.SAML_SLO) { + res.setHeader('location', `${process.env.DIR}/saml/logout`); + return res.status(302).send(); + } // Remove cookie. res.setHeader( 'Set-Cookie', diff --git a/express.js b/express.js index 75d5d6028..0d9a69897 100644 --- a/express.js +++ b/express.js @@ -1,63 +1,88 @@ -require('dotenv').config() +require('dotenv').config(); -const express = require('express') +const express = require('express'); -const cookieParser = require('cookie-parser') +const cookieParser = require('cookie-parser'); -const app = express() +const app = express(); -app.use('/xyz', express.static('docs', { - extensions: ['html'] -})) +app.use( + '/xyz', + express.static('docs', { + extensions: ['html'], + }), +); -app.use(`${process.env.DIR || ''}/public`, express.static('public')) +app.use(`${process.env.DIR || ''}/public`, express.static('public')); -app.use(process.env.DIR || '', express.static('public')) +app.use(process.env.DIR || '', express.static('public')); -app.use(`${process.env.DIR || ''}/tests`, express.static('tests')) +app.use(`${process.env.DIR || ''}/tests`, express.static('tests')); -app.use(process.env.DIR || '', express.static('tests')) +app.use(process.env.DIR || '', express.static('tests')); -app.use(cookieParser()) +app.use(cookieParser()); -const api = require('./api/api') +const api = require('./api/api'); -app.get(`${process.env.DIR || ''}/api/provider/:provider?`, api) +app.get(`${process.env.DIR || ''}/api/provider/:provider?`, api); -app.post(`${process.env.DIR || ''}/api/provider/:provider?`, express.json({ limit: '5mb' }), api) +app.post( + `${process.env.DIR || ''}/api/provider/:provider?`, + express.json({ limit: '5mb' }), + api, +); -app.get(`${process.env.DIR || ''}/api/sign/:signer?`, api) +app.get(`${process.env.DIR || ''}/api/sign/:signer?`, api); +app.get(`${process.env.DIR || ''}/api/query/:template?`, api); -app.get(`${process.env.DIR || ''}/api/query/:template?`, api) +app.post( + `${process.env.DIR || ''}/api/query/:template?`, + express.json({ limit: '5mb' }), + api, +); -app.post(`${process.env.DIR || ''}/api/query/:template?`, express.json({ limit: '5mb' }), api) +app.get(`${process.env.DIR || ''}/api/fetch/:template?`, api); +app.post( + `${process.env.DIR || ''}/api/fetch/:template?`, + express.json({ limit: '5mb' }), + api, +); -app.get(`${process.env.DIR || ''}/api/fetch/:template?`, api) +app.get(`${process.env.DIR || ''}/api/workspace/:key?`, api); -app.post(`${process.env.DIR || ''}/api/fetch/:template?`, express.json({ limit: '5mb' }), api) +app.get(`${process.env.DIR || ''}/api/user/:method?/:key?`, api); +app.post( + `${process.env.DIR || ''}/api/user/:method?`, + [express.urlencoded({ extended: true }), express.json({ limit: '5mb' })], + api, +); -app.get(`${process.env.DIR || ''}/api/workspace/:key?`, api) +app.get(`${process.env.DIR || ''}/saml/metadata`, api); +app.get(`${process.env.DIR || ''}/saml/logout`, api); -app.get(`${process.env.DIR || ''}/api/user/:method?/:key?`, api) +app.get(`${process.env.DIR || ''}/saml/login`, api); -app.post(`${process.env.DIR || ''}/api/user/:method?`, [express.urlencoded({ extended: true }), express.json({ limit: '5mb' })], api) +app.post( + `${process.env.DIR || ''}/saml/acs`, + express.urlencoded({ extended: true }), + api, +); -app.get(`${process.env.DIR || ''}/saml/metadata`, api) +app.post( + `${process.env.DIR || ''}/saml/logout/callback`, + express.urlencoded({ extended: true }), + api, +); -app.get(`${process.env.DIR || ''}/saml/logout`, api) +app.get(`${process.env.DIR || ''}/view/:template?`, api); -app.get(`${process.env.DIR || ''}/saml/login`, api) +app.get(`${process.env.DIR || ''}/:locale?`, api); -app.post(`${process.env.DIR || ''}/saml/acs`, express.urlencoded({ extended: true }), api) +process.env.DIR && app.get(`/`, api); -app.get(`${process.env.DIR || ''}/view/:template?`, api) - -app.get(`${process.env.DIR || ''}/:locale?`, api) - -process.env.DIR && app.get(`/`, api) - -app.listen(process.env.PORT || 3000) \ No newline at end of file +app.listen(process.env.PORT || 3000); diff --git a/mod/user/cookie.js b/mod/user/cookie.js index a93224ae7..fc584cb89 100644 --- a/mod/user/cookie.js +++ b/mod/user/cookie.js @@ -10,11 +10,11 @@ Exports the [user] cookie method for the /api/user/cookie route. @module /user/cookie */ -const acl = require('./acl') +const acl = require('./acl'); -const login = require('./login') +const login = require('./login'); -const jwt = require('jsonwebtoken') +const jwt = require('jsonwebtoken'); /** @function cookie @@ -42,72 +42,86 @@ The token user will be sent back to the client. @property {boolean} [req.params.create] URL parameter flag whether a new cookie should be created. */ module.exports = async function cookie(req, res) { - // acl module will export an empty require object without the ACL being configured. if (acl === null) { - return res.status(500).send('ACL unavailable.') + return res.status(500).send('ACL unavailable.'); } if (req.params.create) { - return login(req, res) + return login(req, res); } - const cookie = req.cookies && req.cookies[process.env.TITLE] + const cookie = req.cookies && req.cookies[process.env.TITLE]; if (!cookie) { return res.send(false); } if (req.params.destroy) { - // Remove cookie. - res.setHeader('Set-Cookie', `${process.env.TITLE}=null;HttpOnly;Max-Age=0;Path=${process.env.DIR || '/'}`) - return res.send('This too shall pass') + res.setHeader( + 'Set-Cookie', + `${process.env.TITLE}=null;HttpOnly;Max-Age=0;Path=${process.env.DIR || '/'}`, + ); + return res.send('This too shall pass'); } - // Verify current cookie - jwt.verify( - cookie, - process.env.SECRET, - async (err, payload) => { + //Getting oldToken object. + //Allows of properties not part of the acl to be passed on. + const oldToken = jwt.decode(cookie); + //Need to delete the exp & iat as jwt sets these. + delete oldToken.exp; + delete oldToken.iat; - if (err) return err + // Verify current cookie + jwt.verify(cookie, process.env.SECRET, async (err, payload) => { + if (err) return err; - // Get updated user credentials from ACL - const rows = await acl(` + // Get updated user credentials from ACL + const rows = await acl( + ` SELECT email, admin, language, roles, blocked FROM acl_schema.acl_table - WHERE lower(email) = lower($1);`, [payload.email]) + WHERE lower(email) = lower($1);`, + [payload.email], + ); - if (rows instanceof Error) { - res.setHeader('Set-Cookie', `${process.env.TITLE}=null;HttpOnly;Max-Age=0;Path=${process.env.DIR || '/'}`) - return res.status(500).send('Failed to retrieve user from ACL'); - } + if (rows instanceof Error) { + res.setHeader( + 'Set-Cookie', + `${process.env.TITLE}=null;HttpOnly;Max-Age=0;Path=${process.env.DIR || '/'}`, + ); + return res.status(500).send('Failed to retrieve user from ACL'); + } - const user = rows[0] + let user = rows[0]; + user = Object.assign(user, oldToken); - // Assign title identifier to user object. - user.title = process.env.TITLE + // Assign title identifier to user object. + user.title = process.env.TITLE; - if (user.blocked) { - res.setHeader('Set-Cookie', `${process.env.TITLE}=null;HttpOnly;Max-Age=0;Path=${process.env.DIR || '/'}`) - return res.status(403).send('Account is blocked'); - } + if (user.blocked) { + res.setHeader( + 'Set-Cookie', + `${process.env.TITLE}=null;HttpOnly;Max-Age=0;Path=${process.env.DIR || '/'}`, + ); + return res.status(403).send('Account is blocked'); + } - delete user.blocked + delete user.blocked; - if (payload.session) { - user.session = payload.session - } + if (payload.session) { + user.session = payload.session; + } - const token = jwt.sign(user, process.env.SECRET, { - expiresIn: parseInt(process.env.COOKIE_TTL) - }) + const token = jwt.sign(user, process.env.SECRET, { + expiresIn: parseInt(process.env.COOKIE_TTL), + }); - const cookie = `${process.env.TITLE}=${token};HttpOnly;Max-Age=${process.env.COOKIE_TTL};Path=${process.env.DIR || '/'};SameSite=Strict${!req.headers.host.includes('localhost') && ';Secure' || ''}` + const cookie = `${process.env.TITLE}=${token};HttpOnly;Max-Age=${process.env.COOKIE_TTL};Path=${process.env.DIR || '/'};SameSite=Strict${(!req.headers.host.includes('localhost') && ';Secure') || ''}`; - res.setHeader('Set-Cookie', cookie) + res.setHeader('Set-Cookie', cookie); - res.send(user) - }) -} \ No newline at end of file + res.send(user); + }); +}; diff --git a/mod/user/saml.js b/mod/user/saml.js index 2ec7f0e92..eb5ea41fe 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -124,25 +124,40 @@ async function saml(req, res) { if (/\/saml\/logout/.exec(req.url)) { try { - const user = await jwt.decode(req.cookies[`${process.env.TITLE}_SAML`]); - const url = await strategy.getLogoutUrlAsync(user); + const user = await jwt.decode(req.cookies[`${process.env.TITLE}`]); + let url = process.env.DIR || '/'; + + if (user.sessionIndex) { + url = await strategy.getLogoutUrlAsync(user); + } else { + //Clear the user cookie. + res.cookie(process.env.TITLE, '', { + httpOnly: true, + expires: new Date(0), + path: process.env.DIR || '/', + }); + } - res.cookie(`${process.env.TITLE}_SAML`, '', { - httpOnly: true, - expires: new Date(0), - path: process.env.DIR || '/', - }); + res.redirect(url); + } catch (error) { + console.error('Logout process failed:', error); + return res.redirect('/'); + } + } + if (/\/saml\/logout\/callback/.exec(req.url)) { + try { + //Clear the user cookie. res.cookie(process.env.TITLE, '', { httpOnly: true, expires: new Date(0), path: process.env.DIR || '/', }); - res.redirect(url); + res.redirect('/'); } catch (error) { console.error('Logout process failed:', error); - return res.redirect('/'); + res.redirect('/'); } } @@ -201,17 +216,12 @@ async function saml(req, res) { expiresIn: parseInt(process.env.COOKIE_TTL), }); - const samlCookie = - `${process.env.TITLE}_SAML=${token};HttpOnly;` + - `Max-Age=${process.env.COOKIE_TTL};` + - `Path=${process.env.DIR || '/'};`; - const cookie = `${process.env.TITLE}=${token};HttpOnly;` + `Max-Age=${process.env.COOKIE_TTL};` + `Path=${process.env.DIR || '/'};`; - res.setHeader('Set-Cookie', [cookie, samlCookie]); + res.setHeader('Set-Cookie', cookie); res.redirect(`${process.env.DIR || '/'}`); } catch (error) { From 1a2f59e48a8898d68e3513ab44de810a0840f3d2 Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Thu, 9 Jan 2025 15:18:29 +0200 Subject: [PATCH 05/19] Update logout callback --- mod/user/saml.js | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/mod/user/saml.js b/mod/user/saml.js index eb5ea41fe..8767d9152 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -122,9 +122,28 @@ async function saml(req, res) { res.send(metadata); } + if (/\/saml\/logout\/callback/.exec(req.url)) { + try { + // await strategy.validatePostLogoutResponseAsync(req.body); + + res.cookie(process.env.TITLE, '', { + httpOnly: true, + expires: new Date(0), + path: process.env.DIR || '/', + }); + + res.redirect('/'); + } catch (error) { + console.error('Logout validation failed:', error); + return res.redirect('/'); + } + } + if (/\/saml\/logout/.exec(req.url)) { try { const user = await jwt.decode(req.cookies[`${process.env.TITLE}`]); + + if (!user) return; let url = process.env.DIR || '/'; if (user.sessionIndex) { @@ -145,22 +164,6 @@ async function saml(req, res) { } } - if (/\/saml\/logout\/callback/.exec(req.url)) { - try { - //Clear the user cookie. - res.cookie(process.env.TITLE, '', { - httpOnly: true, - expires: new Date(0), - path: process.env.DIR || '/', - }); - - res.redirect('/'); - } catch (error) { - console.error('Logout process failed:', error); - res.redirect('/'); - } - } - if (req.params?.login || /\/saml\/login/.exec(req.url)) { // Create Service Provider login request url. try { From 34082a30e42c606eaab9a1384fa21a2ff257d038 Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Thu, 9 Jan 2025 17:21:55 +0200 Subject: [PATCH 06/19] Update publicCert --- mod/user/saml.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mod/user/saml.js b/mod/user/saml.js index 8767d9152..972a5cadb 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -53,14 +53,13 @@ try { String( readFileSync(join(__dirname, `../../${process.env.SAML_SP_CRT}.pem`)), ), - publicKey: + publicCert: process.env.SAML_SP_CRT && String( readFileSync(join(__dirname, `../../${process.env.SAML_SP_CRT}.crt`)), ), logoutUrl: process.env.SAML_SLO, wantAuthnResponseSigned: false, - acceptedClockSkewMs: -1, // Set to -1 to disable time validation }; /** @type {SAML} */ From 9f300164047b502d04bc564d9f72dfa98503475a Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Thu, 9 Jan 2025 17:36:21 +0200 Subject: [PATCH 07/19] rename strat obj --- mod/user/saml.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/mod/user/saml.js b/mod/user/saml.js index 972a5cadb..4b0dcd8a7 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -24,7 +24,7 @@ The idp requires a certificate `${process.env.SAML_IDP_CRT}.crt`, single sign-on @module /user/saml */ -let strategy, samlConfig, logger, jwt, acl; +let samlStrat, samlConfig, logger, jwt, acl; try { const { SAML } = require('@node-saml/node-saml'); @@ -63,7 +63,7 @@ try { }; /** @type {SAML} */ - strategy = new SAML(samlConfig); + samlStrat = new SAML(samlConfig); module.exports = saml; } catch { @@ -106,7 +106,7 @@ The user object is signed as a JSON Web Token and set as a cookie to the HTTP re */ async function saml(req, res) { - if (!strategy) { + if (!samlStrat) { console.warn(`SAML is not available in XYZ instance.`); return; } @@ -114,7 +114,7 @@ async function saml(req, res) { // Return metadata. if (/\/saml\/metadata/.exec(req.url)) { res.setHeader('Content-Type', 'application/xml'); - const metadata = strategy.generateServiceProviderMetadata( + const metadata = samlStrat.generateServiceProviderMetadata( null, samlConfig.idpCert, ); @@ -146,7 +146,7 @@ async function saml(req, res) { let url = process.env.DIR || '/'; if (user.sessionIndex) { - url = await strategy.getLogoutUrlAsync(user); + url = await samlStrat.getLogoutUrlAsync(user); } else { //Clear the user cookie. res.cookie(process.env.TITLE, '', { @@ -169,7 +169,7 @@ async function saml(req, res) { // RelayState can be used to store the URL to redirect to after login const relayState = req.query.returnTo || process.env.DIR; - const url = await strategy.getAuthorizeUrlAsync( + const url = await samlStrat.getAuthorizeUrlAsync( relayState, req.get('host'), // Get host from request { @@ -186,7 +186,7 @@ async function saml(req, res) { if (/\/saml\/acs/.exec(req.url)) { try { - const samlResponse = await strategy.validatePostResponseAsync(req.body); + const samlResponse = await samlStrat.validatePostResponseAsync(req.body); logger(samlResponse, 'saml_response'); From 2bd56297ffaef84854069a753707b65595e1d5e3 Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Thu, 9 Jan 2025 17:36:50 +0200 Subject: [PATCH 08/19] Clean up --- mod/user/saml.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/mod/user/saml.js b/mod/user/saml.js index 4b0dcd8a7..b0e11bd62 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -123,8 +123,6 @@ async function saml(req, res) { if (/\/saml\/logout\/callback/.exec(req.url)) { try { - // await strategy.validatePostLogoutResponseAsync(req.body); - res.cookie(process.env.TITLE, '', { httpOnly: true, expires: new Date(0), From 08a6a348e01615d86d6547be2f7d1458fa844088 Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Thu, 9 Jan 2025 18:06:27 +0200 Subject: [PATCH 09/19] =?UTF-8?q?These=20cookies=20go=20to=20zero=20?= =?UTF-8?q?=F0=9F=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mod/user/saml.js | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/mod/user/saml.js b/mod/user/saml.js index b0e11bd62..426643893 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -123,13 +123,14 @@ async function saml(req, res) { if (/\/saml\/logout\/callback/.exec(req.url)) { try { - res.cookie(process.env.TITLE, '', { - httpOnly: true, - expires: new Date(0), - path: process.env.DIR || '/', - }); + // Most blokes will be settin' their cookies at UTC midnight + // Where can you go from there? Nowhere. + res.setHeader( + 'Set-Cookie', + `${process.env.TITLE}=; HttpOnly; Path=${process.env.DIR || '/'}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`, // But these cookies go to zero. That's one less. + ); - res.redirect('/'); + res.redirect(`${process.env.DIR || '/'}`); } catch (error) { console.error('Logout validation failed:', error); return res.redirect('/'); @@ -146,12 +147,12 @@ async function saml(req, res) { if (user.sessionIndex) { url = await samlStrat.getLogoutUrlAsync(user); } else { - //Clear the user cookie. - res.cookie(process.env.TITLE, '', { - httpOnly: true, - expires: new Date(0), - path: process.env.DIR || '/', - }); + // Most blokes will be settin' their cookies at UTC midnight + // Where can you go from there? Nowhere. + res.setHeader( + 'Set-Cookie', + `${process.env.TITLE}=; HttpOnly; Path=${process.env.DIR || '/'}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`, // But these cookies go to zero. That's one less. + ); } res.redirect(url); @@ -185,9 +186,6 @@ async function saml(req, res) { if (/\/saml\/acs/.exec(req.url)) { try { const samlResponse = await samlStrat.validatePostResponseAsync(req.body); - - logger(samlResponse, 'saml_response'); - const user = { email: samlResponse.profile.nameID, nameID: samlResponse.profile.nameID, From feb6c6dc20f5437afecfca83866ea6125b591301 Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Fri, 10 Jan 2025 11:41:59 +0200 Subject: [PATCH 10/19] Update docs --- api/api.js | 20 +++-- mod/user/saml.js | 225 +++++++++++++++++++++++++++++++++-------------- 2 files changed, 174 insertions(+), 71 deletions(-) diff --git a/api/api.js b/api/api.js index 4d2b38059..604787d93 100644 --- a/api/api.js +++ b/api/api.js @@ -73,13 +73,19 @@ The process.ENV object holds configuration provided to the node process from the @property {String} [KEY_CLOUDFRONT] A key [*.pem] file matching the KEY_CLOUDFRONT value is required for authentication requests in the [cloudfront]{@link module:/provider/cloudfront} provider module. @property {String} [AWS_S3_CLIENT] A AWS_S3_CLIENT env is required to sign requests with the [s3]{@link module:/sign/s3} signer module. @property {String} [CLOUDINARY_URL] A CLOUDINARY_URL env is required to sign requests with the [cloudinary]{@link module:/sign/cloudinary} signer module. -@property {String} [SAML_ENTITY_ID] Required authentication via [SAML]{@link module:/user/saml}. -@property {String} [SAML_LOGIN] Required authentication via [SAML]{@link module:/user/saml}. -@property {String} [SAML_SP_CRT] Required authentication via [SAML]{@link module:/user/saml}. -@property {String} [SAML_ACS] Required authentication via [SAML]{@link module:/user/saml}. -@property {String} [SAML_SSO] Required authentication via [SAML]{@link module:/user/saml}. -@property {String} [SAML_SLO] Required authentication via [SAML]{@link module:/user/saml}. -@property {String} [SAML_IDP_CRT] Required authentication via [SAML]{@link module:/user/saml}. +@property {String} [SAML_ACS] - Assertion Consumer Service URL where SAML responses are received +@property {String} [SAML_SSO] - Single Sign-On URL of the Identity Provider +@property {String} [SAML_SLO] - Single Logout URL for terminating sessions +@property {String} [SAML_ENTITY_ID] - Service Provider Entity ID (your application identifier) +@property {String} [SAML_IDP_CRT] - Path to IdP certificate file for validation +@property {String} [SAML_SP_CRT] - Base name for SP certificate pair files +@property {String} [SAML_WANT_ASSERTIONS_SIGNED] - Require signed assertions (true/false) +@property {String} [SAML_AUTHN_RESPONSE_SIGNED] - Require signed responses (true/false) +@property {String} [SAML_SIGNATURE_ALGORITHM] - Algorithm for signing (e.g., 'sha256') +@property {String} [SAML_IDENTIFIER_FORMAT] - Format for name identifiers +@property {String} [SAML_ACCEPTED_CLOCK_SKEW] - Allowed time difference in ms +@property {String} [SAML_PROVIDER_NAME] - Display name for your service +@property {String} [SLO_CALLBACK] - URL for handling logout callbacks */ const login = require('../mod/user/login'); diff --git a/mod/user/saml.js b/mod/user/saml.js index 426643893..8cbca2dfb 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -1,48 +1,122 @@ /** -## /user/saml - -The SAML user module exports the saml method as an endpoint for request authentication via SAML. - -The module requires the saml2-js module library to be installed. - -The availability of the module [required] is tried during the module initialisation. - -If the module is not available, a warning is logged to the console. - -The SAML Service Provider [sp] and Identity Provider [idp] are stored in module variables. - -Succesful declaration of the sp and idp requires a Service Provider certificatate key pair `${process.env.SAML_SP_CRT}.pem` and `${process.env.SAML_SP_CRT}.crt` in the XYZ process root. - -An Assertation Consumer Service [ACS] endpoint must be provided as `process.env.SAML_ACS` - -The idp requires a certificate `${process.env.SAML_IDP_CRT}.crt`, single sign-on [SSO] login url `process.env.SAML_SSO` and logout url `process.env.SAML_SLO`. - -@requires module:/utils/logger -@requires jsonwebtoken -@requires saml2-js + ### SAML Authentication Setup + +This module handles SAML-based Single Sign-On (SSO) authentication. Here's how to set it up: + +1. Certificate Generation + Generate your Service Provider (SP) certificate pair: + ```bash + # Generate private key + openssl genrsa -out ${SAML_SP_CRT}.pem 2048 + + # Generate public certificate + openssl req -new -x509 -key ${SAML_SP_CRT}.pem -out ${SAML_SP_CRT}.crt -days 36500 + ``` + +2. File Structure Setup + Place certificates in your project root: + ``` + /xyz + ├── ${SAML_SP_CRT}.pem # Your SP private key + ├── ${SAML_SP_CRT}.crt # Your SP public certificate + └── ${SAML_IDP_CRT}.crt # Identity Provider's certificate + ``` + +3. Identity Provider Setup + Configure your IdP (e.g., Auth0, Okta) with: + - Your SP's Entity ID (issuer) + - Your SP's public certificate (${SAML_SP_CRT}.crt) + - Your ACS URL (callback URL) + +4. Environment Variables + Required variables for SAML strategy initialization: + + ```env + # Required Core Settings + SAML_ACS=http://your-domain/saml/acs + SAML_SSO=https://your-idp/saml/login + SAML_ENTITY_ID=your-service-identifier + + # Certificate Paths (without file extensions) + SAML_SP_CRT=sp_certificate + SAML_IDP_CRT=idp_certificate + + # Additional Settings + SAML_SLO=https://your-idp/saml/logout + SAML_SIGNATURE_ALGORITHM=sha256 + ``` + +5. SAML Strategy Initialization + The strategy is initialized with these components: + + ```javascript + samlStrat = new SAML({ + callbackUrl, // Where SAML responses are received + entryPoint, // IdP's SSO endpoint + issuer, // Your SP identifier + idpCert, // IdP's certificate for validation + privateKey, // Your private key for signing + publicCert // Your public cert for IdP + }); + ``` +6. Security Considerations + - Keep private keys secure + - Use strong signature algorithms + - Configure proper certificate expiry + - Implement proper session management + +@typedef {Object} SamlConfig Configuration for SAML authentication +@property {string} callbackUrl - URL where IdP sends SAML response (ACS endpoint) +@property {string} entryPoint - IdP's login URL for SSO +@property {string} issuer - Identifier/Entity ID for your Service Provider +@property {string} idpCert - Identity Provider's certificate for verification +@property {string} privateKey - Your private key for signing requests +@property {string} publicCert - Your public certificate shared with IdP +@property {string} logoutUrl - URL for single logout (SLO) +@property {boolean} wantAssertionsSigned - Whether assertions must be signed +@property {boolean} wantAuthnResponseSigned - Whether responses must be signed +@property {string} signatureAlgorithm - Algorithm for signing SAML requests +@property {string} identifierFormat - Format of the Name Identifier +@property {number} acceptedClockSkewMs - Allowed clock skew in milliseconds +@property {string} providerName - Name of the Service Provider +@property {string} logoutCallbackUrl - URL for logout callbacks + +@requires [@node-saml/node-saml] - SAML protocol implementation +@requires module:/utils/logger - Logging utility +@requires jsonwebtoken - JWT handling +@requires path - File path operations +@requires fs - File system operations + +Module Variables: +@type {SAML} samlStrat - SAML strategy instance for authentication operations +@type {SamlConfig} samlConfig - Configuration object for SAML settings +@type {Object} logger - Utility for logging operations +@type {Object} jwt - For handling JSON Web Tokens +@type {Object} acl - Access Control List management @module /user/saml -*/ +**/ let samlStrat, samlConfig, logger, jwt, acl; try { + // Import required dependencies const { SAML } = require('@node-saml/node-saml'); - const { join } = require('path'); - const { readFileSync } = require('fs'); + // Import utility modules logger = require('../../mod/utils/logger'); - jwt = require('jsonwebtoken'); - acl = require('../user/acl.js'); + // Initialize SAML configuration samlConfig = { callbackUrl: process.env.SAML_ACS, entryPoint: process.env.SAML_SSO, issuer: process.env.SAML_ENTITY_ID, + + // Read and configure certificates idpCert: process.env.SAML_IDP_CRT && String( @@ -58,60 +132,76 @@ try { String( readFileSync(join(__dirname, `../../${process.env.SAML_SP_CRT}.crt`)), ), + + // Configure SAML endpoints and behavior logoutUrl: process.env.SAML_SLO, - wantAuthnResponseSigned: false, + wantAssertionsSigned: process.env.SAML_WANT_ASSERTIONS_SIGNED, + wantAuthnResponseSigned: process.env.SAML_AUTHN_RESPONSE_SIGNED ?? false, + signatureAlgorithm: process.env.SAML_SIGNATURE_ALGORITHM, + identifierFormat: process.env.SAML_IDENTIFIER_FORMAT, + acceptedClockSkewMs: process.env.SAML_ACCEPTED_CLOCK_SKEW, + providerName: process.env.SAML_PROVIDER_NAME, + logoutCallbackUrl: process.env.SLO_CALLBACK, }; - /** @type {SAML} */ + // Create SAML strategy instance samlStrat = new SAML(samlConfig); - module.exports = saml; } catch { - //Check if there are any SAML keys in the process. + // Check for SAML-related environment variables const samlKeys = Object.keys(process.env).filter((key) => key.startsWith('SAML'), ); - //If we have keys then log we that the module is not present + // Log warning if SAML variables exist but module fails to initialize if (samlKeys.length > 0) { console.log('SAML2 module is not available.'); } - module.exports = null; } - /** @function saml - -@description -The saml method requires the sp and idp module variables to be declared as saml2 Service and Identity provider. - -The `req.url` path is matched with either the `metadata`, `login`, or `acs` methods. - -The saml metadata will be sent as `application/xml` content if requested. - -The `saml/login` request path will redirect the request to a saml login request url created by the Service Provider [sp]. - -The sp will assert a post body sent to the `saml/acs` endpoint. - -A lookup of the ACL user record will be attempted by the acl_lookup method. - -The acl record with the user roles will be assigned to the user object from the saml token email. - -The user object is signed as a JSON Web Token and set as a cookie to the HTTP response header. - -@param {Object} req HTTP request. -@param {string} req.url Request path. -@param {Object} res HTTP response. -*/ - +@description Handles SAML authentication flow endpoints and operations + +Provides endpoints for: +- /saml/metadata: Returns SP metadata in XML format +- /saml/login: Initiates SAML login flow +- /saml/logout: Handles SAML logout +- /saml/acs: Assertion Consumer Service endpoint +- /saml/logout/callback: Handles logout callback + +Authentication Flow: +1. User hits login endpoint +2. Gets redirected to IdP +3. IdP authenticates and sends SAML response +4. Response is validated at ACS endpoint +5. User profile is created from SAML attributes +6. Optional ACL lookup enriches profile +7. JWT token created and set as cookie + +@param {Object} req - HTTP request object +@param {req} req.url - Request URL path +@param {req} req.body - POST request body +@param {req} req.query - URL query parameters +@param {req} req.cookies - Request cookies +@param {req} req.params - Route parameters + +@param {Object} res - HTTP response object +@param {res} res.redirect - Redirect function +@param {res} res.send - Send response function +@param {res} res.setHeader - Set response header + +@throws {Error} If SAML is not configured +@throws {Error} If authentication fails +**/ async function saml(req, res) { + // Check SAML availability if (!samlStrat) { console.warn(`SAML is not available in XYZ instance.`); return; } - // Return metadata. + // Metadata endpoint - returns SP configuration if (/\/saml\/metadata/.exec(req.url)) { res.setHeader('Content-Type', 'application/xml'); const metadata = samlStrat.generateServiceProviderMetadata( @@ -121,6 +211,7 @@ async function saml(req, res) { res.send(metadata); } + // Logout callback endpoint - clears session if (/\/saml\/logout\/callback/.exec(req.url)) { try { // Most blokes will be settin' their cookies at UTC midnight @@ -137,6 +228,7 @@ async function saml(req, res) { } } + // Logout endpoint - initiates logout flow if (/\/saml\/logout/.exec(req.url)) { try { const user = await jwt.decode(req.cookies[`${process.env.TITLE}`]); @@ -145,6 +237,7 @@ async function saml(req, res) { let url = process.env.DIR || '/'; if (user.sessionIndex) { + // Get logout URL from IdP if session exists url = await samlStrat.getLogoutUrlAsync(user); } else { // Most blokes will be settin' their cookies at UTC midnight @@ -162,18 +255,17 @@ async function saml(req, res) { } } + // Login endpoint - starts authentication flow if (req.params?.login || /\/saml\/login/.exec(req.url)) { - // Create Service Provider login request url. try { - // RelayState can be used to store the URL to redirect to after login + // Get return URL from query or default to base dir const relayState = req.query.returnTo || process.env.DIR; + // Get authorization URL from IdP const url = await samlStrat.getAuthorizeUrlAsync( relayState, - req.get('host'), // Get host from request - { - additionalParams: {}, - }, + req.get('host'), + { additionalParams: {} }, ); res.redirect(url); @@ -183,9 +275,13 @@ async function saml(req, res) { } } + // ACS endpoint - processes SAML response if (/\/saml\/acs/.exec(req.url)) { try { + // Validate SAML response const samlResponse = await samlStrat.validatePostResponseAsync(req.body); + + // Create user Object from SAML attributes const user = { email: samlResponse.profile.nameID, nameID: samlResponse.profile.nameID, @@ -195,6 +291,7 @@ async function saml(req, res) { spNameQualifier: samlResponse.profile.spNameQualifier, }; + // Perform ACL lookup if enabled if (process.env.SAML_ACL) { const aclResponse = await aclLookUp(user.email); @@ -209,7 +306,7 @@ async function saml(req, res) { Object.assign(user, aclResponse); } - // Create token with 8 hour expiry. + // Create JWT token and set cookie const token = jwt.sign(user, process.env.SECRET, { expiresIn: parseInt(process.env.COOKIE_TTL), }); From e99e78d37432cd5826e667858fb627e50cffa30a Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Fri, 10 Jan 2025 12:58:27 +0200 Subject: [PATCH 11/19] Update saml complexity --- eslint.config.mjs | 30 ++--- mod/user/saml.js | 275 +++++++++++++++++++++++++++++----------------- 2 files changed, 191 insertions(+), 114 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 5a624d6ea..28b1a0215 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,18 +5,22 @@ export default [ { files: ['**/*.js', '**/*.mjs'], rules: { - quotes: ['error', 'single', { 'allowTemplateLiterals': true }], - 'prefer-const': ['error', { - 'destructuring': 'any', - 'ignoreReadBeforeAssign': true - }], - 'max-depth': ['error', + quotes: ['error', 'single', { allowTemplateLiterals: true }], + 'prefer-const': [ + 'error', { - 'max': 4 - } + destructuring: 'any', + ignoreReadBeforeAssign: true, + }, ], - // 'complexity': ['error', { 'max': 15 }], - 'no-nested-ternary': 'error' - } - } -]; \ No newline at end of file + 'max-depth': [ + 'error', + { + max: 4, + }, + ], + // complexity: ['error', { max: 15 }], + 'no-nested-ternary': 'error', + }, + }, +]; diff --git a/mod/user/saml.js b/mod/user/saml.js index 8cbca2dfb..bcbb18d83 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -139,7 +139,7 @@ try { wantAuthnResponseSigned: process.env.SAML_AUTHN_RESPONSE_SIGNED ?? false, signatureAlgorithm: process.env.SAML_SIGNATURE_ALGORITHM, identifierFormat: process.env.SAML_IDENTIFIER_FORMAT, - acceptedClockSkewMs: process.env.SAML_ACCEPTED_CLOCK_SKEW, + acceptedClockSkewMs: process.env.SAML_ACCEPTED_CLOCK_SKEW ?? -1, providerName: process.env.SAML_PROVIDER_NAME, logoutCallbackUrl: process.env.SLO_CALLBACK, }; @@ -194,134 +194,207 @@ Authentication Flow: @throws {Error} If SAML is not configured @throws {Error} If authentication fails **/ -async function saml(req, res) { +function saml(req, res) { // Check SAML availability if (!samlStrat) { console.warn(`SAML is not available in XYZ instance.`); return; } - // Metadata endpoint - returns SP configuration - if (/\/saml\/metadata/.exec(req.url)) { - res.setHeader('Content-Type', 'application/xml'); - const metadata = samlStrat.generateServiceProviderMetadata( - null, - samlConfig.idpCert, + switch (true) { + // Metadata endpoint - returns SP configuration + case /\/saml\/metadata/.test(req.url): + metadata(req, res); + break; + + // Logout callback endpoint - clears session + case /\/saml\/logout\/callback/.test(req.url): + logoutCallback(res); + break; + + // Logout endpoint - initiates logout flow + case /\/saml\/logout/.test(req.url): + logout(req, res); + break; + + // Login endpoint - starts authentication flow + case req.params?.login || /\/saml\/login/.test(req.url): + login(req, res); + break; + + // ACS endpoint - processes SAML response + case /\/saml\/acs/.test(req.url): + acs(req, res); + break; + } +} + +/** +@function metadata +@description Handles the metadata response + +@param {Object} res - HTTP response object +@param {res} res.redirect - Redirect function +@param {res} res.send - Send response function +@param {res} res.setHeader - Set response header +**/ +function metadata(res) { + res.setHeader('Content-Type', 'application/xml'); + const metadata = samlStrat.generateServiceProviderMetadata( + null, + samlConfig.idpCert, + ); + res.send(metadata); +} + +/** +@function logoutCallback +@description Handles the logoutCallback POST from the idp + +@param {Object} res - HTTP response object +@param {res} res.redirect - Redirect function +@param {res} res.setHeader - Set response header +**/ +function logoutCallback(res) { + try { + // Most blokes will be settin' their cookies at UTC midnight + // Where can you go from there? Nowhere. + res.setHeader( + 'Set-Cookie', + `${process.env.TITLE}=; HttpOnly; Path=${process.env.DIR || '/'}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`, // But these cookies go to zero. That's one less. ); - res.send(metadata); + + res.redirect(`${process.env.DIR || '/'}`); + } catch (error) { + console.error('Logout validation failed:', error); + return res.redirect('/'); } +} + +/** +@function logout +@description Handles the logout request from the api.js + +@param {Object} req - HTTP request object +@param {req} req.cookies - Request Cookies + +@param {Object} res - HTTP response object +@param {res} res.redirect - Redirect function +@param {res} res.setHeader - Set response header +**/ +async function logout(req, res) { + try { + const user = await jwt.decode(req.cookies[`${process.env.TITLE}`]); - // Logout callback endpoint - clears session - if (/\/saml\/logout\/callback/.exec(req.url)) { - try { + if (!user) return; + let url = process.env.DIR || '/'; + + if (user.sessionIndex) { + // Get logout URL from IdP if session exists + url = await samlStrat.getLogoutUrlAsync(user); + } else { // Most blokes will be settin' their cookies at UTC midnight // Where can you go from there? Nowhere. res.setHeader( 'Set-Cookie', `${process.env.TITLE}=; HttpOnly; Path=${process.env.DIR || '/'}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`, // But these cookies go to zero. That's one less. ); - - res.redirect(`${process.env.DIR || '/'}`); - } catch (error) { - console.error('Logout validation failed:', error); - return res.redirect('/'); } + + res.redirect(url); + } catch (error) { + console.error('Logout process failed:', error); + return res.redirect('/'); } +} - // Logout endpoint - initiates logout flow - if (/\/saml\/logout/.exec(req.url)) { - try { - const user = await jwt.decode(req.cookies[`${process.env.TITLE}`]); - - if (!user) return; - let url = process.env.DIR || '/'; - - if (user.sessionIndex) { - // Get logout URL from IdP if session exists - url = await samlStrat.getLogoutUrlAsync(user); - } else { - // Most blokes will be settin' their cookies at UTC midnight - // Where can you go from there? Nowhere. - res.setHeader( - 'Set-Cookie', - `${process.env.TITLE}=; HttpOnly; Path=${process.env.DIR || '/'}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`, // But these cookies go to zero. That's one less. - ); - } +/** +@function login +@description Handles the login request from the api.js and redirects to login url. - res.redirect(url); - } catch (error) { - console.error('Logout process failed:', error); - return res.redirect('/'); - } - } +@param {Object} req - HTTP request object +@param {req} req.get - Request get function - // Login endpoint - starts authentication flow - if (req.params?.login || /\/saml\/login/.exec(req.url)) { - try { - // Get return URL from query or default to base dir - const relayState = req.query.returnTo || process.env.DIR; - - // Get authorization URL from IdP - const url = await samlStrat.getAuthorizeUrlAsync( - relayState, - req.get('host'), - { additionalParams: {} }, - ); +@param {Object} res - HTTP response object +@param {res} res.redirect - Redirect function +**/ +async function login(req, res) { + try { + // Get return URL from query or default to base dir + const relayState = process.env.DIR ?? '/'; + + // Get authorization URL from IdP + const url = await samlStrat.getAuthorizeUrlAsync( + relayState, + req.get('host'), + { additionalParams: {} }, + ); - res.redirect(url); - } catch (error) { - console.error('SAML authorization error:', error); - res.status(500).send('Authentication failed'); - } + res.redirect(url); + } catch (error) { + console.error('SAML authorization error:', error); + res.status(500).send('Authentication failed'); } +} - // ACS endpoint - processes SAML response - if (/\/saml\/acs/.exec(req.url)) { - try { - // Validate SAML response - const samlResponse = await samlStrat.validatePostResponseAsync(req.body); - - // Create user Object from SAML attributes - const user = { - email: samlResponse.profile.nameID, - nameID: samlResponse.profile.nameID, - sessionIndex: samlResponse.profile.sessionIndex, - nameIDFormat: samlResponse.profile.nameIDFormat, - nameQualifier: samlResponse.profile.nameQualifier, - spNameQualifier: samlResponse.profile.spNameQualifier, - }; - - // Perform ACL lookup if enabled - if (process.env.SAML_ACL) { - const aclResponse = await aclLookUp(user.email); - - if (!aclResponse) { - return res.status(401).send('User account not found'); - } - - if (aclResponse instanceof Error) { - return res.status(401).send(aclResponse.message); - } - - Object.assign(user, aclResponse); - } +/** +@function acs +@description Handles the acs POST request from the idp - // Create JWT token and set cookie - const token = jwt.sign(user, process.env.SECRET, { - expiresIn: parseInt(process.env.COOKIE_TTL), - }); +@param {Object} req - HTTP request object +@param {req} req.body - Request Body - const cookie = - `${process.env.TITLE}=${token};HttpOnly;` + - `Max-Age=${process.env.COOKIE_TTL};` + - `Path=${process.env.DIR || '/'};`; +@param {Object} res - HTTP response object +@param {res} res.redirect - Redirect function +@param {res} res.status - request status +@param {res} res.send - Send response function +@param {res} res.setHeader - Set response header +**/ +async function acs(req, res) { + try { + // Validate SAML response + const samlResponse = await samlStrat.validatePostResponseAsync(req.body); + + // Create user Object from SAML attributes + const user = { + email: samlResponse.profile.nameID, + nameID: samlResponse.profile.nameID, + sessionIndex: samlResponse.profile.sessionIndex, + nameIDFormat: samlResponse.profile.nameIDFormat, + nameQualifier: samlResponse.profile.nameQualifier, + spNameQualifier: samlResponse.profile.spNameQualifier, + }; + + // Perform ACL lookup if enabled + if (process.env.SAML_ACL) { + const aclResponse = await aclLookUp(user.email); + + if (!aclResponse) { + res.status(401).send('User account not found'); + } - res.setHeader('Set-Cookie', cookie); + if (aclResponse instanceof Error) { + res.status(401).send(aclResponse.message); + } - res.redirect(`${process.env.DIR || '/'}`); - } catch (error) { - console.log(error); + Object.assign(user, aclResponse); } + + // Create JWT token and set cookie + const token = jwt.sign(user, process.env.SECRET, { + expiresIn: parseInt(process.env.COOKIE_TTL), + }); + + const cookie = + `${process.env.TITLE}=${token};HttpOnly;` + + `Max-Age=${process.env.COOKIE_TTL};` + + `Path=${process.env.DIR || '/'};`; + + res.setHeader('Set-Cookie', cookie); + + res.redirect(`${process.env.DIR || '/'}`); + } catch (error) { + console.log(error); } } From 28a49a0887ced83ea59dd5d6e491b06c35b60f72 Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Fri, 10 Jan 2025 13:01:18 +0200 Subject: [PATCH 12/19] Update metadata function --- mod/user/saml.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mod/user/saml.js b/mod/user/saml.js index bcbb18d83..c906f55fc 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -204,7 +204,7 @@ function saml(req, res) { switch (true) { // Metadata endpoint - returns SP configuration case /\/saml\/metadata/.test(req.url): - metadata(req, res); + metadata(res); break; // Logout callback endpoint - clears session From fee71f8354d5a7bda8d942c9bdc47ae2ed95c4ee Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Fri, 10 Jan 2025 13:04:37 +0200 Subject: [PATCH 13/19] Update cookie assignment --- mod/user/cookie.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mod/user/cookie.js b/mod/user/cookie.js index fc584cb89..05929f947 100644 --- a/mod/user/cookie.js +++ b/mod/user/cookie.js @@ -51,7 +51,7 @@ module.exports = async function cookie(req, res) { return login(req, res); } - const cookie = req.cookies && req.cookies[process.env.TITLE]; + const cookie = req.cookies?.[process.env.TITLE]; if (!cookie) { return res.send(false); From 0a18dc4faf770bd78da65a1a4aeee8c8f900ea0f Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Fri, 10 Jan 2025 16:20:20 +0200 Subject: [PATCH 14/19] remove express reference --- mod/user/saml.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mod/user/saml.js b/mod/user/saml.js index c906f55fc..ea76a9ef9 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -326,7 +326,7 @@ async function login(req, res) { // Get authorization URL from IdP const url = await samlStrat.getAuthorizeUrlAsync( relayState, - req.get('host'), + req.headers['x-forwarded-host'], { additionalParams: {} }, ); From ae939a5de9e222af578f726ce07993fadc6b494f Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Fri, 10 Jan 2025 16:51:46 +0200 Subject: [PATCH 15/19] Update user redirect --- mod/user/saml.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mod/user/saml.js b/mod/user/saml.js index ea76a9ef9..ee65068db 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -286,7 +286,11 @@ async function logout(req, res) { try { const user = await jwt.decode(req.cookies[`${process.env.TITLE}`]); - if (!user) return; + // If no user/cookie, redirect to home + if (!user) { + return res.redirect(process.env.DIR || '/'); + } + let url = process.env.DIR || '/'; if (user.sessionIndex) { From d437e0a4a94882bd08069c6cb5fa9c5d5c518845 Mon Sep 17 00:00:00 2001 From: dbauszus-glx Date: Fri, 10 Jan 2025 16:00:03 +0000 Subject: [PATCH 16/19] destroy session on failed saml login --- mod/user/saml.js | 51 +++++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/mod/user/saml.js b/mod/user/saml.js index ee65068db..ec0a09b85 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -1,6 +1,6 @@ /** - ### SAML Authentication Setup - +### SAML Authentication Setup + This module handles SAML-based Single Sign-On (SSO) authentication. Here's how to set it up: 1. Certificate Generation @@ -65,6 +65,23 @@ This module handles SAML-based Single Sign-On (SSO) authentication. Here's how t - Configure proper certificate expiry - Implement proper session management +@requires [@node-saml/node-saml] - SAML protocol implementation +@requires module:/utils/logger - Logging utility +@requires jsonwebtoken - JWT handling +@requires path - File path operations +@requires fs - File system operations + +Module Variables: +@type {SAML} samlStrat - SAML strategy instance for authentication operations +@type {SamlConfig} samlConfig - Configuration object for SAML settings +@type {Object} logger - Utility for logging operations +@type {Object} jwt - For handling JSON Web Tokens +@type {Object} acl - Access Control List management + +@module /user/saml +**/ + +/** @typedef {Object} SamlConfig Configuration for SAML authentication @property {string} callbackUrl - URL where IdP sends SAML response (ACS endpoint) @property {string} entryPoint - IdP's login URL for SSO @@ -80,21 +97,6 @@ This module handles SAML-based Single Sign-On (SSO) authentication. Here's how t @property {number} acceptedClockSkewMs - Allowed clock skew in milliseconds @property {string} providerName - Name of the Service Provider @property {string} logoutCallbackUrl - URL for logout callbacks - -@requires [@node-saml/node-saml] - SAML protocol implementation -@requires module:/utils/logger - Logging utility -@requires jsonwebtoken - JWT handling -@requires path - File path operations -@requires fs - File system operations - -Module Variables: -@type {SAML} samlStrat - SAML strategy instance for authentication operations -@type {SamlConfig} samlConfig - Configuration object for SAML settings -@type {Object} logger - Utility for logging operations -@type {Object} jwt - For handling JSON Web Tokens -@type {Object} acl - Access Control List management - -@module /user/saml **/ let samlStrat, samlConfig, logger, jwt, acl; @@ -297,12 +299,9 @@ async function logout(req, res) { // Get logout URL from IdP if session exists url = await samlStrat.getLogoutUrlAsync(user); } else { - // Most blokes will be settin' their cookies at UTC midnight - // Where can you go from there? Nowhere. - res.setHeader( - 'Set-Cookie', - `${process.env.TITLE}=; HttpOnly; Path=${process.env.DIR || '/'}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`, // But these cookies go to zero. That's one less. - ); + + + return logoutCallback(res) } res.redirect(url); @@ -374,7 +373,11 @@ async function acs(req, res) { const aclResponse = await aclLookUp(user.email); if (!aclResponse) { - res.status(401).send('User account not found'); + + url = await samlStrat.getLogoutUrlAsync(user); + + // Login with non exist SAML user will destroy session and return login. + return res.redirect(url); } if (aclResponse instanceof Error) { From fd6aa3d1b1edc5cc47bfdc2b1a73d5a91c15d52d Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Fri, 10 Jan 2025 18:11:34 +0200 Subject: [PATCH 17/19] Fix missing assignment --- mod/user/saml.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mod/user/saml.js b/mod/user/saml.js index ec0a09b85..af5620628 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -299,9 +299,7 @@ async function logout(req, res) { // Get logout URL from IdP if session exists url = await samlStrat.getLogoutUrlAsync(user); } else { - - - return logoutCallback(res) + return logoutCallback(res); } res.redirect(url); @@ -373,8 +371,7 @@ async function acs(req, res) { const aclResponse = await aclLookUp(user.email); if (!aclResponse) { - - url = await samlStrat.getLogoutUrlAsync(user); + const url = await samlStrat.getLogoutUrlAsync(user); // Login with non exist SAML user will destroy session and return login. return res.redirect(url); From 474c4b982ebc92b1211590e42a22f961a53bf53e Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Fri, 10 Jan 2025 18:16:08 +0200 Subject: [PATCH 18/19] Update url assignment --- mod/user/saml.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mod/user/saml.js b/mod/user/saml.js index af5620628..3a258f439 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -288,13 +288,13 @@ async function logout(req, res) { try { const user = await jwt.decode(req.cookies[`${process.env.TITLE}`]); + let url = process.env.DIR || '/'; + // If no user/cookie, redirect to home if (!user) { return res.redirect(process.env.DIR || '/'); } - let url = process.env.DIR || '/'; - if (user.sessionIndex) { // Get logout URL from IdP if session exists url = await samlStrat.getLogoutUrlAsync(user); From 94e2b817e407f62cb09ebbacf1aea428d2981714 Mon Sep 17 00:00:00 2001 From: Robert Hurst Date: Fri, 10 Jan 2025 18:19:43 +0200 Subject: [PATCH 19/19] Update url assignment issue --- mod/user/saml.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mod/user/saml.js b/mod/user/saml.js index 3a258f439..f466f79aa 100644 --- a/mod/user/saml.js +++ b/mod/user/saml.js @@ -288,8 +288,6 @@ async function logout(req, res) { try { const user = await jwt.decode(req.cookies[`${process.env.TITLE}`]); - let url = process.env.DIR || '/'; - // If no user/cookie, redirect to home if (!user) { return res.redirect(process.env.DIR || '/'); @@ -297,12 +295,11 @@ async function logout(req, res) { if (user.sessionIndex) { // Get logout URL from IdP if session exists - url = await samlStrat.getLogoutUrlAsync(user); + const url = await samlStrat.getLogoutUrlAsync(user); + return res.redirect(url); } else { return logoutCallback(res); } - - res.redirect(url); } catch (error) { console.error('Logout process failed:', error); return res.redirect('/');