Created
August 27, 2025 18:02
-
-
Save mihai-vlc/98fcc82ba783227a7f1e55117a4f3511 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const sax = require('sax'); | |
| function escapeAttr(s) { | |
| return s.replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/"/g, '"') | |
| .replace(/\t/g, '	') | |
| .replace(/\n/g, '
') | |
| .replace(/\r/g, '
'); | |
| } | |
| function escapeText(s) { | |
| return s.replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/\r/g, '
'); | |
| } | |
| function isVisiblyUtilized(pref, elemPrefix, attrs) { | |
| if (pref === '') { | |
| return elemPrefix === '' || attrs.some(a => a.prefix === ''); | |
| } else { | |
| return elemPrefix === pref || attrs.some(a => a.prefix === pref); | |
| } | |
| } | |
| function exclusiveCanonicalize(xmlString, options = {}) { | |
| const { withComments = false, inclusivePrefixList = [] } = options; | |
| const parser = sax.parser(true, { trim: false, normalize: false }); | |
| const output = []; | |
| let topLevelHasChild = false; | |
| const stack = []; | |
| let currentNsInScope = {}; | |
| let currentRendered = {}; | |
| let currentLastUtil = null; | |
| parser.onopentag = (node) => { | |
| // Extract local namespaces and attributes | |
| const localNs = {}; | |
| const attrs = []; | |
| for (const key in node.attributes) { | |
| if (key === 'xmlns') { | |
| localNs[''] = node.attributes[key]; | |
| } else if (key.startsWith('xmlns:')) { | |
| const p = key.substring(6); | |
| localNs[p] = node.attributes[key]; | |
| } else { | |
| let attrPrefix = ''; | |
| let attrLocal = key; | |
| const attrParts = key.split(':'); | |
| if (attrParts.length === 2) { | |
| attrPrefix = attrParts[0]; | |
| attrLocal = attrParts[1]; | |
| } | |
| attrs.push({ | |
| prefix: attrPrefix, | |
| localName: attrLocal, | |
| value: node.attributes[key] | |
| }); | |
| } | |
| } | |
| const nsInScope = { ...currentNsInScope, ...localNs }; | |
| // Parse element name | |
| let elemPrefix = ''; | |
| let elemLocalName = node.name; | |
| const parts = node.name.split(':'); | |
| if (parts.length === 2) { | |
| elemPrefix = parts[0]; | |
| elemLocalName = parts[1]; | |
| } | |
| // Set attribute URIs | |
| attrs.forEach(attr => { | |
| attr.uri = nsInScope[attr.prefix] || ''; | |
| }); | |
| const tagName = elemPrefix ? elemPrefix + ':' + elemLocalName : elemLocalName; | |
| output.push('<' + tagName); | |
| const inclusiveSet = new Set(inclusivePrefixList); | |
| const defaultInclusive = inclusiveSet.has('#default'); | |
| inclusiveSet.delete('#default'); | |
| let nsToRender = []; | |
| for (const [pref, uri] of Object.entries(nsInScope)) { | |
| if (pref === '' && uri === '') continue; | |
| const isInclusive = pref === '' ? defaultInclusive : inclusiveSet.has(pref); | |
| const isVisUtil = isVisiblyUtilized(pref, elemPrefix, attrs); | |
| const renderedUri = currentRendered[pref]; | |
| if ((isInclusive || isVisUtil) && renderedUri !== uri) { | |
| nsToRender.push({ pref, uri }); | |
| } | |
| } | |
| const currDefaultUri = nsInScope[''] || ''; | |
| const utilizesDefault = isVisiblyUtilized('', elemPrefix, attrs); | |
| let addUndeclare = false; | |
| if (currDefaultUri === '') { | |
| if ((utilizesDefault || defaultInclusive) && currentLastUtil && currentLastUtil !== '') { | |
| addUndeclare = true; | |
| nsToRender.push({ pref: '', uri: '' }); | |
| } | |
| } | |
| nsToRender.sort((a, b) => { | |
| if (a.pref === '') return -1; | |
| if (b.pref === '') return 1; | |
| return a.pref.localeCompare(b.pref); | |
| }); | |
| for (const ns of nsToRender) { | |
| const attrName = ns.pref === '' ? 'xmlns' : 'xmlns:' + ns.pref; | |
| output.push(' ' + attrName + '="' + escapeAttr(ns.uri) + '"'); | |
| } | |
| let sortedAttrs = [...attrs]; | |
| sortedAttrs.sort((a, b) => { | |
| const uriA = a.uri || ''; | |
| const uriB = b.uri || ''; | |
| if (uriA !== uriB) return uriA.localeCompare(uriB); | |
| return a.localName.localeCompare(b.localName); | |
| }); | |
| for (const attr of sortedAttrs) { | |
| const attrName = attr.prefix ? attr.prefix + ':' + attr.localName : attr.localName; | |
| output.push(' ' + attrName + '="' + escapeAttr(attr.value) + '"'); | |
| } | |
| output.push('>'); | |
| if (stack.length === 0) { | |
| topLevelHasChild = true; | |
| } | |
| const newNsRendered = { ...currentRendered }; | |
| for (const ns of nsToRender) { | |
| newNsRendered[ns.pref] = ns.uri; | |
| } | |
| let childLastUtilDefaultUri; | |
| if (utilizesDefault) { | |
| childLastUtilDefaultUri = addUndeclare ? '' : currDefaultUri; | |
| } else { | |
| childLastUtilDefaultUri = currentLastUtil; | |
| } | |
| // Push parent contexts to stack | |
| stack.push({ | |
| tagName, | |
| rendered: currentRendered, | |
| lastUtil: currentLastUtil, | |
| nsInScope: currentNsInScope | |
| }); | |
| // Update current contexts | |
| currentRendered = newNsRendered; | |
| currentLastUtil = childLastUtilDefaultUri; | |
| currentNsInScope = nsInScope; | |
| }; | |
| parser.onclosetag = () => { | |
| const popped = stack.pop(); | |
| output.push('</' + popped.tagName + '>'); | |
| currentRendered = popped.rendered; | |
| currentLastUtil = popped.lastUtil; | |
| currentNsInScope = popped.nsInScope; | |
| }; | |
| parser.ontext = (text) => { | |
| const escaped = escapeText(text); | |
| let wrote = false; | |
| if (stack.length > 0) { | |
| output.push(escaped); | |
| wrote = true; | |
| } else if (text.trim() !== '') { | |
| output.push(escaped); | |
| wrote = true; | |
| } | |
| if (wrote && stack.length === 0) { | |
| topLevelHasChild = true; | |
| } | |
| }; | |
| parser.oncomment = (comment) => { | |
| if (withComments) { | |
| if (stack.length === 0) { | |
| if (topLevelHasChild) { | |
| output.push('\n'); | |
| } | |
| } | |
| output.push('<!--' + comment + '-->'); | |
| if (stack.length === 0) { | |
| topLevelHasChild = true; | |
| } | |
| } | |
| }; | |
| parser.onprocessinginstruction = (pi) => { | |
| const data = pi.body ? ' ' + pi.body : ''; | |
| if (stack.length === 0) { | |
| if (topLevelHasChild) { | |
| output.push('\n'); | |
| } | |
| } | |
| output.push('<?' + pi.name + data + '?>'); | |
| if (stack.length === 0) { | |
| topLevelHasChild = true; | |
| } | |
| }; | |
| parser.onerror = (err) => { | |
| throw err; | |
| }; | |
| parser.write(xmlString).close(); | |
| return output.join(''); | |
| } | |
| var res = exclusiveCanonicalize(`<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_8e8dc5f69a98cc4c1ff3427e5ce34606fd672f91e6" Version="2.0" IssueInstant="2014-07-17T01:01:48Z" Destination="http://sp.example.com/demo1/index.php?acs" InResponseTo="ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685"> | |
| <saml:Issuer>http://idp.example.com/metadata.php</saml:Issuer> | |
| <samlp:Status> | |
| <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/> | |
| </samlp:Status> | |
| <saml:Assertion xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="_d71a3a8e9fcc45c9e9d248ef7049393fc8f04e5f75" Version="2.0" IssueInstant="2014-07-17T01:01:48Z"> | |
| <saml:Issuer>http://idp.example.com/metadata.php</saml:Issuer> | |
| <saml:Subject> | |
| <saml:NameID SPNameQualifier="http://sp.example.com/demo1/metadata.php" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7</saml:NameID> | |
| <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"> | |
| <saml:SubjectConfirmationData NotOnOrAfter="2024-01-18T06:21:48Z" Recipient="http://sp.example.com/demo1/index.php?acs" InResponseTo="ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685"/> | |
| </saml:SubjectConfirmation> | |
| </saml:Subject> | |
| <saml:Conditions NotBefore="2014-07-17T01:01:18Z" NotOnOrAfter="2024-01-18T06:21:48Z"> | |
| <saml:AudienceRestriction> | |
| <saml:Audience>http://sp.example.com/demo1/metadata.php</saml:Audience> | |
| </saml:AudienceRestriction> | |
| </saml:Conditions> | |
| <saml:AuthnStatement AuthnInstant="2014-07-17T01:01:48Z" SessionNotOnOrAfter="2024-07-17T09:01:48Z" SessionIndex="_be9967abd904ddcae3c0eb4189adbe3f71e327cf93"> | |
| <saml:AuthnContext> | |
| <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef> | |
| </saml:AuthnContext> | |
| </saml:AuthnStatement> | |
| <saml:AttributeStatement> | |
| <saml:Attribute Name="uid" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"> | |
| <saml:AttributeValue xsi:type="xs:string">test</saml:AttributeValue> | |
| </saml:Attribute> | |
| <saml:Attribute Name="mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"> | |
| <saml:AttributeValue xsi:type="xs:string">test@example.com</saml:AttributeValue> | |
| </saml:Attribute> | |
| <saml:Attribute Name="eduPersonAffiliation" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"> | |
| <saml:AttributeValue xsi:type="xs:string">users</saml:AttributeValue> | |
| <saml:AttributeValue xsi:type="xs:string">examplerole1</saml:AttributeValue> | |
| </saml:Attribute> | |
| </saml:AttributeStatement> | |
| </saml:Assertion> | |
| </samlp:Response>`); | |
| console.log(res); | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment