315 lines
12 KiB
JavaScript
315 lines
12 KiB
JavaScript
const { SignedXml } = require('xml-crypto');
|
||
const { DOMParser } = require('@xmldom/xmldom');
|
||
const crypto = require('crypto');
|
||
const express = require('express');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const bodyParser = require('body-parser');
|
||
const zlib = require('zlib');
|
||
const xml2js = require('xml2js');
|
||
|
||
const app = express();
|
||
const port = 3000;
|
||
|
||
// Enable body parsing
|
||
app.use(bodyParser.urlencoded({ extended: true }));
|
||
|
||
let storedSamlRequest = null;
|
||
let storedRelayState = null;
|
||
let storedQueryParameters = null;
|
||
|
||
// Step 1: Receive SAMLRequest and render login form
|
||
app.get('/saml/sso', async (req, res) => {
|
||
storedQueryParameters = req.query;
|
||
console.log('Received query parameters:', req.query); // 👈 log all query parameters
|
||
|
||
const samlRequest = req.query.SAMLRequest;
|
||
const relayState = req.query.RelayState || '';
|
||
|
||
if (!samlRequest) {
|
||
return res.status(400).send('Missing SAMLRequest');
|
||
}
|
||
|
||
// Store temporarily
|
||
storedSamlRequest = samlRequest;
|
||
storedRelayState = relayState;
|
||
|
||
// Show simple login form
|
||
res.send(createLoginForm());
|
||
});
|
||
|
||
// Step 2: Handle login form and send SAMLResponse
|
||
app.post('/saml/login', (req, res) => {
|
||
const { username, email, orgCode, securityGroup, roles } = req.body;
|
||
if (!storedSamlRequest) {
|
||
return res.status(400).send('No SAMLRequest stored');
|
||
}
|
||
|
||
// Get the issuer URL (the IDP ID [the application.yml = resilient.security.saml2.relyingparty.registration.mock-idp.assertingparty.entity-id])
|
||
const issuerUrlDefault = 'http://localhost:3000/saml/metadata';
|
||
const issuerUrl = storedQueryParameters['issuerUrl'] ? storedQueryParameters['issuerUrl'] : issuerUrlDefault;
|
||
|
||
// Get the base url of ServiceProvider (the server APP)
|
||
const serviceProviderUrlDefault = 'https://localhost:8443'; // Or 'http://localhost:8081'
|
||
const serviceProviderUrl = storedQueryParameters['spUrl'] ? storedQueryParameters['spUrl'] : serviceProviderUrlDefault;
|
||
|
||
// Build ACS (Assertion Consumer Service) URL — this is the Spring Boot app’s endpoint where the SAML Response is posted back after authentication.
|
||
const acsUrlPath = '/login/saml2/sso/mock-idp';
|
||
const acsUrl = serviceProviderUrl + acsUrlPath;
|
||
|
||
console.log('ServiceProvider URL :', serviceProviderUrl);
|
||
console.log('ServiceProvider ACS :', acsUrl);
|
||
|
||
const samlResponse = createFakeSamlResponse(username, email, orgCode, securityGroup, roles, serviceProviderUrl, issuerUrl);
|
||
const relayState = storedRelayState;
|
||
|
||
res.send(`
|
||
<html>
|
||
<body onload="document.forms[0].submit()">
|
||
<form method="POST" action="${acsUrl}">
|
||
<input type="hidden" name="SAMLResponse" value="${samlResponse.replace(/"/g, '"')}" />
|
||
<input type="hidden" name="RelayState" value="${relayState.replace(/"/g, '"')}" />
|
||
</form>
|
||
</body>
|
||
</html>
|
||
`);
|
||
});
|
||
|
||
function createFakeSamlResponse(nameId, email, orgCode, securityGroupCode, roles, serviceProviderUrl, issuerUrl) {
|
||
const issuer = issuerUrl;
|
||
// const audience = "https://localhost:8443/saml2/service-provider-metadata/mock-idp";
|
||
const audience = serviceProviderUrl + "/saml2/service-provider-metadata/mock-idp";
|
||
const now = new Date().toISOString();
|
||
const fiveMinutesLater = new Date(Date.now() + 5 * 60000).toISOString();
|
||
const assertionId = `_assertion_${crypto.randomUUID()}`;
|
||
const responseId = `_response_${crypto.randomUUID()}`;
|
||
|
||
// const recipient = "https://localhost:8443/login/saml2/sso/mock-idp";
|
||
const recipient = serviceProviderUrl + "/login/saml2/sso/mock-idp";
|
||
// const destination = "https://localhost:8443/login/saml2/sso/mock-idp";
|
||
const destination = serviceProviderUrl + "/login/saml2/sso/mock-idp";
|
||
|
||
console.log('SAML Recipient :', recipient);
|
||
console.log('SAML Destination :', destination);
|
||
console.log('SAML Issuer :', issuer);
|
||
|
||
const assertion = `
|
||
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="${assertionId}" IssueInstant="${now}" Version="2.0">
|
||
<saml:Issuer>${issuer}</saml:Issuer>
|
||
<saml:Subject>
|
||
<saml:NameID>${nameId}</saml:NameID>
|
||
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
|
||
<saml:SubjectConfirmationData NotOnOrAfter="${fiveMinutesLater}" Recipient="${recipient}" />
|
||
</saml:SubjectConfirmation>
|
||
</saml:Subject>
|
||
<saml:Conditions NotBefore="${now}" NotOnOrAfter="${fiveMinutesLater}">
|
||
<saml:AudienceRestriction>
|
||
<saml:Audience>${audience}</saml:Audience>
|
||
</saml:AudienceRestriction>
|
||
</saml:Conditions>
|
||
<saml:AuthnStatement AuthnInstant="${now}">
|
||
<saml:AuthnContext>
|
||
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
|
||
</saml:AuthnContext>
|
||
</saml:AuthnStatement>
|
||
<saml:AttributeStatement>
|
||
<saml:Attribute Name="email">
|
||
<saml:AttributeValue>${email}</saml:AttributeValue>
|
||
</saml:Attribute>
|
||
<saml:Attribute Name="organization_code">
|
||
<saml:AttributeValue>${orgCode}</saml:AttributeValue>
|
||
</saml:Attribute>
|
||
<saml:Attribute Name="security_group">
|
||
<saml:AttributeValue>${securityGroupCode}</saml:AttributeValue>
|
||
</saml:Attribute>
|
||
<saml:Attribute Name="roles">
|
||
<saml:AttributeValue>${roles}</saml:AttributeValue>
|
||
</saml:Attribute>
|
||
</saml:AttributeStatement>
|
||
</saml:Assertion>`;
|
||
|
||
const privateKey = fs.readFileSync(path.join(__dirname, 'certs', 'idp-private.key'), 'utf8');
|
||
const certificate = fs.readFileSync(path.join(__dirname, 'certs', 'idp-public.cert'), 'utf8');
|
||
const certBase64 = certificate
|
||
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||
.replace(/-----END CERTIFICATE-----/, '')
|
||
.replace(/\r?\n|\r/g, '');
|
||
|
||
const doc = new DOMParser().parseFromString(assertion);
|
||
const sig = new SignedXml();
|
||
sig.prefix = 'ds';
|
||
sig.addReference("//*[local-name(.)='Assertion']", [
|
||
"http://www.w3.org/2000/09/xmldsig#enveloped-signature",
|
||
"http://www.w3.org/2001/10/xml-exc-c14n#",
|
||
], "http://www.w3.org/2000/09/xmldsig#sha1");
|
||
sig.signingKey = privateKey;
|
||
sig.keyInfoProvider = {
|
||
getKeyInfo: () => `<ds:X509Data><ds:X509Certificate>${certBase64}</ds:X509Certificate></ds:X509Data>`
|
||
};
|
||
sig.computeSignature(assertion, {
|
||
prefix: 'ds',
|
||
location: { reference: "//*[local-name(.)='Issuer']", action: 'after' }
|
||
});
|
||
|
||
const signedAssertion = sig.getSignedXml();
|
||
const samlResponse = `<?xml version="1.0" encoding="UTF-8"?><samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="${responseId}" Version="2.0" IssueInstant="${now}" Destination="${destination}"><saml:Issuer>${issuer}</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>${signedAssertion}</samlp:Response>`;
|
||
|
||
const samlResponseToUse = samlResponse.trim().replace(/^\uFEFF/, '');
|
||
return Buffer.from(samlResponseToUse, 'utf-8').toString('base64');
|
||
}
|
||
|
||
function createLoginFormOLD() {
|
||
const form = `
|
||
<html>
|
||
<body>
|
||
<h2>Mock Login</h2>
|
||
<form method="POST" action="/saml/login">
|
||
Username: <input name="username" value="test-user"/><br/>
|
||
Email: <input name="email" value="test@example.com"/><br/>
|
||
Org Code: <input name="orgCode" value="NOVA"/><br/>
|
||
Security Group: <input name="securityGroup" value="GRP_USER"/><br/>
|
||
Roles: <select name="roles" multiple size="4">
|
||
<option value="ROLE_USER">ROLE_USER</option>
|
||
<option value="ROLE_COORDINATOR">ROLE_COORDINATOR</option>
|
||
<option value="ROLE_MANAGER">ROLE_MANAGER</option>
|
||
<option value="ROLE_ADMIN">ROLE_ADMIN</option>
|
||
</select><br/><br/>
|
||
<button type="submit">Login</button>
|
||
</form>
|
||
</body>
|
||
</html>
|
||
`;
|
||
return form;
|
||
}
|
||
|
||
function createLoginForm() {
|
||
const form = `
|
||
<html>
|
||
<head>
|
||
<style>
|
||
body {
|
||
font-family: Arial, sans-serif;
|
||
background-color: #f4f4f4;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
height: 100vh;
|
||
}
|
||
.form-container {
|
||
background: #fff;
|
||
padding: 30px 40px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||
width: 400px;
|
||
}
|
||
h2 {
|
||
text-align: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
label {
|
||
display: block;
|
||
margin: 10px 0 5px;
|
||
font-weight: bold;
|
||
}
|
||
input[type="text"],
|
||
input[type="email"],
|
||
select {
|
||
width: 100%;
|
||
padding: 8px;
|
||
box-sizing: border-box;
|
||
border-radius: 4px;
|
||
border: 1px solid #ccc;
|
||
}
|
||
button {
|
||
margin-top: 20px;
|
||
width: 100%;
|
||
padding: 10px;
|
||
background-color: #007bff;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
}
|
||
button:hover {
|
||
background-color: #0056b3;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="form-container">
|
||
<h2>Mock Login</h2>
|
||
<form method="POST" action="/saml/login">
|
||
<label for="username">Username</label>
|
||
<input id="username" name="username" value="test-user" type="text"/>
|
||
|
||
<label for="email">Email</label>
|
||
<input id="email" name="email" value="test@example.com" type="email"/>
|
||
|
||
<label for="orgCode">Org Code</label>
|
||
<input id="orgCode" name="orgCode" value="NOVA" type="text"/>
|
||
|
||
<label for="securityGroup">Security Group</label>
|
||
<input id="securityGroup" name="securityGroup" value="GRP_USER" type="text"/>
|
||
|
||
<label for="roles">Roles</label>
|
||
<select id="roles" name="roles" multiple size="4">
|
||
<option value="ROLE_USER">ROLE_USER</option>
|
||
<option value="ROLE_COORDINATOR">ROLE_COORDINATOR</option>
|
||
<option value="ROLE_MANAGER">ROLE_MANAGER</option>
|
||
<option value="ROLE_ADMIN">ROLE_ADMIN</option>
|
||
</select>
|
||
|
||
<button type="submit">Login</button>
|
||
</form>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
`;
|
||
|
||
|
||
return form;
|
||
}
|
||
|
||
// Function to extract Issuer from SAMLRequest (returns a Promise)
|
||
function extractIssuerFromSAMLRequest(samlRequestBase64) {
|
||
return new Promise((resolve, reject) => {
|
||
const samlRequestBuffer = Buffer.from(samlRequestBase64, 'base64');
|
||
|
||
zlib.inflateRaw(samlRequestBuffer, (err, decoded) => {
|
||
if (err) {
|
||
return reject(new Error('Failed to inflate SAMLRequest'));
|
||
}
|
||
|
||
const xml = decoded.toString('utf8');
|
||
|
||
xml2js.parseString(xml, { tagNameProcessors: [xml2js.processors.stripPrefix] }, (parseErr, result) => {
|
||
if (parseErr) {
|
||
return reject(new Error('Failed to parse SAMLRequest XML'));
|
||
}
|
||
|
||
// Debugging: log the entire parsed result to see structure
|
||
// console.log("Parsed XML result:", result);
|
||
|
||
// Extract Issuer from the first element of the array
|
||
const issuerObject = result?.AuthnRequest?.Issuer?.[0]; // Issuer is an array, so we access the first element
|
||
if (!issuerObject) {
|
||
return reject(new Error('Issuer not found in SAMLRequest'));
|
||
}
|
||
|
||
// Access the text content of the Issuer object (it may be in the '#text' property)
|
||
const issuer = issuerObject._ || issuerObject['#text']; // Extract the actual string value
|
||
|
||
if (!issuer) {
|
||
return reject(new Error('Issuer value is missing in the SAMLRequest'));
|
||
}
|
||
|
||
resolve(issuer); // Return the issuer as a string
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
app.listen(port, () => {
|
||
console.log(`Mock IDP running at http://localhost:${port}`);
|
||
});
|