Added custom InMemory SAML2 Authentication Request Repository

Added attributes mapping in application*.yml config
Added attributes default values in application*.yml config
Corrected MOCK-IDP to return InResponseTo and complex attribute names
This commit is contained in:
Orlando M Guerreiro 2025-06-03 08:16:24 +01:00
parent f548a0b31e
commit eb3a621b17
7 changed files with 458 additions and 75 deletions

View file

@ -18,8 +18,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest;
import org.springframework.security.saml2.provider.service.metadata.OpenSamlMetadataResolver;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
import org.springframework.security.saml2.provider.service.web.Saml2MetadataFilter;
import org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
@ -38,6 +40,7 @@ import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.handler.HandlerMappingIntrospector;
import com.oguerreiro.resilient.repository.security.ResilientSaml2AuthenticationRequestRepository;
import com.oguerreiro.resilient.security.AuthoritiesConstants;
import com.oguerreiro.resilient.security.saml2.Saml2AuthenticationHandler;
import com.oguerreiro.resilient.security.saml2.Saml2ResponseLoggingFilter;
@ -70,6 +73,11 @@ public class SecurityConfiguration {
return new BCryptPasswordEncoder();
}
@Bean
Saml2AuthenticationRequestRepository<AbstractSaml2AuthenticationRequest> authenticationRequestRepository() {
return new ResilientSaml2AuthenticationRequestRepository();
}
/**
* For future knowledge in SAMLv2 config, its necessary to :
* <ul>

View file

@ -0,0 +1,86 @@
package com.oguerreiro.resilient.repository.security;
import java.util.HashMap;
import java.util.Map;
import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest;
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
* <p>
* This replaces the standard Saml2 in-memory/session request cache. This is actually almost the same, storing the SAML2
* requests in a HashMap. I was experiencing a problem that SAML2 Request were being lost between SAML request and
* response (probably because of a Angular error that tries user+password login between calls). Nevertheless, the SAML
* should work, but didn't.
* </p>
* <p>
* THEN, I tried to implement SAML request persistence in database... WHAT A MESS. SpringSecurity 6.3+ SAML2 really
* doesn't allow this. The needed AbstractSaml2AuthenticationRequest instance CAN'T be (re)created nor it can be
* serialized. In the end, I'm unable to implement the repository loadAuthenticationRequest() method and return a valid
* AbstractSaml2AuthenticationRequest.
* Quick more stable solution, a custom in-memory/session request cache.
* </p>
* <p>
* But this has limits. If I need a multi-instance server (load-balancing) this will fail. The proposed solution is a
* distributed cache. It will work almost like this, but instead of a in-memory cache per instance, it will be
* centralized. When needed, investigate : "centralized distributed cache (Redis, Hazelcast, etc.)"
* </p>
* <p>
* NOTE: This is NOT a bean it self (@Component) because I need to register this in a special way for the SpringSecurity
* SAML2 auto-binds. In SecurityConfiguration a bean is declared this way:
*
* <pre>
* <code>
&#64;Bean
Saml2AuthenticationRequestRepository<AbstractSaml2AuthenticationRequest> authenticationRequestRepository() {
return new ResilientSaml2AuthenticationRequestRepository();
}
* </code>
* </pre>
* </p>
*/
//@Component - KEEP commented
public class ResilientSaml2AuthenticationRequestRepository
implements Saml2AuthenticationRequestRepository<AbstractSaml2AuthenticationRequest> {
// TODO: Add the creation timestamp, to improve security. Like a 5 second time resolver. Between initiation (request) and authentication (response)
private Map<String, AbstractSaml2AuthenticationRequest> inMemory = new HashMap<String, AbstractSaml2AuthenticationRequest>();
public ResilientSaml2AuthenticationRequestRepository() {
}
@Override
public AbstractSaml2AuthenticationRequest loadAuthenticationRequest(HttpServletRequest request) {
String relayState = request.getParameter("RelayState");
if (relayState != null) {
AbstractSaml2AuthenticationRequest authRequest = inMemory.get(relayState);
return authRequest;
}
return null;
}
@Override
public void saveAuthenticationRequest(AbstractSaml2AuthenticationRequest authenticationRequest,
HttpServletRequest request, HttpServletResponse response) {
String relayState = authenticationRequest.getRelayState();
if (relayState != null) {
inMemory.put(relayState, authenticationRequest);
}
}
@Override
public AbstractSaml2AuthenticationRequest removeAuthenticationRequest(HttpServletRequest request,
HttpServletResponse response) {
String relayState = request.getParameter("RelayState");
if (relayState != null) {
inMemory.remove(relayState);
}
return null;
}
}

View file

@ -58,12 +58,34 @@ public class ResilientSaml2Properties {
private boolean wantAuthnSigned;
private boolean wantAssertionSigned;
// Attributes
private Map<String, String> attributes;
// Attributes defaults
private Map<String, String> defaults;
// Getters and Setters
public Map<String, String> getDefaults() {
return defaults;
}
public void setDefaults(Map<String, String> defaults) {
this.defaults = defaults;
}
public String getEntityId() {
return entityId;
}
public Map<String, String> getAttributes() {
return attributes;
}
public void setAttributes(Map<String, String> attributes) {
this.attributes = attributes;
}
public void setEntityId(String entityId) {
this.entityId = entityId;
}

View file

@ -0,0 +1,110 @@
package com.oguerreiro.resilient.security.saml2;
import java.util.HashMap;
import java.util.Map;
public class Saml2AuthenticationAttributes {
public static final String ATTR_NAME = "name";
public static final String ATTR_USERNAME = "username";
public static final String ATTR_EMAIL = "email";
public static final String ATTR_ORGANIZATION_CODE = "organization-code";
public static final String ATTR_SECURITY_GROUP_CODE = "security-group-code";
public static final String ATTR_ROLE = "role";
private String name;
private String username;
private String email;
private String organizationCode;
private String securityGroupCode;
private String role;
private Map<String, String> additional = new HashMap<String, String>();
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getOrganizationCode() {
return organizationCode;
}
public void setOrganizationCode(String organizationCode) {
this.organizationCode = organizationCode;
}
public String getSecurityGroupCode() {
return securityGroupCode;
}
public void setSecurityGroupCode(String securityGroupCode) {
this.securityGroupCode = securityGroupCode;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public void addAttribute(String key, String value, String defaultValue) {
// Apply default value, if needed an present
if ((value == null || value.isBlank()) && (defaultValue != null && !defaultValue.isBlank())) {
value = defaultValue;
}
switch (key) {
case ATTR_NAME: {
this.setName(value);
break;
}
case ATTR_USERNAME: {
this.setUsername(value);
break;
}
case ATTR_EMAIL: {
this.setEmail(value);
break;
}
case ATTR_ORGANIZATION_CODE: {
this.setOrganizationCode(value);
break;
}
case ATTR_SECURITY_GROUP_CODE: {
this.setSecurityGroupCode(value);
break;
}
case ATTR_ROLE: {
this.setRole(value);
break;
}
}
// Always add. Its a sugar code when using getAttribute(String key)
this.additional.put(key, value);
}
public String getAttribute(String key) {
return this.additional.get(key);
}
}

View file

@ -4,8 +4,12 @@ import java.io.IOException;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.security.core.Authentication;
@ -20,10 +24,13 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHand
import org.springframework.stereotype.Component;
import com.oguerreiro.resilient.domain.Organization;
import com.oguerreiro.resilient.domain.User;
import com.oguerreiro.resilient.repository.OrganizationRepository;
import com.oguerreiro.resilient.repository.UserRepository;
import com.oguerreiro.resilient.repository.security.SecurityGroupRepository;
import com.oguerreiro.resilient.security.custom.ResilientUserDetails;
import com.oguerreiro.resilient.security.resillient.SecurityGroup;
import com.oguerreiro.resilient.security.saml2.ResilientSaml2Properties.Registration;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
@ -31,11 +38,7 @@ import jakarta.servlet.http.HttpServletResponse;
@Component
public class Saml2AuthenticationHandler implements AuthenticationSuccessHandler, AuthenticationFailureHandler {
private static final String ATTR_SECURITYGROUP = "security_group";
private static final String ATTR_EMAIL = "email";
private static final String ATTR_ORGANIZATION = "organization_code";
private static final String ATTR_ROLES = "roles";
protected final Logger log = LoggerFactory.getLogger(this.getClass());
@Autowired
private Environment environment;
@ -49,6 +52,9 @@ public class Saml2AuthenticationHandler implements AuthenticationSuccessHandler,
@Autowired
private ResilientSaml2Properties resilientSaml2Properties;
@Autowired
private UserRepository userRepository;
/**
* AuthenticationSuccessHandler implementation
*/
@ -58,50 +64,87 @@ public class Saml2AuthenticationHandler implements AuthenticationSuccessHandler,
String samlResponse = request.getParameter("SAMLResponse");
String samlXMLResponse = Saml2ResponseLoggingFilter.decodeSamlResponse(samlResponse);
// Read needed information about user security environment
Saml2AuthenticatedPrincipal principal = (Saml2AuthenticatedPrincipal) authentication.getPrincipal();
String username = principal.getName();
String email = getPrincipalAttribute(principal, ATTR_EMAIL);
String securityGroupCode = getPrincipalAttribute(principal, ATTR_SECURITYGROUP);
String organizationCode = getPrincipalAttribute(principal, ATTR_ORGANIZATION);
List<String> roles = principal.getAttribute(ATTR_ROLES);
// Declarations
Collection<? extends GrantedAuthority> authorities;
if (roles == null || roles.isEmpty()) {
authorities = authentication.getAuthorities();
} else {
authorities = roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
}
Organization userOrganization = null;
SecurityGroup securityGroup = null;
// Organization exists?
if (organizationCode == null || organizationCode.isBlank()) {
// TODO: Make this configurable
Long defaultOrg = 1L;
userOrganization = organizationRepository.findById(defaultOrg).orElse(null);
// read SAML2 attributes
Saml2AuthenticatedPrincipal principal = (Saml2AuthenticatedPrincipal) authentication.getPrincipal();
Saml2AuthenticationAttributes attr = this.readSaml2Attributes(principal);
// Search user. If the user (by username) is registered in Resilient, load definitions from Resilient and NOT the ones received from SAML2
String username = attr.getUsername();
if (username == null | username.isBlank()) {
// This is NOT allowed. A username must be provided. If from SAML2, most probably, will be an email
log.error("The attribute username is expected for SAML2 authentication, but it was NULL or Empty.");
this.invalidateLogin(request, response);
return;
}
User user = userRepository.findOneByLogin(username).orElse(null);
if (user != null) {
//@formatter:off
// User found. Setup permissions from resilient config
authorities = user.getAuthorities().stream()
.map(auth -> new SimpleGrantedAuthority(auth.getName()))
.collect(Collectors.toList());
//@formatter:on
securityGroup = user.getSecurityGroup();
userOrganization = organizationRepository.findOneByUser(user).orElse(null);
if (userOrganization == null) {
// This is NOT allowed. Bad user configuration.
log.error(
"The username is registered in Resilient, but without reference to an Organization. This is a configuration error.");
this.invalidateLogin(request, response);
return;
}
} else {
// Find or NULL
userOrganization = organizationRepository.findOneByCode(organizationCode).orElse(null);
}
if (userOrganization == null) {
this.invalidateLogin(request, response);
return;
}
// User not found? Use SAML2 values or defaults.
if (attr.getRole() == null || attr.getRole().isBlank()) {
// This is NOT allowed. SAML2 or Defaults must provide a Role.
log.error(
"A ROLE must be given to a user, but none was calculated. Nor by SAML2 XML, nor defined in defaults.");
this.invalidateLogin(request, response);
return;
}
authorities = List.of(attr.getRole()).stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
// SecurityGroup exists?
String defaultSecurityGroupCode = "default"; // TODO: Make this configurable
if (securityGroupCode == null || securityGroupCode.isBlank()) {
securityGroupCode = defaultSecurityGroupCode;
}
// Organization not found ?
if (attr.getOrganizationCode() == null || attr.getOrganizationCode().isBlank()) {
// This is NOT allowed. SAML2 or Defaults must provide a Organization code.
log.error(
"An OrganizationCode must be given to a user, but none was calculated. Nor by SAML2 XML, nor defined in defaults.");
this.invalidateLogin(request, response);
return;
} else {
// Find or NULL
userOrganization = organizationRepository.findOneByCode(attr.getOrganizationCode()).orElse(null);
}
if (userOrganization == null) {
log.error("The Organization code [" + attr.getOrganizationCode() + "] wasn't found in database.");
this.invalidateLogin(request, response);
return;
}
// Find or fail
securityGroup = securityGroupRepository.findOneByCodeWithEagerRelationships(securityGroupCode).orElse(null);
// SecurityGroup exists?
if (attr.getSecurityGroupCode() == null || attr.getSecurityGroupCode().isBlank()) {
// This is NOT allowed. SAML2 or Defaults must provide a SecurityGroup code.
log.error(
"A SecurityGroup code must be given to a user, but none was calculated. Nor by SAML2 XML, nor defined in defaults.");
this.invalidateLogin(request, response);
return;
}
if (securityGroup == null) {
this.invalidateLogin(request, response);
return;
// Find or fail
securityGroup = securityGroupRepository.findOneByCodeWithEagerRelationships(attr.getSecurityGroupCode()).orElse(
null);
if (securityGroup == null) {
log.error("The SecurityGroup code [" + attr.getSecurityGroupCode() + "] wasn't found in database.");
this.invalidateLogin(request, response);
return;
}
}
// Create a ResilientUserDetails and replace Principal
@ -173,4 +216,31 @@ public class Saml2AuthenticationHandler implements AuthenticationSuccessHandler,
}
}
}
/**
* Extract the expected user attributes from SAML2Response. There's some standard expected attributes, those will be
* mapped to properties, the remaining will be put into a MAP.
*/
private Saml2AuthenticationAttributes readSaml2Attributes(Saml2AuthenticatedPrincipal principal) {
// Get the RelyingParty ID (IDP)
String registrationId = principal.getRelyingPartyRegistrationId();
// Get registration, from config properties for the RelyingParty ID
Registration registration = this.resilientSaml2Properties.getRelyingparty().getRegistration().get(registrationId);
if (registration == null) {
// This is VERY strange
}
Saml2AuthenticationAttributes authAttr = new Saml2AuthenticationAttributes();
Map<String, String> atributes = registration.getAssertingparty().getAttributes();
Map<String, String> defaults = registration.getAssertingparty().getDefaults();
for (Entry<String, String> entry : atributes.entrySet()) {
String attrValue = this.getPrincipalAttribute(principal, entry.getValue());
authAttr.addAttribute(entry.getKey(), attrValue, defaults.get(entry.getKey()));
}
return authAttr;
}
}

View file

@ -144,14 +144,14 @@ resilient:
enabled: false
port: 8081
mock-idp:
enabled: true
enabled: false
path: classpath:mock-idp/idp.js
security:
saml2: # ADDED to support SAMLv2 authentication to IDP.
# Metadata endpoint ${base-url}/saml2/service-provider-metadata/mock-idp
enabled: true
idp-id: unl-idp # The id of the IDP to use. One from the collection in relyingparty.registration
base-url: https://resilient.localhost # old: https://localhost:8443
idp-id: mock-idp # The id of the IDP to use. One from the collection in relyingparty.registration
base-url: http://resilient.localhost # old: https://localhost:8443
success-url: http://resilient.localhost/
failure-url: http://resilient.localhost/login
relyingparty:
@ -163,10 +163,22 @@ resilient:
url: http://mock-idp.localhost/saml/sso # old: http://localhost:3000/saml/sso
# OPTIONAL. A list of query parameters to add to single-sign-on.url. This is usefull for mock-idp, to give instructions on how to behave
query-parameters:
spUrl: https://resilient.localhost # The callback to Service Provider, after IDP authentication (OK | KO). Appends the encoded url: acs=https%3A%2F%2Fresilient.localhost%2Flogin%2Fsaml2%2Fsso%2Fmock-idp
spUrl: http://resilient.localhost # The callback to Service Provider, after IDP authentication (OK | KO). Appends the encoded url: acs=https%3A%2F%2Fresilient.localhost%2Flogin%2Fsaml2%2Fsso%2Fmock-idp
issuerUrl: http://mock-idp.localhost/saml/metadata # The IDP entity-id. This is needed for mock-idp to build saml2 response
single-logout:
url: http://mock-idp.localhost/saml/slo # old: http://localhost:3000/saml/slo
attributes: # This is a mapping between the needed attributes, and the names of the attributes in the SAML2 Response
# Leave BLANK if SAML doesn't provide that attribute'
name: name # the user display name [OPTIONAL]
username: urn:mace:dir:attribute-def:mail # the username, typically for authentication. Fallsback to email. [MANDATORY]
email: email # the user email [MANDATORY]
organization-code: organization_code # organization unit code [OPTIONAL]
security-group-code: security_group # security group code [OPTIONAL]
role: roles # a single role is expected [OPTIONAL]
defaults: # For some attributes defaults can be given. This will be used if SAML2 response doesn't have them
organization-code: NOVA # default organization unit code
security-group-code: GRP_USER # default security group code
role: ROLE_USER # default role
verification:
credentials:
- certificate-location: classpath:saml/idp-public.cert
@ -183,15 +195,28 @@ resilient:
url: http://unl-idp.localhost/saml/sso
# OPTIONAL. A list of query parameters to add to single-sign-on.url. This is usefull for mock-idp, to give instructions on how to behave
query-parameters:
spUrl: https://resilient.localhost # The callback to Service Provider, after IDP authentication (OK | KO). Appends the encoded url: acs=https%3A%2F%2Fresilient.localhost%2Flogin%2Fsaml2%2Fsso%2Fmock-idp
spUrl: http://resilient.localhost # The callback to Service Provider, after IDP authentication (OK | KO). Appends the encoded url: acs=https%3A%2F%2Fresilient.localhost%2Flogin%2Fsaml2%2Fsso%2Fmock-idp
issuerUrl: http://unl-idp.localhost/saml/metadata # The IDP entity-id. This is needed for mock-idp to build saml2 response
single-logout:
url: http://unl-idp.localhost/saml/slo # old: http://localhost:3000/saml/slo
attributes: # This is a mapping between the needed attributes, and the names of the attributes in the SAML2 Response
# Leave BLANK if SAML doesn't provide that attribute'
name: urn:mace:dir:attribute-def:displayName # the user display name [OPTIONAL]
username: urn:mace:dir:attribute-def:mail # the username, typically for authentication. Fallsback to email. [MANDATORY]
email: urn:mace:dir:attribute-def:mail # the user email [MANDATORY]
organization-code: # organization unit code [OPTIONAL]
security-group-code: # security group code [OPTIONAL]
role: # a single role is expected [OPTIONAL]
defaults: # For some attributes defaults can be given. This will be used if SAML2 response doesn't have them
organization-code: NOVA # default organization unit code
security-group-code: GRP_USER # default security group code
role: ROLE_USER # default role
verification:
credentials:
- certificate-location: classpath:saml/idp-public.cert
want-authn-signed: false # Validate signature in entire message response (true-validates/false-doesn't validate)
want-assertion-signed: true # Validate signature in assertions message response (true-validates/false-doesn't validate)
check-in-response-to: false # The UNL IDP doesn't implement this. Must be false.
signing:
credentials:
- private-key-location: classpath:saml/private.key

View file

@ -132,6 +132,91 @@ function createFakeSamlResponse(nameId, email, orgCode, securityGroupCode, roles
console.log('SAML Destination :', destination);
console.log('SAML Issuer :', issuer);
const assertion = `
<saml:Assertion
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
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:Attribute Name="urn:mace:dir:attribute-def:displayName" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">Pedro Reis</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="urn:oid:2.16.840.1.113730.3.1.241" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">Pedro Reis</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="urn:mace:dir:attribute-def:mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">pedro.reis@unl.pt</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="urn:oid:0.9.2342.19200300.100.1.3" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">pedro.reis@unl.pt</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}" InResponseTo="${inResponseTo}"><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');
}
// OLD assertion string. This contains also some extra attributes
function assertionOLD() {
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>
@ -166,35 +251,6 @@ function createFakeSamlResponse(nameId, email, orgCode, securityGroupCode, roles
</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}" InResponseTo="${inResponseTo}"><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() {
@ -350,5 +406,11 @@ function extractIssuerFromSAMLRequest(samlRequestBase64) {
}
app.listen(port, () => {
const now = new Date();
const localISOTime = now.toLocaleString('pt-PT'); // ISO-like, local time
const nowUTC = new Date().toISOString();
console.log(`Mock IDP running at http://localhost:${port}`);
console.log(`Started at ${localISOTime} (UTC is ${now})`);
});