Is Azure AD Spring Boot authentication compatible with Tomcat 10?

1.7k Views Asked by At

I realize that the most likely explanation is that I must have made an error, but after extensive research it seems prudent to ask: Is Azure AD authentication for Spring Boot webapps still compatible with WAR deployment in Tomcat 10? Or perhaps "not yet"?

My trivial Hello World app used to work in Tomcat 9, this is the code after migrating it to Spring Boot 3 / Spring 6 / Spring Security 6 / Tomcat 10.

This error is thrown during WAR deploy to Tomcat 10:

Cannot cast ch.qos.logback.classic.servlet.LogbackServletContainerInitializer to jakarta.servlet.ServletContainerInitializer

In case it is relevant, Tomcat 10 is configured for log4j 2.20.0 logging with a log4j file similar to the Tomcat 9 one, and includes log4j-api, log4j-appserver and log4j-core JARs.

The most relevant information I found is: https://learn.microsoft.com/en-us/azure/developer/java/spring-framework/spring-boot-starter-for-azure-active-directory-developer-guide?tabs=SpringCloudAzure5x but I fail to see anything there that would indicate what is wrong with my setup.

Application:

@EnableWebSecurity
@EnableMethodSecurity
@SpringBootApplication
public class WebSbAzureHelloApplication extends SpringBootServletInitializer {

  @Override
  protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
    return application.sources(WebSbAzureHelloApplication.class);
  }
  
    public static void main(String[] args) {
        SpringApplication.run(WebSbAzureHelloApplication.class, args);
        System.out.println("Hello World");
    }

}

Controller:

@RestController
public class HelloController {
   @GetMapping("Admin")
   @ResponseBody
   @PreAuthorize("hasAuthority('APPROLE_Admin')")
   public String Admin() {
       return "Admin message";
   }
}

POM:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.0.4</version>
    <relativePath/>
  </parent>

  <groupId>net.cndc</groupId>
  <artifactId>webSbAzureHello</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <name>webSbAzureHello</name>
  <description>SpringBoot test for Azure AD authentication</description>
  <packaging>war</packaging>

  <properties>
    <java.version>17</java.version>
    <start-class>net.cndc.webSbAzureHello.WebSbAzureHelloApplication</start-class>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-oauth2-client</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>com.azure.spring</groupId>
      <artifactId>spring-cloud-azure-starter-active-directory</artifactId>
      <version>5.0.0</version>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

application.properties:

azure.activedirectory.tenant-id= my tenant ID from Azure here
azure.activedirectory.client-id= my client ID from Azure here
azure.activedirectory.client-secret= my client secret from Azure here
azure.activedirectory.redirect-uri-template=https://localhost/webSbAzureHello/login/oauth2/code/
spring.main.allow-bean-definition-overriding=true
2

There are 2 best solutions below

14
ch4mp On

Configuring a Spring Boot app without spring-cloud-azure-starter-active-directory is actually quite simple.

OAuth2 Client

For the server-side rendered UI with login and logout, use just the spring-boot-starter-oauth2-client you already depend on. Requests from the browser to this client will be secured with sessions (not access tokens).

The following properties should be enough:

azure-ad-tenant-id: change-me
azure-ad-client-id: change-me
azure-ad-client-secret: change-me

spring:
  security:
    oauth2:
      client:
        provider:
          azure-ad:
            issuer-uri: https://login.windows.net/${azure-ad-tenant-id}/
        registration:
          azure-ad-confidential-user:
            authorization-grant-type: authorization_code
            client-name: Azure AD
            client-id: ${azure-ad-client-id}
            client-secret: ${azure-ad-client-secret}
            provider: azure-ad
            scope: openid,profile,email,offline_access

Azure AD implements strictly the RP-Initiated Logout and exposes an end_session_endpoint in its OpenID configuration (at least, it does on my test tenant). This means you can use OidcClientInitiatedLogoutSuccessHandler to invalidate user session on Azure AD too when terminating his session on your client, but for that, you'll have to provide with a complete client security filter-chain configuration:

@Bean
SecurityFilterChain clientSecurityFilterChain(
        HttpSecurity http,
        ClientRegistrationRepository clientRegistrationRepository) throws Exception {
    http.oauth2Login();
    http.logout(logout -> {
        logout.logoutSuccessHandler(new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository));
    });
    http.authorizeHttpRequests(ex -> ex
            .requestMatchers("/login/**", "/oauth2/**").permitAll()
            .anyRequest().authenticated());
    return http.build();
}

OAuth2 Resource Server

Applications secured with OAuth2 access tokens are resource servers. The dependency to use is spring-boot-starter-oauth2-resource-server.

The following properties should be enough to configure a single tenant resource server with authorities mapped from scope claim:

azure-ad-tenant-id: change-me
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://login.windows.net/${azure-ad-tenant-id}/

Stateless resource server, Roles mapping, multi-tenancy,...

For more advanced configuration, please refer to my tutorials. It cover subjects like

  • mapping authorities from private claims instead of scope one
  • using more than one OpenID Provider as trusted token issuer
  • CORS configuration
  • sessions and CSRF protection configuration
  • configuring an application as both an OAuth2 client and an OAuth2 resource server (publicly exposes both a REST API and a Thymeleaf UI to query it)
0
Bruno Genovese On

SOLUTION:

Turns out that my original approach was mostly correct, but Microsoft changed some things and the changes were not easy to find.

First was a likely bug in their logback handling. This morning doing one of many clean builds per day I noticed it was downloading updates to the dependency code. After that the the logback error went away on its own.

Then there was a change in application.properties, where you no longer use azure.activedirectory.*** entries. Instead you use:

spring.cloud.azure.active-directory.enabled=true
spring.cloud.azure.active-directory.profile.tenant-id=azureValue
spring.cloud.azure.active-directory.credential.client-id=azureValue
spring.cloud.azure.active-directory.credential.client-secret=azureValue
spring.main.allow-bean-definition-overriding=true

Also, while in the previous version you did not need a SpringConfig class, you do now. For example:

@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

  @Bean
  SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.apply(AadWebApplicationHttpSecurityConfigurer.aadWebApplication())
        .and()
        .authorizeHttpRequests().anyRequest().authenticated();
    return http.build();
  }
}

ch4mp's approach is quite valid, but this is simpler and returns data (including Azure roles) in an easier to handle manner.

I hope the solution helps others in a similar situation.