diff --git a/addendum/README.txt b/addendum/README.txt
new file mode 100644
index 0000000..2f794fc
--- /dev/null
+++ b/addendum/README.txt
@@ -0,0 +1,5 @@
+Supplementary files:
+
+ collectSamlResponseAttributes.js -- Example: collect SAML Response Attributes into small javascript object;
+ enreechXmlTree.js -- restore Xml metadata properties in xml tree after JSON.parse(JSON.stringify(tree));
+ mXml.js -- just a simple xml parser imlemented in poor javascript.
diff --git a/addendum/collectSamlResponseAttributes.js b/addendum/collectSamlResponseAttributes.js
new file mode 100644
index 0000000..d8ca8b1
--- /dev/null
+++ b/addendum/collectSamlResponseAttributes.js
@@ -0,0 +1,16 @@
+/*
+ * Example of how Attributes from SAML Rresponse can be collected into compact data structure
+ * for saving into keyval, if necessary
+ */
+
+
+var tree = xml.parse(some_saml_text);
+
+function get_Attributes($tags$Attribute) {
+ return $tags$Attribute.reduce((a, v) => {
+ a[v.$attr$Name] = v.$tags$AttributeValue.reduce((a, v) => {a.push(v.$text); return a}, []);
+ return a
+ }, {})
+}
+
+var attrs = get_Attributes(tree.Response.Assertion.AttributeStatement.$tags$Attribute);
diff --git a/addendum/enreechXmlTree.js b/addendum/enreechXmlTree.js
new file mode 100644
index 0000000..bdb3077
--- /dev/null
+++ b/addendum/enreechXmlTree.js
@@ -0,0 +1,108 @@
+/*
+ * It restores helper xml_tree properties after:
+ * xml_tree = xml.parse(xml_text);
+ * xml_minimal = JSON.parse(JSON.stringify(xml_tree)));
+ * xml_tree = enreechXmlTree(xml_minimal);
+ *
+ * It adds following properties properties in result tree:
+ *
+ * $parent -- parent node
+ * $attr$name -- access to attributes by name
+ * $tags$name -- access to set of tags by name, or
+ * name -- access to tag by name
+ *
+ * Example 1:
+ *
+ * var xml = require("xml");
+ * var tree = xml.parse("");
+ * // we have here access to tree.a.c
+ * var stringified_tree = JSON.stringify(tree)
+ * // tree is serialized to minimal representation, and lost helper
+ * // properties. so after parsing:
+ * var parsed_tree = JSON.parse(stringified_tree);
+ * // we have no access to parsed_tree.a.c
+ * // we need restore all properties by:
+ * var enreeched_tree = enreechXmlTree(parsed_tree);
+ * // now we have access to enreeched_tree.a.c
+ *
+ * Example 2:
+ *
+ * suppose you need obtain xml tree in one request, and share it with other requests.
+ *
+ * so in one request processing you can obtain xml tree, stringify it and save it keyval:
+ * xml = require('xml');
+ * tree = xml.parse(some_source_xml_text);
+ * r.variables.keyval_key = JSON.stringify(tree);
+ *
+ * to restore tree from keyval in other request you need:
+ * tree = enreechXmlTree(JSON.parse(r.variables.keyval_key));
+ */
+
+function enreechXmlTree(root) {
+ var i, r;
+
+ function set_once_not_enum_prop(r, name, value) {
+ if ('undefined' == typeof r[name]){
+ Object.defineProperty(r, name, {
+ writable: true,
+ value: value
+ });
+
+ }
+ }
+
+ function _enreechTree(tag, parent) {
+ var i, child, shortcut, r;
+
+ r = {};
+ Object.assign(r, tag);
+
+ /* $parent. */
+
+ if (parent) {
+ set_once_not_enum_prop(r, '$parent', parent);
+
+ }
+
+ /* $attrs$, $attr$name */
+
+ if (tag.$attrs) {
+ for (i in tag.$attrs) {
+ set_once_not_enum_prop(r, '$attr$' + i, tag.$attrs[i]);
+
+ }
+
+ }
+
+ /* $tags, $tags$, $tags$name */
+
+ if (tag.$tags) {
+
+ tag.$tags.forEach(t => {
+
+ child = _enreechTree(t, r);
+
+ /* 1st child */
+
+ set_once_not_enum_prop(r, t.$name, child);
+
+ /* all children */
+
+ shortcut = '$tags$' + t.$name;
+ set_once_not_enum_prop(r, shortcut, []);
+ r[shortcut].push(child);
+ });
+
+ }
+ return r;
+ }
+
+ r = {};
+ for (i in root) {
+ set_once_not_enum_prop(r, '$root', root[i]);
+ r[i] = _enreechTree(root[i], null);
+ }
+
+ return r;
+
+}
diff --git a/addendum/mXml.js b/addendum/mXml.js
new file mode 100644
index 0000000..6a59181
--- /dev/null
+++ b/addendum/mXml.js
@@ -0,0 +1,254 @@
+/*
+ * mXml.js: mini xml parser.
+ *
+ * it is intended accept mostly valid/trusted xml and produce minimal parsing tree, like JSON.parse(JSON.stringify(native_xml.parse(source_xml_text)))
+ *
+ * Usage:
+ *
+ * var xml = mXml();
+ * var tree = xml.parse(source_xml_text);
+ *
+ * Note:
+ *
+ * if you need obtain Xml metadata properties, as after native_xml.parse(source_xml_tree), then use additionally:
+ *
+ * tree = enreechXmlTree(tree);
+ *
+ */
+
+function mXml () {
+
+ return function (parser) {
+ return {
+ parse: function (a) {
+ var t = parser(a);
+ var r = {}
+ r[t.$name] = t;
+ return r;
+ }
+ }
+ }(function () {
+
+ var tagstart = /(<)(?:(?:([A-Z_][A-Z0-9_\.-]*):)?(?:([A-Z_][A-Z0-9_\.-]*)(\s*)(\/?>)?)|\/(?:([A-Z_][A-Z0-9_\.-]*):)?(?:([A-Z_][A-Z0-9_\.-]*)(\s*)(>))|(!--(?:-(?!-[^>])|[^-])*-->))/gi;
+
+ var attr = /(\/?>)|(?:(?:([A-Z_][A-Z0-9_\.-]*):)?(?:([A-Z_][A-Z0-9_\.-]*)?)?)(?:\s*=\s*(?:"([^"]*)")?)?(\s*)/ig;
+
+
+ function xml_unescape (s) {
+ return s.replace(/\&(?:(quot|apos|lt|gt|amp)|#(\d+)|#x([a-fA-F0-9]+));/g, function (a, name, dec, hex){
+ if (name) {
+ switch (name) {
+ case 'quot' : return '"';
+ case 'apos': return "'";
+ case 'lt': return '<';
+ case 'gt': return '>';
+ case 'amp': return '&';
+ }
+ }
+ if (dec) {
+ if (+dec > 0xFFFFF) throw new Error("xmlParseCharRef: character reference out of bounds");
+ return String.fromCharCode(dec)
+ }
+ if (hex) {
+ if (parseInt(hex,16) > 0xFFFFF) throw new Error("xmlParseCharRef: character reference out of bounds");
+ return String.fromCharCode(parseInt(hex,16))
+ }
+ })
+ }
+
+
+ function create_top_ns (old_top_ns) {
+ stack_of_nss.push(old_top_ns);
+ var new_top_ns = {}
+ new_top_ns.__proto__ = old_top_ns;
+ return new_top_ns;
+ }
+
+ var stack_of_nss = [];
+ var top_ns = void 0;
+
+
+ function resolve_tag_ns (tag, top_ns) {
+ if (tag.$ns) {
+ if (top_ns[tag.$ns]) {
+ tag.$ns = top_ns[tag.$ns];
+
+ } else {
+ tag.$name = tag.$ns+':'+ tag.$name;
+ delete tag.$ns
+
+ }
+ }
+ //tbd: it seems we need resolve attrs ns as well here
+ }
+
+
+ return function (buffer) {
+
+ // skip leading spaces
+ var re_skip = /\s*/g
+ var res = re_skip.exec(buffer);
+
+ var last_closed_tag = null; // last closed tag;
+ var parent = null; // parent tag
+ var stack_of_parents = []; // stack of parent tags up to parent tag;
+
+ // accept only single element with all children elements
+
+ tagstart.lastIndex = re_skip.lastIndex;
+ while (tagstart.lastIndex < buffer.length) { // loop over tags
+
+
+ // add all text up to '<' to parent tag as text node
+ var re_skip = /[^<]*/g
+
+ re_skip.lastIndex = tagstart.lastIndex;
+ var res = re_skip.exec(buffer);
+
+ if (parent) {
+ if (tagstart.lastIndex != re_skip.lastIndex) {
+ parent.$text += xml_unescape(buffer.substring(tagstart.lastIndex, re_skip.lastIndex));
+ }
+ } else {
+ if (tagstart.lastIndex != re_skip.lastIndex) {
+ throw new Error("garbage before root tag")
+ }
+ }
+
+ tagstart.lastIndex = re_skip.lastIndex;
+
+
+ // try obtain tagstart
+ var lastInput = tagstart.lastIndex;
+ var t = tagstart.exec(buffer)
+
+ if (t === null || t.index != lastInput) {
+ throw new Error("can't get tag at buffer position="+lastInput);
+ }
+
+ if (t[10] !== void(0)) {
+ // comment, skip it (or add to parent?)
+ continue;
+ }
+
+
+ if (t[7] !== void(0)) {
+ // close tag
+ var tag = {
+ //begin: t[1],
+ $ns: t[6],
+ $name: t[7],
+ //spaces: t[8],
+ //end: t[9],
+ $attrs:{},
+ $tags:[],
+ $text:''
+ }
+ var tag_end = t[9];
+ } else {
+ // open tag or self closed tag;
+ var tag = {
+ //begin: t[1],
+ $ns: t[2],
+ $name: t[3],
+ //spaces: t[4],
+ //end: t[5],
+ $attrs:{},
+ $tags:[],
+ $text: ''
+ }
+ var tag_end = t[5];
+ }
+
+ if (t[7] !== void(0)) {
+ // close tag ("" or "/>"
+ break;
+ }
+
+
+ // process xmlns scheme in attribute
+ if (a[2] === void(0)) { // attr_name w/o ns
+ tag.$attrs[a[3]] = xml_unescape(a[4]);
+ } else { // attr name with ns
+ if (a[2] === 'xmlns') { // definition of namespace
+ top_ns[a[3]] = xml_unescape(a[4]);
+ } else {
+ tag.$attrs[a[3]] = xml_unescape(a[4]);
+
+ /*
+ * Note: we lost attribute ns for compatibility
+ * with native xml.parse()
+ */
+ }
+ }
+
+ }
+ tagstart.lastIndex = attr.lastIndex;
+ }
+ if (tag_end == '/>') {
+ resolve_tag_ns(tag, top_ns);
+
+ ns_top = stack_of_nss.pop()
+
+ // self closed tag
+ if (!parent) {
+ return tag;
+ }
+ parent.$tags.push(tag);
+ continue;
+ }
+ if (tag_end == '>') {
+ resolve_tag_ns(tag, top_ns);
+
+ if (!parent) {
+ parent = tag;
+ } else {
+ parent.$tags.push(tag);
+ stack_of_parents.push(parent);
+ parent = tag;
+ }
+
+ }
+ }
+ }
+
+ } // function parser
+ }());
+}
diff --git a/frontend_server.conf b/frontend_server.conf
new file mode 100644
index 0000000..9ca0fd0
--- /dev/null
+++ b/frontend_server.conf
@@ -0,0 +1,81 @@
+#user nobody;
+worker_processes 1;
+
+#error_log logs/error.log;
+#error_log logs/error.log notice;
+#error_log logs/error.log info;
+
+#pid logs/nginx.pid;
+
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include mime.types;
+ default_type application/octet-stream;
+
+ #log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ # '$status $body_bytes_sent "$http_referer" '
+ # '"$http_user_agent" "$http_x_forwarded_for"';
+
+ #access_log logs/access.log main;
+
+ sendfile on;
+ #tcp_nopush on;
+
+ #keepalive_timeout 0;
+ keepalive_timeout 65;
+
+ #gzip on;
+
+ # -----------------------------------------------------------------------------------
+ # The frontend server - reverse proxy with OpenID Connect authentication
+ #
+ # This is the backend application we are protecting with SAML SP
+ upstream my_backend {
+ zone my_backend 64k;
+ server localhost:8088;
+ }
+
+ # log_format main_sp '$remote_addr - $samlsp_attr_sub [$time_local] "$request" $status '
+ # '$body_bytes_sent "$http_referer" "$http_user_agent" "$http_x_forwarded_for"';
+
+ include saml_sp_configuration.conf; # configuration maps, keyvals, js_import
+
+ server {
+
+ include saml_sp.server_conf; # Authorization code flow and Relying Party processing
+
+
+ error_log logs/error.log debug; # Reduce severity level as required
+
+ listen 80; # Use SSL/TLS in production
+
+ location / {
+
+ error_page 401 = @do_samlsp_flow;
+
+ if ($location_root_granted != "1") {
+ return 401;
+ }
+
+ proxy_pass http://my_backend; # The backend site/app
+
+ default_type text/html;
+ }
+ }
+
+ server {
+ listen 8088;
+ server_name localhost;
+
+ location / {
+ root html;
+ index index.html index.htm;
+ }
+ }
+ # -----------------------------------------------------------------------------------
+
+}
\ No newline at end of file
diff --git a/saml_sp.js b/saml_sp.js
new file mode 100644
index 0000000..5293f4e
--- /dev/null
+++ b/saml_sp.js
@@ -0,0 +1,477 @@
+/*
+ * JavaScript functions for providing SAML SP with NGINX Plus
+ *
+ * Copyright (C) 2023 Nginx, Inc.
+ */
+
+// Note:
+// For now all communications with IdP performed via user-agent/browser, they all are not reliable
+// we keep all state in static config data -- maps -- at bootstrap loaded from config file
+// and in dynamic data -- keyvals -- at bootstap loading from local files (zones)
+
+export default {send_saml_request_to_idp, process_idp_response};
+
+
+const xml = require("xml");
+const querystring = require("querystring");
+const fs = require("fs");
+
+function getEscapeXML() {
+ const fpc = Function.prototype.call;
+ const _replace = fpc.bind(fpc, String.prototype.replace);
+
+ const tbl = {
+ '<': '<',
+ '>': '>',
+ "'": ''',
+ '"': '"',
+ '&': '&',
+ };
+ tbl.__proto__ = null;
+
+ return function (str) {
+ return _replace(str, /[<>'"&]/g, c => tbl[c]);
+ }
+};
+
+const escapeXML = getEscapeXML();
+
+function createAuthnRequest_saml2_0(
+ id,
+ issueInstant,
+ destination,
+ protocolBinding,
+ assertionConsumerServiceUrl,
+ forceAuthn,
+ issuer
+) {
+
+ /* Apply escapeXML to all arguments, as they all are going to xml. */
+
+ id = escapeXML(id);
+ issueInstant = escapeXML(issueInstant);
+ destination = escapeXML(destination);
+ protocolBinding = escapeXML(protocolBinding);
+ assertionConsumerServiceUrl = escapeXML(assertionConsumerServiceUrl);
+ forceAuthn = escapeXML(forceAuthn);
+ issuer = escapeXML(issuer);
+
+ // if (assertionConsumerServiceUrl !== '') {
+ // assertionConsumerServiceUrl = ' AssertionConsumerServiceURL="'+assertionConsumerServiceUrl+'"'
+ // }
+
+ let xml =
+ '' +
+ `${issuer}` +
+ '' +
+ '';
+
+ return xml;
+}
+
+function generateID() {
+ let buf = Buffer.alloc(20);
+ return (crypto.getRandomValues(buf)).toString('hex');
+}
+
+
+function send_saml_request_to_idp (r) {
+ try {
+ // send redirect with autosubmit POST:
+ // 0) check if we are configured for IdP, so we can request metadata from IdP here (see response)
+ // 1) create saml request to IdP ( uniq_ID, target->process_IdP_response);
+ // 2) sign it if required
+
+ function doAuthnRequest() {
+ var relayState = r.variables.saml_sp_relay_state;
+ var destination = r.variables.saml_idp_sso_url;
+
+ r.variables.saml_request_id = "nginx_" + generateID();
+
+ r.variables.saml_have_session = "1";
+
+ var authnRequest_saml_text = createAuthnRequest_saml2_0(
+ r.variables.saml_request_id, // ID
+ new Date().toISOString(), // IssueInstant
+ destination, // Destination
+ r.variables.saml_request_binding, // ProtocolBinding
+ r.variables.saml_sp_acs_url, // AssertionConsumerServiceURL
+ r.variables.saml_sp_force_authn, // ForceAuthn
+ r.variables.saml_sp_entity_id // Issuer
+ );
+
+ var xml_tree = xml.parse(authnRequest_saml_text);
+
+ let dec = new TextDecoder();
+
+ if (r.variables.saml_idp_sign_authn === "true") {
+ // TBD:
+ //var c14n = dec.decode(dec.AuthnRequest.exclusiveC14n());
+ //<* sign c14n (use cert, and purivate_key) *>
+ //var xml_norm_signed_text = dec.decode(xml.parse(<* inject signature to xml_tree *>).exclusiveC14n())
+ saml_error(r, 500, "Request Signing not supported yet");
+ return;
+ } else {
+ // just normalize it. May be omitted for not signed request?
+ var xml_norm_authn = dec.decode(xml.exclusiveC14n(xml_tree));
+ }
+ var encodedRequest = xml_norm_authn.toString("base64");
+
+ if (r.variables.saml_request_binding === 'HTTP-POST') {
+ var form = `
';
+ var autoSubmit = '';
+
+ r.headersOut['Content-Type'] = "text/html";
+ r.return(200, form + autoSubmit);
+ } else {
+ r.return(302, r.variables.redirect_base + 'SAMLRequest=' + encodedRequest + relayState?'&RelayState='+relayState:'');
+ }
+ }
+
+ doAuthnRequest();
+
+ } catch (e) {
+ saml_error(r, 500, "send_saml_request_to_idp internal error e.message="+e.message)
+ }
+}
+
+
+///////////////////////////////////////////////////////////////
+
+function saml_error(r, http_code, msg) {
+ r.error("SAMLSP " + msg);
+ r.return(http_code, "SAMLSP " + msg);
+}
+
+
+async function process_idp_response (r) {
+ try {
+ let reqBody = (r.requestText).toString();
+ let postResponse = querystring.parse(reqBody);
+ let SAMLResponseRaw = postResponse.SAMLResponse;
+
+ // Base64 decode of SAML Response
+ //let SAMLResponseDec = querystring.unescape(SAMLResponseRaw); POST body cannot be URL encoded
+ var SAMLResponse = Buffer.from(SAMLResponseRaw, 'base64');
+
+ var xmlDoc;
+ try {
+ xmlDoc = xml.parse(SAMLResponse);
+ }catch(e) {
+ saml_error(r, 500, "XML parsing failed: "+e.message);
+ return;
+ }
+
+ if (!xmlDoc) {
+ saml_error(r, 500, "no XML found in Response");
+ return;
+ }
+
+ /*
+ * series of check ups, part of them are general, part custom;
+ */
+
+
+ /*
+ * perform general sanity check
+ */
+
+
+ if (xmlDoc.Response.EncryptedAssertion) {
+ saml_error(r, 500, "Encrypted Response not supported yet -- failed");
+ return;
+
+ //tbd <* decrypt xml *>
+ }
+
+ if (r.variables.saml_sp_want_signed_assertion === "true") {
+ if (!xmlDoc.Response.Signature) {
+ saml_error(r, 500, "NGINX SAML requires signed assertion -- failed");
+ }
+ let key_data = fs.readFileSync(`conf/${r.variables.saml_idp_verification_certificate}`);
+ let signed_assertion = await verifySAMLSignature(xmlDoc, key_data);
+ }
+
+
+ /*
+ * check response correctness: is it response to sent request, is timeouts ok, config/auth response, and so on
+ */
+
+
+ // Responce is single document root -- sanity check
+ if (!xmlDoc.Response) {
+ saml_error(r, 500, "No Response tag");
+ return;
+ }
+
+ // single Response attrinute InResponseTo value is present in state cache, it can be used as session later
+ if (xmlDoc.Response.$attr$InResponseTo) {
+ r.variables.saml_request_id = xmlDoc.Response.$attr$InResponseTo;
+ if (r.variables.saml_have_session != '1') {
+ saml_error(r, 500, "Wrong InResponseTo " + xmlDoc.Response.$attr$InResponseTo);
+ return;
+ }
+ r.variables.saml_have_session = '0';
+ } else {
+ saml_error(r, 500, "Response tag has no InResponseTo attribute, or has more than 1");
+ return;
+ }
+
+ /*
+ * perform any other general check up
+ */
+
+ // response example from data provided by customer.
+
+ // do we need check namespaces?
+
+ // Response.$attr$consent == "urn:oasis:names:tc:SAML:2.0:consent:obtained"
+ // Response.$attr$Destination == "xxx"
+ // Response.$attr$ID --> use for logging
+ // Response.$attr$IssueInstant --> check if it is in the past, and use it later
+
+ // Response.Issuer.$attr$Format == "urn:oasis:names:tc:SAML:2.0:nameid-format:entity" and
+ // Response.Issuer.$text == https://ecas.cc.cec.eu.int:7002/cas/login --> some expected value
+
+ // ===> Probably most important test:
+ // Response.Status.StatusCode.$attr$Value === "urn:oasis:names:tc:SAML:2.0:status:Success"
+ // Response.Status.StatusMessage.$text$ === "successful EU Login authentication"
+
+ // Response.Assertion.$attr$ID --> save for logging issues with Assertions
+ // Response.Assertion.$attr$IssueInstant --> check if this is not from future, can be used to check time range in Assertion.Conditions later?
+ // Response.Assertion.Issuer
+ // Response.Assertion.Subject
+ // Response.Assertion.Conditions --> general check time range (audience check?), oneTimeUse, ProxyRestriction
+ // Response.AuthnStatement
+ // Response.AttributeStatement.$tags$Attribute[i].$tags$AttributeValue[j].$text -->
+ // name N
+
+ // $name
+ // $name$N? --> for example $groups$1, etc
+
+
+ /*
+ * end of general check up
+ */
+
+
+ /*
+ * application level action need to be placed here.
+ * either simple or customized
+ * (for now we are ok with Response, keep stringified saml in keyval, create session and redirect back to protected root url)
+ */
+
+ // generate cookie_auth_token
+ r.variables.cookie_auth_token = "nginx_" + generateID();
+
+ // simple_action()
+ //r.variables.response_xml_json = JSON.stringify(xml);
+
+ // custom_action()
+ // var policy_result = {}
+ // eval_policy(r.variables, policy, xml.Response);
+ // it will set r.variables.$location_N_granted in keyvals for later use
+
+
+ // grant access
+ r.variables.location_root_granted = '1';
+
+ r.headersOut["Set-Cookie"] = "auth_token=" + r.variables.cookie_auth_token + "; " + r.variables.saml_cookie_flags;
+ // redirect back to root
+ r.return(302, "/"); // should be relay state or landing page
+ } catch(e) {
+ saml_error(r, 500, "process_idp_response internal error e.message="+e.message)
+ }
+}
+
+/*
+ * verifySAMLSignature() implements a verify clause
+ * from Profiles for the OASIS SAML V2.0
+ * 4.1.4.3 Message Processing Rules
+ * Verify any signatures present on the assertion(s) or the response
+ *
+ * verification is done in accordance with
+ * Assertions and Protocols for the OASIS SAML V2.0
+ * 5.4 XML Signature Profile
+ *
+ * The following signature algorithms are supported:
+ * - http://www.w3.org/2001/04/xmldsig-more#rsa-sha256
+ * - http://www.w3.org/2000/09/xmldsig#rsa-sha1
+ *
+ * The following digest algorithms are supported:
+ * - http://www.w3.org/2000/09/xmldsig#sha1
+ * - http://www.w3.org/2001/04/xmlenc#sha256
+ *
+ * @param doc an XMLDoc object returned by xml.parse().
+ * @param key_data is SubjectPublicKeyInfo in PEM format.
+ */
+
+async function verifySAMLSignature(saml, key_data) {
+ const root = saml.$root;
+ const rootSignature = root.Signature;
+
+ if (!rootSignature) {
+ throw Error(`SAML message is unsigned`);
+ }
+
+ const assertion = root.Assertion;
+ const assertionSignature = assertion ? assertion.Signature : null;
+
+ if (assertionSignature) {
+ if (!await verifyDigest(assertionSignature)) {
+ return false;
+ }
+
+ if (!await verifySignature(assertionSignature, key_data)) {
+ return false;
+ }
+ }
+
+ if (rootSignature) {
+ if (!await verifyDigest(rootSignature)) {
+ return false;
+ }
+
+ if (!await verifySignature(rootSignature, key_data)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+async function verifyDigest(signature) {
+ const parent = signature.$parent;
+ const signedInfo = signature.SignedInfo;
+ const reference = signedInfo.Reference;
+
+ /* Sanity check. */
+
+ const URI = reference.$attr$URI;
+ const ID = parent.$attr$ID;
+
+ if (URI != `#${ID}`) {
+ throw Error(`signed reference URI ${URI} does not point to the parent ${ID}`);
+ }
+
+ /*
+ * Assertions and Protocols for the OASIS SAML V2.0
+ * 5.4.4 Transforms
+ *
+ * Signatures in SAML messages SHOULD NOT contain transforms other than
+ * the http://www.w3.org/2000/09/xmldsig#enveloped-signature and
+ * canonicalization transforms http://www.w3.org/2001/10/xml-exc-c14n# or
+ * http://www.w3.org/2001/10/xml-exc-c14n#WithComments.
+ */
+
+ const transforms = reference.Transforms.$tags$Transform;
+ const transformAlgs = transforms.map(t => t.$attr$Algorithm);
+
+ if (transformAlgs[0] != 'http://www.w3.org/2000/09/xmldsig#enveloped-signature') {
+ throw Error(`unexpected digest transform ${transforms[0]}`);
+ }
+
+ if (!transformAlgs[1].startsWith('http://www.w3.org/2001/10/xml-exc-c14n#')) {
+ throw Error(`unexpected digest transform ${transforms[1]}`);
+ }
+
+ const namespaces = transformAlgs[1].InclusiveNamespaces;
+ const prefixList = namespaces ? namespaces.$attr$PrefixList: null;
+
+ const withComments = transformAlgs[1].slice(39) == 'WithComments';
+
+ let hash;
+ const alg = reference.DigestMethod.$attr$Algorithm;
+
+ switch (alg) {
+ case "http://www.w3.org/2000/09/xmldsig#sha1":
+ hash = "SHA-1";
+ break;
+ case "http://www.w3.org/2001/04/xmlenc#sha256":
+ hash = "SHA-256";
+ break;
+ case "http://www.w3.org/2001/04/xmlenc#sha512":
+ hash = "SHA-512";
+ break;
+ default:
+ throw Error(`unexpected digest Algorithm ${alg}`);
+ }
+
+ const expectedDigest = signedInfo.Reference.DigestValue.$text;
+
+ const c14n = xml.exclusiveC14n(parent, signature, withComments, prefixList);
+ const dgst = await crypto.subtle.digest(hash, c14n);
+ const b64dgst = Buffer.from(dgst).toString('base64');
+
+ return expectedDigest === b64dgst;
+}
+
+function keyPem2Der(pem, type) {
+ const pemJoined = pem.toString().split('\n').join('');
+ const pemHeader = `-----BEGIN ${type} KEY-----`;
+ const pemFooter = `-----END ${type} KEY-----`;
+ const pemContents = pemJoined.substring(pemHeader.length, pemJoined.length - pemFooter.length);
+ return Buffer.from(pemContents, 'base64');
+}
+
+function base64decode(b64) {
+ const joined = b64.toString().split('\n').join('');
+ return Buffer.from(joined, 'base64');
+}
+
+async function verifySignature(signature, key_data) {
+ const der = keyPem2Der(key_data, "PUBLIC");
+
+ let method, hash;
+ const signedInfo = signature.SignedInfo;
+ const alg = signedInfo.SignatureMethod.$attr$Algorithm;
+
+ switch (alg) {
+ case "http://www.w3.org/2000/09/xmldsig#rsa-sha1":
+ method = "RSASSA-PKCS1-v1_5";
+ hash = "SHA-1";
+ break;
+ case "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256":
+ method = "RSASSA-PKCS1-v1_5";
+ hash = "SHA-256";
+ break;
+ case "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512":
+ method = "RSASSA-PKCS1-v1_5";
+ hash = "SHA-512";
+ break;
+ default:
+ throw Error(`unexpected signature Algorithm ${alg}`);
+ }
+
+ const expectedValue = base64decode(signature.SignatureValue.$text);
+ const withComments = signedInfo.CanonicalizationMethod
+ .$attr$Algorithm.slice(39) == 'WithComments';
+
+ const signedInfoC14n = xml.exclusiveC14n(signedInfo, null, withComments);
+
+ const key = await crypto.subtle.importKey("spki", der, { name: method, hash },
+ false, [ "verify" ]);
+
+ return await crypto.subtle.verify({ name: method }, key, expectedValue,
+ signedInfoC14n);
+}
+
+function p(args, default_opts) {
+ let params = merge({}, default_opts);
+ params = merge(params, args);
+
+ return params;
+}
diff --git a/saml_sp.server_conf b/saml_sp.server_conf
new file mode 100644
index 0000000..cca222b
--- /dev/null
+++ b/saml_sp.server_conf
@@ -0,0 +1,40 @@
+# Advanced configuration TEST START
+
+ set $internal_error_message "NGINX / SAMLSP login failure\n";
+ set $saml_request_id "";
+
+ resolver 8.8.8.8; # For DNS lookup of IdP endpoints;
+ gunzip on; # Decompress IdP responses if necessary
+
+# Advanced configuration END
+
+ set $redir_location "/saml/acs";
+ location = /saml/acs {
+ # This location is called by the IdP after successful authentication
+ client_max_body_size 10m;
+ client_body_buffer_size 128k;
+ status_zone "SAMLSP code exchange";
+ js_content samlsp.process_idp_response;
+ error_page 500 502 504 @saml_error;
+ }
+
+ location @do_samlsp_flow {
+ js_content samlsp.send_saml_request_to_idp;
+ set $cookie_auth_token "";
+ # 'Internal Server Error', 'Bad Gateway', 'Gateway Timeout'
+ error_page 500 502 504 @saml_error;
+ }
+
+ location @saml_error {
+ # This location is called when no access is granted for protected root location
+ status_zone "SAMLSP error";
+ default_type text/plain;
+ return 500 $internal_error_message;
+ }
+
+ location /api/ {
+ api write=on;
+ allow 127.0.0.1; # Only the NGINX host may call the NGINX Plus API
+ deny all;
+ access_log off;
+ }
diff --git a/saml_sp_configuration.conf b/saml_sp_configuration.conf
new file mode 100644
index 0000000..3c753ae
--- /dev/null
+++ b/saml_sp_configuration.conf
@@ -0,0 +1,120 @@
+## used in AuthnRequest
+map $host $saml_sp_entity_id {
+ # Unique identifier that identifies the SP to the IdP.
+ default "http://sp.route443.dev";
+}
+
+## is used in AuthRrequest
+map $host $saml_sp_acs_url {
+ # SP endpoint that the IdP will send the SAML Response to after successful authentication.
+ # Can be hardcoded, but need for XML Metadata generation
+ default "http://sp.route443.dev:80/saml/acs";
+}
+
+## to be used in logout redirect to IdP
+map $host $saml_sp_slo_url {
+ # SP endpoint that the IdP will send the SAML Logout Request to initiate a logout process.
+ default "http://sp.route443.dev:80/saml/slo";
+}
+##? is used as parameter in POST form
+map $host $saml_sp_relay_state {
+ # Optional parameter that can be used to send additional data along with the SAML authn message.
+ # Can be used to identify the initial authentication request. For example via NGINX $request_id.
+ default "http://sp.route443.dev:80/landing_page";
+}
+
+## to be used in signing requests
+map $host $saml_sp_signing_certificate {
+ # Maps SP to the certificate file that will be used to sign the AuthnRequest or LogoutRequest sent to the IdP.
+ default "authn_sign.crt";
+}
+
+## to be used in signing requests
+map $host $saml_sp_signing_key {
+ # Maps SP to the private key file that will be used to sign the AuthnRequest or LogoutRequest sent to the IdP.
+ default "authn_sign.key";
+}
+
+## is used in AuthRequest
+map $host $saml_sp_force_authn {
+ # Whether the SP should force re-authentication of the user by the IdP.
+ # We need to think about what could be a good anchor. Perhaps ***REMOVED*** will tell us how they use it now.
+ default "false";
+}
+
+## it is just expectation of SP. not transferred to IdP (what about encrypted Assertions?)
+map $host $saml_sp_want_signed_assertion {
+ # Whether the SP wants the SAML Assertion from the IdP to be digitally signed.
+ # This is the AuthnRequest parameter that informs the IdP.
+ default "true";
+}
+
+## to be checked in Responses
+map $host $saml_idp_entity_id {
+ # Unique identifier that identifies the IdP to the SP.
+ default "http://idp.route443.dev:8080/simplesaml/saml2/idp/metadata.php";
+}
+
+## use used in AuthnREquest and "