expressxml-signaturepassport-saml

Why passport-saml can't find a signature - Invalid document signature


Why a signature can't be found?

I've checked the code during execution and saw that signatures was empty:

if (signatures.length !== 1) {
    return false;
}

Because an ID from Response didn't match URI from Reference

<saml2p:Response ID="K0WO32GJF96P5SJBU9I5V0YI53OBOCIUSG6G4RX9" ...>
    ...
    <ds:Reference URI="#Q65FHLGKK1SSG05I7UWVEQS5URLD0EUVKIP0GY06">

passport-saml source code:

https://github.com/node-saml/passport-saml/blob/6ba76ba3a015fea96a2dd38f661a6c1f85bc44a1/src/node-saml/saml.ts#L710-L712

an excerpt from it:

const validateSignature = (fullXml, currentNode, certs) => {
    const xpathSigQuery = ".//*[" +
        "local-name(.)='Signature' and " +
        "namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#' and " +
        "descendant::*[local-name(.)='Reference' and @URI='#" +
        currentNode.getAttribute("ID") +
        "']" +
        "]";

    const signatures = exports.xpath.selectElements(currentNode, xpathSigQuery);

    // it fails here coz it's empty
    if (signatures.length !== 1) {
        return false;
    }
    ...
}

Content ofxpathSigQuery variable:

const xpathSigQuery = './/*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#' and descendant::*[local-name(.)='Reference' and @URI='#K0WO32GJF96P5SJBU9I5V0YI53OBOCIUSG6G4RX9']]'

Content of fullXml argument:

<?xml version="1.0" encoding="UTF-8"?>
<saml2p:Response Consent="urn:oasis:names:tc:SAML:2.0:consent:unspecified" Destination="https://ppt2cf-8080.csb.app/login-idp/callback" ID="K0WO32GJF96P5SJBU9I5V0YI53OBOCIUSG6G4RX9" InResponseTo="_ed3b8b9d9b6bb14d1e9660ca902a8c0da7708c9e" IssueInstant="2023-06-23T05:40:25.157Z" Version="2.0" xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol">
    <saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">https://ppt2cf-8080.csb.app/login-idp/callback</saml2:Issuer>
    <saml2p:Status>
        <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
    </saml2p:Status>
    <saml2:Assertion ID="Q65FHLGKK1SSG05I7UWVEQS5URLD0EUVKIP0GY06" IssueInstant="2023-06-23T05:40:25.157Z" Version="2.0" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
        <saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">https://ppt2cf-8080.csb.app/login-idp/callback</saml2:Issuer>
        <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
            <ds:SignedInfo>
                <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
                <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
                <ds:Reference URI="#Q65FHLGKK1SSG05I7UWVEQS5URLD0EUVKIP0GY06">
                    <ds:Transforms>
                        <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
                        <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
                    </ds:Transforms>
                    <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
                    <ds:DigestValue>yHH5fUXBy+hkTKCvhTutPTvzJc/vwNnlwE3tZiDVJBI=</ds:DigestValue>
                </ds:Reference>
            </ds:SignedInfo>
            <ds:SignatureValue>AWGSLsBapRN9O2YTOwb/HBe/kALxU6wmhNLoSNwb+UO1kFC50sMQ+wM8DDmR0Zgq08iB68Y8ckI5KXQZhFH0xUvgmt1gqB8NCXx3C7Zx9W0vhX+vEURkasBigybFSUWf22VqndUTIhHbS7/QXj+xi4C9Ujz3WLP8Txx+DGFc4GcnAIvD46YAzD0NhU65+T5sCoyTmVIQ3WcEpyQkvWjxvzAq3yTHj2YJ54w7c+X8RTtFMKh0aq48Ec+FJHpl1VdRx7vXkD089qpUpA6CvOZu88WlUJ9+82hF+JVRb10WG6TzVnw3IjrD5n6r2BmdnaGUQ9yG52CP+nk5auB7YCwnVg==</ds:SignatureValue>
            <ds:KeyInfo>
                <ds:X509Data>
                    <ds:X509Certificate>MIIDgDCCAmgCCQCLAP0JHUDNijANBgkqhkiG9w0BAQsFADCBgTELMAkGA1UEBhMCVUExEDAOBgNVBAgMB0RvbmJhc3MxDTALBgNVBAcMBEtpZXYxEjAQBgNVBAoMCURpZ2l0YWxpczENMAsGA1UECwwERGV2czEMMAoGA1UEAwwDRGFuMSAwHgYJKoZIhvcNAQkBFhFkZW5pc0BheG9ub3BzLmNvbTAeFw0yMzA2MTIwMzI4MDRaFw0yNjA2MTEwMzI4MDRaMIGBMQswCQYDVQQGEwJVQTEQMA4GA1UECAwHRG9uYmFzczENMAsGA1UEBwwES2lldjESMBAGA1UECgwJRGlnaXRhbGlzMQ0wCwYDVQQLDAREZXZzMQwwCgYDVQQDDANEYW4xIDAeBgkqhkiG9w0BCQEWEWRlbmlzQGF4b25vcHMuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA43efCJashd9GkzSK8hPtCZ1DburaGB6dow1wLVneAkidS7O7hnueGtbWGyKj7tMZ+6fh7cXl5DqtO/xu/Fz6LCT3EdGPCA3P+ozyqn62aF+qlQGm3QpR5sRVJ0/hYV5f5u883CR+72rV+S4xeXGISXuRqbobqnHnqB4f2rcT1tOpujXpMu2Awa+bjObjRGBCwkRvgIMGs0xbhbIiMPi35xqkWo7IfpFsFXXyHij+mdKBeaUOQEMHUJ2Xt6z8gYOMRy+hPxexYVTZl/chs918pWSMzU3uu3B+nzEXflJ8T4UMx2pYfnAFP7Ifccj2aQZm+p2GFnOc1TyE3iNTssJaqwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAP+/SKddgUzT+lLvBR3mQT++DuII2WLr4b7/kYMXp5ohOJsh3D7a2TbGxzHEsK7SNZ1kKjxUhlFJ9kLWUmn3AakPDl46C/b7E0FEmjVbU/H6kJw8BPrRj6zyMVZBNWpvi8IyhGDKAgfVHXY6wicKy70QXSZyoR/WhpJ9SEwr4iodeAjVGsDjPYn5S+/4n+WHqH5IWYvukHU0VCNltRfy4Axwhl28eUW4u7ivQXBchUsaSEdCMFWgJYUteU6mC1myfG9hlbiZybqBbsSfVXVFMUxrtdWrSc926qfwJd1rYSUOJwzhGg0aAa8ngatq2HUDb5KnxKapkwEjm7M7zl4ZsB</ds:X509Certificate>
                </ds:X509Data>
            </ds:KeyInfo>
        </ds:Signature>
        <saml2:Subject>
            <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">denis@axonops.com</saml2:NameID>
            <saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
                <saml2:SubjectConfirmationData InResponseTo="_ed3b8b9d9b6bb14d1e9660ca902a8c0da7708c9e" NotOnOrAfter="2023-06-23T05:45:25.157Z" Recipient="https://ppt2cf-8080.csb.app/login-idp/callback"/>
            </saml2:SubjectConfirmation>
        </saml2:Subject>
        <saml2:Conditions NotBefore="2023-06-23T05:35:25.157Z" NotOnOrAfter="2023-06-23T05:45:25.157Z">
            <saml2:AudienceRestriction>
                <saml2:Audience>https://ppt2cf-8080.csb.app/saml/metadata</saml2:Audience>
            </saml2:AudienceRestriction>
        </saml2:Conditions>
        <saml2:AuthnStatement AuthnInstant="2023-06-23T05:40:25.157Z" SessionIndex="2fe7b4b9-36ce-4f47-933d-df071e906e16">
            <saml2:AuthnContext>
                <saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef>
            </saml2:AuthnContext>
        </saml2:AuthnStatement>
    </saml2:Assertion>
</saml2p:Response>

passport-saml config:

const passport = require("passport");
const passportSaml = require("@node-saml/passport-saml");
const fs = require("fs");

passport.serializeUser((user, done) => done(null, user));

passport.deserializeUser((user, done) => done(null, user));

const strategy = new passportSaml.Strategy(
  {
    entryPoint: "https://sso.jumpcloud.com/saml2/test-app",
    issuer: "https://sso.jumpcloud.com",

    path: "login-idp/callback",
    cert: fs.readFileSync("./certs/cert.pem", "utf-8"),
    privateKey: fs.readFileSync("./certs/private.pem", "utf-8"),

    signatureAlgorithm: "sha256",
    identifierFormat: null,

    // From the metadata document
    audience: "https://ppt2cf-8080.csb.app/saml/metadata",
  },
  (profile, done) => {
    return done(null, profile);
  }
);

passport.use(strategy);

module.exports = {
  passport,
  strategy,
};

app.js

const express = require("express");
const cookieParser = require("cookie-parser");
const session = require("express-session");
const path = require("path");
const bodyParser = require("body-parser");
const { passport: passportConfig } = require("./config/passport");
const router = require("./routes/index");

const app = express();

app.use(
  session({
    secret: "secret",
    resave: false,
    saveUninitialized: true,
    // cookie: { maxAge: 7 * 24 * 60 * 60 * 1000 }, // 7 days
  })
);

app.use(cookieParser());
// app.use(express.static(path.join(__dirname, "public")));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

app.use(passportConfig.initialize());
app.use(passportConfig.session());

app.use(router);

const listener = app.listen(8080, function () {
  console.log("Listening on port " + listener.address().port);
});

routes.js

const express = require("express");
const fs = require("fs");
const bodyParser = require("body-parser");
const { passport, strategy } = require("./../config/passport");

const router = express.Router();

function ensureAuthenticated(req, res, next) {
  if (req.isAuthenticated()) return next();
  // else return res.redirect("/login-idp");
  else
    return res.send(
      `<button onclick="location.pathname='/login-idp'">Login</botton>`
    );
}

router.get("/", ensureAuthenticated, function (req, res) {
  res.send("Authenticated");
});

router.get(
  "/login-idp",
  passport.authenticate("saml", {
    failureFlash: true,
    failureRedirect: "/saml/auth/error",
    // successRedirect: '/saml/auth/success',
  }),
  function (req, res) {
    console.log("==> saml/login callback");
    res.redirect("/");
  }
);

router.post(
  "/login-idp/callback",
  bodyParser.urlencoded({ extended: false }),
  passport.authenticate("saml", {
    failureRedirect: "/login/fail",
    failureFlash: true,
  }),
  function (req, res) {
    console.log("==> login/callback");
    res.redirect("/");
  }
);

router.get("/login/fail", function (req, res) {
  res.status(401).send("Login failed");
});

router.get("/saml/metadata", function (req, res) {
  res.type("application/xml");
  res
    .status(200)
    .send(
      strategy.generateServiceProviderMetadata(
        fs.readFileSync("./certs/cert.pem", "utf8"),
      )
    );
});

module.exports = router;

package.json:

{
  "name": "express-example-starter",
  "version": "1.0.0",
  "description": "An Express-based application skeleton",
  "main": "app.js",
  "scripts": {
    "start": "nodemon app.js localhost 8080"
  },
  "dependencies": {
    "@node-saml/passport-saml": "^4.0.4",
    "body-parser": "^1.20.2",
    "cookie-parser": "~1.4.4",
    "debug": "~2.6.9",
    "express": "~4.16.1",
    "express-session": "^1.17.3",
    "morgan": "~1.9.1",
    "passport": "^0.6.0",
    "saml2js": "^0.1.2",
    "samlify": "^2.8.10",
    "useragent": "^2.3.0"
  },
  "devDependencies": {
    "nodemon": "1.18.4"
  },
  "keywords": [
    "expressjs",
    "express"
  ]
}

I use jumploud as a idP jumpcloud is used as idP


Solution

  • This question (with same content as this SO question) is answered here: https://github.com/node-saml/passport-saml/discussions/870#discussioncomment-6278844

    Short answer: Your IdP is signing only assertion. Starting from @node-saml/passport-saml's version 4.0.0 default configuration is to require signed top/response level signature and signed assertion. It is not finding top/response level signature thus it reports it cannot find signature. Developer may configure different settings. For more information see linked answer from passport-saml discussions.