我有两个非常简单的node.js应用程序:
idp
(身份提供商)和sp
(服务提供商)。这些应用程序没有任何特定的业务逻辑,我只想在node.js 中创建一个非常简单的单点登录(SSO)示例。更具体地说,我想实现身份提供商 (IdP) 发起的单点登录 (SSO),但我不明白如何生成 SAML IdP 响应,而且,我不明白什么是 IdP/SP元数据文件应该有(我是 SSO 和 SAML 2.0 协议的新手)。
我有以下 Node.js 服务器:
身份提供商 (IdP) 服务器:
/*
* 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}`);
});
服务提供商(SP)服务器:
/*
* 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(此 XML 取自 https://samlify.js.org/#/idp + https://samlify.js.org/#/key- Generation,用于生成X509证书)
<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>[email protected]</EmailAddress>
</ContactPerson>
</EntityDescriptor>
sp-metadata.xml(此 XML 取自 https://samlify.js.org/#/sp + https://samlify.js.org/#/key- Generation,用于生成X509证书)
<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>
我尝试使用
idp.createLoginResponse(sp)
但它对我不起作用,因为我收到了 ERR_CREATE_RESPONSE_UNDEFINED_BINDING
错误。
我也尝试过这样生成它:
const user = { email: '[email protected]' };
const sampleRequestInfo = { extract: { request: { id: 'request_id' } } };
const samlResponse = await idp.createLoginResponse(sp, sampleRequestInfo, 'post', user);
但是我收到以下错误:
"Error [ERR_CRYPTO_SIGN_KEY_REQUIRED]: No key provided for signing."
。
我在 https://samlify.js.org/ 中找不到使用 IdP 发起的 SSO 的工作示例,因此如果您能为我提供一个最小的工作示例或建议任何好的节点,我将非常感激。可以处理此问题的 Node.js 库(或仅具有 IdP 发起的 SSO 示例)或任何其他语言的任何其他示例。
嗯,经过几天的奋斗,我终于明白了SSO是如何工作的并实现了它(IdP发起的SSO,我们的服务充当第三方服务的身份提供商)。我会在这里留下我的答案,以防有人必须做同样的事情并遇到问题。
首先,我建议您阅读 SSO 流程的工作原理。我将在这里留下一些很好的资源供您阅读:
因此,简而言之,IdP 发起的 SSO 流程(您的服务充当 IdP)的工作原理如下:
SAMLResponse
。SAMLResponse
的 HTTP POST 请求。在开始编写代码之前,您需要执行以下操作:
您可以在此处创建您自己的元数据文件(IdP 元数据):https://www.samltool.com/idp_metadata.php.
强烈建议对 XML 元数据文件进行签名,因此通过运行以下命令创建文件
private_key.pem
和 public_cert.cer
:
openssl genrsa -passout pass:jXmKf9By6ruLnUdRo90G -out private_key.pem 4096
openssl req -new -x509 -key private_key.pem -out public_cert.cer -days 3650
在此处创建 IdP 元数据文件时,您应该将
public_cert.cer
中的正文添加到“SP X.509 证书(用于签名/加密的相同证书)”字段:https://www.samltool.com/idp_metadata。 php
最后,当一切准备就绪(您拥有 SP 和 IdP 元数据文件)时,我们可以继续创建 IdP。
我们的 IdP 是用 Node.js 编写的,因此我们使用 samlify 来创建 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: '[email protected]' };
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}`);
});
启动服务器并向
http://localhost:3000/api/sso/saml2/idp/login
发送HTTP POST请求,您将收到以下JSON响应正文:
{
"samlResponse": "base64-encoded string",
"entityEndpoint": "http://localhost:3001/api/sso/saml2/sp/acs"
}
之后,将内容类型为
application/x-www-form-urlencoded
的 HTTP POST 请求发送到 entityEndpoint
,正文为 SAMLResponse
。