/**
## /user/saml
The SAML user module exports the saml method as an enpoint for request authentication via SAML.
The module requires the saml2-js module library to be installed. The availability of the module [required] is tries during the module initialisation.
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
@module /user/saml
*/
let acl, sp, idp;
const logger = require('../utils/logger');
const jwt = require('jsonwebtoken');
try {
const saml2 = require('saml2-js');
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 &&
String(
readFileSync(join(__dirname, `../../${process.env.SAML_SP_CRT}.pem`))
),
certificate:
process.env.SAML_SP_CRT &&
String(
readFileSync(join(__dirname, `../../${process.env.SAML_SP_CRT}.crt`))
),
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 && [
String(
readFileSync(join(__dirname, `../../${process.env.SAML_IDP_CRT}.crt`))
),
],
sign_get_request: true,
});
} catch {
console.log('SAML2 module is not available.')
}
/**
@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.
*/
module.exports = function saml(req, res) {
if (!sp || !idp) {
console.warn(`SAML SP or IDP are 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();
}
);
}
};
/**
@function acl_lookup
@description
The acl_lookup 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].
@param {string} email User email.
@returns {Promise<Object|Error>}
User object or Error.
*/
async function acl_lookup(email) {
if (acl === null) {
return new Error('ACL unavailable.')
}
const date = new Date()
// Update access_log and return user record matched by email.
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])
if (rows instanceof Error) return new Error('Failed to query to ACL.')
// Get user record from first row.
const user = rows[0]
if (!user) return null;
// Blocked user cannot login.
if (user.blocked) {
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')
}
if (!user.approved) {
return new Error('User not approved in ACL')
}
return {
roles: user.roles,
language: user.language,
admin: user.admin
}
}