@sidewinder1138/saml-idp
Version:
Test Identity Provider (IdP) for SAML 2.0 Web Browser SSO Profile
942 lines (863 loc) • 26.1 kB
JavaScript
/**
* Module dependencies.
*/
const chalk = require("chalk"),
express = require("express"),
os = require("os"),
fs = require("fs"),
http = require("http"),
https = require("https"),
path = require("path"),
extend = require("extend"),
hbs = require("hbs"),
logger = require("morgan"),
bodyParser = require("body-parser"),
session = require("express-session"),
yargs = require("yargs/yargs"),
xmlFormat = require("xml-formatter"),
samlp = require("samlp"),
Parser = require("@xmldom/xmldom").DOMParser,
SessionParticipants = require("samlp/lib/sessionParticipants"),
SimpleProfileMapper = require("./lib/simpleProfileMapper.js");
/**
* Globals
*/
const IDP_PATHS = {
SSO: "/saml/sso",
SLO: "/saml/slo",
METADATA: "/metadata",
SIGN_IN: "/signin",
SIGN_OUT: "/signout",
SETTINGS: "/settings",
};
const CERT_OPTIONS = [
"cert",
"key",
"encryptionCert",
"encryptionPublicKey",
"httpsPrivateKey",
"httpsCert",
];
const WILDCARD_ADDRESSES = ["0.0.0.0", "::"];
const UNDEFINED_VALUE = "None";
const CRYPT_TYPES = {
certificate: /-----BEGIN CERTIFICATE-----[^-]*-----END CERTIFICATE-----/,
"RSA private key":
/-----BEGIN RSA PRIVATE KEY-----\n[^-]*\n-----END RSA PRIVATE KEY-----/,
"public key": /-----BEGIN PUBLIC KEY-----\n[^-]*\n-----END PUBLIC KEY-----/,
};
const KEY_CERT_HELP_TEXT = dedent(chalk`
To generate a key/cert pair for the IdP, run the following command:
{gray openssl req -x509 -new -newkey rsa:2048 -nodes \
-subj '/C=US/ST=California/L=San Francisco/O=JankyCo/CN=Test Identity Provider' \
-keyout idp-private-key.pem \
-out idp-public-cert.pem -days 7300}`);
function matchesCertType(value, type) {
return CRYPT_TYPES[type] && CRYPT_TYPES[type].test(value);
}
function resolveFilePath(filePath) {
if (filePath.startsWith("saml-idp/")) {
// Allows file path options to files included in this package, like config.js
const resolvedPath = require.resolve(
filePath.replace(/^saml\-idp\//, `${__dirname}/`)
);
return fs.existsSync(resolvedPath) && resolvedPath;
}
var possiblePath;
if (fs.existsSync(filePath)) {
return filePath;
}
if (filePath.startsWith("~/")) {
possiblePath = path.resolve(process.env.HOME, filePath.slice(2));
if (fs.existsSync(possiblePath)) {
return possiblePath;
} else {
// for ~/ paths, don't try to resolve further
return filePath;
}
}
return [".", __dirname]
.map((base) => path.resolve(base, filePath))
.find((possiblePath) => fs.existsSync(possiblePath));
}
function makeCertFileCoercer(type, description, helpText) {
return function certFileCoercer(value) {
if (matchesCertType(value, type)) {
return value;
}
const filePath = resolveFilePath(value);
if (filePath) {
return fs.readFileSync(filePath);
}
throw new Error(
chalk`{red Invalid / missing {bold ${description}}} - {yellow not a valid crypt key/cert or file path}${
helpText ? "\n" + helpText : ""
}`
);
};
}
function getHashCode(str) {
var hash = 0;
if (str.length == 0) return hash;
for (i = 0; i < str.length; i++) {
char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash;
}
function dedent(str) {
// Reduce the indentation of all lines by the indentation of the first line
const match = str.match(/^\n?( +)/);
if (!match) {
return str;
}
const indentRe = new RegExp(`\n${match[1]}`, "g");
return str.replace(indentRe, "\n").replace(/^\n/, "");
}
function formatOptionValue(key, value) {
if (typeof value === "string") {
return value;
}
if (CERT_OPTIONS.includes(key)) {
return chalk`${value
.toString()
.replace(/-----.+?-----|\n/g, "")
.substring(0, 80)}{white …}`;
}
if (!value && value !== false) {
return UNDEFINED_VALUE;
}
if (typeof value === "function") {
const lines = `${value}`.split("\n");
return lines[0].slice(0, -2);
}
return `${JSON.stringify(value)}`;
}
function prettyPrintXml(xml, indent) {
// This works well, because we format the xml before applying the replacements
const prettyXml = xmlFormat(xml, { indentation: " " })
// Matches `<{prefix}:{name} .*?>`
.replace(
/<(\/)?((?:[\w]+)(?::))?([\w]+)(.*?)>/g,
chalk`<{green $1$2{bold $3}}$4>`
)
// Matches ` {attribute}="{value}"
.replace(/ ([\w:]+)="(.+?)"/g, chalk` {white $1}={cyan "$2"}`);
if (indent) {
return prettyXml.replace(/(^|\n)/g, `$1${" ".repeat(indent)}`);
}
return prettyXml;
}
/**
* Arguments
*/
function processArgs(args, options) {
var baseArgv;
if (options) {
baseArgv = yargs(args).config(options);
} else {
baseArgv = yargs(args);
}
return baseArgv
.usage(
"\nSimple IdP for SAML 2.0 WebSSO & SLO Profile\n\n" +
"Launches an IdP web server that mints SAML assertions or logout responses for a Service Provider (SP)\n\n" +
"Usage:\n\t$0 --acsUrl {url} --audience {uri}"
)
.alias({ h: "help" })
.options({
host: {
description: "IdP Web Server Listener Host",
required: false,
default: "localhost",
},
port: {
description: "IdP Web Server Listener Port",
required: true,
alias: "p",
default: 7000,
},
cert: {
description: "IdP Signature PublicKey Certificate",
required: true,
default: "./idp-public-cert.pem",
coerce: makeCertFileCoercer(
"certificate",
"IdP Signature PublicKey Certificate",
KEY_CERT_HELP_TEXT
),
},
key: {
description: "IdP Signature PrivateKey Certificate",
required: true,
default: "./idp-private-key.pem",
coerce: makeCertFileCoercer(
"RSA private key",
"IdP Signature PrivateKey Certificate",
KEY_CERT_HELP_TEXT
),
},
issuer: {
description: "IdP Issuer URI",
required: true,
alias: "iss",
default: "urn:example:idp",
},
acsUrl: {
description: "SP Assertion Consumer URL",
required: true,
alias: "acs",
},
sloUrl: {
description: "SP Single Logout URL",
required: false,
alias: "slo",
},
audience: {
description: "SP Audience URI",
required: true,
alias: "aud",
},
serviceProviderId: {
description: "SP Issuer/Entity URI",
required: false,
alias: "spId",
string: true,
},
relayState: {
description: "Default SAML RelayState for SAMLResponse",
required: false,
alias: "rs",
},
disableRequestAcsUrl: {
description:
"Disables ability for SP AuthnRequest to specify Assertion Consumer URL",
required: false,
boolean: true,
alias: "static",
default: false,
},
encryptAssertion: {
description: "Encrypts assertion with SP Public Key",
required: false,
boolean: true,
alias: "enc",
default: false,
},
encryptionCert: {
description: "SP Certificate (pem) for Assertion Encryption",
required: false,
string: true,
alias: "encCert",
coerce: makeCertFileCoercer("certificate", "Encryption cert"),
},
encryptionPublicKey: {
description:
"SP RSA Public Key (pem) for Assertion Encryption " +
"(e.g. openssl x509 -pubkey -noout -in sp-cert.pem)",
required: false,
string: true,
alias: "encKey",
coerce: makeCertFileCoercer("public key", "Encryption public key"),
},
httpsPrivateKey: {
description: "Web Server TLS/SSL Private Key (pem)",
required: false,
string: true,
coerce: makeCertFileCoercer("RSA private key"),
},
httpsCert: {
description: "Web Server TLS/SSL Certificate (pem)",
required: false,
string: true,
coerce: makeCertFileCoercer("certificate"),
},
https: {
description:
"Enables HTTPS Listener (requires httpsPrivateKey and httpsCert)",
required: true,
boolean: true,
default: false,
},
signResponse: {
description: "Enables signing of responses",
required: false,
boolean: true,
default: true,
alias: "signResponse",
},
configFile: {
description: "Path to a SAML attribute config file",
required: true,
default: "saml-idp/config.js",
alias: "conf",
},
rollSession: {
description:
"Create a new session for every authn request instead of reusing an existing session",
required: false,
boolean: true,
default: false,
},
authnContextClassRef: {
description: "Authentication Context Class Reference",
required: false,
string: true,
default:
"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport",
alias: "acr",
},
authnContextDecl: {
description: "Authentication Context Declaration (XML FilePath)",
required: false,
string: true,
alias: "acd",
coerce: function (value) {
const filePath = resolveFilePath(value);
if (filePath) {
return fs.readFileSync(filePath, "utf8");
}
},
},
})
.example(
"$0 --acsUrl http://acme.okta.com/auth/saml20/exampleidp --audience https://www.okta.com/saml2/service-provider/spf5aFRRXFGIMAYXQPNV",
""
)
.check(function (argv, aliases) {
if (argv.encryptAssertion) {
if (argv.encryptionPublicKey === undefined) {
return "encryptionPublicKey argument is also required for assertion encryption";
}
if (argv.encryptionCert === undefined) {
return "encryptionCert argument is also required for assertion encryption";
}
}
return true;
})
.check(function (argv, aliases) {
if (argv.config) {
return true;
}
const configFilePath = resolveFilePath(argv.configFile);
if (!configFilePath) {
return (
'SAML attribute config file path "' +
argv.configFile +
'" is not a valid path.\n'
);
}
try {
argv.config = require(configFilePath);
} catch (error) {
return (
'Encountered an exception while loading SAML attribute config file "' +
configFilePath +
'".\n' +
error
);
}
return true;
})
.wrap(baseArgv.terminalWidth());
}
function _runServer(argv, callback = null) {
const quiet = callback !== null; //TODO: separate quiet option?
function log(...args) {
if (!quiet) {
console.log(...args);
}
}
const app = express();
const httpServer = argv.https
? https.createServer(
{ key: argv.httpsPrivateKey, cert: argv.httpsCert },
app
)
: http.createServer(app);
const blocks = {};
log(
dedent(chalk`
Listener Port:
{cyan ${argv.host}:${argv.port}}
HTTPS Enabled:
{cyan ${argv.https}}
{bold [{yellow Identity Provider}]}
Issuer URI:
{cyan ${argv.issuer}}
Sign Response Message:
{cyan ${argv.signResponse}}
Encrypt Assertion:
{cyan ${argv.encryptAssertion}}
Authentication Context Class Reference:
{cyan ${argv.authnContextClassRef || UNDEFINED_VALUE}}
Authentication Context Declaration:
{cyan ${argv.authnContextDecl || UNDEFINED_VALUE}}
Default RelayState:
{cyan ${argv.relayState || UNDEFINED_VALUE}}
{bold [{yellow Service Provider}]}
Issuer URI:
{cyan ${argv.serviceProviderId || UNDEFINED_VALUE}}
Audience URI:
{cyan ${argv.audience || UNDEFINED_VALUE}}
ACS URL:
{cyan ${argv.acsUrl || UNDEFINED_VALUE}}
SLO URL:
{cyan ${argv.sloUrl || UNDEFINED_VALUE}}
Trust ACS URL in Request:
{cyan ${!argv.disableRequestAcsUrl}}
`)
);
/**
* IdP Configuration
*/
const idpOptions = {
issuer: argv.issuer,
serviceProviderId: argv.serviceProviderId || argv.audience,
cert: argv.cert,
key: argv.key,
audience: argv.audience,
recipient: argv.acsUrl,
destination: argv.acsUrl,
acsUrl: argv.acsUrl,
sloUrl: argv.sloUrl,
RelayState: argv.relayState,
allowRequestAcsUrl: !argv.disableRequestAcsUrl,
digestAlgorithm: "sha256",
signatureAlgorithm: "rsa-sha256",
signResponse: argv.signResponse,
encryptAssertion: argv.encryptAssertion,
encryptionCert: argv.encryptionCert,
encryptionPublicKey: argv.encryptionPublicKey,
encryptionAlgorithm: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
keyEncryptionAlgorithm: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
lifetimeInSeconds: 3600,
authnContextClassRef: argv.authnContextClassRef,
authnContextDecl: argv.authnContextDecl,
includeAttributeNameFormat: true,
profileMapper: SimpleProfileMapper.fromMetadata(argv.config.metadata),
postEndpointPath: IDP_PATHS.SSO,
redirectEndpointPath: IDP_PATHS.SSO,
logoutEndpointPaths: argv.sloUrl
? {
redirect: IDP_PATHS.SLO,
post: IDP_PATHS.SLO,
}
: {},
getUserFromRequest: function (req) {
return req.user;
},
getPostURL: function (audience, authnRequestDom, req, callback) {
return callback(
null,
req.authnRequest && req.authnRequest.acsUrl
? req.authnRequest.acsUrl
: req.idp.options.acsUrl
);
},
transformAssertion: function (assertionDom) {
if (argv.authnContextDecl) {
var declDoc;
try {
declDoc = new Parser().parseFromString(argv.authnContextDecl);
} catch (err) {
log("Unable to parse Authentication Context Declaration XML", err);
}
if (declDoc) {
const authnContextDeclEl = assertionDom.createElementNS(
"urn:oasis:names:tc:SAML:2.0:assertion",
"saml:AuthnContextDecl"
);
authnContextDeclEl.appendChild(declDoc.documentElement);
const authnContextEl =
assertionDom.getElementsByTagName("saml:AuthnContext")[0];
authnContextEl.appendChild(authnContextDeclEl);
}
}
},
responseHandler: function (response, opts, req, res, next) {
log(
dedent(chalk`
Sending SAML Response to {cyan ${
opts.postUrl
}} =>
{bold RelayState} =>
{cyan ${opts.RelayState || UNDEFINED_VALUE}}
{bold SAMLResponse} =>`)
);
log(prettyPrintXml(response.toString(), 4));
res.render("samlresponse", {
AcsUrl: opts.postUrl,
SAMLResponse: response.toString("base64"),
RelayState: opts.RelayState,
});
},
};
/**
* App Environment
*/
app.set("host", process.env.HOST || argv.host);
app.set("port", process.env.PORT || argv.port);
app.set("views", path.join(__dirname, "views"));
/**
* View Engine
*/
app.set("view engine", "hbs");
app.set("view options", { layout: "layout" });
app.engine("handlebars", hbs.__express);
// Register Helpers
hbs.registerHelper("extend", function (name, context) {
var block = blocks[name];
if (!block) {
block = blocks[name] = [];
}
block.push(context.fn(this));
});
hbs.registerHelper("block", function (name) {
const val = (blocks[name] || []).join("\n");
// clear the block
blocks[name] = [];
return val;
});
hbs.registerHelper("select", function (selected, options) {
return options
.fn(this)
.replace(
new RegExp(' value="' + selected + '"'),
'$& selected="selected"'
);
});
hbs.registerHelper("getProperty", function (attribute, context) {
return context[attribute];
});
hbs.registerHelper("serialize", function (context) {
return new Buffer(JSON.stringify(context)).toString("base64");
});
/**
* Middleware
*/
if (!quiet) {
app.use(
logger(
":date> :method :url - {:referrer} => :status (:response-time ms)",
{
skip: function (req, res) {
return (
req.path.startsWith("/bower_components") ||
req.path.startsWith("/css")
);
},
}
)
);
}
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, "public")));
app.use(
session({
secret:
"The universe works on a math equation that never even ever really ends in the end",
resave: false,
saveUninitialized: true,
name: "idp_sid",
cookie: { maxAge: 60 * 60 * 1000 },
})
);
/**
* View Handlers
*/
const showUser = function (req, res, next) {
res.render("user", {
user: req.user,
participant: req.participant,
metadata: req.metadata,
authnRequest: req.authnRequest,
idp: req.idp.options,
paths: IDP_PATHS,
});
};
/**
* Shared Handlers
*/
const parseSamlRequest = function (req, res, next) {
samlp.parseRequest(req, function (err, data) {
if (err) {
return res.render("error", {
message: "SAML AuthnRequest Parse Error: " + err.message,
error: err,
});
}
if (data) {
req.authnRequest = {
relayState: req.query.RelayState || req.body.RelayState,
id: data.id,
issuer: data.issuer,
destination: data.destination,
acsUrl: data.assertionConsumerServiceURL,
forceAuthn: data.forceAuthn === "true",
};
log("Received AuthnRequest => \n", req.authnRequest);
}
return showUser(req, res, next);
});
};
const getSessionIndex = function (req) {
if (req && req.session) {
return Math.abs(getHashCode(req.session.id)).toString();
}
};
const getParticipant = function (req) {
return {
serviceProviderId: req.idp.options.serviceProviderId,
sessionIndex: getSessionIndex(req),
nameId: req.user.userName,
nameIdFormat: req.user.nameIdFormat,
serviceProviderLogoutURL: req.idp.options.sloUrl,
};
};
const parseLogoutRequest = function (req, res, next) {
if (!req.idp.options.sloUrl) {
return res.render("error", {
message:
"SAML Single Logout Service URL not defined for Service Provider",
});
}
log("Processing SAML SLO request for participant => \n", req.participant);
return samlp.logout({
issuer: req.idp.options.issuer,
cert: req.idp.options.cert,
key: req.idp.options.key,
digestAlgorithm: req.idp.options.digestAlgorithm,
signatureAlgorithm: req.idp.options.signatureAlgorithm,
sessionParticipants: new SessionParticipants([req.participant]),
clearIdPSession: function (callback) {
log(
"Destroying session " + req.session.id + " for participant",
req.participant
);
req.session.destroy();
callback();
},
})(req, res, next);
};
/**
* Routes
*/
app.use(function (req, res, next) {
if (argv.rollSession) {
req.session.regenerate(function (err) {
return next();
});
} else {
next();
}
});
app.use(function (req, res, next) {
req.user = argv.config.user;
req.metadata = argv.config.metadata;
req.idp = { options: idpOptions };
req.participant = getParticipant(req);
next();
});
app.get(["/", "/idp", IDP_PATHS.SSO], parseSamlRequest);
app.post(["/", "/idp", IDP_PATHS.SSO], parseSamlRequest);
app.get(IDP_PATHS.SLO, parseLogoutRequest);
app.post(IDP_PATHS.SLO, parseLogoutRequest);
app.post(IDP_PATHS.SIGN_IN, function (req, res) {
const authOptions = extend({}, req.idp.options);
Object.keys(req.body).forEach(function (key) {
var buffer;
if (key === "_authnRequest") {
buffer = new Buffer(req.body[key], "base64");
req.authnRequest = JSON.parse(buffer.toString("utf8"));
// Apply AuthnRequest Params
authOptions.inResponseTo = req.authnRequest.id;
if (req.idp.options.allowRequestAcsUrl && req.authnRequest.acsUrl) {
authOptions.acsUrl = req.authnRequest.acsUrl;
authOptions.recipient = req.authnRequest.acsUrl;
authOptions.destination = req.authnRequest.acsUrl;
authOptions.forceAuthn = req.authnRequest.forceAuthn;
}
if (req.authnRequest.relayState) {
authOptions.RelayState = req.authnRequest.relayState;
}
} else {
req.user[key] = req.body[key];
}
});
if (!authOptions.encryptAssertion) {
delete authOptions.encryptionCert;
delete authOptions.encryptionPublicKey;
}
// Set Session Index
authOptions.sessionIndex = getSessionIndex(req);
// Keep calm and Single Sign On
log(
dedent(chalk`
Generating SAML Response using =>
{bold User} => ${Object.entries(req.user)
.map(
([key, value]) => chalk`
${key}: {cyan ${value}}`
)
.join("")}
{bold SAMLP Options} => ${Object.entries(authOptions)
.map(
([key, value]) => chalk`
${key}: {cyan ${formatOptionValue(key, value)}}`
)
.join("")}
`)
);
samlp.auth(authOptions)(req, res);
});
app.get(IDP_PATHS.METADATA, function (req, res, next) {
samlp.metadata(req.idp.options)(req, res);
});
app.post(IDP_PATHS.METADATA, function (req, res, next) {
if (req.body && req.body.attributeName && req.body.displayName) {
var attributeExists = false;
const attribute = {
id: req.body.attributeName,
optional: true,
displayName: req.body.displayName,
description: req.body.description || "",
multiValue: req.body.valueType === "multi",
};
req.metadata.forEach(function (entry) {
if (entry.id === req.body.attributeName) {
entry = attribute;
attributeExists = true;
}
});
if (!attributeExists) {
req.metadata.push(attribute);
}
log("Updated SAML Attribute Metadata => \n", req.metadata);
res.status(200).end();
}
});
app.get(IDP_PATHS.SIGN_OUT, function (req, res, next) {
if (req.idp.options.sloUrl) {
log(
"Initiating SAML SLO request for user: " +
req.user.userName +
" with sessionIndex: " +
getSessionIndex(req)
);
res.redirect(IDP_PATHS.SLO);
} else {
log("SAML SLO is not enabled for SP, destroying IDP session");
req.session.destroy(function (err) {
if (err) {
throw err;
}
res.redirect("back");
});
}
});
app.get([IDP_PATHS.SETTINGS], function (req, res, next) {
res.render("settings", {
idp: req.idp.options,
});
});
app.post([IDP_PATHS.SETTINGS], function (req, res, next) {
Object.keys(req.body).forEach(function (key) {
switch (req.body[key].toLowerCase()) {
case "true":
case "yes":
case "1":
req.idp.options[key] = true;
break;
case "false":
case "no":
case "0":
req.idp.options[key] = false;
break;
default:
req.idp.options[key] = req.body[key];
break;
}
if (req.body[key].match(/^\d+$/)) {
req.idp.options[key] = parseInt(req.body[key], "10");
}
});
log("Updated IdP Configuration => \n", req.idp.options);
res.redirect("/");
});
// catch 404 and forward to error handler
app.use(function (req, res, next) {
const err = new Error("Route Not Found");
err.status = 404;
next(err);
});
// development error handler
app.use(function (err, req, res, next) {
if (err) {
res.status(err.status || 500);
res.render("error", {
message: err.message,
error: err,
});
}
});
/**
* Start IdP Web Server
*/
log(
chalk`Starting IdP server on port {cyan ${app.get("host")}:${app.get(
"port"
)}}...\n`
);
httpServer.listen(app.get("port"), app.get("host"), function () {
const scheme = argv.https ? "https" : "http",
{ address, port } = httpServer.address(),
hostname = WILDCARD_ADDRESSES.includes(address)
? os.hostname()
: "localhost",
baseUrl = `${scheme}://${hostname}:${port}`;
log(
dedent(chalk`
IdP Metadata URL:
{cyan ${baseUrl}${IDP_PATHS.METADATA}}
SSO Bindings:
urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST
=> {cyan ${baseUrl}${IDP_PATHS.SSO}}
urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect
=> {cyan ${baseUrl}${IDP_PATHS.SSO}}
${
argv.sloUrl
? `
SLO Bindings:
urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST
=> {cyan ${baseUrl}${IDP_PATHS.SLO}}
urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect
=> {cyan ${baseUrl}${IDP_PATHS.SLO}}
`
: ""
}
IdP server ready at
{cyan ${baseUrl}}
`)
);
if (callback !== null) {
callback(httpServer);
}
});
}
function runServer(options, callback = null) {
const args = processArgs([], options);
_runServer(args.argv, callback);
}
function main() {
const args = processArgs(process.argv.slice(2));
_runServer(args.argv);
}
module.exports = {
runServer,
main,
};
if (require.main === module) {
main();
}