/.well-known/openid-configuration URI is not absolute with @WebMvcTest

47 Views Asked by At

In a Spring Boot 3.2.2 application, I have a controller that will look at the Oauth2 token from the request, and behaves differently according to what it finds. The token is provided by Auth0 / Okta .

It works as expected, but I want to write a test for my controller :

@WebMvcTest(MembersController::class)
class MembersControllerTest(@Autowired val mockMvc: MockMvc) {
    
    @Test
    fun `should return 403 when name in token is unknown`() {
        val oauth2User = DefaultOAuth2User(
            setOf(),  // Authorities
            mapOf("sub" to "123", "name" to "someUnknownName", "email" to "[email protected]"),
            "sub"
        )

        mockMvc.post("/api/v1/members") {
            with(oauth2Login().oauth2User(oauth2User))
        }
        .andExpect {
            status { isUnauthorized() }
        }
    }

} 

When I run the test, I get this error

ERROR org.springframework.boot.SpringApplication -- Application run failed java.lang.IllegalArgumentException: URI is not absolute

In debug, I see the URL that has a problem is /.well-known/openid-configuration .

In the stacktrace, I see it's related to the okta starter

at org.springframework.web.client.RestTemplate.execute(RestTemplate.java:781)
    at org.springframework.web.client.RestTemplate.getForEntity(RestTemplate.java:422)
    at com.okta.spring.boot.oauth.env.OktaOAuth2PropertiesMappingEnvironmentPostProcessor.postProcessEnvironment(OktaOAuth2PropertiesMappingEnvironmentPostProcessor.java:122)
    at org.springframework.boot.env.EnvironmentPostProcessorApplicationListener.onApplicationEnvironmentPreparedEvent(EnvironmentPostProcessorApplicationListener.java:109)
    at org.springframework.boot.env.EnvironmentPostProcessorApplicationListener.onApplicationEvent(EnvironmentPostProcessorApplicationListener.java:94)

I was not expecting that this would happens with @WebMvcTest..

Is there a way to workaround this and use @WebMvcTest ? or do I need to test this at a higher level with @SpringBootTest and send real requests with real tokens ?

1

There are 1 best solutions below

4
ch4mp On

First, the oauth2Login() post-processor you are using is designed for OAuth2 clients with oauth2Login(), not for resource servers authorizing requests with a JWT decoder oauth2ResourceServer(rs -> rs.jwt(...)). You should probably be using the jwt() post-processor.

In a @WebMvcTest of an OAuth2 resource server with a JWT decoder, you should @MockBean JwtDecoder jwtDecoder; and use either:

  • SecurityMockMvcRequestPostProcessors.jwt() from spring-security-test
  • @WithJwt from spring-addons-oauth2-test

Disclaimer: same author for both (myself), and is the same for this Baeldung article covering the subject.

Edit

What I expose above is not enough with Okta Spring Boot starter and it seems pretty difficult to mock some of the beans it defines and which throw an exception if it can't access the authorization server (which is a problem for unit tests).

Alternatively to Okta starter, you could use this one I wrote. It is designed to work with any OpenID Provider: Okta, but also Auth0 (including the handling of audience parameter on authorization & token endpoints, and the non exactly standard RP-Initiated Logout), Keycloak, Amazon Cognito, and many others.

<?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 https://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.2.2</version>
        <relativePath /> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.c4-soft</groupId>
    <artifactId>okta-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>okta-demo</name>
    <description>Sample OAuth2 resource server</description>
    
    <properties>
        <java.version>17</java.version>
    </properties>
    
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>com.c4-soft.springaddons</groupId>
            <artifactId>spring-addons-starter-oidc</artifactId>
            <version>7.4.1</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.c4-soft.springaddons</groupId>
            <artifactId>spring-addons-starter-oidc-test</artifactId>
            <scope>test</scope>
            <version>7.4.1</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>
oktaDomain: change-me
authorizationServerId: change-me

com:
  c4-soft:
    springaddons:
      oidc:
        ops:
        # this is an array, you can trust as many issuers as you like, Okta or any other OpenID Provider
        - iss: https://${oktaDomain}/oauth2/${authorizationServerId}
          # username-claim is a JSON path, you can use expression like $['https://c4-soft.com/user']['name']
          username-claim: $.sub
          authorities:
          # you can map Spring authorities from as many claims as you like, with optional transformation (case and prefix)
          - path: $.groups
        resourceserver:
          # optionally allow anonymous access to random path-matchers
          permit-all:
          - "/public/**"
@Configuration
@EnableMethodSecurity
public class SecurityConfiguration {}
@RestController
public class MembersController {

    @PostMapping(path = "/api/v1/members", consumes = MediaType.APPLICATION_JSON_VALUE)
    @PreAuthorize("@allowedMemberCreators.isMember(authentication)")
    public ResponseEntity<Void> createMemeber(@RequestBody @Valid MemberCreationDto dto) {
        return ResponseEntity.accepted().build();
    }

    public static record MemberCreationDto(@NotEmpty String userName) {
    }

    static interface AllowedMemberCreators {
        boolean isMember(Authentication auth);
    }
}
@WebMvcTest(MembersController.class)
@AutoConfigureAddonsWebmvcResourceServerSecurity
@Import(SecurityConfiguration.class)
class MembersControllerTest {
    @MockBean(name = "allowedMemberCreators")
    AllowedMemberCreators allowedMemberCreators;

    @Autowired
    MockMvcSupport mockMvc;

    @Test
    @WithJwt("user.json")
    void givenUserIsAnAllowedMemeberCreator_whenCreateMember_thenOk() throws Exception {
        when(allowedMemberCreators.isMember(any())).thenReturn(true);
        mockMvc.post(new MembersController.MemberCreationDto("machin"), "/api/v1/members").andExpect(status().isAccepted());
    }

    @Test
    @WithAnonymousUser
    void givenRequestIsAnonlymous_whenCreateMember_thenUnauthorized() throws Exception {
        mockMvc.post(new MembersController.MemberCreationDto("machin"), "/api/v1/members").andExpect(status().isUnauthorized());
    }

    @Test
    @WithJwt("user.json")
    void givenUserIsNotAnAllowedMemeberCreator_whenCreateMember_thenForbidden() throws Exception {
        when(allowedMemberCreators.isMember(any())).thenReturn(false);
        mockMvc.post(new MembersController.MemberCreationDto("machin"), "/api/v1/members").andExpect(status().isForbidden());
    }

}

src/test/resources/user.json:

{
    "iss": "https://change-me/oauth2/change-me",
    "sub": "123",
    "name": "john",
    "email": "[email protected]"
}

remember to change the iss claim in your test resources when you update the iss in your application.yml