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:
parent
a70bd388b3
commit
331c3cf318
1738
package-lock.json
generated
1738
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@ -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"
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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 };
|
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user