ch4mpy / spring-addons

Ease spring OAuth2 resource-servers configuration and testing
Apache License 2.0
552 stars 89 forks source link

Support for parametrized OAuth2 Authentications in `@ParameterizedTest` #122

Closed thekalinga closed 1 year ago

thekalinga commented 1 year ago

Is your feature request related to a problem? Please describe.

If an endpoint is accessible to users with multiple authorities, I have to create one test per role and put same annotations on each of them method except authority.

@Test
@WithMockJwtAuth(authorities = {"role1"})
void secureEndpoint_Scenario() {
....
}

@Test
@WithMockJwtAuth(authorities = {"role2"})
void secureEndpoint_Scenario() {
....
}

@Test
@WithMockJwtAuth(authorities = {"role3"})
void secureEndpoint_Scenario() {
....

Describe the solution you'd like

I would want to use junit's ParameterizedTest to pass this information

@ParameterizedTest
@ValueSource(strings = {"role1", "role2", "role3"})
@WithMockJwtAuth(authorities = <build custom Security Context with Jwt Auth referring to value source>)
void secureEndpoint_Scenario() {
....
}

Describe alternatives you've considered None as I am still a noob with this framework.

ch4mpy commented 1 year ago

I'm not sure that what you ask is possible: as per its name, @ParameterizedTest parameterizes the test itself, not its annotations. When the test instance is called with parameters, the security context has already been populated by @WithMockJwtAuth and I know no hook in WithSecurityContextFactory to read @ParameterizedTest value.

If you only test @Controller security (not a @Service or other @Component), what you can already do is use MockMvc request post-processor or WebTestClient mutator:

@ParameterizedTest
@ValueSource(strings = { "NICE", "VERY_NICE" })
void givenUserIsGrantedWithAnyNiceAuthority_whenGetRestricted_thenOk(String authority) throws Exception {
    api.perform(get("/restricted").with(SecurityMockMvcRequestPostProcessors.jwt().authorities(new SimpleGrantedAuthority(authority))))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.body").value("You are so nice!"));
}

I will investigate how complicated it would be to write an equivalent of @ValueSource for Authentication instances. Something like @JwtAuthenticationSource(auths = { @WithMockJwtAuth("role1"), @WithMockJwtAuth("role2") }) which would instantiate a JwtAuthenticationToken for each entry, populate the test security context with it and then pass it as an argument to the test function

ch4mpy commented 1 year ago

Here is what I came to so far:

@ParameterizedTest
@JwtAuthenticationSource({ @WithMockJwtAuth(authorities = "NICE"), @WithMockJwtAuth(authorities = "VERY_NICE") })
void givenUserIsGrantedWithAnyNiceAuthentication_whenGetRestricted_thenOk(@JwtAuth JwtAuthenticationToken auth) throws Exception {
    api.perform(get("/restricted"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.body").value("You are so nice!"));
}

Mind the @JwtAuthenticationSource decorating the test method and the @JwtAuth decorating the variable argument. Both are required:

@thekalinga what do you think of this solution?

This relies on the following:

@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@ArgumentsSource(JwtAuthenticationSource.JwtAuthenticationsProvider.class)
public @interface JwtAuthenticationSource {
    WithMockJwtAuth[] value() default {};

    static class JwtAuthenticationsProvider implements ArgumentsProvider, AnnotationConsumer<JwtAuthenticationSource> {
        private final WithMockJwtAuth.JwtAuthenticationTokenFactory authFactory = new WithMockJwtAuth.JwtAuthenticationTokenFactory();

        private Collection<JwtAuthenticationToken> arguments;

        @Override
        public void accept(JwtAuthenticationSource source) {
            // @formatter:off
            arguments =
                    Stream.of(source.value())
                    .map(authFactory::authentication)
                    .toList();
        }

        @Override
        public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
            return arguments.stream().map(Arguments::of);
        }

    }
}
public class JwtAuthenticationArgumentProcessor extends TypedArgumentConverter<JwtAuthenticationToken, JwtAuthenticationToken> {

    protected JwtAuthenticationArgumentProcessor() {
        super(JwtAuthenticationToken.class, JwtAuthenticationToken.class);
    }

    @Override
    protected JwtAuthenticationToken convert(JwtAuthenticationToken source) {
        SecurityContextHolder.getContext().setAuthentication(source);

        return source;
    }

}

@Target({ ElementType.ANNOTATION_TYPE, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@ConvertWith(JwtAuthenticationArgumentProcessor.class)
public @interface JwtAuth {
}
ch4mpy commented 1 year ago

Released in 6.1.12. See release notes for usage details.