0

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
user229044
  • 232,980
  • 40
  • 330
  • 338
  • Please don't edit "Solved" and answers into your question. Post the answer below and mark your question solved with the green checkmark beside the answer. – user229044 Apr 14 '23 at 19:16

2 Answers2

0

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)
ch4mp
  • 6,622
  • 6
  • 29
  • 49
  • Thank you ch4amp. I followed your instructions (converting the yaml config to application.properties) and I seem to be close. Two questions: (1) Is the resource server portion supposed to also go in application.properties or elsewhere? (2) I got the error [The Issuer "https://sts.windows.net/myTenantID" provided in the configuration metadata did not match the requested issuer "https://login.windows.net/myTenantID] so I replaced login wtih sts in both locations but now there is a redirect URI mismatch. Is it no longer: https://localhost/webSbAzureHello/login/oauth2/code/? – Bruno Genovese Apr 11 '23 at 15:24
  • Didn't you forget the trailing slash when copying from my answer? This string must be exactly the same as in `.well-known/openid-configuration` JWTs and `iss` claim. – ch4mp Apr 11 '23 at 15:36
  • The conf for resource server obove is for access tokens in the JWT format. Are you sure you configured your Azure AD to issue such tokens (have you tried to read the token payload in a tool like https://jwt.io)? – ch4mp Apr 11 '23 at 15:40
  • PS I just edited to remove the permitAll to "/". This is not necessary (just a UX choice: expose a lnding page to unauthenticated users). PermitAll to "/login/**" and"/oauth2/**" is required for login to work, don't skip it. – ch4mp Apr 11 '23 at 15:48
  • I found and added [spring.security.oauth2.client.registration.azure-ad-confidential-user.redirect-uri=https://localhost/webSbAzureHello/login/oauth2/code/]. Authentication is working although it is not recognizing me as having APPROLE_Admin when I do @Preauthorize even though I see myself in that role in Azure. Will continue putzing :) – Bruno Genovese Apr 11 '23 at 15:55
  • I probably should add that I have limited access on Azure. – Bruno Genovese Apr 11 '23 at 15:58
  • How did you check roles? – ch4mp Apr 11 '23 at 16:02
  • PS: I seem to remember having code "somewhere" that can tell me what roles are being received from Azure. I have a hunch that this method might be returning roles in a format different than Microsoft's "APPROLE_...". Seeing will help troubleshoot. – Bruno Genovese Apr 11 '23 at 16:07
  • You should read the token JSON payload (as already instructed) to see if Azure AD sends this role and how, and then read my tutorials (as already instructed too) to map this Azure roles to Spring authorities. – ch4mp Apr 11 '23 at 16:13
  • Confirmed. Authentication is succeeding but the only GrantedAuthority coming through from Azure to the Request (in the Principal or SecurityContext) is OIDC_USER. The Azure Roles are not coming through with this approach. I will add the code to examine Roles in the body above. – Bruno Genovese Apr 11 '23 at 16:27
  • You are displaying roles after it is mapped to authorities. Will you at last do as I wrote and submit the JWT to https://jwt.io to get its JSON payload? – ch4mp Apr 11 '23 at 17:16
  • I would love to. How do I place myself in the middle of the authentication/authorization process to capture the tokens? Although I am no expert I understand "conceptually" the various steps of OAUTH2. I've reread your tutorials 3 times and if you are explaining in those tutorials how to capture it, I must be missing some critical knowledge needed to understand it. Short of writing a custom Filter and AuthenticationProvider to tap into the token I am not quite sure how to proceed, and based on what you say it seems that getting the JWT tokens should be easier than that. – Bruno Genovese Apr 11 '23 at 19:23
  • You can use Postman OAuth 2.0 authentication, it exposes tokens after it fetches it. You can also look at token attributes in the `OAuth2User` which is the principal of the `OAuth2AuthenticationToken` you have in the security context of your Spring client. Plus, there is authorities mapping in each and every of my introduction tutorials, mapping from private claims (clients or resource servers, webmvc or webflux, and using "my" or official starters)... – ch4mp Apr 11 '23 at 21:34
  • I figured out what I was missing. I thought your tutorials directory structure was for source code, I did not realize that it was a menu to descriptive pages and assumed the main page was all there was to it. Will study in depth. Might take me a few days. – Bruno Genovese Apr 12 '23 at 12:44
0

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.