Switch to saml20 (#21)

* Use boxyhq/saml20

* use sign from saml20

* cleaned up GetKeyInfo

* cleaned up getPublicKeyPemFromCertificate

* cleaned up node-forge

* use hasValidSignature from saml20

* cleanup and update saml20 to the beta version

* throw an error if signature is not valid

* updated saml20
This commit is contained in:
Deepak Prabhakara 2022-04-26 18:02:12 +01:00 committed by GitHub
parent a70bd388b3
commit 331c3cf318
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 821 additions and 1052 deletions

1738
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,31 +11,30 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@xmldom/xmldom": "0.8.1", "@boxyhq/saml20": "1.0.0",
"next": "12.1.0", "next": "12.1.4",
"node-forge": "1.3.0", "react": "18.0.0",
"react": "17.0.2", "react-dom": "18.0.0",
"react-dom": "17.0.2",
"react-gtm-module": "2.0.11", "react-gtm-module": "2.0.11",
"xml-crypto": "2.1.3", "xml-crypto": "2.1.3",
"xml2js": "0.4.23", "xml2js": "0.4.23",
"xmlbuilder": "15.1.1" "xmlbuilder": "15.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "17.0.21", "@types/node": "17.0.23",
"@types/node-forge": "1.0.1", "@types/node-forge": "1.0.1",
"@types/react": "17.0.41", "@types/react": "18.0.0",
"@types/react-gtm-module": "2.0.1", "@types/react-gtm-module": "2.0.1",
"@types/xml-crypto": "1.4.2", "@types/xml-crypto": "1.4.2",
"@types/xml2js": "0.4.9", "@types/xml2js": "0.4.9",
"autoprefixer": "10.4.4", "autoprefixer": "10.4.4",
"eslint": "8.11.0", "eslint": "8.12.0",
"eslint-config-next": "12.1.0", "eslint-config-next": "12.1.4",
"postcss": "8.4.12", "postcss": "8.4.12",
"prettier": "2.6.0", "prettier": "2.6.2",
"prettier-plugin-tailwindcss": "0.1.8", "prettier-plugin-tailwindcss": "0.1.8",
"tailwindcss": "3.0.23", "tailwindcss": "3.0.23",
"typescript": "4.6.2" "typescript": "4.6.3"
}, },
"engines": { "engines": {
"node": ">=14.18.1 <=16.x" "node": ">=14.18.1 <=16.x"

View File

@ -2,7 +2,8 @@ import { createHash } from 'crypto';
import config from 'lib/env'; import config from 'lib/env';
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import type { User } from 'types'; import type { User } from 'types';
import { createResponseForm, createResponseXML, signResponseXML } from 'utils'; import { createResponseXML, signResponseXML } from 'utils';
import saml from '@boxyhq/saml20';
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') { if (req.method === 'POST') {
@ -32,7 +33,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const xmlSigned = await signResponseXML(xml, config.privateKey, config.publicKey); const xmlSigned = await signResponseXML(xml, config.privateKey, config.publicKey);
const encodedSamlResponse = Buffer.from(xmlSigned).toString('base64'); const encodedSamlResponse = Buffer.from(xmlSigned).toString('base64');
const html = createResponseForm(relayState, encodedSamlResponse, acsUrl); const html = saml.createPostForm(acsUrl, relayState, {
name: 'SAMLResponse',
value: encodedSamlResponse,
});
res.send(html); res.send(html);
} else { } else {

View File

@ -3,7 +3,8 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import stream from 'stream'; import stream from 'stream';
import { IdPMetadata } from 'types'; import { IdPMetadata } from 'types';
import { promisify } from 'util'; import { promisify } from 'util';
import { createIdPMetadataXML, stripCertHeaderAndFooter } from 'utils'; import { createIdPMetadataXML } from 'utils';
import saml from '@boxyhq/saml20';
const pipeline = promisify(stream.pipeline); const pipeline = promisify(stream.pipeline);
@ -20,7 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
const xml = await createIdPMetadataXML({ const xml = await createIdPMetadataXML({
idpEntityId: config.entityId, idpEntityId: config.entityId,
idpSsoUrl: config.ssoUrl, idpSsoUrl: config.ssoUrl,
certificate: stripCertHeaderAndFooter(config.publicKey), certificate: saml.stripCertHeaderAndFooter(config.publicKey),
}); });
res.setHeader('Content-type', 'text/xml'); res.setHeader('Content-type', 'text/xml');

View File

@ -1,5 +1,6 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import { decodeBase64, extractSAMLRequestAttributes, hasValidSignature } from 'utils'; import { decodeBase64, extractSAMLRequestAttributes } from 'utils';
import saml from '@boxyhq/saml20';
export default async function handler(req: NextApiRequest, res: NextApiResponse<string>) { export default async function handler(req: NextApiRequest, res: NextApiResponse<string>) {
switch (req.method) { switch (req.method) {
@ -30,7 +31,10 @@ async function processSAMLRequest(req: NextApiRequest, res: NextApiResponse, isP
const { id, audience, acsUrl, providerName, publicKey } = await extractSAMLRequestAttributes(rawRequest); const { id, audience, acsUrl, providerName, publicKey } = await extractSAMLRequestAttributes(rawRequest);
await hasValidSignature(rawRequest, publicKey); const { valid } = await saml.hasValidSignature(rawRequest, publicKey, null);
if (!valid) {
throw new Error('Invalid signature');
}
const params = new URLSearchParams({ id, audience, acsUrl, providerName, relayState }); const params = new URLSearchParams({ id, audience, acsUrl, providerName, relayState });

View File

@ -1,5 +1,3 @@
import { asn1, pki, util } from 'node-forge';
const fetchPublicKey = (): string => { const fetchPublicKey = (): string => {
return process.env.PUBLIC_KEY ? Buffer.from(process.env.PUBLIC_KEY!, 'base64').toString('ascii') : ''; return process.env.PUBLIC_KEY ? Buffer.from(process.env.PUBLIC_KEY!, 'base64').toString('ascii') : '';
}; };
@ -8,50 +6,4 @@ const fetchPrivateKey = (): string => {
return process.env.PRIVATE_KEY ? Buffer.from(process.env.PRIVATE_KEY!, 'base64').toString('ascii') : ''; return process.env.PRIVATE_KEY ? Buffer.from(process.env.PRIVATE_KEY!, 'base64').toString('ascii') : '';
}; };
const getPublicKeyPemFromCertificate = (x509Certificate: string) => { export { fetchPublicKey, fetchPrivateKey };
const certDerBytes = util.decode64(x509Certificate);
const obj = asn1.fromDer(certDerBytes);
const cert = pki.certificateFromAsn1(obj);
return pki.publicKeyToPem(cert.publicKey);
};
const stripCertHeaderAndFooter = (cert: string): string => {
cert = cert.replace(/-+BEGIN CERTIFICATE-+\r?\n?/, '');
cert = cert.replace(/-+END CERTIFICATE-+\r?\n?/, '');
cert = cert.replace(/\r\n/g, '\n');
return cert;
};
const certToPEM = (certificate: string) => {
if (certificate.indexOf('BEGIN CERTIFICATE') === -1 && certificate.indexOf('END CERTIFICATE') === -1) {
certificate = certificate.match(/.{1,64}/g)!.join('\n');
certificate = '-----BEGIN CERTIFICATE-----\n' + certificate;
certificate = certificate + '\n-----END CERTIFICATE-----\n';
}
return certificate;
};
function GetKeyInfo(this: any, x509Certificate: string, signatureConfig: any = {}) {
x509Certificate = stripCertHeaderAndFooter(x509Certificate);
this.getKeyInfo = () => {
const prefix = signatureConfig.prefix ? `${signatureConfig.prefix}:` : '';
return `<${prefix}X509Data><${prefix}X509Certificate>${x509Certificate}</${prefix}X509Certificate></${prefix}X509Data>`;
};
this.getKey = () => {
return getPublicKeyPemFromCertificate(x509Certificate).toString();
};
}
export {
fetchPublicKey,
fetchPrivateKey,
stripCertHeaderAndFooter,
getPublicKeyPemFromCertificate,
GetKeyInfo,
certToPEM,
};

View File

@ -1,5 +1,5 @@
import xmlbuilder from 'xmlbuilder'; import xmlbuilder from 'xmlbuilder';
import { stripCertHeaderAndFooter } from './certificate'; import saml from '@boxyhq/saml20';
const createIdPMetadataXML = async ({ const createIdPMetadataXML = async ({
idpEntityId, idpEntityId,
@ -10,7 +10,7 @@ const createIdPMetadataXML = async ({
idpSsoUrl: string; idpSsoUrl: string;
certificate: string; certificate: string;
}): Promise<string> => { }): Promise<string> => {
certificate = stripCertHeaderAndFooter(certificate); certificate = saml.stripCertHeaderAndFooter(certificate);
const nodes = { const nodes = {
EntityDescriptor: { EntityDescriptor: {

View File

@ -1,7 +1,4 @@
import { DOMParser as Dom } from '@xmldom/xmldom';
import { promisify } from 'util'; import { promisify } from 'util';
import { certToPEM } from 'utils';
import { SignedXml, xpath as select } from 'xml-crypto';
import xml2js from 'xml2js'; import xml2js from 'xml2js';
import { inflateRaw } from 'zlib'; import { inflateRaw } from 'zlib';
@ -44,43 +41,4 @@ const extractSAMLRequestAttributes = async (rawRequest: string) => {
}; };
}; };
// Validate signature export { extractSAMLRequestAttributes, decodeBase64 };
const hasValidSignature = async (xml: string, certificate: string): Promise<boolean> => {
return new Promise((resolve, reject) => {
const doc = new Dom().parseFromString(xml);
const signature =
select(
doc,
"/*/*/*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']"
)[0] ||
select(
doc,
"/*/*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']"
)[0] ||
select(
doc,
"/*/*/*/*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']"
)[0];
const signed = new SignedXml();
signed.keyInfoProvider = {
file: '',
getKey: function getKey(keyInfo: any) {
return Buffer.from(certToPEM(certificate), 'utf8');
},
getKeyInfo: function getKeyInfo(key: any) {
return '<X509Data></X509Data>';
},
};
signed.loadSignature(signature.toString());
const response = signed.checkSignature(xml);
return !response ? reject(false) : resolve(true);
});
};
export { extractSAMLRequestAttributes, hasValidSignature, decodeBase64 };

View File

@ -1,8 +1,10 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { SignedXml } from 'xml-crypto';
import xmlbuilder from 'xmlbuilder'; import xmlbuilder from 'xmlbuilder';
import { User } from '../types'; import { User } from '../types';
import { GetKeyInfo } from './certificate'; import saml from '@boxyhq/saml20';
const responseXPath =
'/*[local-name(.)="Response" and namespace-uri(.)="urn:oasis:names:tc:SAML:2.0:protocol"]';
const createResponseXML = async (params: { const createResponseXML = async (params: {
idpIdentityId: string; idpIdentityId: string;
@ -130,59 +132,8 @@ const createResponseXML = async (params: {
return xmlbuilder.create(nodes, { encoding: 'UTF-8' }).end(); return xmlbuilder.create(nodes, { encoding: 'UTF-8' }).end();
}; };
// Create the HTML form to submit the response
const createResponseForm = (relayState: string, encodedSamlResponse: string, acsUrl: string) => {
const formElements = [
'<!DOCTYPE html>',
'<html>',
'<head>',
'<meta charset="utf-8">',
'<meta http-equiv="x-ua-compatible" content="ie=edge">',
'</head>',
'<body onload="document.forms[0].submit()">',
'<noscript>',
'<p>Note: Since your browser does not support JavaScript, you must press the Continue button once to proceed.</p>',
'</noscript>',
'<form method="post" action="' + encodeURI(acsUrl) + '">',
'<input type="hidden" name="RelayState" value="' + relayState + '"/>',
'<input type="hidden" name="SAMLResponse" value="' + encodedSamlResponse + '"/>',
'<input type="submit" value="Continue" />',
'</form>',
'<script>document.forms[0].style.display="none";</script>',
'</body>',
'</html>',
];
return formElements.join('');
};
const signResponseXML = async (xml: string, signingKey: any, publicKey: any): Promise<string> => { const signResponseXML = async (xml: string, signingKey: any, publicKey: any): Promise<string> => {
const sig = new SignedXml(); return await saml.sign(xml, signingKey, publicKey, responseXPath);
const responseXPath =
'/*[local-name(.)="Response" and namespace-uri(.)="urn:oasis:names:tc:SAML:2.0:protocol"]';
const issuerXPath =
'/*[local-name(.)="Issuer" and namespace-uri(.)="urn:oasis:names:tc:SAML:2.0:assertion"]';
sig.signatureAlgorithm = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256';
// @ts-ignore
sig.keyInfoProvider = new GetKeyInfo(publicKey, {
prefix: '',
});
sig.signingKey = signingKey;
sig.addReference(
responseXPath,
['http://www.w3.org/2000/09/xmldsig#enveloped-signature', 'http://www.w3.org/2001/10/xml-exc-c14n#'],
'http://www.w3.org/2001/04/xmlenc#sha256'
);
sig.computeSignature(xml, {
location: { reference: responseXPath + issuerXPath, action: 'after' },
});
return sig.getSignedXml();
}; };
export { createResponseXML, createResponseForm, signResponseXML }; export { createResponseXML, signResponseXML };