Search code examples
spring-bootspring-securityazure-active-directorysaml-2.0spring-saml

Integrate Spring Boot Security SAML with Azure AD Gallery app as multi tenant


I am Developing Java Spring Boot System and trying to Integrate with Azure non-gallery app using SAML Single Sign-On.

I found how to create Non-gallery applications, how to apply non-gallery app to Azure Gallery list etc. For example this link is about configuring SAML SSO: Configure SAML-based single sign-on So I understood Azure side configurations and procedures.

I am using Spring Security SAML Extensions. But I can't find the Spring Boot side configuration even I made a lot of research except official SAML Extension documentation which is XML based.

By the way, my main goal is adding our organization app to Azure gallery app list. Our app used by a multiple companies so if we add our organization app to Azure Gallery App list our customers can configure their Azure AD account as SSO integration.

My question is below:

  1. How to integrate Azure Non-Gallery App to Spring Boot app?
  2. How to handle multiple Azure AD tenants?

Is anybody help me with this?


EDIT: Currently I made a single tenant SSO login with Spring Boot and Azure AD non-gallery app. I configured IdP metadata using Azure AD Federation XML Metadata URL. You can see source code below:

@Configuration
@Order(1)
public static class ApiWebSecurityConfigurationAdapter extends WebSecurityConfigurerAdapter {
    @Value("${security.saml2.metadata-url}")
    private String IdPMetadataURL;

    @Value("${server.ssl.key-alias}")
    private String keyStoreAlias;

    @Value("${server.ssl.key-store-password}")
    private String keyStorePassword;

    @Value("${server.port}")
    String port;

    @Value("${server.ssl.key-store}")
    private String keyStoreFile;

    @Autowired
    private SAMLUserService samlUserService;

    @Override
    protected void configure(final HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/saml/**", "/", "/login", "/home", "/about").permitAll()
                .anyRequest().authenticated()
                .and()
                .apply(saml())
                .webSSOProfileConsumer(getWebSSOProfileConsumerImpl())
                .userDetailsService(samlUserService)
                .serviceProvider()
                .keyStore()
                .storeFilePath(this.keyStoreFile)
                .password(this.keyStorePassword)
                .keyname(this.keyStoreAlias)
                .keyPassword(this.keyStorePassword)
                .and()
                .protocol("https")
                .hostname(String.format("%s:%s", "localhost", this.port))
                .basePath("/")
                .and()
                .identityProvider()
                .metadataFilePath(IdPMetadataURL)
                .and();
    }

    public WebSSOProfileConsumerImpl getWebSSOProfileConsumerImpl(){
        WebSSOProfileConsumerImpl consumer = new WebSSOProfileConsumerImpl();
        consumer.setMaxAuthenticationAge(26000000); //300 days
        return consumer;
    }
}

From now I need to generate IdP Metadata XML instead of using IdP metadata URL. Using fields such as:

  • IdP Entity ID
  • IdP SSO URL
  • IdP Public certificate

The process is I am thinking about is:

  1. Our customers register their Azure AD IdP fields above
  2. My Spring Boot system automatically generate IdP Metadata XML
  3. Then customer's Azure AD SSO can integrated to our system

If is there anything wrong please teach me out.


Solution

  • finally I did my solution for dynamic IDP. I used spring-boot-security-saml this simplified project.Thank you for ulisesbocchio this guy who implemented it. Also big thanks to ledjon who shared me with his experience.

    Here is how I'm configuring the saml part of the http security

    http.apply(saml)
        .serviceProvider()
            .metadataGenerator()
            .entityId(LocalSamlConfig.LOCAL_SAML_ENTITY_ID)
            .entityBaseURL(entityBaseUrl)
            .includeDiscoveryExtension(false)
        .and()
            .sso()
            .successHandler(new SendToSuccessUrlPostAuthSuccessHandler(canvasAuthService))
        .and()
            .metadataManager(new LocalMetadataManagerAdapter(samlAuthProviderService))
            .extendedMetadata()
            .idpDiscoveryEnabled(false)
        .and()
            .keyManager()
            .privateKeyDERLocation("classpath:/saml/localhost.key.der")
            .publicKeyPEMLocation("classpath:/saml/localhost.cert")
        .and()
            .http()
                .authorizeRequests()
                .requestMatchers(saml.endpointsMatcher())
                .permitAll();
    

    The important part here is the .metadataManager(new LocalMetadataManagerAdapter(samlAuthProviderService)) which is what we're trying to solve for here. The object samlAuthProviderService is a Bean-managed object and it contains the logic to actually retrieve the metadata from the database, so there's not a lot that is specially about it. But here is what my LocalMetadataManagerAdapter roughly looks like:

    @Slf4j
    public class LocalMetadataManagerAdapter extends CachingMetadataManager {
    
        private final SamlAuthProviderService samlAuthProviderService;
    
        public LocalMetadataManagerAdapter(SamlAuthProviderService samlAuthProviderService) throws MetadataProviderException {
            super(null);
            this.samlAuthProviderService = samlAuthProviderService;
        }
    
        @Override
        public boolean isRefreshRequired() {
            return false;
        }
    
        @Override
        public EntityDescriptor getEntityDescriptor(String entityID) throws MetadataProviderException {
            // we don't really want to use our default at all, so we're going to throw an error
            // this string value is defined in the "classpath:/saml/idp-metadata.xml" file:
            // which is then referenced in application.properties as saml.sso.idp.metadata-location=classpath:/saml/idp-metadata.xml
            if("defaultidpmetadata".equals(entityID)) {
                throw exNotFound("Unable to process requests for default idp. Please select idp with ?idp=x parameter.");
            }
    
            EntityDescriptor staticEntity = super.getEntityDescriptor(entityID);
    
            if(staticEntity != null)
                return staticEntity;
    
            // we need to inject one, and try again:
            injectProviderMetadata(entityID);
    
            return super.getEntityDescriptor(entityID);
        }
    
        @SneakyThrows
        private void injectProviderMetadata(String entityID) {
            String xml =
                samlAuthProviderService.getMetadataForConnection(entityID)
                    .orElseThrow(() -> exRuntime("Unable to find metadata for entity: " + entityID));
    
            addMetadataProvider(new LocalMetadataProvider(entityID, xml));
    
            // this will force a refresh/re-wrap of the new entity
            super.refreshMetadata();
        }
    }
    

    The important part here is the override of getEntityDescriptor() which will get called to get the metadata object at runtime. I'm also disabling refreshes by overriding isRefreshRequired() to return false. You can determine if this makes sense for your use case or not.

    The referenced LocalMetadataProvider is just a wrapper class to store/return the xml string when required:

    public class LocalMetadataProvider extends AbstractReloadingMetadataProvider {
    
        private final String Id;
        private final String xmlData;
    
        public LocalMetadataProvider(String id, String xmlData) {
            this.Id = id;
            this.xmlData = xmlData;
    
            setParserPool(LocalBeanUtil.getBeanOrThrow(ParserPool.class));
        }
    
        @Override
        protected String getMetadataIdentifier() {
            return this.Id;
        }
    
        @Override
        protected byte[] fetchMetadata() throws MetadataProviderException {
            return xmlData.getBytes();
        }
    }
    

    Finally we can pass idp metadata entityID as a parameter. And retrieve entityID metadata from DB etc: /saml/login?idp=X where X is the entityID value we want to get passed to getEntityDescriptor().