node.jssingle-sign-onsaml-2.0idpsamlify

How to generate Identity Provider (IdP) SAML Response in node.js?


I have two very simple node.js applications: idp (Identity Provider) and sp (Service Provider). These apps don't have any specific business logic, I just want to create a very simple single sign-on (SSO) example in node.js. More specifically, I want to implement an Identity Provider (IdP) initiated single sign-on (SSO), but I don't understand how to generate a SAML IdP response, and moreover, I don't understand what IdP/SP metadata files should have (I am new to SSO and SAML 2.0 protocol).

I have the following node.js servers:

Identity Provider (IdP) Server:

/*
 * This is the code in the idp.js file
 */
const express = require('express');
const saml = require('samlify');
const fs = require('fs');

const app = express();
const port = 3000;

const idp = saml.IdentityProvider({
  metadata: fs.readFileSync(__dirname + '/idp-metadata.xml')
});

const sp = saml.ServiceProvider({
  metadata: fs.readFileSync(__dirname + '/sp-metadata.xml')
});

app.get('/metadata', (req, res) => {
  res.type('application/xml');
  res.send(idp.getMetadata());
});

/*
 * The endpoint that is used to initiate IdP SSO.
 */
app.get('/idpinitsso', async (req, res) => {
  try {
    // As far as I understand when this endpoint is called I need to generate SAML Response 
    // and then send an HTTP POST request with SAMLResponse url-encoded body. 
    // But the question is how to generate SAML Response?
  } catch (e) {
    console.log(e)
  }

  res.status(204).send()
})

app.listen(port, () => {
  console.log(`Server listening at http://localhost:${port}`);
});

Service Provider (SP) Server:

/*
 * This is the code in the sp.js file
 */
const express = require('express');
const saml = require('samlify');
const fs = require('fs');

const app = express();
const port = 3001;

const sp = saml.ServiceProvider({
  metadata: fs.readFileSync(__dirname + '/sp-metadata.xml')
});

const idp = saml.IdentityProvider({
  metadata: fs.readFileSync(__dirname + '/idp-metadata.xml')
});

app.get('/metadata', (req, res) => {
  res.type('application/xml');
  res.send(sp.getMetadata());
});

app.post('/acs', async (req, res) => {
  const parseResult = sp.parseLoginResponse(idp, 'post', req)
  console.log(parseResult)

  res.status(204).send()
});

app.listen(port, () => {
  console.log(`Server listening at http://localhost:${port}`);
});

idp-metadata.xml (this XML is taken from https://samlify.js.org/#/idp + https://samlify.js.org/#/key-generation that was used to generate X509Certificate)

<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:3000/metadata">
  <IDPSSODescriptor xmlns:ds="http://www.w3.org/2000/09/xmldsig#" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
    <KeyDescriptor use="signing">
      <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
        <ds:X509Data>
          <ds:X509Certificate>MIIFeDCCA2ACCQDM8Gu+flnutzANBgkqhkiG9w0BAQsFADB+MQswCQYDVQQGEwJVUzERMA8GA1UECAwITmV3IFlvcmsxETAPBgNVBAcMCE5ldyBZb3JrMQ0wCwYDVQQKDARBeG9uMQ0wCwYDVQQLDARBeG9uMQ0wCwYDVQQDDARBeG9uMRwwGgYJKoZIhvcNAQkBFg1uaWtAZ21haWwuY29tMB4XDTIzMTExMzE5MTkwMVoXDTMzMTExMDE5MTkwMVowfjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMREwDwYDVQQHDAhOZXcgWW9yazENMAsGA1UECgwEQXhvbjENMAsGA1UECwwEQXhvbjENMAsGA1UEAwwEQXhvbjEcMBoGCSqGSIb3DQEJARYNbmlrQGdtYWlsLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANK68twNmdNIYN5+Au/fNOM5f7JTlet/Cyxb4znmK51YNDYioROdvk1Z1Zvn7eZZrwA34ID/sMnHxKHMksdTSbgAmXox50LLJpx4/kTWwzw/NH1IvXD137nBfkcdr41pe56+i6uc5O2yWbzaVMzZKbEC448lkL6bFAoc3s5aRL1YVlPZsHolItINZReBCW80jdEwT1lI0jQTuD3qRaU3QPbNaT/RY39NGdVkuOoBWICyyvO6N7HHwv+UmIlLzvQH1gLYI2+pDbTyH33lUDnslN5tch5/x/m/TZFek2KpZQ2gIoihYtrZscvHzYVsaeW5A/PEqvsOfQMHbpzFktlfufZ/dcgV8lBey36itxp82/DW5SEQmZBUqnoISVTNiq1j2goALvoF5l+lnQtkyJCRACayln/U7z4ktaTJGxs/O9eXkXsi+FTOmWVWn0NRCmHQTERX+3zCreLExMGTCLSNPKBRbyo0ydYsHR55GkkxCQbwRy671hkm0W4yF/YkDcW2WIFF2bvSy0/wCHFTU0PxzIl07vwlMejIaYibW8cv+hxa9nLvhilvpZ9wPFaLaMzWKsPcYDgnic/W/3niy2uSGrH5uLBPax3jb3cyLiFNEdUAEdYLOhGco0WWDbUEUMZOhGBlF7M4wtnti+94F4zqW76QRT6WBk8S9Au8Fk/B6fQTAgMBAAEwDQYJKoZIhvcNAQELBQADggIBAAu1lt88IOsQXHnpFSP7ewK1GOjxiNI/k6mYiGT4OowCjBDmeX06/OnVSP58JkdnJUwSRC9f3iblvAD02NyY9IRjGvPPEUgA6G2zmcrTt72XyZIMYh1yDyLdMuWQAtRQvs75x9MeQWHe7wN5XXkoazSoLxCmyZs8LzYoGwnMxdjO6gq4A/DwXklplMUXSoj3rTbKDXi65CxFzDyEkYPlqJrRE3N7DKCBtuhp5m+EQJZeeCEKBxahhoww1QV5K+qHbMo8Hjg89b+8o82YRYXLcaCYQ9tJayXadx2qk9RghpAhG2TNVZpegPzM9UAJ0bFgh1O4v/oc5QiywRuEEhzO8Ml4fCJ3y3MQBJ/7ESnkJtQZkaErT4TYT8i3hkZL5HPeIZ2/NQbc+DYDyZQQVWy8M26rBQYTEqNuWQCaXKJr03vc2MyXKgZ8Hr/JywzRJOnCvBdSwvu8PffgJsYgexwmU1dwMQQAdA1utJkayOQTsc24YFOIDV4a+p3cvD1GeUDON7swJKnyKX4XBQFejMp5kG7V1p2KB5s0aDOJ+twDnpPCALX3Zs07PScd0H/wiDQkjI/ZQpEMO/2BTH5D9/x40vhnA4olVHRwuDV+xLvi0fwtQfA6T10f7cl9GWfjX0lsf95Qa1u0tj9BjQ2XWcncY5po31Q6aEOLK6biDxlE5kd1</ds:X509Certificate>
        </ds:X509Data>
      </ds:KeyInfo>
    </KeyDescriptor>
    <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
    <SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:3000/trust/saml2/http-post/sso/486670"/>
    <SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:3000/trust/saml2/http-post/sso/486670"/>
    <SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="http://localhost:3000/trust/saml2/soap/sso/486670"/>
  </IDPSSODescriptor>
  <ContactPerson contactType="technical">
    <SurName>Support</SurName>
    <EmailAddress>support@onelogin.com</EmailAddress>
  </ContactPerson>
</EntityDescriptor>

sp-metadata.xml (this XML is taken from https://samlify.js.org/#/sp + https://samlify.js.org/#/key-generation that was used to generate X509Certificate)

<EntityDescriptor
 xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
 xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
 xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
 entityID="https://sp.example.org/metadata">
    <SPSSODescriptor WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
        <KeyDescriptor use="signing">
            <KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
                <X509Data>
                    <X509Certificate>MIIFeDCCA2ACCQDM8Gu+flnutzANBgkqhkiG9w0BAQsFADB+MQswCQYDVQQGEwJVUzERMA8GA1UECAwITmV3IFlvcmsxETAPBgNVBAcMCE5ldyBZb3JrMQ0wCwYDVQQKDARBeG9uMQ0wCwYDVQQLDARBeG9uMQ0wCwYDVQQDDARBeG9uMRwwGgYJKoZIhvcNAQkBFg1uaWtAZ21haWwuY29tMB4XDTIzMTExMzE5MTkwMVoXDTMzMTExMDE5MTkwMVowfjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMREwDwYDVQQHDAhOZXcgWW9yazENMAsGA1UECgwEQXhvbjENMAsGA1UECwwEQXhvbjENMAsGA1UEAwwEQXhvbjEcMBoGCSqGSIb3DQEJARYNbmlrQGdtYWlsLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANK68twNmdNIYN5+Au/fNOM5f7JTlet/Cyxb4znmK51YNDYioROdvk1Z1Zvn7eZZrwA34ID/sMnHxKHMksdTSbgAmXox50LLJpx4/kTWwzw/NH1IvXD137nBfkcdr41pe56+i6uc5O2yWbzaVMzZKbEC448lkL6bFAoc3s5aRL1YVlPZsHolItINZReBCW80jdEwT1lI0jQTuD3qRaU3QPbNaT/RY39NGdVkuOoBWICyyvO6N7HHwv+UmIlLzvQH1gLYI2+pDbTyH33lUDnslN5tch5/x/m/TZFek2KpZQ2gIoihYtrZscvHzYVsaeW5A/PEqvsOfQMHbpzFktlfufZ/dcgV8lBey36itxp82/DW5SEQmZBUqnoISVTNiq1j2goALvoF5l+lnQtkyJCRACayln/U7z4ktaTJGxs/O9eXkXsi+FTOmWVWn0NRCmHQTERX+3zCreLExMGTCLSNPKBRbyo0ydYsHR55GkkxCQbwRy671hkm0W4yF/YkDcW2WIFF2bvSy0/wCHFTU0PxzIl07vwlMejIaYibW8cv+hxa9nLvhilvpZ9wPFaLaMzWKsPcYDgnic/W/3niy2uSGrH5uLBPax3jb3cyLiFNEdUAEdYLOhGco0WWDbUEUMZOhGBlF7M4wtnti+94F4zqW76QRT6WBk8S9Au8Fk/B6fQTAgMBAAEwDQYJKoZIhvcNAQELBQADggIBAAu1lt88IOsQXHnpFSP7ewK1GOjxiNI/k6mYiGT4OowCjBDmeX06/OnVSP58JkdnJUwSRC9f3iblvAD02NyY9IRjGvPPEUgA6G2zmcrTt72XyZIMYh1yDyLdMuWQAtRQvs75x9MeQWHe7wN5XXkoazSoLxCmyZs8LzYoGwnMxdjO6gq4A/DwXklplMUXSoj3rTbKDXi65CxFzDyEkYPlqJrRE3N7DKCBtuhp5m+EQJZeeCEKBxahhoww1QV5K+qHbMo8Hjg89b+8o82YRYXLcaCYQ9tJayXadx2qk9RghpAhG2TNVZpegPzM9UAJ0bFgh1O4v/oc5QiywRuEEhzO8Ml4fCJ3y3MQBJ/7ESnkJtQZkaErT4TYT8i3hkZL5HPeIZ2/NQbc+DYDyZQQVWy8M26rBQYTEqNuWQCaXKJr03vc2MyXKgZ8Hr/JywzRJOnCvBdSwvu8PffgJsYgexwmU1dwMQQAdA1utJkayOQTsc24YFOIDV4a+p3cvD1GeUDON7swJKnyKX4XBQFejMp5kG7V1p2KB5s0aDOJ+twDnpPCALX3Zs07PScd0H/wiDQkjI/ZQpEMO/2BTH5D9/x40vhnA4olVHRwuDV+xLvi0fwtQfA6T10f7cl9GWfjX0lsf95Qa1u0tj9BjQ2XWcncY5po31Q6aEOLK6biDxlE5kd1</X509Certificate>
                </X509Data>
            </KeyInfo>
        </KeyDescriptor>
        <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
        <AssertionConsumerService isDefault="true" index="0" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:3001/acs"/>
    </SPSSODescriptor>
</EntityDescriptor>

I tried to use idp.createLoginResponse(sp) but it didn't work for me because I got the ERR_CREATE_RESPONSE_UNDEFINED_BINDING error.

I also tried to generate it like this:

const user = { email: 'user@esaml2.com' };
const sampleRequestInfo = { extract: { request: { id: 'request_id' } } };

const samlResponse = await idp.createLoginResponse(sp, sampleRequestInfo, 'post', user);

But I get the following error: "Error [ERR_CRYPTO_SIGN_KEY_REQUIRED]: No key provided for signing.".

I couldn't find a working example in https://samlify.js.org/ with IdP-initiated SSO, so I would really appreciate it if you could provide me with a minimal working example or suggest any good node.js library that can handle this (or just have an IdP-Initiated SSO example) or any other example in any other language.


Solution

  • Well, after several days of struggle, I finally understood how SSO works and implemented it (IdP-initiated SSO, where our service acts as an Identity Provider for a third-party service). I'll leave my answer here in case someone has to do the same and has problems.

    First of all, I recommend that you read how SSO flow works. I'll leave a couple of good resources here for you to read:

    1. http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0-cd-02.html#5.1.1.Introduction|outline
    2. https://developer.okta.com/docs/concepts/saml/

    So, in a nutshell, an IdP-initiated SSO flow (where your service acts as the IdP) works like this:

    1. The user is logged into the Identity Provider (your service).
    2. The user is goes to a page with integrations (or any page where the user can start integrating with a Service Provider)
    3. The user clicks something like "Login to [Service Provider Name]".
    4. The IdP generates a SAMLResponse.
    5. The IdP sends an HTTP POST request with a SAMLResponse to the Service Provider's ACS (Assertion Consumer Service) URL.

    Before you start writing code, you need to do the following:

    1. Ask your Service Provider for an XML metadata file (SP metadata).
    2. Create your own XML metadata file (IdP metadata) and send it to your Service Provider.

    You can create your own metadata file (IdP metadata) here: https://www.samltool.com/idp_metadata.php.

    It is highly recommended to sign the XML metadata file, so create the files private_key.pem and public_cert.cer by running the following commands:

    openssl genrsa -passout pass:jXmKf9By6ruLnUdRo90G -out private_key.pem 4096
    openssl req -new -x509 -key private_key.pem -out public_cert.cer -days 3650
    

    You should then add the body from public_cert.cer to the "SP X.509 cert (same cert for sign/encrypt)" field when creating the IdP metadata file here: https://www.samltool.com/idp_metadata.php

    Finally, when everything is ready (you have both SP and IdP metadata files), we can move on to creating our IdP.

    Our IdP was written in Node.js, so we used samlify for creating SAMLResponse:

    /**
     * idp.js
     */
    const express = require('express');
    const saml = require('samlify');
    
    const { addMinutes } = require('date-fns')
    const { readFileSync } = require('fs');
    const { randomUUID } = require('crypto');
    
    const app = express();
    const port = 3000;
    
    const generateRequestID = () => {
        return '_' + randomUUID()
    }
    
    // Unfortunately as it's said in https://github.com/tngan/samlify/issues/373
    // to add attributes to your SAMLResponse using samlify you need to
    // completely duplicate the logic from here: https://github.com/tngan/samlify/blob/2e1a93671f4b98980472d4857e08a2f99a236acd/src/binding-post.ts#L93-L123
    const createTemplateCallback = (idp, sp, email) => template => {
        const assertionConsumerServiceUrl = sp.entityMeta.getAssertionConsumerService(saml.Constants.wording.binding.post)
    
        const nameIDFormat = idp.entitySetting.nameIDFormat
        const selectedNameIDFormat = Array.isArray(nameIDFormat) ? nameIDFormat[0] : nameIDFormat
    
        const id = generateRequestID()
        const now = new Date()
        const fiveMinutesLater = addMinutes(now, 5)
    
        const tagValues = {
            ID: id,
            AssertionID: generateRequestID(),
            Destination: assertionConsumerServiceUrl,
            Audience: sp.entityMeta.getEntityID(),
            EntityID: sp.entityMeta.getEntityID(),
            SubjectRecipient: assertionConsumerServiceUrl,
            Issuer: idp.entityMeta.getEntityID(),
            IssueInstant: now.toISOString(),
            AssertionConsumerServiceURL: assertionConsumerServiceUrl,
            StatusCode: 'urn:oasis:names:tc:SAML:2.0:status:Success',
            ConditionsNotBefore: now.toISOString(),
            ConditionsNotOnOrAfter: fiveMinutesLater.toISOString(),
            SubjectConfirmationDataNotOnOrAfter: fiveMinutesLater.toISOString(),
            NameIDFormat: selectedNameIDFormat,
            NameID: email,
            InResponseTo: 'null',
            AuthnStatement: '',
            /**
             * Custom attributes
             */
            attrFirstName: 'Jon',
            attrLastName: 'Snow',
        }
    
        return {
            id,
            context: saml.SamlLib.replaceTagsByValue(template, tagValues)
        }
    }
    
    /*
     * Service Provider config (required for creating SAMLResponse)
     */
    const sp = saml.ServiceProvider({
        metadata: readFileSync(__dirname + '/metadata/sp-metadata.xml')
    });
    
    /*
     * Identity Provider config
     */
    const idp = saml.IdentityProvider({
        metadata: readFileSync(__dirname + '/metadata/idp-metadata.xml'),
        privateKey: readFileSync(__dirname + '/key/idp/private_key.pem'),
        privateKeyPass: 'jXmKf9By6ruLnUdRo90G',
        isAssertionEncrypted: false,
        loginResponseTemplate: {
            context: '<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{ID}" Version="2.0" IssueInstant="{IssueInstant}" Destination="{Destination}" InResponseTo="{InResponseTo}"><saml:Issuer>{Issuer}</saml:Issuer><samlp:Status><samlp:StatusCode Value="{StatusCode}"/></samlp:Status><saml:Assertion ID="{AssertionID}" Version="2.0" IssueInstant="{IssueInstant}" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"><saml:Issuer>{Issuer}</saml:Issuer><saml:Subject><saml:NameID Format="{NameIDFormat}">{NameID}</saml:NameID><saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml:SubjectConfirmationData NotOnOrAfter="{SubjectConfirmationDataNotOnOrAfter}" Recipient="{SubjectRecipient}" InResponseTo="{InResponseTo}"/></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore="{ConditionsNotBefore}" NotOnOrAfter="{ConditionsNotOnOrAfter}"><saml:AudienceRestriction><saml:Audience>{Audience}</saml:Audience></saml:AudienceRestriction></saml:Conditions>{AttributeStatement}</saml:Assertion></samlp:Response>',
            attributes: [
                { name: 'firstName', valueTag: 'firstName', nameFormat: 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', valueXsiType: 'xs:string' },
                { name: 'lastName', valueTag: 'lastName', nameFormat: 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', valueXsiType: 'xs:string' },
            ],
        }
    });
    
    app.get('/api/sso/saml2/idp/metadata', (req, res) => {
        res.type('application/xml');
        res.send(idp.getMetadata());
    });
    
    app.post('/api/sso/saml2/idp/login', async (req, res) => {
        try {
            const user = { email: 'user@gmail.com' };
            const { context, entityEndpoint } = await idp.createLoginResponse(sp, null, saml.Constants.wording.binding.post, user, createTemplateCallback(idp, sp, user.email));
    
            res.status(200).send({ samlResponse: context, entityEndpoint })
        } catch (e) {
            console.log(e)
            res.status(500).send()
        }
    })
    
    app.listen(port, () => {
        console.log(`Identity Provider server listening at http://localhost:${port}`);
    });
    

    Start the server and send an HTTP POST request to http://localhost:3000/api/sso/saml2/idp/login, you will receive the following JSON body in response:

    {
        "samlResponse": "base64-encoded string",
        "entityEndpoint": "http://localhost:3001/api/sso/saml2/sp/acs"
    }
    

    After that, send an HTTP POST request with Content-Type application/x-www-form-urlencoded to entityEndpoint with the body SAMLResponse.

    For your convenience, I will also post a minimal working example here: https://github.com/MykytaManuilenko/sso-saml-example