545 lines
24 KiB
JavaScript
545 lines
24 KiB
JavaScript
// SAML IdP HTTP endpoints: metadata, SP-initiated SSO, IdP-initiated SSO, and
|
|
// Single Logout (SLO). All SAML messages are validated against a registered-SP
|
|
// allow-list (lib/saml/sps.js): the IdP only issues an assertion to a registered
|
|
// SP and only to one of its allow-listed ACS URLs, so a forged AuthnRequest
|
|
// cannot redirect a signed assertion to an attacker-chosen Destination. Inbound
|
|
// XML is DTD/ENTITY-screened (defence-in-depth over samlify's XXE-safe parser)
|
|
// and, for SPs registered with a signing cert, the AuthnRequest signature is
|
|
// verified. Mounted under /idp/ (CSRF-exempt; SAML messages aren't Saltcorn forms).
|
|
|
|
const zlib = require("zlib");
|
|
const crypto = require("crypto");
|
|
const User = require("@saltcorn/data/models/user");
|
|
|
|
const constants = require("../constants");
|
|
const claims = require("../claims");
|
|
const web = require("../web");
|
|
const samlIdp = require("./idp");
|
|
const samlSps = require("./sps");
|
|
|
|
const { issuerForReq } = require("../oidc/discovery");
|
|
|
|
// A SAML protocol message never legitimately carries a DOCTYPE or ENTITY
|
|
// declaration; reject any that does before it reaches the XML parser.
|
|
const DTD_RE = /<!DOCTYPE|<!ENTITY/i;
|
|
|
|
const CONDITION_WINDOW_MS = 5 * 60 * 1000;
|
|
const STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success";
|
|
const NAMEID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
|
|
|
|
|
|
const newId = () => {
|
|
return "_" + crypto.randomBytes(16).toString("hex");
|
|
};
|
|
|
|
|
|
const decodeRequest = (samlReq, binding) => {
|
|
// These handlers are fully unauthenticated (noCsrf, no apiToken gate), so
|
|
// bound the work before any allocation: reject an oversized base64 blob, then
|
|
// cap the inflate output (DEFLATE can expand ~1000:1 -- an unbounded
|
|
// inflateRawSync is a memory bomb that crashes the worker).
|
|
if (typeof samlReq !== "string" || samlReq.length > constants.SAML_MAX_MSG_B64_BYTES) {
|
|
throw new Error("saltcorn-idp: SAML message too large");
|
|
}
|
|
const buf = Buffer.from(samlReq, "base64");
|
|
let xml;
|
|
if (binding === samlIdp.REDIRECT_BINDING) {
|
|
xml = zlib.inflateRawSync(buf, { maxOutputLength: constants.SAML_MAX_XML_BYTES }).toString("utf8");
|
|
} else {
|
|
if (buf.length > constants.SAML_MAX_XML_BYTES) {
|
|
throw new Error("saltcorn-idp: SAML message too large");
|
|
}
|
|
xml = buf.toString("utf8");
|
|
}
|
|
if (DTD_RE.test(xml)) {
|
|
throw new Error("saltcorn-idp: SAML message contains a DOCTYPE/ENTITY declaration");
|
|
}
|
|
return xml;
|
|
};
|
|
|
|
|
|
const matchIssuer = (xml) => {
|
|
const m = xml.match(/<(?:[\w]+:)?Issuer[^>]*>([^<]+)<\/(?:[\w]+:)?Issuer>/);
|
|
return m ? m[1].trim() : null;
|
|
};
|
|
|
|
|
|
const matchAcs = (xml) => {
|
|
const m = xml.match(/AssertionConsumerServiceURL="([^"]+)"/);
|
|
return m ? m[1] : null;
|
|
};
|
|
|
|
|
|
const escapeXmlAttr = (s) => {
|
|
return String(s === null || s === undefined ? "" : s)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
};
|
|
|
|
|
|
// Reconstruct the exact octet string the SP signed for a redirect-binding
|
|
// request: SAMLRequest=<v>&RelayState=<v>&SigAlg=<v> (RelayState omitted when
|
|
// absent), using the RAW (still percent-encoded) values from req.originalUrl so
|
|
// the bytes byte-match what the SP signed (req.query is already decoded).
|
|
const rawQueryOctetString = (req) => {
|
|
const url = req.originalUrl || "";
|
|
const qpos = url.indexOf("?");
|
|
const q = qpos >= 0 ? url.slice(qpos + 1) : "";
|
|
const raw = {};
|
|
for (const part of q.split("&")) {
|
|
const eq = part.indexOf("=");
|
|
if (eq > 0) {
|
|
raw[part.slice(0, eq)] = part.slice(eq + 1);
|
|
}
|
|
}
|
|
let s = "SAMLRequest=" + (raw.SAMLRequest || "");
|
|
if (raw.RelayState !== undefined) {
|
|
s += "&RelayState=" + raw.RelayState;
|
|
}
|
|
s += "&SigAlg=" + (raw.SigAlg || "");
|
|
return s;
|
|
};
|
|
|
|
|
|
// A real AuthnStatement fragment (raw XML markup). AuthnInstant + SessionIndex
|
|
// are server-generated (ISO time / hex), the class ref is a fixed URN, so none
|
|
// need XML escaping; this is injected verbatim, not through the value loop.
|
|
const buildAuthnStatement = (nowIso, sessionIndex) => {
|
|
return '<saml:AuthnStatement AuthnInstant="' + nowIso + '" SessionIndex="' + sessionIndex + '">'
|
|
+ "<saml:AuthnContext><saml:AuthnContextClassRef>"
|
|
+ constants.SAML_AUTHN_CONTEXT
|
|
+ "</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement>";
|
|
};
|
|
|
|
|
|
// Expand the single Groups <saml:AttributeValue>{attrGroups}</saml:AttributeValue>
|
|
// (samlify bakes ONE AttributeValue per attribute into the template) into ONE
|
|
// element per group, so SAML emits multi-valued group attributes matching the
|
|
// OIDC/LDAP array form. We capture the AttributeValue's own attributes from the
|
|
// template so the xmlns/xsi:type markup is reproduced verbatim (keeps the
|
|
// assertion schema-valid + signable); each group text is XML-escaped. An empty
|
|
// group list collapses to a single empty AttributeValue (valid, no membership).
|
|
const expandGroupValues = (template, groupsArr) => {
|
|
const re = /<saml:AttributeValue\b([^>]*)>\{attrGroups\}<\/saml:AttributeValue>/;
|
|
const m = template.match(re);
|
|
if (!m) {
|
|
return template;
|
|
}
|
|
const attrs = m[1];
|
|
const list = Array.isArray(groupsArr) ? groupsArr : (groupsArr === null || groupsArr === undefined ? [] : [groupsArr]);
|
|
let values = "";
|
|
if (list.length === 0) {
|
|
values = "<saml:AttributeValue" + attrs + "></saml:AttributeValue>";
|
|
} else {
|
|
for (const g of list) {
|
|
values += "<saml:AttributeValue" + attrs + ">" + escapeXmlAttr(g) + "</saml:AttributeValue>";
|
|
}
|
|
}
|
|
return template.replace(re, values);
|
|
};
|
|
|
|
|
|
// Builds the customTagReplacement callback for a login Response. samlify hands
|
|
// us the (already AttributeStatement-expanded) template and uses our returned
|
|
// context verbatim, so we fill the FULL token set ourselves: standard tokens +
|
|
// the attribute-value tokens ({attrEmail}) and the multi-valued group elements.
|
|
// The AuthnStatement is injected after the escaping loop because it is markup,
|
|
// not a text value.
|
|
const makeLoginResponseRenderer = (opts) => {
|
|
return (template) => {
|
|
const nowIso = new Date().toISOString();
|
|
const laterIso = new Date(Date.now() + CONDITION_WINDOW_MS).toISOString();
|
|
const respId = newId();
|
|
const sessionIndex = newId();
|
|
const tvalue = {
|
|
ID: respId,
|
|
AssertionID: newId(),
|
|
Destination: opts.acs,
|
|
Audience: opts.spEntityId,
|
|
SubjectRecipient: opts.acs,
|
|
Issuer: opts.issuer + "/saml",
|
|
IssueInstant: nowIso,
|
|
StatusCode: STATUS_SUCCESS,
|
|
ConditionsNotBefore: nowIso,
|
|
ConditionsNotOnOrAfter: laterIso,
|
|
SubjectConfirmationDataNotOnOrAfter: laterIso,
|
|
NameIDFormat: NAMEID_FORMAT_EMAIL,
|
|
NameID: opts.attrs.email,
|
|
attrEmail: opts.attrs.email
|
|
};
|
|
// Multi-value the Groups attribute BEFORE the token loop so the per-group
|
|
// text goes through the same XML escaping as every other value.
|
|
let work = expandGroupValues(template, opts.attrs.groups);
|
|
if (opts.idpInitiated) {
|
|
// An unsolicited Response must NOT carry InResponseTo (on the Response
|
|
// or the SubjectConfirmationData) -- drop both attributes.
|
|
work = work.replace(/\s+InResponseTo="\{InResponseTo\}"/g, "");
|
|
} else {
|
|
tvalue.InResponseTo = (opts.requestInfo && opts.requestInfo.extract && opts.requestInfo.extract.request && opts.requestInfo.extract.request.id) || "";
|
|
}
|
|
for (const k of Object.keys(tvalue)) {
|
|
work = work.replace(new RegExp("\\{" + k + "\\}", "g"), escapeXmlAttr(tvalue[k]));
|
|
}
|
|
work = work.replace(/\{AuthnStatement\}/g, buildAuthnStatement(nowIso, sessionIndex));
|
|
return { id: respId, context: work };
|
|
};
|
|
};
|
|
|
|
|
|
// customTagReplacement for a LogoutResponse (fills the default samlify logout
|
|
// template tokens: ID/IssueInstant/Destination/InResponseTo/Issuer/StatusCode).
|
|
const makeLogoutResponseRenderer = (opts) => {
|
|
return (template) => {
|
|
const respId = newId();
|
|
const tvalue = {
|
|
ID: respId,
|
|
IssueInstant: new Date().toISOString(),
|
|
Destination: opts.destination || "",
|
|
InResponseTo: opts.requestId || "",
|
|
Issuer: opts.issuer + "/saml",
|
|
StatusCode: STATUS_SUCCESS
|
|
};
|
|
let work = template;
|
|
for (const k of Object.keys(tvalue)) {
|
|
work = work.replace(new RegExp("\\{" + k + "\\}", "g"), escapeXmlAttr(tvalue[k]));
|
|
}
|
|
return { id: respId, context: work };
|
|
};
|
|
};
|
|
|
|
|
|
const autoPostForm = (acs, samlResponse, relayState) => {
|
|
const rs = relayState
|
|
? `<input type="hidden" name="RelayState" value="${web.escapeHtml(relayState)}">`
|
|
: "";
|
|
return `<!doctype html>
|
|
<html><body onload="document.forms[0].submit()">
|
|
<form method="post" action="${web.escapeHtml(acs)}">
|
|
<input type="hidden" name="SAMLResponse" value="${web.escapeHtml(samlResponse)}">
|
|
${rs}
|
|
<noscript><button type="submit">Continue</button></noscript>
|
|
</form></body></html>`;
|
|
};
|
|
|
|
|
|
// Builds + signs the login Response and auto-POSTs it to the (validated) ACS.
|
|
const sendLoginResponse = async (res, opts) => {
|
|
const customTagReplacement = makeLoginResponseRenderer({
|
|
issuer: opts.issuer,
|
|
spEntityId: opts.spEntityId,
|
|
acs: opts.acs,
|
|
requestInfo: opts.requestInfo,
|
|
idpInitiated: opts.idpInitiated,
|
|
attrs: opts.attrs
|
|
});
|
|
const response = await opts.idp.createLoginResponse(
|
|
opts.sp,
|
|
opts.requestInfo,
|
|
samlIdp.POST_BINDING,
|
|
{ email: opts.attrs.email, groups: opts.attrs.groups },
|
|
{ relayState: opts.relayState, customTagReplacement: customTagReplacement }
|
|
);
|
|
// Always deliver to the validated ACS (never response.entityEndpoint, which
|
|
// could echo an unvalidated request value).
|
|
res.type("text/html").send(autoPostForm(opts.acs, response.context, opts.relayState));
|
|
};
|
|
|
|
|
|
const metadataHandler = async (req, res) => {
|
|
try {
|
|
const xml = await samlIdp.getMetadata(issuerForReq(req));
|
|
res.set("Content-Type", "application/samlmetadata+xml").send(xml);
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[${constants.PLUGIN_NAME}] saml metadata failed:`, e);
|
|
res.status(503).type("text/plain").send("saml metadata unavailable");
|
|
}
|
|
};
|
|
|
|
|
|
// SP-initiated SSO. Validates the SP registration + ACS allow-list BEFORE any
|
|
// assertion is issued (and verifies the AuthnRequest signature for SPs that
|
|
// registered a signing cert).
|
|
const ssoHandler = async (req, res) => {
|
|
try {
|
|
const binding = req.method === "POST" ? samlIdp.POST_BINDING : samlIdp.REDIRECT_BINDING;
|
|
const samlReq = (req.body && req.body.SAMLRequest) || (req.query && req.query.SAMLRequest);
|
|
const relayState = (req.body && req.body.RelayState) || (req.query && req.query.RelayState) || "";
|
|
if (!samlReq) {
|
|
res.status(400).type("text/plain").send("missing SAMLRequest");
|
|
return;
|
|
}
|
|
let xml;
|
|
try {
|
|
xml = decodeRequest(samlReq, binding);
|
|
} catch (e) {
|
|
res.status(400).type("text/plain").send("malformed SAML request");
|
|
return;
|
|
}
|
|
const spEntityId = matchIssuer(xml);
|
|
if (!spEntityId) {
|
|
res.status(400).type("text/plain").send("could not parse SAML AuthnRequest");
|
|
return;
|
|
}
|
|
// Registry gate: only registered SPs get assertions. Checked before the
|
|
// login bounce so an attacker cannot use an unknown SP to drive a login.
|
|
const spRow = await samlSps.getSp(spEntityId);
|
|
if (!spRow) {
|
|
res.status(403).type("text/plain").send("unknown SAML SP");
|
|
return;
|
|
}
|
|
const allowed = samlSps.acsUrls(spRow);
|
|
if (allowed.length === 0) {
|
|
res.status(403).type("text/plain").send("SP has no registered ACS");
|
|
return;
|
|
}
|
|
if (!(req.user && req.user.id)) {
|
|
res.redirect("/auth/login?dest=" + encodeURIComponent(req.originalUrl));
|
|
return;
|
|
}
|
|
const issuer = issuerForReq(req);
|
|
const wantSigned = samlSps.wantsSignedRequests(spRow);
|
|
const idp = wantSigned ? await samlIdp.getSignedIdp(issuer) : await samlIdp.getIdp(issuer);
|
|
const sp = samlIdp.buildSp(spEntityId, allowed[0], { signingCert: wantSigned ? spRow.signing_cert : null });
|
|
|
|
const parseReq = { query: req.query, body: req.body };
|
|
if (wantSigned && binding === samlIdp.REDIRECT_BINDING) {
|
|
parseReq.octetString = rawQueryOctetString(req);
|
|
}
|
|
let requestInfo;
|
|
try {
|
|
requestInfo = await idp.parseLoginRequest(sp, binding, parseReq);
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[${constants.PLUGIN_NAME}] saml AuthnRequest rejected:`, e && e.message);
|
|
res.status(403).type("text/plain").send("SAML request rejected");
|
|
return;
|
|
}
|
|
// Parser-differential guard: the authoritative (parsed) issuer must match
|
|
// the entityID we looked the SP up by.
|
|
const parsedIssuer = (requestInfo.extract && requestInfo.extract.issuer) || spEntityId;
|
|
if (parsedIssuer !== spEntityId) {
|
|
res.status(403).type("text/plain").send("issuer mismatch");
|
|
return;
|
|
}
|
|
// ACS allow-list: prefer the parsed ACS, fall back to the regex; if the
|
|
// request named an ACS it MUST be allow-listed, else use the first one.
|
|
const reqAcs = (requestInfo.extract && requestInfo.extract.request && requestInfo.extract.request.assertionConsumerServiceUrl) || matchAcs(xml);
|
|
let acs;
|
|
if (reqAcs) {
|
|
if (!samlSps.acsAllowed(spRow, reqAcs)) {
|
|
res.status(403).type("text/plain").send("ACS not allowed");
|
|
return;
|
|
}
|
|
acs = reqAcs;
|
|
} else {
|
|
acs = allowed[0];
|
|
}
|
|
const user = await User.findOne({ id: req.user.id });
|
|
if (!user) {
|
|
res.status(403).type("text/plain").send("no such user");
|
|
return;
|
|
}
|
|
const attrs = await claims.samlAttributes(user);
|
|
await sendLoginResponse(res, { idp, sp, requestInfo, issuer, spEntityId, acs, attrs, relayState, idpInitiated: false });
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[${constants.PLUGIN_NAME}] saml sso failed:`, e);
|
|
if (!res.headersSent) {
|
|
res.status(500).type("text/plain").send("saml sso error");
|
|
}
|
|
}
|
|
};
|
|
|
|
|
|
// IdP-initiated SSO: an unsolicited Response for a registered SP named by query
|
|
// param (?sp=<entityID>&acs=<acsUrl>). The target SP + ACS are still validated
|
|
// against the registry/allow-list (no AuthnRequest to trust).
|
|
const initHandler = async (req, res) => {
|
|
try {
|
|
const spEntityId = String((req.query && req.query.sp) || "");
|
|
if (!spEntityId) {
|
|
res.status(400).type("text/plain").send("missing sp");
|
|
return;
|
|
}
|
|
const spRow = await samlSps.getSp(spEntityId);
|
|
if (!spRow) {
|
|
res.status(403).type("text/plain").send("unknown SAML SP");
|
|
return;
|
|
}
|
|
const allowed = samlSps.acsUrls(spRow);
|
|
if (allowed.length === 0) {
|
|
res.status(403).type("text/plain").send("SP has no registered ACS");
|
|
return;
|
|
}
|
|
if (!(req.user && req.user.id)) {
|
|
res.redirect("/auth/login?dest=" + encodeURIComponent(req.originalUrl));
|
|
return;
|
|
}
|
|
const reqAcs = String((req.query && req.query.acs) || "");
|
|
let acs;
|
|
if (reqAcs) {
|
|
if (!samlSps.acsAllowed(spRow, reqAcs)) {
|
|
res.status(403).type("text/plain").send("ACS not allowed");
|
|
return;
|
|
}
|
|
acs = reqAcs;
|
|
} else {
|
|
acs = allowed[0];
|
|
}
|
|
const issuer = issuerForReq(req);
|
|
const idp = await samlIdp.getIdp(issuer);
|
|
const sp = samlIdp.buildSp(spEntityId, acs);
|
|
const user = await User.findOne({ id: req.user.id });
|
|
if (!user) {
|
|
res.status(403).type("text/plain").send("no such user");
|
|
return;
|
|
}
|
|
const attrs = await claims.samlAttributes(user);
|
|
const relayState = String((req.query && req.query.RelayState) || "");
|
|
await sendLoginResponse(res, { idp, sp, requestInfo: {}, issuer, spEntityId, acs, attrs, relayState, idpInitiated: true });
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[${constants.PLUGIN_NAME}] saml idp-initiated sso failed:`, e);
|
|
if (!res.headersSent) {
|
|
res.status(500).type("text/plain").send("saml init error");
|
|
}
|
|
}
|
|
};
|
|
|
|
|
|
// Single Logout: parse a LogoutRequest from a registered SP, terminate the local
|
|
// Saltcorn session, and return a signed LogoutResponse.
|
|
const sloHandler = async (req, res) => {
|
|
try {
|
|
const binding = req.method === "POST" ? samlIdp.POST_BINDING : samlIdp.REDIRECT_BINDING;
|
|
const samlReq = (req.body && req.body.SAMLRequest) || (req.query && req.query.SAMLRequest);
|
|
const relayState = (req.body && req.body.RelayState) || (req.query && req.query.RelayState) || "";
|
|
if (!samlReq) {
|
|
res.status(400).type("text/plain").send("missing SAMLRequest");
|
|
return;
|
|
}
|
|
let xml;
|
|
try {
|
|
xml = decodeRequest(samlReq, binding);
|
|
} catch (e) {
|
|
res.status(400).type("text/plain").send("malformed SAML request");
|
|
return;
|
|
}
|
|
const spEntityId = matchIssuer(xml);
|
|
if (!spEntityId) {
|
|
res.status(400).type("text/plain").send("could not parse LogoutRequest");
|
|
return;
|
|
}
|
|
const spRow = await samlSps.getSp(spEntityId);
|
|
if (!spRow) {
|
|
res.status(403).type("text/plain").send("unknown SAML SP");
|
|
return;
|
|
}
|
|
// SLO terminates THE CURRENT user's session. An unauthenticated request
|
|
// has no session to end and must not reach the logout below -- this is
|
|
// what stops a forged LogoutRequest from destroying a session.
|
|
if (!(req.user && req.user.id)) {
|
|
res.status(403).type("text/plain").send("no authenticated session");
|
|
return;
|
|
}
|
|
const issuer = issuerForReq(req);
|
|
const allowed = samlSps.acsUrls(spRow);
|
|
// The LogoutResponse destination MUST be a registered URL. Mirror the SSO
|
|
// handler's empty-list rejection and NEVER fall back to a URL parsed from
|
|
// the request: matchAcs(xml) would let an SP registered with no ACS (or a
|
|
// crafted LogoutRequest) steer the signed LogoutResponse to an
|
|
// attacker-chosen endpoint (open redirect + assertion/RelayState leak).
|
|
if (allowed.length === 0) {
|
|
res.status(403).type("text/plain").send("SP has no registered ACS");
|
|
return;
|
|
}
|
|
// MVP: deliver the LogoutResponse to the SP's first registered endpoint.
|
|
const sloAcs = allowed[0];
|
|
// For an SP that registered a signing cert, REQUIRE + verify the
|
|
// LogoutRequest signature (getSignedIdp sets wantLogoutRequestSigned),
|
|
// mirroring the AuthnRequest path -- this blocks forged/replayed
|
|
// LogoutRequests for opted-in SPs. RESIDUAL: an SP WITHOUT a registered
|
|
// cert can't have its requests verified, so a cross-site GET could still
|
|
// force the CURRENT user to log out THEMSELVES (the req.user + NameID==
|
|
// session checks above bound it to self-logout). Register the SP's signing
|
|
// cert to close it fully; we don't reject unsigned SLO outright because
|
|
// that would break SPs that legitimately don't sign LogoutRequests.
|
|
// The signed redirect binding needs the
|
|
// raw octet string reconstructed exactly as the SP signed it.
|
|
const wantSigned = samlSps.wantsSignedRequests(spRow);
|
|
const idp = wantSigned ? await samlIdp.getSignedIdp(issuer) : await samlIdp.getIdp(issuer);
|
|
const sp = samlIdp.buildSpForLogout(spEntityId, sloAcs, { signingCert: spRow.signing_cert });
|
|
const parseReq = { query: req.query, body: req.body };
|
|
if (wantSigned && binding === samlIdp.REDIRECT_BINDING) {
|
|
parseReq.octetString = rawQueryOctetString(req);
|
|
}
|
|
let requestInfo;
|
|
try {
|
|
requestInfo = await idp.parseLogoutRequest(sp, binding, parseReq);
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[${constants.PLUGIN_NAME}] saml LogoutRequest rejected:`, e && e.message);
|
|
res.status(403).type("text/plain").send("SAML logout request rejected");
|
|
return;
|
|
}
|
|
// Parser-differential guard (mirror the SSO handler): the authoritative
|
|
// parsed issuer must equal the entityID we looked the SP up by.
|
|
const parsedIssuer = (requestInfo.extract && requestInfo.extract.issuer) || spEntityId;
|
|
if (parsedIssuer !== spEntityId) {
|
|
res.status(403).type("text/plain").send("issuer mismatch");
|
|
return;
|
|
}
|
|
// The LogoutRequest MUST name the currently authenticated user; never end
|
|
// a session the request does not pertain to.
|
|
const reqNameId = String((requestInfo.extract && requestInfo.extract.nameID) || "").trim().toLowerCase();
|
|
const sessionEmail = String((req.user && req.user.email) || "").trim().toLowerCase();
|
|
if (!reqNameId || !sessionEmail || reqNameId !== sessionEmail) {
|
|
res.status(403).type("text/plain").send("logout subject does not match the session");
|
|
return;
|
|
}
|
|
// The actual logout: end the local Saltcorn session.
|
|
if (typeof req.logout === "function") {
|
|
try {
|
|
req.logout(() => {});
|
|
} catch (e) {
|
|
// older passport: req.logout() is synchronous
|
|
}
|
|
}
|
|
if (req.session && typeof req.session.destroy === "function") {
|
|
try {
|
|
req.session.destroy(() => {});
|
|
} catch (e) {
|
|
// best effort
|
|
}
|
|
}
|
|
const requestId = (requestInfo.extract && requestInfo.extract.request && requestInfo.extract.request.id) || "";
|
|
const customTagReplacement = makeLogoutResponseRenderer({ issuer, destination: sloAcs, requestId });
|
|
const response = await idp.createLogoutResponse(sp, requestInfo, samlIdp.POST_BINDING, { relayState: relayState, customTagReplacement: customTagReplacement });
|
|
res.type("text/html").send(autoPostForm(sloAcs, response.context, relayState));
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[${constants.PLUGIN_NAME}] saml slo failed:`, e);
|
|
if (!res.headersSent) {
|
|
res.status(500).type("text/plain").send("saml slo error");
|
|
}
|
|
}
|
|
};
|
|
|
|
|
|
const samlRoutes = [
|
|
{ url: constants.SAML_METADATA_PATH, method: "get", callback: metadataHandler, noCsrf: true },
|
|
{ url: constants.SAML_SSO_PATH, method: "get", callback: ssoHandler, noCsrf: true },
|
|
{ url: constants.SAML_SSO_PATH, method: "post", callback: ssoHandler, noCsrf: true },
|
|
{ url: constants.SAML_INIT_PATH, method: "get", callback: initHandler, noCsrf: true },
|
|
{ url: constants.SAML_SLO_PATH, method: "get", callback: sloHandler, noCsrf: true },
|
|
{ url: constants.SAML_SLO_PATH, method: "post", callback: sloHandler, noCsrf: true }
|
|
];
|
|
|
|
|
|
module.exports = {
|
|
samlRoutes
|
|
};
|