package utils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.bouncycastle.asn1.DERNull;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.asn1.pkcs.RSAPrivateKey;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.opensaml.saml.saml2.core.LogoutRequest;
import org.opensaml.saml.saml2.core.NameID;
import org.opensaml.saml.saml2.core.NameIDType;
import org.opensaml.saml.saml2.core.Response;
import org.opensaml.saml.saml2.core.SessionIndex;
import org.opensaml.saml.saml2.core.SubjectConfirmationData;
import org.opensaml.saml.saml2.metadata.SPSSODescriptor;
import org.opensaml.xmlsec.encryption.support.DecryptionException;
import org.owasp.encoder.Encode;
import org.pac4j.core.context.CallContext;
import org.pac4j.core.credentials.Credentials;
import org.pac4j.core.exception.http.HttpAction;
import org.pac4j.core.http.callback.NoParameterCallbackUrlResolver;
import org.pac4j.core.keystore.generation.KeystoreGenerator;
import org.pac4j.core.profile.UserProfile;
import org.pac4j.jee.context.JEEContext;
import org.pac4j.jee.context.session.JEESessionStore;
import org.pac4j.jee.http.adapter.JEEHttpActionAdapter;
import org.pac4j.saml.client.SAML2Client;
import org.pac4j.saml.config.SAML2Configuration;
import org.pac4j.saml.context.SAML2MessageContext;
import org.pac4j.saml.credentials.SAML2Credentials;
import org.pac4j.saml.exceptions.SAMLAuthnContextClassRefException;
import org.pac4j.saml.metadata.SAML2FileSystemMetadataGenerator;
import org.pac4j.saml.profile.SAML2Profile;
import org.pac4j.saml.sso.impl.SAML2AuthnResponseValidator;
import org.springframework.core.io.ByteArrayResource;
/**
* SAML 2.0 single sign-on, backed by pac4j-saml.
* The service provider and identity provider are configured from onelogin.saml.properties on the
* classpath, using the same keys the previous java-saml implementation read, so existing
* deployments keep their configuration.
* This file is part of the Security Shepherd Project.
*
*
The Security Shepherd project is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software Foundation, either
* version 3 of the License, or (at your option) any later version.
*
*
The Security Shepherd project is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
* PURPOSE. See the GNU General Public License for more details.
*
*
You should have received a copy of the GNU General Public License along with the Security
* Shepherd project. If not, see .
*/
public class SamlSso {
private static final Logger log = LogManager.getLogger(SamlSso.class);
/** Session attribute holding the authenticated user's SAML profile, used for single logout */
public static final String PROFILE_SESSION_ATTRIBUTE = "samlProfile";
static final String PROPERTIES_FILE = "onelogin.saml.properties";
static final String SP_ENTITY_ID = "onelogin.saml2.sp.entityid";
static final String SP_ACS_URL = "onelogin.saml2.sp.assertion_consumer_service.url";
static final String SP_ACS_BINDING = "onelogin.saml2.sp.assertion_consumer_service.binding";
static final String SP_SLS_URL = "onelogin.saml2.sp.single_logout_service.url";
static final String SP_NAMEID_FORMAT = "onelogin.saml2.sp.nameidformat";
static final String SP_CERT = "onelogin.saml2.sp.x509cert";
static final String SP_KEY = "onelogin.saml2.sp.privatekey";
static final String IDP_ENTITY_ID = "onelogin.saml2.idp.entityid";
static final String IDP_SSO_URL = "onelogin.saml2.idp.single_sign_on_service.url";
static final String IDP_SSO_BINDING = "onelogin.saml2.idp.single_sign_on_service.binding";
static final String IDP_SLO_URL = "onelogin.saml2.idp.single_logout_service.url";
static final String IDP_SLO_RESPONSE_URL =
"onelogin.saml2.idp.single_logout_service.response.url";
static final String IDP_SLO_BINDING = "onelogin.saml2.idp.single_logout_service.binding";
static final String IDP_CERT = "onelogin.saml2.idp.x509cert";
static final String IDP_CERT_FINGERPRINT = "onelogin.saml2.idp.certfingerprint";
static final String STRICT = "onelogin.saml2.strict";
static final String AUTHN_REQUEST_SIGNED = "onelogin.saml2.security.authnrequest_signed";
static final String LOGOUT_REQUEST_SIGNED = "onelogin.saml2.security.logoutrequest_signed";
static final String WANT_MESSAGES_SIGNED = "onelogin.saml2.security.want_messages_signed";
static final String WANT_ASSERTIONS_SIGNED = "onelogin.saml2.security.want_assertions_signed";
static final String SIGN_METADATA = "onelogin.saml2.security.sign_metadata";
static final String REQUESTED_AUTHN_CONTEXT = "onelogin.saml2.security.requested_authncontext";
static final String REQUESTED_AUTHN_CONTEXT_COMPARISON =
"onelogin.saml2.security.requested_authncontextcomparison";
/** The key as misspelled in the pre-pac4j sample file, still accepted */
static final String LEGACY_REQUESTED_AUTHN_CONTEXT_COMPARISON =
"onelogin.saml2.security.onelogin.saml2.security.requested_authncontextcomparison";
static final String SIGNATURE_ALGORITHM = "onelogin.saml2.security.signature_algorithm";
private static final String HTTP_REDIRECT_BINDING =
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
private static final String HTTP_POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
private static final String SP_KEY_ALIAS = "shepherd-sp";
/**
* HTTP-Redirect binding signature algorithms accepted on logout messages (no SHA-1). XML-DSig
* ECDSA signatures are the raw r || s concatenation (IEEE P1363), not ASN.1 DER.
*/
private static final Map REDIRECT_SIGNATURE_ALGORITHMS = new HashMap<>();
static {
REDIRECT_SIGNATURE_ALGORITHMS.put(
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "SHA256withRSA");
REDIRECT_SIGNATURE_ALGORITHMS.put(
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", "SHA384withRSA");
REDIRECT_SIGNATURE_ALGORITHMS.put(
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha512", "SHA512withRSA");
REDIRECT_SIGNATURE_ALGORITHMS.put(
"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256", "SHA256withECDSAinP1363Format");
REDIRECT_SIGNATURE_ALGORITHMS.put(
"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384", "SHA384withECDSAinP1363Format");
REDIRECT_SIGNATURE_ALGORITHMS.put(
"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512", "SHA512withECDSAinP1363Format");
}
private static final Set REDIRECT_SIGNED_PARAMETERS =
Set.of("SAMLRequest", "SAMLResponse", "RelayState", "SigAlg", "Signature");
/** How long after its IssueInstant an IdP LogoutRequest is accepted (plus clock skew) */
static final Duration LOGOUT_REQUEST_LIFETIME = Duration.ofMinutes(5);
/** Allowed clock difference between the IdP and this server */
static final Duration CLOCK_SKEW = Duration.ofMinutes(5);
/** Upper bound on remembered LogoutRequest IDs; beyond it new requests are refused */
static int maxRememberedLogoutRequests = 10000;
/**
* IDs of LogoutRequests already acted on, with when they can be forgotten. An entry outlives the
* window in which its request would be accepted, so a replay is always caught here or by the age
* check.
*/
private static final Map seenLogoutRequests = new ConcurrentHashMap<>();
/** The configured SAML client and the settings SamlSso enforces itself */
static final class Sso {
final SAML2Client client;
final X509Certificate idpCertificate;
/** want_messages_signed: logout messages must carry a valid IdP signature */
final boolean requireSignedLogout;
/**
* This SP's single logout endpoint, which IdP LogoutRequests must be addressed to; null when
* single logout isn't configured
*/
final String singleLogoutUrl;
/** Defaults for omitted NameQualifier / SPNameQualifier (SAML Core 8.3.7) */
final String idpEntityId;
final String spEntityId;
/** Binds each sent AuthnRequest to the browser that started the login */
final SamlRequestStore requestStore;
Sso(
SAML2Client client,
X509Certificate idpCertificate,
boolean requireSignedLogout,
String singleLogoutUrl,
String idpEntityId,
String spEntityId,
SamlRequestStore requestStore) {
this.client = client;
this.idpCertificate = idpCertificate;
this.requireSignedLogout = requireSignedLogout;
this.singleLogoutUrl = singleLogoutUrl;
this.idpEntityId = idpEntityId;
this.spEntityId = spEntityId;
this.requestStore = requestStore;
}
}
private static volatile Sso sso;
private static Sso get() {
Sso result = sso;
if (result == null) {
synchronized (SamlSso.class) {
result = sso;
if (result == null) {
result = build(loadProperties());
sso = result;
}
}
}
return result;
}
/** Replaces the configuration built from onelogin.saml.properties; for tests. */
static void set(Sso replacement) {
sso = replacement;
}
/**
* Returns the SAML client, building it from onelogin.saml.properties on first use.
*
* @return The initialised SAML client
*/
public static SAML2Client getClient() {
return get().client;
}
/**
* Sends the browser to the identity provider with a SAML authentication request.
*
* @param request The incoming request
* @param response The response the redirect (or auto-submitting form) is written to
*/
public static void login(HttpServletRequest request, HttpServletResponse response) {
CallContext ctx = callContext(request, response);
HttpAction action =
getClient()
.getRedirectionAction(ctx)
.orElseThrow(() -> new IllegalStateException("No SAML redirection action"));
JEEHttpActionAdapter.INSTANCE.adapt(action, ctx.webContext());
}
/**
* Validates the SAML response posted to the assertion consumer service.
*
* @param request The ACS request carrying the SAML response
* @param response The ACS response
* @return The authenticated user's profile, or empty if the response did not authenticate a user
*/
public static Optional authenticate(
HttpServletRequest request, HttpServletResponse response) {
Sso config = get();
SAML2Client saml = config.client;
CallContext ctx = callContext(request, response);
try {
Optional extracted = saml.getCredentials(ctx);
if (extracted.isEmpty()) {
return Optional.empty();
}
// Only answers to a login started in this browser are accepted (login CSRF): an unsolicited
// (IdP-initiated) Response has no InResponseTo to bind it to a browser, so it is refused;
// otherwise pac4j checks InResponseTo against this browser's pending requests
String inResponseTo = inResponseTo(extracted.get());
if (inResponseTo == null) {
log.error("SAML response rejected: unsolicited (no InResponseTo)");
return Optional.empty();
}
Optional credentials = saml.validateCredentials(ctx, extracted.get());
if (credentials.isEmpty()) {
return Optional.empty();
}
config.requestStore.complete(inResponseTo, response);
Optional profile = saml.getUserProfile(ctx, credentials.get());
return profile.filter(SAML2Profile.class::isInstance).map(SAML2Profile.class::cast);
} catch (RuntimeException e) {
// Signature, issuer, audience, destination and time checks fail with exceptions
log.error("SAML response rejected: " + e.getMessage());
log.debug("SAML response rejected", e);
return Optional.empty();
}
}
private static String inResponseTo(Credentials credentials) {
if (!(credentials instanceof SAML2Credentials)) {
return null;
}
Object message = ((SAML2Credentials) credentials).getContext().getMessageContext().getMessage();
return message instanceof Response ? ((Response) message).getInResponseTo() : null;
}
/**
* Starts SP-initiated single logout for the given profile.
*
* @param request The logout request
* @param response The response the redirect (or auto-submitting form) is written to
* @param profile The SAML profile captured at login
* @return true if the browser was sent to the identity provider, false if no logout could be
* started (no IdP logout URL configured) and the caller must respond itself
*/
public static boolean logout(
HttpServletRequest request, HttpServletResponse response, SAML2Profile profile) {
CallContext ctx = callContext(request, response);
Optional action =
getClient().getLogoutAction(ctx, profile, null).map(HttpAction.class::cast);
if (action.isEmpty()) {
return false;
}
JEEHttpActionAdapter.INSTANCE.adapt(action.get(), ctx.webContext());
return true;
}
/**
* Records a post-login session against the SAML login that created it, so an IdP-initiated logout
* naming that login can end it.
*
* @param session The post-login session
* @param profile The profile from the validated assertion
*/
public static void registerLoginSession(HttpSession session, SAML2Profile profile) {
SamlSessionRegistry.register(
session,
nameIdKey(
get(),
profile.getId(),
profile.getSamlNameIdFormat(),
profile.getSamlNameIdNameQualifier(),
profile.getSamlNameIdSpNameQualifier()),
profile.getSessionIndex());
}
/** The complete identity of a NameID, as {@link SamlSessionRegistry} matches it */
static String nameIdKey(
String value, String format, String nameQualifier, String spNameQualifier) {
return nameIdKey(get(), value, format, nameQualifier, spNameQualifier);
}
/**
* Two NameIDs identify the same principal only if value, Format, NameQualifier and
* SPNameQualifier all match. Omitted fields take their SAML defaults: unspecified format, and the
* IdP / SP entity IDs as qualifiers (SAML Core 8.3.7).
*/
private static String nameIdKey(
Sso config, String value, String format, String nameQualifier, String spNameQualifier) {
if (value == null) {
return null;
}
return String.join(
"\u0000",
value,
format == null || format.isEmpty() ? NameIDType.UNSPECIFIED : format,
nameQualifier == null || nameQualifier.isEmpty() ? config.idpEntityId : nameQualifier,
spNameQualifier == null || spNameQualifier.isEmpty() ? config.spEntityId : spNameQualifier);
}
/** Outcome of a message received on the single logout service */
public enum LogoutMessage {
/**
* The IdP asked to end a login: the matching sessions were invalidated and a LogoutResponse has
* been sent back to the IdP
*/
IDP_LOGOUT_REQUEST,
/** The IdP confirmed an SP-initiated logout */
LOGOUT_RESPONSE,
/** The message was missing, unsigned when a signature is required, or failed validation */
INVALID
}
/**
* Validates a LogoutRequest or LogoutResponse received on the single logout service.
*
* When want_messages_signed is set, the message must be signed by the configured IdP: either
* an XML signature (HTTP-POST binding) or a query-string signature (HTTP-Redirect binding).
*
*
For an IdP-initiated LogoutRequest, the sessions created by the login it names (NameID and
* SessionIndex) are invalidated, whichever browser delivered the request, and the LogoutResponse
* is written to the response. A LogoutResponse ends nothing itself: the local session was already
* ended when the SP-initiated logout started.
*
* @param request The SLS request
* @param response The SLS response
* @return What was received
*/
public static LogoutMessage processLogoutMessage(
HttpServletRequest request, HttpServletResponse response) {
Sso config = get();
SAML2Client saml = config.client;
CallContext ctx = callContext(request, response);
try {
Optional credentials =
saml.getCredentials(ctx).flatMap(c -> saml.validateCredentials(ctx, c));
if (credentials.isEmpty() || !(credentials.get() instanceof SAML2Credentials)) {
return LogoutMessage.INVALID;
}
SAML2MessageContext messageContext = ((SAML2Credentials) credentials.get()).getContext();
// pac4j validates an XML signature when one is present, but never requires one and does not
// check HTTP-Redirect query-string signatures
boolean signed =
messageContext.getSAMLPeerEntityContext().isAuthenticated()
|| hasValidRedirectSignature(request, config.idpCertificate);
if (config.requireSignedLogout && !signed) {
log.error("SAML logout message rejected: no valid IdP signature");
return LogoutMessage.INVALID;
}
Object message = messageContext.getMessageContext().getMessage();
if (!(message instanceof LogoutRequest)) {
return LogoutMessage.LOGOUT_RESPONSE;
}
LogoutRequest logoutRequest = (LogoutRequest) message;
// pac4j checks none of these for LogoutRequests
String rejection = checkLogoutRequest(logoutRequest, signed, config.singleLogoutUrl);
if (rejection != null) {
log.error("SAML LogoutRequest rejected: " + rejection);
return LogoutMessage.INVALID;
}
NameID nameId = logoutRequest.getNameID();
if (nameId == null && logoutRequest.getEncryptedID() != null) {
nameId = (NameID) saml.getDecrypter().decrypt(logoutRequest.getEncryptedID());
}
String nameIdKey =
nameId == null
? null
: nameIdKey(
config,
nameId.getValue(),
nameId.getFormat(),
nameId.getNameQualifier(),
nameId.getSPNameQualifier());
List sessionIndexes = new ArrayList<>();
for (SessionIndex index : logoutRequest.getSessionIndexes()) {
if (index != null && index.getValue() != null) {
sessionIndexes.add(index.getValue());
}
}
// Only a request that will end something takes a slot in the replay cache: a signed request,
// or an unsigned one naming a login we track. Otherwise unauthenticated no-op requests could
// fill the cache and lock out genuine signed logouts. (A NameID alone is guessable, so only
// a signed request may end all of a user's sessions.)
boolean acts =
signed
|| sessionIndexes.stream()
.anyMatch(index -> SamlSessionRegistry.isRegistered(nameIdKey, index));
int ended = 0;
if (acts) {
String replay = recordLogoutRequest(logoutRequest);
if (replay != null) {
log.error("SAML LogoutRequest rejected: " + replay);
return LogoutMessage.INVALID;
}
if (!sessionIndexes.isEmpty()) {
for (String sessionIndex : sessionIndexes) {
ended += SamlSessionRegistry.invalidate(nameIdKey, sessionIndex);
}
} else {
ended = SamlSessionRegistry.invalidate(nameIdKey, null);
}
} else {
log.error("Unsigned SAML LogoutRequest names no tracked login; no sessions ended");
}
log.debug("IdP-initiated SAML logout ended " + ended + " session(s)");
HttpAction action = saml.processLogout(ctx, credentials.get());
JEEHttpActionAdapter.INSTANCE.adapt(action, ctx.webContext());
return LogoutMessage.IDP_LOGOUT_REQUEST;
} catch (RuntimeException | DecryptionException e) {
log.error("SAML logout message rejected: " + e.getMessage());
log.debug("SAML logout message rejected", e);
return LogoutMessage.INVALID;
}
}
/**
* Checks an IdP LogoutRequest's addressing and freshness before it may end sessions. A signed
* request must name this SP's logout endpoint as its Destination (SAML Bindings 3.4.5.2 /
* 3.5.5.2); any Destination given must match. Replay is checked by {@link #recordLogoutRequest},
* only for requests that will act.
*
* @return why the request is rejected, or null if it may be acted on
*/
static String checkLogoutRequest(LogoutRequest request, boolean signed, String singleLogoutUrl) {
if (singleLogoutUrl == null) {
return "no single logout endpoint is configured (" + SP_SLS_URL + ")";
}
String destination = request.getDestination();
if (destination == null ? signed : !destination.equals(singleLogoutUrl)) {
return "Destination " + destination + " is not " + singleLogoutUrl;
}
Instant now = Instant.now();
Instant issued = request.getIssueInstant();
if (issued == null
|| issued.isAfter(now.plus(CLOCK_SKEW))
|| issued.isBefore(now.minus(LOGOUT_REQUEST_LIFETIME).minus(CLOCK_SKEW))) {
return "IssueInstant " + issued + " is outside the accepted window";
}
Instant notOnOrAfter = request.getNotOnOrAfter();
if (notOnOrAfter != null && !now.minus(CLOCK_SKEW).isBefore(notOnOrAfter)) {
return "expired at " + notOnOrAfter;
}
if (request.getID() == null) {
return "no ID";
}
return null;
}
/**
* Records a LogoutRequest's ID (per issuer) before it ends sessions, rejecting a replay. Fails
* closed when the cache is full.
*
* @return why the request is rejected, or null if it was recorded
*/
static String recordLogoutRequest(LogoutRequest request) {
Instant now = Instant.now();
String issuer = request.getIssuer() == null ? "" : request.getIssuer().getValue();
seenLogoutRequests.values().removeIf(forgetAt -> forgetAt.isBefore(now));
if (seenLogoutRequests.size() >= maxRememberedLogoutRequests) {
return "too many recent logout requests to check for replay";
}
Instant forgetAt = now.plus(LOGOUT_REQUEST_LIFETIME).plus(CLOCK_SKEW.multipliedBy(2));
if (seenLogoutRequests.putIfAbsent(issuer + "|" + request.getID(), forgetAt) != null) {
return "replay of " + request.getID();
}
return null;
}
/** Empties the replay cache; for tests. */
static void clearRememberedLogoutRequests() {
seenLogoutRequests.clear();
}
/**
* Verifies an HTTP-Redirect binding signature (SAML Bindings 3.4.4.1): the signature covers
* SAMLRequest/SAMLResponse, RelayState and SigAlg exactly as they appear, URL-encoded, in the
* query string.
*
* The signed values must be the ones the servlet container (and so pac4j) reads: only a GET is
* accepted, each signed parameter must appear exactly once under its literal name (no
* percent-encoded aliases, no duplicates, nothing extra in a request body), and its decoded value
* must equal the container's.
*/
static boolean hasValidRedirectSignature(HttpServletRequest request, X509Certificate idpCert) {
String query = request.getQueryString();
if (!"GET".equalsIgnoreCase(request.getMethod()) || query == null || idpCert == null) {
return false;
}
Map raw = new HashMap<>();
for (String pair : query.split("&")) {
if (pair.isEmpty()) {
continue;
}
int eq = pair.indexOf('=');
String rawName = eq < 0 ? pair : pair.substring(0, eq);
String name;
try {
name = URLDecoder.decode(rawName, StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
return false;
}
if (!REDIRECT_SIGNED_PARAMETERS.contains(name)) {
continue;
}
if (!name.equals(rawName)
|| raw.putIfAbsent(name, eq < 0 ? "" : pair.substring(eq + 1)) != null) {
log.error("SAML redirect query has an encoded or repeated " + name + " parameter");
return false;
}
}
if (raw.containsKey("SAMLRequest") == raw.containsKey("SAMLResponse")) {
return false;
}
for (Map.Entry param : raw.entrySet()) {
String[] values = request.getParameterValues(param.getKey());
String decoded;
try {
decoded = URLDecoder.decode(param.getValue(), StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
return false;
}
if (values == null || values.length != 1 || !decoded.equals(values[0])) {
log.error("SAML redirect parameter " + param.getKey() + " does not match the signed query");
return false;
}
}
String messageParam = raw.containsKey("SAMLRequest") ? "SAMLRequest" : "SAMLResponse";
String sigAlg = raw.get("SigAlg");
String signature = raw.get("Signature");
if (sigAlg == null || signature == null) {
return false;
}
String algorithm =
REDIRECT_SIGNATURE_ALGORITHMS.get(URLDecoder.decode(sigAlg, StandardCharsets.UTF_8));
if (algorithm == null) {
log.error("Unsupported SAML redirect signature algorithm: " + sigAlg);
return false;
}
StringBuilder signedContent = new StringBuilder();
signedContent.append(messageParam).append('=').append(raw.get(messageParam));
if (raw.containsKey("RelayState")) {
signedContent.append("&RelayState=").append(raw.get("RelayState"));
}
signedContent.append("&SigAlg=").append(sigAlg);
try {
Signature verifier = Signature.getInstance(algorithm);
verifier.initVerify(idpCert.getPublicKey());
verifier.update(signedContent.toString().getBytes(StandardCharsets.UTF_8));
return verifier.verify(
Base64.getDecoder().decode(URLDecoder.decode(signature, StandardCharsets.UTF_8)));
} catch (GeneralSecurityException | IllegalArgumentException e) {
log.debug("SAML redirect signature verification failed", e);
return false;
}
}
/**
* Returns this service provider's SAML metadata.
*
* @return The SP metadata XML
*/
public static String getServiceProviderMetadata() {
return getClient().getServiceProviderMetadataResolver().getMetadata();
}
static CallContext callContext(HttpServletRequest request, HttpServletResponse response) {
return new CallContext(new JEEContext(request, response), new JEESessionStore());
}
static Properties loadProperties() {
try (InputStream in = SamlSso.class.getClassLoader().getResourceAsStream(PROPERTIES_FILE)) {
if (in == null) {
throw new IllegalStateException(
"SAML properties file '" + PROPERTIES_FILE + "' not found in the classpath");
}
Properties props = new Properties();
props.load(in);
return props;
} catch (IOException e) {
throw new IllegalStateException(
"SAML properties file '" + PROPERTIES_FILE + "' cannot be loaded", e);
}
}
/**
* Builds and initialises the SAML client from java-saml style properties.
*
* @param props onelogin.saml.properties contents
* @return The initialised client and the settings SamlSso enforces itself
*/
static Sso build(Properties props) {
if (!flag(props, STRICT, true)) {
log.warn(STRICT + "=false is ignored: SAML messages are always validated strictly");
}
SAML2Configuration config = new SAML2Configuration();
config.setServiceProviderEntityId(required(props, SP_ENTITY_ID));
config.setIdentityProviderEntityId(required(props, IDP_ENTITY_ID));
config.setSingleSignOutServiceUrl(optional(props, SP_SLS_URL));
config.setResponseBindingType(optional(props, SP_ACS_BINDING, HTTP_POST_BINDING));
config.setAuthnRequestBindingType(optional(props, IDP_SSO_BINDING, HTTP_REDIRECT_BINDING));
String sloBinding = optional(props, IDP_SLO_BINDING, HTTP_REDIRECT_BINDING);
config.setSpLogoutRequestBindingType(sloBinding);
config.setSpLogoutResponseBindingType(sloBinding);
config.setNameIdPolicyFormat(optional(props, SP_NAMEID_FORMAT));
// Send NameQualifier / SPNameQualifier back in LogoutRequests, so IdPs that issue qualified
// NameIDs can match the logout to the login
config.setUseNameQualifier(true);
config.setAuthnRequestSigned(flag(props, AUTHN_REQUEST_SIGNED, false));
config.setSpLogoutRequestSigned(flag(props, LOGOUT_REQUEST_SIGNED, false));
config.setWantsResponsesSigned(flag(props, WANT_MESSAGES_SIGNED, false));
config.setWantsAssertionsSigned(flag(props, WANT_ASSERTIONS_SIGNED, false));
config.setSignMetadata(flag(props, SIGN_METADATA, false));
String authnContext = optional(props, REQUESTED_AUTHN_CONTEXT);
if (authnContext != null) {
config.setAuthnContextClassRefs(splitList(authnContext));
// pac4j drops RequestedAuthnContext entirely when no comparison is set
String comparison =
optional(
props,
REQUESTED_AUTHN_CONTEXT_COMPARISON,
optional(props, LEGACY_REQUESTED_AUTHN_CONTEXT_COMPARISON, "exact"));
config.setComparisonType(comparison);
}
String signatureAlgorithm = optional(props, SIGNATURE_ALGORITHM);
if (signatureAlgorithm != null) {
config.setSignatureAlgorithms(List.of(signatureAlgorithm));
}
configureKeystore(config, spCredentials(props));
config.setMetadataGenerator(
new SupportedBindingsMetadataGenerator(
optional(props, SP_SLS_URL) != null, optional(props, SP_CERT) != null));
config.setIdentityProviderMetadataResource(
new ByteArrayResource(
buildIdentityProviderMetadata(props).getBytes(StandardCharsets.UTF_8),
"identity provider metadata from " + PROPERTIES_FILE));
// pac4j's default store keeps nothing, so a Response's InResponseTo was never checked
SamlRequestStore requestStore = new SamlRequestStore(required(props, SP_ACS_URL));
config.setSamlMessageStoreFactory(requestStore);
SAML2Client saml = new AlternativeAuthnContextClient(config);
// The ACS URL is registered with the IdP as-is, so don't append ?client_name=...
saml.setCallbackUrlResolver(new NoParameterCallbackUrlResolver());
saml.setCallbackUrl(required(props, SP_ACS_URL));
saml.init();
X509Certificate idpCertificate;
try {
idpCertificate = parseCertificate(required(props, IDP_CERT));
} catch (GeneralSecurityException | IllegalArgumentException e) {
throw new IllegalStateException("Cannot load " + IDP_CERT + ": " + e.getMessage(), e);
}
// Null when no SLS URL is configured: no logout endpoint is advertised, and IdP
// LogoutRequests are refused
String singleLogoutUrl = optional(props, SP_SLS_URL);
return new Sso(
saml,
idpCertificate,
flag(props, WANT_MESSAGES_SIGNED, false),
singleLogoutUrl,
required(props, IDP_ENTITY_ID),
required(props, SP_ENTITY_ID),
requestStore);
}
/**
* pac4j requires every requested AuthnContextClassRef to appear in the response, but SAML treats
* the requested classes as alternatives (SAML Core 3.3.2.2.1). With "exact" comparison, at least
* one returned class must be one of those requested. With minimum / better / maximum the IdP
* judges relative strength, which an SP can't check in general (a stronger class may be one that
* wasn't listed), so the returned class is accepted, as java-saml did.
*/
private static final class AlternativeAuthnContextClient extends SAML2Client {
AlternativeAuthnContextClient(SAML2Configuration configuration) {
super(configuration);
}
@Override
protected void initSAMLResponseValidator(boolean forceReinit) {
if (authnResponseValidator == null || forceReinit) {
String comparison = configuration.getComparisonType();
authnResponseValidator =
new SAML2AuthnResponseValidator(
signatureTrustEngineProvider,
decrypter,
replayCache,
configuration,
findSessionLogoutHandler()) {
@Override
protected void validateAuthnContextClassRefs(
SAML2MessageContext context, List provided) {
List requested = configuration.getAuthnContextClassRefs();
if (requested.isEmpty()
|| (comparison != null && !"exact".equalsIgnoreCase(comparison))) {
return;
}
if (provided.stream().noneMatch(requested::contains)) {
throw new SAMLAuthnContextClassRefException(
"None of the requested authentication context classes "
+ requested
+ " is in the IdP's authentication statements "
+ provided);
}
}
/**
* pac4j binds a response to this browser's request only through the Response's
* InResponseTo, which isn't signed unless the whole message is. The bearer
* confirmation sits inside the signed assertion and must name the same request (SAML
* Profiles 4.1.4.3), so an assertion can't be moved into an envelope naming another
* request. Only SP-initiated login is supported, so it must name one. This runs on
* decrypted assertions too.
*/
@Override
protected boolean isValidBearerSubjectConfirmationData(
SubjectConfirmationData data, SAML2MessageContext context) {
if (!super.isValidBearerSubjectConfirmationData(data, context)) {
return false;
}
Object message = context.getMessageContext().getMessage();
String requestId =
message instanceof Response ? ((Response) message).getInResponseTo() : null;
if (requestId == null || !requestId.equals(data.getInResponseTo())) {
log.error(
"SAML response rejected: the assertion's bearer confirmation doesn't answer"
+ " the request named by the response");
return false;
}
return true;
}
};
authnResponseValidator.setAcceptedSkew(configuration.getAcceptedSkew());
}
}
}
/**
* pac4j advertises HTTP-POST, HTTP-POST-SimpleSign, HTTP-Redirect and SOAP single logout
* endpoints. SamlSso can only verify signatures for HTTP-POST (XML signature) and HTTP-Redirect
* (query-string signature), so advertise just those. With no SLS URL configured pac4j would
* advertise the ACS URL, which doesn't process logout messages, so advertise none. Without a
* configured SP certificate the key is temporary, so publish no KeyDescriptor. The descriptor is
* signed afterwards.
*/
private static final class SupportedBindingsMetadataGenerator
extends SAML2FileSystemMetadataGenerator {
private final boolean advertiseSingleLogout;
private final boolean publishKeys;
SupportedBindingsMetadataGenerator(boolean advertiseSingleLogout, boolean publishKeys) {
super(null);
this.advertiseSingleLogout = advertiseSingleLogout;
this.publishKeys = publishKeys;
}
@Override
protected SPSSODescriptor buildSPSSODescriptor() {
SPSSODescriptor descriptor = super.buildSPSSODescriptor();
descriptor
.getSingleLogoutServices()
.removeIf(
service ->
!advertiseSingleLogout
|| (!HTTP_POST_BINDING.equals(service.getBinding())
&& !HTTP_REDIRECT_BINDING.equals(service.getBinding())));
if (!publishKeys) {
// A temporary key changes on every restart, so an IdP must not sign-verify or encrypt to it
descriptor.getKeyDescriptors().clear();
}
return descriptor;
}
}
/**
* The SP's signing/decryption key and certificate. Like java-saml, both settings may be left out
* when the SP signs nothing (authnrequest_signed, logoutrequest_signed and sign_metadata all
* false): pac4j still needs a key pair, so a temporary one is generated in memory. Encrypted
* assertions then can't be used, since the key changes on every restart.
*/
private static KeyStore.PrivateKeyEntry spCredentials(Properties props) {
String certPem = optional(props, SP_CERT);
String keyPem = optional(props, SP_KEY);
try {
if (certPem != null && keyPem != null) {
return new KeyStore.PrivateKeyEntry(
parsePrivateKey(keyPem), new Certificate[] {parseCertificate(certPem)});
}
if (certPem != null || keyPem != null) {
throw new IllegalStateException("Set both " + SP_CERT + " and " + SP_KEY + ", or neither");
}
List signingSettings = new ArrayList<>();
for (String setting : List.of(AUTHN_REQUEST_SIGNED, LOGOUT_REQUEST_SIGNED, SIGN_METADATA)) {
if (flag(props, setting, false)) {
signingSettings.add(setting);
}
}
if (!signingSettings.isEmpty()) {
throw new IllegalStateException(
"Missing SAML setting "
+ SP_CERT
+ " / "
+ SP_KEY
+ ", required by "
+ String.join(", ", signingSettings));
}
log.info(
"No SP certificate configured: using a temporary in-memory key pair. SP signing and"
+ " encrypted assertions are unavailable.");
return temporaryCredentials();
} catch (GeneralSecurityException | IOException | OperatorCreationException e) {
throw new IllegalStateException(
"Cannot load " + SP_CERT + " / " + SP_KEY + ": " + e.getMessage(), e);
}
}
private static KeyStore.PrivateKeyEntry temporaryCredentials()
throws GeneralSecurityException, OperatorCreationException {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
KeyPair keys = generator.generateKeyPair();
X500Name name = new X500Name("CN=Security Shepherd SAML SP (temporary)");
Instant now = Instant.now();
X509Certificate cert =
new JcaX509CertificateConverter()
.getCertificate(
new JcaX509v3CertificateBuilder(
name,
BigInteger.valueOf(now.toEpochMilli()),
Date.from(now.minus(Duration.ofDays(1))),
Date.from(now.plus(Duration.ofDays(3650))),
name,
keys.getPublic())
.build(new JcaContentSignerBuilder("SHA256withRSA").build(keys.getPrivate())));
return new KeyStore.PrivateKeyEntry(keys.getPrivate(), new Certificate[] {cert});
}
/**
* Loads the SP's private key and certificate into an in-memory keystore, so pac4j neither reads
* nor generates a keystore file.
*/
private static void configureKeystore(
SAML2Configuration config, KeyStore.PrivateKeyEntry credentials) {
String password = UUID.randomUUID().toString();
byte[] keystore;
try {
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null, null);
ks.setKeyEntry(
SP_KEY_ALIAS,
credentials.getPrivateKey(),
password.toCharArray(),
credentials.getCertificateChain());
ByteArrayOutputStream out = new ByteArrayOutputStream();
ks.store(out, password.toCharArray());
keystore = out.toByteArray();
} catch (GeneralSecurityException | IOException e) {
throw new IllegalStateException("Cannot build the SP keystore: " + e.getMessage(), e);
}
config.getKeystore().setKeystoreResource(new ByteArrayResource(keystore, "SP keystore"));
config.getKeystore().setKeyStoreType("PKCS12");
config.getKeystore().setKeyStoreAlias(SP_KEY_ALIAS);
config.getKeystore().setKeystorePassword(password);
config.getKeystore().setPrivateKeyPassword(password);
config
.getKeystore()
.setKeystoreGenerator(
new KeystoreGenerator() {
@Override
public void generate() {}
@Override
public boolean shouldGenerate() {
return false;
}
@Override
public InputStream retrieve() {
return new ByteArrayInputStream(keystore);
}
});
}
/**
* pac4j reads the IdP from SAML metadata, while onelogin.saml.properties describes it with
* individual keys. Build the equivalent metadata document from those keys.
*/
static String buildIdentityProviderMetadata(Properties props) {
String cert = optional(props, IDP_CERT);
if (cert == null) {
String reason =
optional(props, IDP_CERT_FINGERPRINT) != null
? " (" + IDP_CERT_FINGERPRINT + " alone is not supported; set the full certificate)"
: "";
throw new IllegalStateException("Missing SAML setting " + IDP_CERT + reason);
}
StringBuilder xml = new StringBuilder();
xml.append("")
.append("")
.append("")
.append(pemBody(cert))
.append("");
String sloUrl = optional(props, IDP_SLO_URL);
if (sloUrl != null) {
xml.append("");
}
xml.append("")
.append("");
return xml.toString();
}
static X509Certificate parseCertificate(String pem) throws GeneralSecurityException {
byte[] der = Base64.getDecoder().decode(pemBody(pem));
return (X509Certificate)
CertificateFactory.getInstance("X.509").generateCertificate(new ByteArrayInputStream(der));
}
/** Accepts PKCS#8 ("BEGIN PRIVATE KEY") or PKCS#1 ("BEGIN RSA PRIVATE KEY") RSA keys. */
static PrivateKey parsePrivateKey(String pem) throws GeneralSecurityException, IOException {
byte[] der = Base64.getDecoder().decode(pemBody(pem));
KeyFactory rsa = KeyFactory.getInstance("RSA");
try {
return rsa.generatePrivate(new PKCS8EncodedKeySpec(der));
} catch (InvalidKeySpecException notPkcs8) {
PrivateKeyInfo pkcs8 =
new PrivateKeyInfo(
new AlgorithmIdentifier(PKCSObjectIdentifiers.rsaEncryption, DERNull.INSTANCE),
RSAPrivateKey.getInstance(der));
return rsa.generatePrivate(new PKCS8EncodedKeySpec(pkcs8.getEncoded()));
}
}
/** Strips PEM armour and whitespace, leaving the base64 body. */
static String pemBody(String pem) {
return pem.replaceAll("-----(BEGIN|END)[^-]*-----", "").replaceAll("\\s", "");
}
private static List splitList(String value) {
List items = new ArrayList<>();
for (String item : value.split(",")) {
if (!item.trim().isEmpty()) {
items.add(item.trim());
}
}
return items;
}
private static String required(Properties props, String key) {
String value = optional(props, key);
if (value == null) {
throw new IllegalStateException("Missing SAML setting " + key + " in " + PROPERTIES_FILE);
}
return value;
}
private static String optional(Properties props, String key) {
String value = props.getProperty(key);
return value == null || value.trim().isEmpty() ? null : value.trim();
}
private static String optional(Properties props, String key, String defaultValue) {
String value = optional(props, key);
return value == null ? defaultValue : value;
}
private static boolean flag(Properties props, String key, boolean defaultValue) {
String value = optional(props, key);
return value == null ? defaultValue : Boolean.parseBoolean(value);
}
}