diff --git a/Dockerfile b/Dockerfile index 153014168..e6446a3e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,11 +11,15 @@ EXPOSE 7000 7000 # ADD ./node_modules node_modules ADD ./lib lib +ADD ./templates templates ADD ./views views ADD ./app.js app.js ADD ./config.js config.js +add ./idp-metadata.js idp-metadata.js ADD ./idp-public-cert.pem idp-public-cert.pem ADD ./idp-private-key.pem idp-private-key.pem +ADD ./sp-cert.pem sp-cert.pem +ADD ./sp-key.pem sp-key.pem ADD ./public public -ENTRYPOINT [ "node", "app.js", "--acs", "https://deptva-vetsgov-eval.okta.com/sso/saml2/0oa1pbnlkmlWpo0q22p7", "--issuer", "samlproxy-idp.vetsgov.dev", "--aud", "https://www.okta.com/saml2/service-provider/spshbtcxwhreqinrtome", "--ibu", "https://dev.vets.gov/samlproxy/idp" ] +ENTRYPOINT ["node", "app.js", "--idpAcsUrl", "https://deptva-vetsgov-eval.okta.com/sso/saml2/0oa1pbnlkmlWpo0q22p7", "--idpIssuer", "samlproxy-idp.vetsgov.dev", "--idpAudience", "https://www.okta.com/saml2/service-provider/spshbtcxwhreqinrtome", "--idpBaseUrl", "https://dev.vets.gov/samlproxy/idp", "--spIdpMetaUrl", "https://api.idmelabs.com/saml/metadata/provider", "--spNameIDFormat", "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", "--spAudience", "samlproxy.vetsgov.dev", "--spIdpIssuer", "api.idmelabs.com", "--spAuthnContextClassRef", "http://idmanagement.gov/ns/assurance/loa/3", "--spAcsUrls", "/samlproxy/sp/saml/sso"] diff --git a/app.js b/app.js index d13524c1e..f02c28cde 100644 --- a/app.js +++ b/app.js @@ -4,6 +4,7 @@ */ const express = require('express'), + _ = require('underscore'), os = require('os'), fs = require('fs'), http = require('http'), @@ -18,9 +19,14 @@ const express = require('express'), yargs = require('yargs/yargs'), xmlFormat = require('xml-formatter'), samlp = require('samlp'), + SamlStrategy = require('passport-wsfed-saml2').Strategy, + passport = require('passport'), + PassportSaml = require('passport-wsfed-saml2').SAML, + PassportSamlp = require('passport-wsfed-saml2').samlp, Parser = require('xmldom').DOMParser, SessionParticipants = require('samlp/lib/sessionParticipants'), - SimpleProfileMapper = require('./lib/simpleProfileMapper.js'); + SimpleProfileMapper = require('./lib/simpleProfileMapper.js'), + IdPMetadata = require('./idp-metadata'); /** * Globals @@ -35,12 +41,42 @@ const IDP_PATHS = { SETTINGS: '/samlproxy/idp/settings' } +const AUTHN_REQUEST_TEMPLATE = _.template( + fs.readFileSync(path.join(__dirname, '/templates/authnrequest.tpl'), 'utf8') +); +const METADATA_TEMPLATE = _.template( + fs.readFileSync(path.join(__dirname, '/templates/metadata.tpl'), 'utf8') +); + +const SP_SLO_URL = '/samlproxy/sp/saml/slo'; +const SP_PROFILE_URL = '/samlproxy/sp/profile'; +const SP_LOGIN_URL ='/samlproxy/sp/login'; +const SP_LOGOUT_URL = '/samlproxy/sp/logout'; +const SP_METADATA_URL = '/samlproxy/sp/metadata'; +const SP_SETTINGS_URL = '/samlproxy/sp/settings'; +const SP_ERROR_URL = '/samlproxy/sp/error'; + +const BINDINGS = { + REDIRECT: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + POST: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' +} + +const NAMEID_FORMAT_PREFERENCE = [ + 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified', + 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient', + 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', + 'urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos', + 'urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName' +] + + const cryptTypes = { - 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-----/, - }, - KEY_CERT_HELP_TEXT = "Please generate a key-pair for the IdP using the following openssl command:\n" + + 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-----/, +}, + KEY_CERT_HELP_TEXT = "Please generate a key-pair for the IdP using the following openssl command:\n" + "\topenssl 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"; @@ -94,11 +130,22 @@ function makeCertFileCoercer(type, description, helpText) { } throw new Error( 'Invalid ' + description + ', not a valid crypt cert/key or file path' + - (helpText ? '\n' + helpText : '') + (helpText ? '\n' + helpText : '') ) }; } +function certToPEM(cert) { + if (/-----BEGIN CERTIFICATE-----/.test(cert)) { + return cert; + } + + cert = cert.match(/.{1,64}/g).join('\n'); + cert = "-----BEGIN CERTIFICATE-----\n" + cert; + cert = cert + "\n-----END CERTIFICATE-----\n"; + return cert; +} + function getHashCode(str) { var hash = 0; if (str.length == 0) return hash; @@ -110,6 +157,25 @@ function getHashCode(str) { return hash; } +function getPath(path) { + if (path) { + return path.startsWith('/') ? path : '/' + path; + } +} + +function getReqUrl(req, path) { + if (req) { + return (req.get('x-forwarded-proto') || req.protocol) + '://' + (req.get('x-forwarded-host') || req.get('host')) + getPath(path || req.originalUrl); + } +}; + +function removeHeaders(cert) { + const pem = /-----BEGIN (\w*)-----([^-]*)-----END (\w*)-----/g.exec(cert); + if (pem && pem.length > 0) { + return pem[2].replace(/[\n|\r\n]/g, ''); + } + return cert; +}; /** * Arguments @@ -129,137 +195,122 @@ function processArgs(args, options) { } 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 -acs {url} -aud {uri}') + 'Launches an IdP web server that mints SAML assertions or logout responses for a Service Provider (SP)\n\n' + + 'Usage:\n\t$0 -acs {url} -aud {uri}') .options({ - port: { + idpPort: { description: 'IdP Web Server Listener Port', required: true, - alias: 'p', default: 7000 }, - cert: { + idpCert: { description: 'IdP Signature PublicKey Certificate', required: true, default: './idp-public-cert.pem', coerce: makeCertFileCoercer('certificate', 'IdP Signature PublicKey Certificate', KEY_CERT_HELP_TEXT) }, - key: { + idpKey: { 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: { + idpIssuer: { description: 'IdP Issuer URI', required: true, - alias: 'iss', default: 'urn:example:idp' }, - acsUrl: { + idpAcsUrl: { description: 'SP Assertion Consumer URL', required: true, - alias: 'acs' }, - sloUrl: { + idpSloUrl: { description: 'SP Single Logout URL', required: false, - alias: 'slo' }, - audience: { + idpAudience: { description: 'SP Audience URI', required: true, - alias: 'aud' }, - serviceProviderId: { + idpServiceProviderId: { description: 'SP Issuer/Entity URI', required: false, - alias: 'spId', string: true }, - relayState: { + idpRelayState: { description: 'Default SAML RelayState for SAMLResponse', required: false, - alias: 'rs' }, - disableRequestAcsUrl: { + idpDisableRequestAcsUrl: { description: 'Disables ability for SP AuthnRequest to specify Assertion Consumer URL', required: false, boolean: true, - alias: 'static', default: false }, - encryptAssertion: { + idpEncryptAssertion: { description: 'Encrypts assertion with SP Public Key', required: false, boolean: true, - alias: 'enc', default: false }, - encryptionCert: { + idpEncryptionCert: { description: 'SP Certificate (pem) for Assertion Encryption', required: false, string: true, - alias: 'encCert', coerce: makeCertFileCoercer('certificate', 'Encryption cert') }, - encryptionPublicKey: { + idpEncryptionPublicKey: { description: 'SP RSA Public Key (pem) for Assertion Encryption ' + - '(e.g. openssl x509 -pubkey -noout -in sp-cert.pem)', + '(e.g. openssl x509 -pubkey -noout -in sp-cert.pem)', required: false, string: true, - alias: 'encKey', coerce: makeCertFileCoercer('public key', 'Encryption public key') }, - httpsPrivateKey: { + idpHttpsPrivateKey: { description: 'Web Server TLS/SSL Private Key (pem)', required: false, string: true, coerce: makeCertFileCoercer('RSA private key') }, - httpsCert: { + idpHttpsCert: { description: 'Web Server TLS/SSL Certificate (pem)', required: false, string: true, coerce: makeCertFileCoercer('certificate') }, - https: { + idpHttps: { description: 'Enables HTTPS Listener (requires httpsPrivateKey and httpsCert)', required: true, boolean: true, default: false }, - signResponse: { + idpSignResponse: { description: 'Enables signing of responses', required: false, boolean: true, default: true, - alias: 'signResponse' }, - configFile: { + idpConfigFile: { description: 'Path to a SAML attribute config file', required: true, default: require.resolve('./config.js'), - alias: 'conf' }, - rollSession: { + idpRollSession: { description: 'Create a new session for every authn request instead of reusing an existing session', required: false, boolean: true, default: false }, - authnContextClassRef: { + idpAuthnContextClassRef: { description: 'Authentication Context Class Reference', required: false, string: true, default: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', - alias: 'acr' }, - authnContextDecl: { + idpAuthnContextDecl: { description: 'Authentication Context Declaration (XML FilePath)', required: false, string: true, - alias: 'acd', coerce: function (value) { const filePath = resolveFilePath(value); if (filePath) { @@ -271,499 +322,873 @@ function processArgs(args, options) { description: 'IdP Base URL', required: false, string: true, - alias: 'ibu' + }, + spPort: { + description: 'Web Server listener port', + required: true, + number: true, + default: 7070 + }, + spProtocol: { + description: 'Federation Protocol', + required: true, + string: true, + default: 'samlp' + }, + spIdpIssuer: { + description: 'IdP Issuer URI', + required: false, + string: true, + default: 'urn:example:idp' + }, + spIdpSsoUrl: { + description: 'IdP Single Sign-On Service URL (SSO URL)', + required: false, + string: true + }, + spIdpSsoBinding: { + description: 'IdP Single Sign-On AuthnRequest Binding', + required: true, + string: true, + default: BINDINGS.REDIRECT + }, + spIdpSloUrl: { + description: 'IdP Single Logout Service URL (SLO URL) (SAMLP)', + required: false, + string: true + }, + spIdpSloBinding: { + description: 'IdP Single Logout Request Binding (SAMLP)', + required: true, + string: true, + default: BINDINGS.REDIRECT + }, + spIdpCert: { + description: 'IdP Public Key Signing Certificate (PEM)', + required: false, + string: true, + coerce: (value) => { + return certToPEM(makeCertFileCoercer('certificate', 'IdP Public Key Signing Certificate (PEM)', KEY_CERT_HELP_TEXT)); + } + }, + spIdpThumbprint: { + description: 'IdP Public Key Signing Certificate SHA1 Thumbprint', + required: false, + string: true, + coerce: (value) => { + return value ? value.replace(/:/g, '') : value + } + }, + spIdpMetaUrl: { + description: 'IdP SAML Metadata URL', + required: false, + string: true + }, + spAudience: { + description: 'SP Audience URI / RP Realm', + required: false, + string: true, + default: 'urn:example:sp' + }, + spProviderName: { + description: 'SP Provider Name', + required: false, + string: true, + default: 'Simple SAML Service Provider' + }, + spAcsUrls: { + description: 'SP Assertion Consumer Service (ACS) URLs (Relative URL)', + required: true, + array: true, + default: ['/saml/sso'] + }, + spSignAuthnRequests: { + description: 'Sign AuthnRequest Messages (SAMLP)', + required: true, + boolean: true, + default: true, + }, + spSignatureAlgorithm: { + description: 'Signature Algorithm', + required: false, + string: true, + default: 'rsa-sha256' + }, + spDigestAlgorithm: { + description: 'Digest Algorithm', + required: false, + string: true, + default: 'sha256' + }, + spRequestNameIDFormat : { + description: 'Request Subject NameID Format (SAMLP)', + required: false, + boolean: true, + default: true + }, + spValidateNameIDFormat : { + description: 'Validate format of Assertion Subject NameID', + required: false, + boolean: true, + default: true + }, + spNameIDFormat : { + description: 'Assertion Subject NameID Format', + required: false, + string: true, + default: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' + }, + spRequestAuthnContext : { + description: 'Request Authentication Context (SAMLP)', + required: false, + boolean: true, + default: true + }, + spAuthnContextClassRef : { + description: 'Authentication Context Class Reference', + required: false, + string: true, + default: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport' + }, + spCert: { + description: 'SP/RP Public Key Signature & Encryption Certificate (PEM)', + string: true, + required: false, + default: path.resolve(__dirname, './sp-cert.pem'), + coerce: makeCertFileCoercer('certificate', 'SP Signing Public Key Certificate (PEM)', KEY_CERT_HELP_TEXT) + }, + spKey: { + description: 'SP/RP Private Key Signature & Decryption Certificate(PEM)', + string: true, + required: false, + default: path.resolve(__dirname, './sp-key.pem'), + coerce: makeCertFileCoercer('privateKey', 'SP Signing Private Key (PEM)', KEY_CERT_HELP_TEXT) + }, + spHttpsPrivateKey: { + description: 'Web Server TLS/SSL Private Key (PEM)', + required: false, + string: true, + coerce: makeCertFileCoercer('privateKey', 'Web Server TLS/SSL Private Key (PEM)', KEY_CERT_HELP_TEXT) + }, + spHttpsCert: { + description: 'Web Server TLS/SSL Certificate (PEM)', + required: false, + string: true, + coerce: makeCertFileCoercer('certificate', 'Web Server TLS/SSL Public Key Certificate (PEM)', KEY_CERT_HELP_TEXT) + }, + spHttps: { + description: 'Enables HTTPS Listener (requires httpsPrivateKey and httpsCert)', + required: false, + boolean: true, + default: false + }, + spRelayState: { + description: 'Default Relay State', + required: false, + string: true } }) .example('\t$0 --acs http://acme.okta.com/auth/saml20/exampleidp --aud https://www.okta.com/saml2/service-provider/spf5aFRRXFGIMAYXQPNV', '') .check(function(argv, aliases) { - if (argv.encryptAssertion) { - if (argv.encryptionPublicKey === undefined) { + if (argv.idpEncryptAssertion) { + if (argv.idpEncryptionPublicKey === undefined) { return 'encryptionPublicKey argument is also required for assertion encryption'; } - if (argv.encryptionCert === undefined) { + if (argv.idpEncryptionCert === undefined) { return 'encryptionCert argument is also required for assertion encryption'; } } return true; }) .check(function(argv, aliases) { - if (argv.config) { + if (argv.idpConfig) { return true; } - const configFilePath = resolveFilePath(argv.configFile); + const configFilePath = resolveFilePath(argv.idpConfigFile); if (!configFilePath) { - return 'SAML attribute config file path "' + argv.configFile + '" is not a valid path.\n'; + return 'SAML attribute config file path "' + argv.idpConfigFile + '" is not a valid path.\n'; } try { - argv.config = require(configFilePath); + argv.idpConfig = require(configFilePath); } catch (error) { return 'Encountered an exception while loading SAML attribute config file "' + configFilePath + '".\n' + error; } return true; }) + .check((argv, aliases) => { + if (!_.isString(argv.spIdpMetaUrl)) { + if (!_.isString(argv.spIdpSsoUrl) || argv.spIdpSsoUrl === '') { + return 'IdP SSO Assertion Consumer URL (spIdpSsoUrl) is required when IdP metadata is not specified'; + } + if (!_.isString(argv.spIdpCert) && !_.isString(argv.spIdpThumbprint)) { + return ' IdP Signing Certificate (spIdpCert) or IdP Signing Key Thumbprint (spIdpThumbprint) is required when IdP metadata is not specified'; + } + // convert cert to PEM + argv.spIdpCertPEM = certToPEM(argv.spIdpCert) + } + return true; + }) .wrap(baseArgv.terminalWidth()); } function _runServer(argv) { - const app = express(); - const httpServer = argv.https ? - https.createServer({ key: argv.httpsPrivateKey, cert: argv.httpsCert }, app) : - http.createServer(app); - const blocks = {}; + IdPMetadata.fetch(argv.spIdpMetaUrl) + .then((metadata) => { + if (metadata.protocol) { + argv.protocol = metadata.protocol; + if (metadata.signingKeys[0]) { + argv.spIdpCert = certToPEM(metadata.signingKeys[0]); + } - console.log(); - console.log('Listener Port:\n\t' + argv.port); - console.log('HTTPS Enabled:\n\t' + argv.https); - console.log(); - console.log('[IdP]'); - console.log(); - console.log('Issuer URI:\n\t' + argv.issuer); - console.log('Sign Response Message:\n\t' + argv.signResponse); - console.log('Encrypt Assertion:\n\t' + argv.encryptAssertion); - console.log('Authentication Context Class Reference:\n\t' + argv.authnContextClassRef); - console.log('Authentication Context Declaration:\n\n' + argv.authnContextDecl); - console.log('Default RelayState:\n\t' + argv.relayState); - console.log(); - console.log('[SP]'); - console.log(); - console.log('Issuer URI:\n\t' + argv.serviceProviderId); - console.log('Audience URI:\n\t' + argv.audience); - console.log('ACS URL:\n\t' + argv.acsUrl); - console.log('SLO URL:\n\t' + argv.sloUrl); - console.log('Trust ACS URL in Request:\n\t' + !argv.disableRequestAcsUrl); - console.log(); + switch (metadata.protocol) { + case 'samlp': + if (metadata.sso.redirectUrl) { + argv.spIdpSsoUrl = metadata.sso.redirectUrl; + } else if (metadata.sso.postUrl) { + argv.spIdpSsoUrl = metadata.sso.postUrl; + } + if (metadata.slo.redirectUrl) { + argv.spIdpSloUrl = metadata.slo.redirectUrl; + } else if (metadata.slo.postUrl) { + argv.spIdpSloUrl = metadata.slo.postUrl; + } + if (metadata.signRequest) { + argv.spSignAuthnRequests = metadata.signRequest; + } + break; + case 'wsfed': + if (metadata.sso.redirectUrl) { + argv.spIdpSsoUrl = metadata.sso.redirectUrl; + } + break; + } + } + }) + .then(() => { + const app = express(); + const httpServer = argv.idpHttps ? + https.createServer({ key: argv.idpHttpsPrivateKey, cert: argv.idpHttpsCert }, app) : + http.createServer(app); + const blocks = {}; - console.log(); + console.log(); + console.log('Listener Port:\n\t' + argv.idpPort); + console.log('HTTPS Enabled:\n\t' + argv.idpHttps); + console.log(); + console.log('[IdP]'); + console.log(); + console.log('Issuer URI:\n\t' + argv.idpIssuer); + console.log('Sign Response Message:\n\t' + argv.idpSignResponse); + console.log('Encrypt Assertion:\n\t' + argv.idpEncryptAssertion); + console.log('Authentication Context Class Reference:\n\t' + argv.idpAuthnContextClassRef); + console.log('Authentication Context Declaration:\n\n' + argv.idpAuthnContextDecl); + console.log('Default RelayState:\n\t' + argv.idpRelayState); + console.log(); + console.log('[IdP SP]'); + console.log(); + console.log('Issuer URI:\n\t' + argv.idpServiceProviderId); + console.log('Audience URI:\n\t' + argv.idpAudience); + console.log('ACS URL:\n\t' + argv.idpAcsUrl); + console.log('SLO URL:\n\t' + argv.idpSloUrl); + console.log('Trust ACS URL in Request:\n\t' + !argv.idpDisableRequestAcsUrl); + console.log(); + console.log(); - /** - * IdP Configuration - */ - - SimpleProfileMapper.prototype.metadata = argv.config.metadata; - - const idpOptions = { - idpBaseUrl: argv.idpBaseUrl, - 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, - 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 : - argv.acsUrl); - }, - transformAssertion: function(assertionDom) { - if (argv.authnContextDecl) { - var declDoc; - try { - declDoc = new Parser().parseFromString(argv.authnContextDecl); - } catch(err){ - console.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) { - console.log(); - console.log(`Sending SAMLResponse to ${opts.postUrl} with RelayState ${opts.RelayState} =>\n`); - console.log(xmlFormat(response.toString(), {indentation: ' '})); - console.log(); - res.render('samlresponse', { - AcsUrl: opts.postUrl, - SAMLResponse: response.toString('base64'), - RelayState: opts.RelayState - }); - } - } + console.log('[SP]'); + console.log(); + console.log('Protocol: ' + "SAMLP"); + console.log(); + console.log('IdP Issuer URI:\n\t' + argv.spIdpIssuer); + console.log('IdP SSO ACS URL:\n\t' + argv.spIdpSsoUrl); + console.log('IdP SLO URL:\n\t' + argv.spIdpSloUrl); + console.log(); + console.log('SP Issuer URI:\n\t' + argv.spAudience); + console.log('SP Audience URI:\n\t' + argv.spAudience); + console.log('SP NameID Format:\n\t' + argv.spNameIDFormat); + console.log('SP ACS Binding:\n\turn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'); + console.log('SP ACS URL:'); + argv.spAcsUrls.forEach(function(acsUrl) { + console.log('\t' + acsUrl); + }); + console.log('SP Default Relay State:\n\t' + argv.spRelayState); + console.log(); - /** - * App Environment - */ + /** + * IdP Configuration + */ + + SimpleProfileMapper.prototype.metadata = argv.idpConfig.metadata; + + const idpOptions = { + idpBaseUrl: argv.idpBaseUrl, + issuer: argv.idpIssuer, + serviceProviderId: argv.idpServiceProviderId || argv.idpAudience, + cert: argv.idpCert, + key: argv.idpKey, + audience: argv.idpAudience, + recipient: argv.idpAcsUrl, + destination: argv.idpAcsUrl, + acsUrl: argv.idpAcsUrl, + sloUrl: argv.idpSloUrl, + RelayState: argv.idpRelayState, + allowRequestAcsUrl: !argv.idpDisableRequestAcsUrl, + digestAlgorithm: 'sha256', + signatureAlgorithm: 'rsa-sha256', + signResponse: argv.idpSignResponse, + encryptAssertion: argv.idpEncryptAssertion, + encryptionCert: argv.idpEncryptionCert, + encryptionPublicKey: argv.idpEncryptionPublicKey, + 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.idpAuthnContextClassRef, + authnContextDecl: argv.idpAuthnContextDecl, + includeAttributeNameFormat: true, + profileMapper: SimpleProfileMapper, + postEndpointPath: IDP_PATHS.SSO, + redirectEndpointPath: IDP_PATHS.SSO, + logoutEndpointPaths: argv.idpSloUrl ? + { + 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 : + argv.idpAcsUrl); + }, + transformAssertion: function(assertionDom) { + if (argv.idpAuthnContextDecl) { + var declDoc; + try { + declDoc = new Parser().parseFromString(argv.idpAuthnContextDecl); + } catch(err){ + console.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) { + console.log(); + console.log(`req.session.ssoResponse = ${JSON.stringify(req.session.ssoResponse)}\n`); + console.log(`Sending SAMLResponse to ${opts.postUrl} with RelayState ${opts.RelayState} =>\n`); + console.log(xmlFormat(response.toString(), {indentation: ' '})); + console.log(); + res.render('samlresponse', { + AcsUrl: opts.postUrl, + SAMLResponse: response.toString('base64'), + RelayState: opts.RelayState + }); + } + } - app.set('port', process.env.PORT || argv.port); - app.set('views', path.join(__dirname, 'views')); + const spConfig = { + + port: argv.spPort, + protocol: argv.spProtocol, + idpIssuer: argv.spIdpIssuer, + idpSsoUrl: argv.spIdpSsoUrl, + idpSsoBinding: argv.spIdpSsoBinding, + idpSloUrl: argv.spIdpSloUrl, + idpSloBinding: argv.spIdpSloBinding, + idpCert: argv.spIdpCert, + idpThumbprint: argv.spIdpThumbprint, + idpMetaUrl: argv.spIdpMetaUrl, + audience: argv.spAudience, + providerName: argv.spProviderName, + acsUrls: argv.spAcsUrls, + signAuthnRequests: argv.spSignAuthnRequests, + signatureAlgorithm: argv.spSignatureAlgorithm, + digestAlgorithm: argv.spDigestAlgorithm, + requestNameIDFormat: argv.spRequestNameIDFormat, + validateNameIDFormat: argv.spValidateNameIDFormat, + nameIDFormat: argv.spNameIDFormat, + requestAuthnContext: argv.spRequestAuthnContext, + authnContextClassRef: argv.spAuthnContextClassRef, + spCert: argv.spCert, + spKey: argv.spKey, + httpsPrivateKey: argv.spHttpsPrivateKey, + httpsCert: argv.spHttpsCert, + https: argv.spHttps, + relayState: argv.spRelayState, + + requestAcsUrl: argv.spAcsUrls[0], + failureRedirect: SP_ERROR_URL, + failureFlash: true, + + // can't use arrow functions due to lexical scoping + + getMetadataParams: function(req) { + return { + protocol: this.protocol, + entityID: this.audience, + realm: this.audience, + cert: removeHeaders(this.spCert), + acsUrls: this.acsUrls.map(url => getReqUrl(req, url)), + sloUrl: getReqUrl(req, SP_SLO_URL), + nameIDFormat: this.nameIDFormat + } + }, - /** - * View Engine - */ + getRequestSecurityTokenParams: function(wreply, wctx) { + return { + wreply: wreply, + wctx: wctx || this.relayState, + } + }, + getAuthnRequestParams: function(acsUrl, forceAuthn, relayState) { + const params = { + protocol: this.protocol, + realm: this.audience, + callback: acsUrl, + protocolBinding: this.idpSsoBinding, + identityProviderUrl: this.idpSsoUrl, + providerName: this.providerName, + forceAuthn: forceAuthn, + authnContext: this.authnContextClassRef, + requestContext: { + NameIDFormat: this.nameIDFormat + }, + requestTemplate: AUTHN_REQUEST_TEMPLATE({ + ForceAuthn: forceAuthn, + NameIDFormat: this.requestNameIDFormat, + AuthnContext: this.requestAuthnContext, + }), + signatureAlgorithm: this.signatureAlgorithm, + digestAlgorithm: this.digestAlgorithm, + deflate: this.deflate, + RelayState: relayState || this.relayState, + failureRedirect: this.failureRedirect, + failureFlash: this.failureFlash + } - app.set('view engine', 'hbs'); - app.set('view options', { layout: 'layout' }) - app.engine('handlebars', hbs.__express); - app.use('/samlproxy/idp/bower_components', express.static(__dirname + '/bower_components')) + if (this.signAuthnRequests) { + params.signingKey = { + cert: this.spCert, + key: this.spKey + } + } + return params; + }, + getResponseParams: function(destinationUrl) { + return { + protocol: this.protocol, + thumbprint: this.idpThumbprint, + cert: removeHeaders(this.idpCert), + realm: this.audience, + identityProviderUrl: this.idpSsoUrl, //wsfed + recipientUrl: destinationUrl, + destinationUrl: destinationUrl, + decryptionKey: this.spKey, + checkResponseID: true, + checkDestination: true, + checkInResponseTo: true, + checkExpiration: true, + checkAudience: true, + checkNameQualifier: true, + checkSPNameQualifier: true, + failureRedirect: this.failureRedirect, + failureFlash: this.failureFlash + } + }, + + getLogoutParams: function() { + return { + issuer: this.audience, + protocolBinding: this.idpSloBinding, + deflate: this.deflate, + identityProviderUrl: this.idpSloUrl, + identityProviderSigningCert: this.idpCert, + key: this.spKey, + cert: this.spCert + } + } - // Register Helpers - hbs.registerHelper('extend', function(name, context) { - var block = blocks[name]; - if (!block) { - block = blocks[name] = []; - } + }; - block.push(context.fn(this)); - }); + responseParams = spConfig.getResponseParams(); + const strategy = new SamlStrategy(responseParams, + (profile, done) => { + console.log(); + console.log('Assertion => ' + JSON.stringify(profile, null, '\t')); + console.log(); + return done(null, { + issuer: profile.issuer, + userName: profile.nameIdAttributes.value, + nameIdFormat: profile.nameIdAttributes.Format, + authnContext: { + sessionIndex: profile.sessionIndex, + authnMethod: profile['http://schemas.microsoft.com/ws/2008/06/identity/claims/authenticationmethod'] + }, + claims: _.chain(profile) + .omit('issuer', 'sessionIndex', 'nameIdAttributes', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier', + 'http://schemas.microsoft.com/ws/2008/06/identity/claims/authenticationmethod') + .value() + }); + + } + ); + passport.use(strategy); + + passport.serializeUser(function(user, done) { + done(null, user); + }); - hbs.registerHelper('block', function(name) { - const val = (blocks[name] || []).join('\n'); - // clear the block - blocks[name] = []; - return val; - }); + passport.deserializeUser(function(user, done) { + done(null, user); + }); + /** + * App Environment + */ + + app.set('port', process.env.PORT || argv.idpPort); + 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); + app.use(express.static(path.join(__dirname, 'public'))); + app.use('/samlproxy/idp/bower_components', express.static(__dirname + '/public/bower_components')) + app.use(passport.initialize()); + + // Register Helpers + hbs.registerHelper('extend', function(name, context) { + var block = blocks[name]; + if (!block) { + block = blocks[name] = []; + } - hbs.registerHelper('select', function(selected, options) { - return options.fn(this).replace( - new RegExp(' value=\"' + selected + '\"'), '$& selected="selected"'); - }); + block.push(context.fn(this)); + }); - hbs.registerHelper('getProperty', function(attribute, context) { - return context[attribute]; - }); + hbs.registerHelper('block', function(name) { + const val = (blocks[name] || []).join('\n'); + // clear the block + blocks[name] = []; + return val; + }); - hbs.registerHelper('serialize', function(context) { - return new Buffer(JSON.stringify(context)).toString('base64'); - }); - /** - * Middleware - */ + hbs.registerHelper('select', function(selected, options) { + return options.fn(this).replace( + new RegExp(' value=\"' + selected + '\"'), '$& selected="selected"'); + }); - app.use(logger(':date> :method :url - {:referrer} => :status (:response-time ms)', { - skip: function (req, res) - { - return req.path.startsWith('/samlproxy/idp/bower_components') || req.path.startsWith('/samlproxy/idp/css') - } - })); - app.use(bodyParser.urlencoded({extended: true})) - app.use(cookieParser()); - 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: 60000 } - })); - - /** - * 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 - }); - } + hbs.registerHelper('getProperty', function(attribute, context) { + return context[attribute]; + }); - /** - * Shared Handlers - */ + hbs.registerHelper('serialize', function(context) { + return new Buffer(JSON.stringify(context)).toString('base64'); + }); + + /** + * Middleware + */ + + app.use(logger(':date> :method :url - {:referrer} => :status (:response-time ms)', { + skip: function (req, res) + { + return req.path.startsWith('/samlproxy/idp/bower_components') || req.path.startsWith('/samlproxy/idp/css'); + } + })); + app.use(bodyParser.urlencoded({extended: true})); + app.use(cookieParser()); + 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: 60000 } + })); + + /** + * View Handlers + */ + + const showUser = function (req, res, next) { + const acsUrl = req.query.acsUrl ? + getReqUrl(req, req.query.acsUrl) : + getReqUrl(req, spConfig.requestAcsUrl); + + params = spConfig.getAuthnRequestParams( + acsUrl, + req.query.forceauthn === '' || req.query.forceAuthn === '' || req.query.forceauthn || req.query.forceAuthn, + req.authnRequest.relayState + ); + console.log('Generating SSO Request with Params ', params); + passport.authenticate('wsfed-saml2', params)(req, res, next); + }; - 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 + /** + * 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' + }; + req.session.authnRequest = req.authnRequest; + console.log('Received AuthnRequest => \n', req.authnRequest); + } + return showUser(req, res, next); }); }; - 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' + + 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 }; - console.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 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' + }); + }; - 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 - } - } + console.log('Processing SAML SLO request for participant => \n', req.participant); + + return samlp.logout({ + 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) { + console.log('Destroying session ' + req.session.id + ' for participant', req.participant); + req.session.destroy(); + callback(); + } + })(req, res, next); + }; - 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' + /** + * Routes + */ + + app.use(function(req, res, next){ + if (argv.idpRollSession) { + req.session.regenerate(function(err) { + return next(); + }); + } else { + next(); + } }); - }; - - console.log('Processing SAML SLO request for participant => \n', req.participant); - - return samlp.logout({ - 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) { - console.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(); + app.use(function(req, res, next){ + req.user = argv.idpConfig.user; + req.metadata = argv.idpConfig.metadata; + req.idp = { options: idpOptions }; + req.participant = getParticipant(req); + 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]; + } + }); - 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; + if (!authOptions.encryptAssertion) { + delete authOptions.encryptionCert; + delete authOptions.encryptionPublicKey; } - } else { - req.user[key] = req.body[key]; - } - }); - if (!authOptions.encryptAssertion) { - delete authOptions.encryptionCert; - delete authOptions.encryptionPublicKey; - } + // Set Session Index + authOptions.sessionIndex = getSessionIndex(req); - // Set Session Index - authOptions.sessionIndex = getSessionIndex(req); + // Keep calm and Single Sign On + console.log('Sending SAML Response\nUser => \n%s\nOptions => \n', + JSON.stringify(req.user, null, 2), authOptions); + samlp.auth(authOptions)(req, res); + }); - // Keep calm and Single Sign On - console.log('Sending SAML Response\nUser => \n%s\nOptions => \n', - JSON.stringify(req.user, null, 2), authOptions); - samlp.auth(authOptions)(req, res); - }) + app.get(IDP_PATHS.METADATA, function(req, res, next) { + samlp.metadata(req.idp.options)(req, res); + }); - app.get(IDP_PATHS.METADATA, function(req, res, next) { - samlp.metadata(req.idp.options)(req, res); - }); + app.get(SP_METADATA_URL, function(req, res, next) { + const xml = METADATA_TEMPLATE(spConfig.getMetadataParams(req)); + console.log(xml); + res.set('Content-Type', 'text/xml'); + res.send(xml); + }); - 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' - }; + spConfig.acsUrls.forEach(function(acsUrl) { + console.log(getPath(acsUrl)); + app.get(getPath(acsUrl), + function (req, res, next) { + console.log(req.method); + console.log(req.query); + if (req.method === 'GET' && req.query && (req.query.SAMLResponse || req.body.wresult)) { + req.body = req.query; + const ssoResponse = { + state: req.query.RelayState || req.body.wctx, + url: getReqUrl(req, acsUrl) + }; + req.session.ssoResponse = ssoResponse; + console.log(); + console.log('Received SSO Response on ACS URL %s', ssoResponse.url); + console.log(); + + const params = spConfig.getResponseParams(ssoResponse.url); + console.log('Validating SSO Response with Params ', params); + _.extend(strategy.options, params); + passport.authenticate('wsfed-saml2', params)(req, res, next); + } else { + res.redirect(SP_LOGIN_URL); + } + }, + function(req, res, next) { + const authOptions = extend({}, req.idp.options); + authOptions.RelayState = req.session.ssoResponse.state; + console.log('SP Sending SAML Response\nUser => \n%s\nOptions => \n', + JSON.stringify(req.user, null, 2), authOptions); + samlp.auth(authOptions)(req, res); + }); + }); - req.metadata.forEach(function(entry) { - if (entry.id === req.body.attributeName) { - entry = attribute; - attributeExists = true; + app.get(IDP_PATHS.SIGN_OUT, function(req, res, next) { + if (req.idp.options.sloUrl) { + console.log('Initiating SAML SLO request for user: ' + req.user.userName + + ' with sessionIndex: ' + getSessionIndex(req)); + res.redirect(IDP_PATHS.SLO); + } else { + console.log('SAML SLO is not enabled for SP, destroying IDP session'); + req.session.destroy(function(err) { + if (err) { + throw err; + } + res.redirect('back'); + }) } }); - if (!attributeExists) { - req.metadata.push(attribute); - } - console.log("Updated SAML Attribute Metadata => \n", req.metadata) - res.status(200).end(); - } - }); + // 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); + }); - app.get(IDP_PATHS.SIGN_OUT, function(req, res, next) { - if (req.idp.options.sloUrl) { - console.log('Initiating SAML SLO request for user: ' + req.user.userName + - ' with sessionIndex: ' + getSessionIndex(req)); - res.redirect(IDP_PATHS.SLO); - } else { - console.log('SAML SLO is not enabled for SP, destroying IDP session'); - req.session.destroy(function(err) { + // development error handler + app.use(function(err, req, res, next) { if (err) { - throw err; + res.status(err.status || 500); + res.render('error', { + message: err.message, + error: 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'); - } - }); - - console.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 - */ + /** + * Start IdP Web Server + */ - console.log('starting idp server on port %s', app.get('port')); + console.log('starting idp server on port %s', app.get('port')); - httpServer.listen(app.get('port'), function() { - const scheme = argv.https ? 'https' : 'http', - address = httpServer.address(), - hostname = os.hostname(); + httpServer.listen(app.get('port'), function() { + const scheme = argv.idpHttps ? 'https' : 'http', + address = httpServer.address(), + hostname = os.hostname(); baseUrl = address.address === '0.0.0.0' || address.address === '::' ? scheme + '://' + hostname + ':' + address.port : scheme + '://localhost:' + address.port; - /* - * baseUrl = argv.idpBaseUrl ? argv.idpBaseUrl : baseUrl; -*/ - - console.log(); - console.log('SAML IdP Metadata URL: '); - console.log('\t=> ' + baseUrl + IDP_PATHS.METADATA); - console.log(); - console.log('SSO Bindings: '); - console.log(); - console.log('\turn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'); - console.log('\t\t=> ' + baseUrl + IDP_PATHS.SSO); - console.log('\turn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'); - console.log('\t\t=> ' + baseUrl + IDP_PATHS.SSO); - console.log(); - if (argv.sloUrl) { - console.log('SLO Bindings: '); - console.log(); - console.log('\turn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'); - console.log('\t\t=> ' + baseUrl + IDP_PATHS.SLO); - console.log('\turn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'); - console.log('\t\t=> ' + baseUrl + IDP_PATHS.SLO); - console.log(); - } - console.log('idp server ready'); - console.log('\t=> ' + baseUrl); - console.log(); - }); + + console.log(); + console.log('SAML IdP Metadata URL: '); + console.log('\t=> ' + baseUrl + IDP_PATHS.METADATA); + console.log(); + console.log('SSO Bindings: '); + console.log(); + console.log('\turn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'); + console.log('\t\t=> ' + baseUrl + IDP_PATHS.SSO); + console.log('\turn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'); + console.log('\t\t=> ' + baseUrl + IDP_PATHS.SSO); + console.log(); + if (argv.idpSloUrl) { + console.log('SLO Bindings: '); + console.log(); + console.log('\turn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'); + console.log('\t\t=> ' + baseUrl + IDP_PATHS.SLO); + console.log('\turn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'); + console.log('\t\t=> ' + baseUrl + IDP_PATHS.SLO); + console.log(); + } + console.log('idp server ready'); + console.log('\t=> ' + baseUrl); + console.log(); + }); + }); } function runServer(options) { diff --git a/bower.json b/bower.json index b8cbbbc40..4acee3572 100644 --- a/bower.json +++ b/bower.json @@ -1,5 +1,5 @@ { - "name": "saml-idp", + "name": "saml-proxy", "private": true, "dependencies": { "bootstrap-tagsinput": "0.8.0", @@ -10,11 +10,11 @@ "jquery-validation": "1.17.0" }, "authors": [ - "Karl McGuinness" + "Patrick Vinograd" ], "repository": { "type": "git", - "url": "https://github.com/mcguinness/saml-idp" + "url": "https://github.com/patrickvinograd/saml-proxy" }, "license": "MIT", "ignore": [ diff --git a/config.js b/config.js index f0247a5ec..751db5c81 100644 --- a/config.js +++ b/config.js @@ -3,61 +3,81 @@ * User Profile */ var profile = { - userName: 'saml.jackson@example.com', - nameIdFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', - firstName: 'Saml', - lastName: 'Jackson', - displayName: 'saml jackson', - email: 'saml.jackson@example.com', - mobilePhone: '+1-415-555-5141', - groups: 'Simple IdP Users, West Coast Users, Cloud Users' + birth_date: '1936-04-10', + email: 'vets.gov.user+503@id.me', + fname: 'Wendeline', + social: '564930708', + gender: 'Female', + lname: 'O\'Heffernan', + level_of_assurance: '3', + mname: 'Kitty', + multifactor: 'true', + uuid: '43bb64d44a44452a8b30929003a89f53' } /** * SAML Attribute Metadata */ var metadata = [{ - id: "firstName", + id: "fname", optional: false, displayName: 'First Name', - description: 'The given name of the user', + description: 'The given name of the Veteran', multiValue: false }, { - id: "lastName", + id: "lname", optional: false, displayName: 'Last Name', - description: 'The surname of the user', + description: 'The surname of the Veteran', multiValue: false }, { - id: "displayName", + id: "mname", optional: true, - displayName: 'Display Name', - description: 'The display name of the user', + displayName: 'Middle Name', + description: 'The middle name of the Veteran', multiValue: false }, { id: "email", optional: false, displayName: 'E-Mail Address', - description: 'The e-mail address of the user', + description: 'The e-mail address of the Veteran', multiValue: false },{ - id: "mobilePhone", + id: "social", optional: true, - displayName: 'Mobile Phone', - description: 'The mobile phone of the user', + displayName: 'SSN', + description: 'The SSN of the Veteran', multiValue: false }, { - id: "groups", + id: "multifactor", optional: true, - displayName: 'Groups', - description: 'Group memberships of the user', - multiValue: true + displayName: 'Multifactor', + description: 'If the Veteran has two factor auth enabled', + multiValue: false +}, { + id: "gender", + optional: true, + displayName: 'Gender', + description: 'The gender of the Veteran', + multiValue: false }, { - id: "userType", + id: "uuid", optional: true, - displayName: 'User Type', - description: 'The type of user', - options: ['Admin', 'Editor', 'Commenter'] + displayName: 'uuid', + description: 'UUID of the Veteran model', + multiValue: false +}, { + id: "level_of_assurance", + optional: true, + displayName: 'Level of Assurance', + description: 'Level of identify proofing available for the Veteran', + multiValue: false +}, { + id: "birth_date", + optional: false, + displayName: 'Birth Date', + description: 'The birth date of the Veteran', + multiValue: false }]; module.exports = { diff --git a/docker-compose.yml b/docker-compose.yml index ffc171b90..56cb19caa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -saml-idp: +saml-proxy: build: . ports: - "7000:7000" diff --git a/idp-metadata.js b/idp-metadata.js new file mode 100644 index 000000000..dbebea42a --- /dev/null +++ b/idp-metadata.js @@ -0,0 +1,119 @@ +'use strict'; + +const util = require('util'), + request = require("request"), + xml2js = require('xml2js'); + +function getBindingLocation(serviceEl, bindingUri) { + var location; + if (serviceEl && serviceEl.length > 0) { + serviceEl.forEach((element, index, array) => { + if (element.$.Binding.toLowerCase() === bindingUri) { + location = element.$.Location; + } + }); + } + return location; +}; + +function getFirstCert(keyEl) { + if (keyEl.KeyInfo && + keyEl.KeyInfo.length === 1, + keyEl.KeyInfo[0].X509Data && + keyEl.KeyInfo[0].X509Data.length === 1, + keyEl.KeyInfo[0].X509Data[0].X509Certificate && + keyEl.KeyInfo[0].X509Data[0].X509Certificate.length === 1) { + + return keyEl.KeyInfo[0].X509Data[0].X509Certificate[0]._; + } +} + +function fetch(url) { + + return new Promise((resolve, reject) => { + const metadata = { sso: {}, slo: {}, nameIdFormats: [], signingKeys: [] }; + + if (typeof url === 'undefined' || url === null) { + return resolve(metadata); + } + + console.log('downloading IdP metadata from ' + url) + request.get(url, (err, resp, body) => { + if (err) { + console.log('unable to fetch metadata: ' + err.message); + return reject(err); + }; + + console.log(); + console.log(body); + console.log(); + + const parserConfig = { + explicitRoot: true, + explicitCharkey: true, + tagNameProcessors: [xml2js.processors.stripPrefix] + }, + parser = new xml2js.Parser(parserConfig), + nameIds = []; + + parser.parseString(body, (err, docEl) => { + if (err) { + return reject(err); + } + + if (docEl.EntityDescriptor) { + metadata.issuer = docEl.EntityDescriptor.$.entityID + + if (docEl.EntityDescriptor.IDPSSODescriptor && docEl.EntityDescriptor.IDPSSODescriptor.length === 1) { + + metadata.protocol = 'samlp'; + + let ssoEl = docEl.EntityDescriptor.IDPSSODescriptor[0]; + metadata.signRequest = ssoEl.$.WantAuthnRequestsSigned; + + ssoEl.KeyDescriptor.forEach((keyEl) => { + if (keyEl.$.use && keyEl.$.use.toLowerCase() !== 'encryption') { + metadata.signingKeys.push(getFirstCert(keyEl)); + } + }) + + if (ssoEl.NameIDFormat) { + ssoEl.NameIDFormat.forEach((element, index, array) => { + if (element._) { + metadata.nameIdFormats.push(element._); + } + }); + } + + metadata.sso.redirectUrl = getBindingLocation(ssoEl.SingleSignOnService, 'urn:oasis:names:tc:saml:2.0:bindings:http-redirect'); + metadata.sso.postUrl = getBindingLocation(ssoEl.SingleSignOnService, 'urn:oasis:names:tc:saml:2.0:bindings:http-post'); + + metadata.slo.redirectUrl = getBindingLocation(ssoEl.SingleLogoutService, 'urn:oasis:names:tc:saml:2.0:bindings:http-redirect'); + metadata.slo.postUrl = getBindingLocation(ssoEl.SingleLogoutService, 'urn:oasis:names:tc:saml:2.0:bindings:http-post'); + } + } + + if (docEl.EntityDescriptor.RoleDescriptor) { + metadata.protocol = 'wsfed'; + try { + let roleEl = docEl.EntityDescriptor.RoleDescriptor.find((el) => { + return el.$['xsi:type'].endsWith(':SecurityTokenServiceType'); + }); + metadata.sso.redirectUrl = roleEl.PassiveRequestorEndpoint[0].EndpointReference[0].Address[0]._ + + roleEl.KeyDescriptor.forEach((keyEl) => { + metadata.signingKeys.push(getFirstCert(keyEl)); + }) + } catch(e) { + console.log('unable to parse RoleDescriptor metadata', e); + } + } + return resolve(metadata); + }); + }); + }); +} + +module.exports = { + fetch: fetch +}; diff --git a/lib/simpleProfileMapper.js b/lib/simpleProfileMapper.js index e6e66eab3..ff41865cd 100644 --- a/lib/simpleProfileMapper.js +++ b/lib/simpleProfileMapper.js @@ -11,10 +11,11 @@ SimpleProfileMapper.prototype.getClaims = function() { SimpleProfileMapper.prototype.metadata.forEach(function(entry) { claims[entry.id] = entry.multiValue ? - self._pu[entry.id].split(',') : - self._pu[entry.id]; + self._pu['claims'][entry.id].split(',') : + self._pu['claims'][entry.id]; }); + console.log(claims); return Object.keys(claims).length && claims; }; @@ -67,4 +68,4 @@ SimpleProfileMapper.prototype.metadata = [ { multiValue: true }]; -module.exports = SimpleProfileMapper; \ No newline at end of file +module.exports = SimpleProfileMapper; diff --git a/package-lock.json b/package-lock.json index c89a870c6..7d860fed9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "saml-idp", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -18,6 +18,17 @@ "negotiator": "0.6.1" } }, + "ajv": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", + "requires": { + "co": "^4.6.0", + "fast-deep-equal": "^1.0.0", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.3.0" + } + }, "align-text": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", @@ -43,11 +54,36 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, + "asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=" + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + }, "async": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + }, + "aws4": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.7.0.tgz", + "integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==" + }, "basic-auth": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.0.tgz", @@ -56,6 +92,15 @@ "safe-buffer": "5.1.1" } }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "optional": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, "body-parser": { "version": "1.18.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.3.tgz", @@ -83,12 +128,25 @@ } } }, + "boom": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/boom/-/boom-0.4.2.tgz", + "integrity": "sha1-emNune1O/O+xnO9JR6PGffrukRs=", + "requires": { + "hoek": "0.9.x" + } + }, "bower": { "version": "1.8.4", "resolved": "https://registry.npmjs.org/bower/-/bower-1.8.4.tgz", "integrity": "sha1-54dqB23rgTf30GUl3F6MZtuC8oo=", "dev": true }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "bytes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", @@ -100,6 +158,11 @@ "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=", "optional": true }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + }, "center-align": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", @@ -134,11 +197,24 @@ "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=" }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" + }, "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" }, + "combined-stream": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "requires": { + "delayed-stream": "~1.0.0" + } + }, "content-disposition": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", @@ -168,6 +244,11 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, "crc": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/crc/-/crc-3.4.4.tgz", @@ -183,6 +264,22 @@ "which": "^1.2.9" } }, + "cryptiles": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-0.2.2.tgz", + "integrity": "sha1-7ZH/HxetE9N0gohZT4pIoNJvMlw=", + "requires": { + "boom": "0.4.x" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "requires": { + "assert-plus": "^1.0.0" + } + }, "debug": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", @@ -196,6 +293,11 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -206,6 +308,23 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" }, + "ecc-jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", + "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", + "optional": true, + "requires": { + "jsbn": "~0.1.0" + } + }, + "ecdsa-sig-formatter": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.10.tgz", + "integrity": "sha1-HFlQAPBKiJffuFAAiSoPTDOvhsM=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -389,6 +508,21 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=" }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + }, + "fast-deep-equal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=" + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" + }, "finalhandler": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", @@ -436,11 +570,31 @@ "utils-flatten": "^1.0.0" } }, + "foreach": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", + "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=" + }, "foreachasync": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/foreachasync/-/foreachasync-3.0.0.tgz", "integrity": "sha1-VQKYfchxS+M5IJfzLgBxyd7gfPY=" }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + }, + "form-data": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", + "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "1.0.6", + "mime-types": "^2.1.12" + } + }, "forwarded": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", @@ -461,6 +615,14 @@ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "requires": { + "assert-plus": "^1.0.0" + } + }, "handlebars": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.5.tgz", @@ -472,6 +634,20 @@ "uglify-js": "^2.6" } }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + }, + "har-validator": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", + "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", + "requires": { + "ajv": "^5.1.0", + "har-schema": "^2.0.0" + } + }, "hbs": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/hbs/-/hbs-4.0.1.tgz", @@ -481,6 +657,11 @@ "walk": "2.3.9" } }, + "hoek": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-0.9.1.tgz", + "integrity": "sha1-PTIkYrrfB3Fup+uFuviAec3c5QU=" + }, "http-errors": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", @@ -492,6 +673,16 @@ "statuses": ">= 1.4.0 < 2" } }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, "iconv-lite": { "version": "0.4.23", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz", @@ -500,6 +691,11 @@ "safer-buffer": ">= 2.1.2 < 3" } }, + "indexof": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", + "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=" + }, "inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", @@ -515,6 +711,11 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.6.0.tgz", "integrity": "sha1-4/o1e3c9phnybpXwSdBVxyeW+Gs=" }, + "is": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/is/-/is-0.2.7.tgz", + "integrity": "sha1-OzSixI81mXLzUEKEkZOucmS2NWI=" + }, "is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", @@ -525,16 +726,90 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" }, + "is-object": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-0.1.2.tgz", + "integrity": "sha1-AO+8CIFsM8/ErIJR0TLhDcZQmNc=" + }, "is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "optional": true + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + }, + "json-schema-traverse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=" + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + }, + "jsonwebtoken": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-5.0.5.tgz", + "integrity": "sha1-ZZLMBe4D3VrZ4DqRCRGk2nmv4Pg=", + "requires": { + "jws": "^3.0.0" + } + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "jwa": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.1.6.tgz", + "integrity": "sha512-tBO/cf++BUsJkYql/kBbJroKOgHWEigTKBAjjBEmrMGYd1QMBC74Hr4Wo2zCZw6ZrVhlJPvoMrkcOnlWR/DJfw==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.10", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.1.5.tgz", + "integrity": "sha512-GsCSexFADNQUr8T5HPJvayTjvPIfoyJPtLQBwn5a4WZQchcrPMPMAWcC1AzJVRDKyD6ZPROPAxgv6rfHViO4uQ==", + "requires": { + "jwa": "^1.1.5", + "safe-buffer": "^5.0.1" + } + }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -691,6 +966,21 @@ "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" }, + "oauth-sign": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=" + }, + "object-keys": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.2.0.tgz", + "integrity": "sha1-zd7AKZiwkb5CvxA1rjLknxy26mc=", + "requires": { + "foreach": "~2.0.1", + "indexof": "~0.0.1", + "is": "~0.2.6" + } + }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -754,6 +1044,52 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz", "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=" }, + "passport": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.4.0.tgz", + "integrity": "sha1-xQlWkTR71a07XhgCOMORTRbwWBE=", + "requires": { + "passport-strategy": "1.x.x", + "pause": "0.0.1" + } + }, + "passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" + }, + "passport-wsfed-saml2": { + "version": "github:edpaget/passport-wsfed-saml2#2667ba83974af3fc1dba75ed3a62faaf04e8aa06", + "from": "github:edpaget/passport-wsfed-saml2#auth-demo", + "requires": { + "cryptiles": "~0.2.2", + "ejs": "2.5.5", + "jsonwebtoken": "~5.0.4", + "passport-strategy": "^1.0.0", + "uid2": "0.0.x", + "valid-url": "^1.0.9", + "xml-crypto": "github:auth0/xml-crypto#65fa1e22c5884e45135d0bacccb99ad11e900bf1", + "xml-encryption": "0.11.0", + "xml2js": "0.1.x", + "xmldom": "github:auth0/xmldom#3376bc7beb5551bf68e12b0cc6b0e3669f77d392", + "xpath": "0.0.5", + "xtend": "~2.0.3" + }, + "dependencies": { + "xml2js": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.1.14.tgz", + "integrity": "sha1-UnTmf1pkxfkpdM2FE54DMq3GuQw=", + "requires": { + "sax": ">=0.1.1" + } + }, + "xmldom": { + "version": "github:auth0/xmldom#3376bc7beb5551bf68e12b0cc6b0e3669f77d392", + "from": "github:auth0/xmldom#v0.1.19-auth0_1" + } + } + }, "path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -769,6 +1105,16 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" }, + "pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, "proxy-addr": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.3.tgz", @@ -783,6 +1129,11 @@ "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + }, "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", @@ -819,6 +1170,33 @@ "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" }, + "request": { + "version": "2.87.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.87.0.tgz", + "integrity": "sha512-fcogkm7Az5bsS6Sl0sibkbhcKsnyon/jV1kF3ajGmF0c8HrttdKTPRT9hieOaQHA5HEq6r8OyWOo/o781C1tNw==", + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.6.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.1", + "forever-agent": "~0.6.1", + "form-data": "~2.3.1", + "har-validator": "~5.0.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.17", + "oauth-sign": "~0.8.2", + "performance-now": "^2.1.0", + "qs": "~6.5.1", + "safe-buffer": "^5.1.1", + "tough-cookie": "~2.3.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.1.0" + } + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -875,8 +1253,8 @@ } }, "samlp": { - "version": "github:mcguinness/node-samlp#e6c5e3ed4c02546e0b4008a339173a71d86b602f", - "from": "github:mcguinness/node-samlp", + "version": "github:edpaget/node-samlp#4e93a51feaae42fe69d32592dc1452fbcd94d53b", + "from": "github:edpaget/node-samlp#auth-demo", "requires": { "@auth0/thumbprint": "0.0.6", "ejs": "2.5.5", @@ -892,9 +1270,19 @@ "xmldom": { "version": "github:auth0/xmldom#3376bc7beb5551bf68e12b0cc6b0e3669f77d392", "from": "github:auth0/xmldom#v0.1.19-auth0_1" + }, + "xtend": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-1.0.3.tgz", + "integrity": "sha1-P12Tc1PM7Y4IU5mlY/2yJUHClgo=" } } }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, "send": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", @@ -977,6 +1365,22 @@ "amdefine": ">=0.0.4" } }, + "sshpk": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz", + "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=", + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, "statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -1004,6 +1408,28 @@ "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=" }, + "tough-cookie": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", + "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", + "requires": { + "punycode": "^1.4.1" + } + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "optional": true + }, "type-is": { "version": "1.6.16", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.16.tgz", @@ -1058,6 +1484,16 @@ "random-bytes": "~1.0.0" } }, + "uid2": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz", + "integrity": "sha1-SDEm4Rd03y9xuLY53NeZw3YWK4I=" + }, + "underscore": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.9.1.tgz", + "integrity": "sha512-5/4etnCkd9c8gwgowi5/om/mYO5ajCaOgdzj/oW+0eQV9WxKBDZw5+ycmKmeaTXjInS/W0BzpGLo2xR2aBwZdg==" + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1073,6 +1509,11 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + }, "valid-url": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", @@ -1083,6 +1524,16 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, "walk": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/walk/-/walk-2.3.9.tgz", @@ -1158,9 +1609,8 @@ } }, "xml-crypto": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-0.10.1.tgz", - "integrity": "sha1-+DL3TM9W8kr8rhFjofyrRNlndKg=", + "version": "github:auth0/xml-crypto#65fa1e22c5884e45135d0bacccb99ad11e900bf1", + "from": "github:auth0/xml-crypto#fix-digest", "requires": { "xmldom": "=0.1.19", "xpath.js": ">=0.0.3" @@ -1236,6 +1686,20 @@ } } }, + "xml2js": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", + "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~9.0.1" + } + }, + "xmlbuilder": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" + }, "xmldom": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz", @@ -1252,9 +1716,13 @@ "integrity": "sha512-jg+qkfS4K8E7965sqaUl8mRngXiKb3WZGfONgE18pr03FUQiuSV6G+Ej4tS55B+rIQSFEIw3phdVAQ4pPqNWfQ==" }, "xtend": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-1.0.3.tgz", - "integrity": "sha1-P12Tc1PM7Y4IU5mlY/2yJUHClgo=" + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.0.6.tgz", + "integrity": "sha1-XqZXptukRwacLlnFihE4ywxebO4=", + "requires": { + "is-object": "~0.1.2", + "object-keys": "~0.2.0" + } }, "y18n": { "version": "3.2.1", diff --git a/package.json b/package.json index 94c4a4b20..b0b9846c8 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { - "name": "saml-idp", - "description": "Test Identity Provider (IdP) for SAML 2.0 Web Browser SSO Profile", + "name": "saml-proxy", + "description": "SAML 2.0 Proxy allows modification/mediation of requests/responses", "version": "1.1.0", "private": false, - "author": "Karl McGuinness", + "author": "Patrick Vinograd", "keywords": [ "saml", "idp", @@ -12,10 +12,10 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/mcguinness/saml-idp" + "url": "https://github.com/patrickvinograd/saml-proxy" }, "bugs": { - "url": "https://github.com/mcguinness/saml-idp/issues" + "url": "https://github.com/patrickvinograd/saml-proxy/issues" }, "main": "./app.js", "scripts": { @@ -35,9 +35,14 @@ "extend": "^3.0.1", "hbs": "~4.0.1", "morgan": "~1.9.0", - "samlp": "mcguinness/node-samlp", + "passport": "^0.4.0", + "passport-wsfed-saml2": "edpaget/passport-wsfed-saml2#auth-demo", + "samlp": "edpaget/node-samlp#auth-demo", + "underscore": "^1.9.0", "xml-formatter": "^1.0.1", "xmldom": "^0.1.27", + "request": "^2.87.0", + "xml2js": "^0.4.19", "yargs": "^11.0.0" }, "devDependencies": { diff --git a/templates/authnrequest.tpl b/templates/authnrequest.tpl new file mode 100644 index 000000000..ac0f18562 --- /dev/null +++ b/templates/authnrequest.tpl @@ -0,0 +1,7 @@ + ForceAuthn="true"<% } %>> + @@Issuer@@ + <% if (NameIDFormat) { %><% } %> + <% if (AuthnContext) { %> + @@AuthnContext@@ + <% } %> + diff --git a/templates/metadata.tpl b/templates/metadata.tpl new file mode 100644 index 000000000..c9787ea5f --- /dev/null +++ b/templates/metadata.tpl @@ -0,0 +1,15 @@ + + + + + + + <%= cert %> + + + + + <%= nameIDFormat %> + <% for (var i=0; i<% } %> + + diff --git a/views/layout.hbs b/views/layout.hbs index d7af4e369..a84572491 100644 --- a/views/layout.hbs +++ b/views/layout.hbs @@ -5,46 +5,31 @@ Simple Identity Provider + - - - -
{{{body}}}
- + + {{{block "scripts"}}}