Skip to content

Commit

Permalink
feat: customisation tag <md:Info> metadata saml
Browse files Browse the repository at this point in the history
  • Loading branch information
nathancailbourdin committed Nov 15, 2024
1 parent 649b970 commit 35df479
Show file tree
Hide file tree
Showing 5 changed files with 346 additions and 10 deletions.
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ And has a number of custom enhancements :
- Principal release with dynamic API call (externalid), with exception thrown when principal attribute is not found
- Fix for concurrent access to service index map (see [this](https://groups.google.com/a/apereo.org/g/cas-user/c/pI9l9aT1gtU))
- Soft/Hard timeout expiration policy **per service**
- Custom <md:Info> SAML idp metadata generation

Current CAS Base version : **7.1.1**

Expand Down Expand Up @@ -72,11 +73,13 @@ All the important parts of the project are listed below:
│ │ ├── oidc
│ │ │ └── token
│ │ │ └── OidcIdTokenGeneratorService.java
│ │ ── services
│ │ ── services
│ │ │ ├── HardAndSoftTimeoutRegisteredServiceTicketGrantingTicketExpirationPolicy.java
│ │ │ ├── PrincipalExternalIdRegisteredServiceUsernameProvider.java
│ │ │ ├── PrincipalExternalIdRegisteredOidcServiceUsernameProvider.java
│ │ │ └── TimeBasedRegisteredServiceAccessStrategy.java
│ │ ├── support/saml/idp/metadata/generator
│ │ │ └── TimeBasedRegisteredServiceAccessStrategy.java
│ │ └── web/flow
│ │ ├── error
│ │ │ └── DefaultDelegatedClientAuthenticationFailureEvaluator.java
Expand All @@ -88,13 +91,21 @@ All the important parts of the project are listed below:
| ├── META-INF
| | └── spring
| | └── org.springframework.boot.autoconfigure.AutoConfiguration.imports
| ├── services-test
| | └── ...
| ├── templates
| | ── delegated-authn
| | ── delegated-authn
| | | └── casDelegatedAuthnStopWebflow.html
| | └── interrupt
| | └── casInterruptView.html
| | ├── interrupt
| | | └── casInterruptView.html
| | └── login
| | └── casGenericSuccessView.html
| ├── application-test.yml
| └── application.yml
| ├── application.yml
| ├── custom_messages_fr.properties
| ├── custom_messages.properties
| ├── log4j2.xml
| └── template-idp-metadata.vm.yml
|
├── build.gradle
├── README.md
Expand Down
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -370,4 +370,8 @@ dependencies {
compileOnly "org.apereo.cas:cas-server-support-oauth-services"
// Custom OIDC + externalid
compileOnly "org.apereo.cas:cas-server-core-services-authentication"
// Custom SAML metadata generation
compileOnly "org.apereo.cas:cas-server-support-saml-core-api"
compileOnly "org.apereo.cas:cas-server-support-saml-idp-core"

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
package org.apereo.cas.support.saml.idp.metadata.generator;

import org.apereo.cas.support.saml.SamlUtils;
import org.apereo.cas.support.saml.services.SamlRegisteredService;
import org.apereo.cas.support.saml.services.idp.metadata.SamlIdPMetadataDocument;
import org.apereo.cas.util.spring.SpringExpressionLanguageValueResolver;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.experimental.SuperBuilder;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.velocity.VelocityContext;
import org.jooq.lambda.Unchecked;
import org.opensaml.saml.saml2.metadata.EntityDescriptor;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import java.io.Serial;
import java.io.Serializable;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.concurrent.Executors;

/**
* A metadata generator based on a predefined template.
*
* @author Misagh Moayyed
* @since 5.0.0
*/
@Slf4j
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public abstract class BaseSamlIdPMetadataGenerator implements SamlIdPMetadataGenerator {

protected final SamlIdPMetadataGeneratorConfigurationContext configurationContext;

@Override
public SamlIdPMetadataDocument generate(final Optional<SamlRegisteredService> registeredService) throws Throwable {
val idp = configurationContext.getCasProperties().getAuthn().getSamlIdp();
LOGGER.debug("Preparing to generate metadata for entity id [{}]", idp.getCore().getEntityId());
val samlIdPMetadataLocator = configurationContext.getSamlIdPMetadataLocator();
if (!samlIdPMetadataLocator.exists(registeredService)) {
val owner = getAppliesToFor(registeredService);
LOGGER.trace("Metadata does not exist for [{}]", owner);
if (shouldGenerateMetadata(registeredService)) {
LOGGER.trace("Creating metadata artifacts for [{}]...", owner);

val doc = newSamlIdPMetadataDocument();
try (val executor = Executors.newVirtualThreadPerTaskExecutor()) {
val signingCertTask = Unchecked.callable(() -> {
LOGGER.info("Creating self-signed certificate for signing...");
return buildSelfSignedSigningCert(registeredService);
});
val encryptionCertTask = Unchecked.callable(() -> {
LOGGER.info("Creating self-signed certificate for encryption...");
return buildSelfSignedEncryptionCert(registeredService);
});

val signingFuture = executor.submit(signingCertTask);
val encryptionFuture = executor.submit(encryptionCertTask);
val signing = signingFuture.get();
val encryption = encryptionFuture.get();
LOGGER.info("Creating SAML2 metadata for identity provider...");
val metadata = buildMetadataGeneratorParameters(signing, encryption, registeredService);

doc.setEncryptionCertificate(encryption.getKey());
doc.setEncryptionKey(encryption.getValue());
doc.setSigningCertificate(signing.getKey());
doc.setSigningKey(signing.getValue());
doc.setMetadata(metadata);
}
return finalizeMetadataDocument(doc, registeredService);
}
LOGGER.debug("Skipping metadata generation process for [{}]", owner);
}

return samlIdPMetadataLocator.fetch(registeredService);
}

protected boolean shouldGenerateMetadata(final Optional<SamlRegisteredService> registeredService) {
val samlIdPMetadataLocator = configurationContext.getSamlIdPMetadataLocator();
return samlIdPMetadataLocator.shouldGenerateMetadataFor(registeredService);
}

/**
* Build self signed encryption cert.
*
* @param registeredService registered service
* @return the pair
* @throws Throwable the throwable
*/
public abstract Pair<String, String> buildSelfSignedEncryptionCert(Optional<SamlRegisteredService> registeredService) throws Throwable;

/**
* Build self signed signing cert.
*
* @param registeredService registered service
* @return the pair
* @throws Throwable the throwable
*/
public abstract Pair<String, String> buildSelfSignedSigningCert(Optional<SamlRegisteredService> registeredService) throws Throwable;

/**
* New saml id p metadata document.
*
* @return the saml id p metadata document
*/
protected SamlIdPMetadataDocument newSamlIdPMetadataDocument() {
return new SamlIdPMetadataDocument();
}

/**
* Finalize metadata document saml idp metadata document.
*
* @param doc the doc
* @param registeredService the registered service
* @return the saml id p metadata document
* @throws Exception the exception
*/
protected SamlIdPMetadataDocument finalizeMetadataDocument(final SamlIdPMetadataDocument doc,
final Optional<SamlRegisteredService> registeredService) throws Throwable {
return doc;
}

/**
* Write metadata.
*
* @param metadata the metadata
* @param registeredService registered service
* @return the string
* @throws Throwable the throwable
*/
protected String writeMetadata(final String metadata, final Optional<SamlRegisteredService> registeredService) throws Throwable {
return metadata;
}

protected Pair<String, String> generateCertificateAndKey() throws Exception {
try (val certWriter = new StringWriter(); val keyWriter = new StringWriter()) {
configurationContext.getSamlIdPCertificateAndKeyWriter().writeCertificateAndKey(keyWriter, certWriter);
val encryptionKey = configurationContext.getMetadataCipherExecutor().encode(keyWriter.toString());
return Pair.of(certWriter.toString(), encryptionKey);
}
}

@SuperBuilder
@Getter
public static class IdPMetadataTemplateContext implements Serializable {
@Serial
private static final long serialVersionUID = -8084689071916142718L;

private final String entityId;

private final String scope;

private final String endpointUrl;

private final String errorUrl;

private final String encryptionCertificate;

private final String signingCertificate;

private final boolean ssoServicePostBindingEnabled;

private final boolean ssoServicePostSimpleSignBindingEnabled;

private final boolean ssoServiceRedirectBindingEnabled;

private final boolean ssoServiceSoapBindingEnabled;

private final boolean sloServicePostBindingEnabled;

private final boolean sloServiceRedirectBindingEnabled;

// Custom mdui:UIInfo
private final String displayName;
private final String description;
private final String logo;

}

private String getIdPEndpointUrl() {
val resolver = SpringExpressionLanguageValueResolver.getInstance();
return resolver.resolve(configurationContext.getCasProperties().getServer().getPrefix().concat("/idp"));
}

/**
* Build metadata generator parameters by passing the encryption,
* signing and back-channel certs to the parameter generator.
*
* @param signing the signing
* @param encryption the encryption
* @param registeredService registered service
* @return the metadata
*/
private String buildMetadataGeneratorParameters(final Pair<String, String> signing,
final Pair<String, String> encryption,
final Optional<SamlRegisteredService> registeredService) throws Throwable {

val signingCert = SamlIdPMetadataGenerator.cleanCertificate(signing.getKey());
val encryptionCert = SamlIdPMetadataGenerator.cleanCertificate(encryption.getKey());

val idp = configurationContext.getCasProperties().getAuthn().getSamlIdp();
try (val writer = new StringWriter()) {
val resolver = SpringExpressionLanguageValueResolver.getInstance();
val entityId = resolver.resolve(idp.getCore().getEntityId());
val scope = resolver.resolve(configurationContext.getCasProperties().getServer().getScope());

// Custom parameters in context
val displayName = configurationContext.getCasProperties().getCustom().getProperties().get("saml.metadata.display-name");
val description = configurationContext.getCasProperties().getCustom().getProperties().get("saml.metadata.description");
val logo = configurationContext.getCasProperties().getCustom().getProperties().get("saml.metadata.logo");

val metadataCore = idp.getMetadata().getCore();
val context = IdPMetadataTemplateContext.builder()
.encryptionCertificate(encryptionCert)
.signingCertificate(signingCert)
.entityId(entityId)
.scope(scope)
.displayName(displayName)
.description(description)
.logo(logo)
.endpointUrl(getIdPEndpointUrl())
.ssoServicePostBindingEnabled(metadataCore.isSsoServicePostBindingEnabled())
.ssoServicePostSimpleSignBindingEnabled(metadataCore.isSsoServicePostSimpleSignBindingEnabled())
.ssoServiceRedirectBindingEnabled(metadataCore.isSsoServiceRedirectBindingEnabled())
.ssoServiceSoapBindingEnabled(metadataCore.isSsoServiceSoapBindingEnabled())
.sloServicePostBindingEnabled(metadataCore.isSloServicePostBindingEnabled())
.sloServiceRedirectBindingEnabled(metadataCore.isSloServiceRedirectBindingEnabled())
.errorUrl(StringUtils.appendIfMissing(getIdPEndpointUrl(), "/error"))
.build();

val template = configurationContext.getVelocityEngine()
.getTemplate("/template-idp-metadata.vm", StandardCharsets.UTF_8.name());

val velocityContext = new VelocityContext();
velocityContext.put("context", context);
template.merge(velocityContext, writer);
var metadata = writer.toString();

val customizers = configurationContext.getApplicationContext()
.getBeansOfType(SamlIdPMetadataCustomizer.class).values();
if (!customizers.isEmpty()) {
val openSamlConfigBean = configurationContext.getOpenSamlConfigBean();
val entityDescriptor = SamlUtils.transformSamlObject(openSamlConfigBean, metadata, EntityDescriptor.class);
customizers.stream()
.sorted(AnnotationAwareOrderComparator.INSTANCE)
.forEach(customizer -> customizer.customize(entityDescriptor, registeredService));
metadata = SamlUtils.transformSamlObject(openSamlConfigBean, entityDescriptor).toString();
}
writeMetadata(metadata, registeredService);
return metadata;
}
}

}
64 changes: 64 additions & 0 deletions src/main/resources/template-idp-metadata.vm
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:shibmd="urn:mace:shibboleth:metadata:1.0" xmlns:xml="http://www.w3.org/XML/1998/namespace" xmlns:mdui="urn:oasis:names:tc:SAML:metadata:ui" entityID="$context.EntityId">
<IDPSSODescriptor errorURL="$context.ErrorUrl" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol urn:oasis:names:tc:SAML:1.1:protocol urn:mace:shibboleth:1.0">
<Extensions>
<shibmd:Scope regexp="false">$context.Scope</shibmd:Scope>

<mdui:UIInfo xmlns:mdui="urn:oasis:names:tc:SAML:metadata:ui">
<mdui:DisplayName xml:lang="en">${context.DisplayName}</mdui:DisplayName>
<mdui:Description xml:lang="en">${context.Description}</mdui:Description>
<mdui:Logo height="80" width="80">${context.Logo}</mdui:Logo>
</mdui:UIInfo>

</Extensions>
<KeyDescriptor use="signing">
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>$context.SigningCertificate</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>
<KeyDescriptor use="encryption">
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>$context.EncryptionCertificate</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>

#if( $context.SloServicePostBindingEnabled )
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="$context.EndpointUrl/profile/SAML2/POST/SLO"/>
#end

#if( $context.SloServiceRedirectBindingEnabled )
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="$context.EndpointUrl/profile/SAML2/Redirect/SLO" />
#end

<NameIDFormat>urn:mace:shibboleth:1.0:nameIdentifier</NameIDFormat>
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>

#if( $context.SsoServicePostBindingEnabled )
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="$context.EndpointUrl/profile/SAML2/POST/SSO"/>
#end

#if( $context.SsoServicePostSimpleSignBindingEnabled )
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST-SimpleSign"
Location="$context.EndpointUrl/profile/SAML2/POST-SimpleSign/SSO"/>
#end

#if( $context.SsoServiceRedirectBindingEnabled )
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="$context.EndpointUrl/profile/SAML2/Redirect/SSO"/>
#end

#if( $context.SsoServiceSoapBindingEnabled )
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
Location="$context.EndpointUrl/profile/SAML2/SOAP/ECP"/>
#end

</IDPSSODescriptor>

</EntityDescriptor>
Loading

0 comments on commit 35df479

Please sign in to comment.