diff --git a/oauth2-test/README.MD b/oauth2-test/README.MD new file mode 100644 index 00000000000..175073c083e --- /dev/null +++ b/oauth2-test/README.MD @@ -0,0 +1,166 @@ += Unit-testing OAuth2 secured `@Component`s +The aim here is offering the ability to configure unit tests security-context with OAuth2 authentication. + +Three different authentication implementations are supported: JWT, access-token and OidcId token. + +Each of those authentication type can be setup three different ways: + * request post-processors. Concise and easy to aggregate in functions of your own for most common scenari, but limited to servlets `@Controller`s testing + * annotations. Familiar for those used to `@WithMockUser` and allow testing any kind of `@Component` (`@Controller`s of course but also services) + * mutators (implement `WebTestClientConfigurer` and `MockServerConfigurer`). Provide with a flow API for reactive apps + +== Authorities related processing +Quite a few tricks are applied to merge authorities, roles and scopes: + * authorities with "SCOPE_" prefix are added to scope claim + * scopes from scope claim (with either "scope" or "scp" name) are added to authorities after being pre-pended with "SCOPE_" + * scope claim value can either be a single string with roles separated by spaces or a `Collection` implementation + * roles are pre-pended with "ROLE_" before being added to authorities + +== Request post-processors +MockMvc `.with(...)` method is an extension point for further request processing. +This feature is used here to setup SecurityContext with highly configurable authentications. + +=== `JwtRequestPostProcessor` +`org.springframework.security.test.oauth2.request.OAuth2MockMvcRequestPostProcessors.jwt()` +returns a post-processor which populates SecurityContext with a `JwtAuthenticationToken` you can tune +setting roles, scopes, authorities, authentication name, etc. + +Sample usage: +``` java +@RunWith(SpringRunner.class) +@WebMvcTest(OAuth2ResourceServerController.class) +public class OAuth2ResourceServerControllerTest { + + @Autowired + MockMvc mockMvc; + + @MockBean + JwtDecoder jwtDecoder; + + @Test + public void testRequestPostProcessor() throws Exception { + // No post-processor => no authorization => unauthorized + mockMvc.perform(get("/message")).andDo(print()).andExpect(status().isUnauthorized()); + + //Run with default Authentication: subject is "user" and sole authority is "USER" role + mockMvc.perform(get("/").with(jwt())) + .andExpect(status().isOk()) + .andExpect(content().string(is("Hello, user!"))); + + //Customize Authentication name + mockMvc.perform(get("/").with(jwt().name("ch4mpy"))) + .andExpect(status().isOk()) + .andExpect(content().string(is("Hello, ch4mpy!"))); + + //Customize roles, scopes and authorities + mockMvc.perform(get("/message").with(jwt().scope("message:read"))) + .andExpect(status().isOk()) + .andExpect(content().string(is("secret message"))); + + //Add custom claims + mockMvc.perform(get("/message") + .with(jwt() + .name("ch4mpy") + .authority("SCOPE_message:read") + .claim("iat", Instant.parse("2019-03-28T16:36:00Z")))) + .andExpect(status().isOk()) + .andExpect(content().string(is("secret message"))); + } +} +``` + + === `AccessTokenRequestPostProcessor` +`org.springframework.security.test.oauth2.request.OAuth2MockMvcRequestPostProcessors.accessToken()` +returns a post-processor which populates SecurityContext with a `OAuth2IntrospectionAuthenticationToken` you can tune +at wish. + +Usage is pretty similar to `JwtRequestPostProcessor`. + +=== `OidcIdTokenRequestPostProcessor` +Just as `AccessTokenRequestPostProcessor` or `JwtRequestPostProcessor`, call +`org.springframework.security.test.oauth2.request.OAuth2MockMvcRequestPostProcessors.oidcIdToken()` to configure an `OAuth2LoginAuthenticationToken`. + +== Annotations +With `@WithMockUser` as model, allow OAuth2 applications components unit testing + +=== `@WithMockJwt` +Offers the same SecurityContext configuration options as `JwtRequestPostProcessor`. + +Complete sample usage on a service: +``` java +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = MessageServiceTest.SecurityConfiguration.class) +public class MessageServiceTest { + + @Autowired + private MessageService messageService; + + @Test(expected = AuthenticationCredentialsNotFoundException.class) + public void greetWitoutMockJwt() { + messageService.getGreeting(); + } + + @Test + @WithMockJwt(name = "ch4mpy") + public void greetWithMockJwt() { + assertThat(messageService.getGreeting()).isEqualTo("Hello, ch4mpy!"); + } + + @Test(expected = AccessDeniedException.class) + @WithMockJwt + public void secretWithoutMessageReadScope() { + assertThat(messageService.getSecret()).isEqualTo("Secret message"); + } + + @Test + @WithMockJwt("SCOPE_message:read") // same as: + // @WithMockJwt(scopes = "message:read") + // @WithMockJwt(claims = @Attribute(name = "scope", value = "message:read", parser = StringSetParser.class)) + public void secretWithScopeMessageReadAuthority() { + assertThat(messageService.getSecret()).isEqualTo("Secret message"); + } + + interface MessageService { + + @PreAuthorize("authenticated") + String getGreeting(); + + @PreAuthorize("hasAuthority('SCOPE_message:read')") + String getSecret(); + } + + @Component + static final class MessageServiceImpl implements MessageService { + + @Override + public String getGreeting() { + return String.format("Hello, %s!", SecurityContextHolder.getContext().getAuthentication().getName()); + } + + @Override + public String getSecret() { + return "Secret message"; + } + + } + + @EnableGlobalMethodSecurity(prePostEnabled = true) + @ComponentScan(basePackageClasses = MessageService.class) + static class SecurityConfiguration { + + @Bean + JwtDecoder jwtDecoder() { + return null; + } + } +} +``` + +=== `@WithMockAccessToken` +Offers the same SecurityContext configuration options as `AccessTokenRequestPostProcessor`. + +=== `@WithMockOidcIdToken` +Offers the same SecurityContext configuration options as `OidcIdTokenRequestPostProcessor`. + +== Reactive API +Not much more to say here as usage is pretty similar to servlet request post-processors. +Just `import static org.springframework.security.test.oauth2.reactive.server.OAuth2SecurityMockServerConfigurers.*;` and you're all set. diff --git a/oauth2-test/spring-security-oauth2-resource-server-test.gradle b/oauth2-test/spring-security-oauth2-resource-server-test.gradle new file mode 100644 index 00000000000..aa8252c0bca --- /dev/null +++ b/oauth2-test/spring-security-oauth2-resource-server-test.gradle @@ -0,0 +1,16 @@ +apply plugin: 'io.spring.convention.spring-module' + +dependencies { + compile project(':spring-security-test') + compile project(':spring-security-oauth2-resource-server') + compile project(':spring-security-oauth2-jose') + compile project(':spring-security-oauth2-client') + + compile 'org.springframework:spring-test' + + testCompile project(':spring-security-config') + + optional 'org.springframework:spring-webflux' + + provided 'javax.servlet:javax.servlet-api' +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/annotation/AttributeValueParser.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/annotation/AttributeValueParser.java new file mode 100644 index 00000000000..dcd08b7b255 --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/annotation/AttributeValueParser.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.annotation; + +/** + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + * + * @param type to parse (source) + * @param type after parsing (target) + * + */ +public interface AttributeValueParser { + + /** + * @param value to de-serialize + * @return an Object + */ + TO_TYPE parse(FROM_TYPE value); +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/annotation/StringAttribute.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/annotation/StringAttribute.java new file mode 100644 index 00000000000..fffb6c163f1 --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/annotation/StringAttribute.java @@ -0,0 +1,186 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.annotation; + +import static org.springframework.util.StringUtils.isEmpty; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.net.MalformedURLException; +import java.net.URL; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.util.StringUtils; + +/** + *

+ * Annotation to create an entry in a {@link java.util.Map Map<String, Object>} such + * as {@link org.springframework.security.oauth2.jwt.Jwt JWT} headers or claims. + *

+ * You might implement your own, bust most frequently used {@link AttributeValueParser} + * are provided out of the box: + *
    + *
  • {@link BooleanParser}
  • + *
  • {@link DoubleParser}
  • + *
  • {@link FloatParser}
  • + *
  • {@link InstantParser}
  • + *
  • {@link IntegerParser}
  • + *
  • {@link LongParser}
  • + *
  • {@link NoOpParser}
  • + *
  • {@link SpacedSeparatedStringsParser}
  • + *
  • {@link StringListParser}
  • + *
  • {@link StringSetParser}
  • + *
  • {@link UrlParser}
  • + *
+ * + * Sample usage:
+ * + *
+ * @WithMockJwt(
+ *   claims = {
+ *     @StringAttribute(name = JwtClaimNames.AUD, value = "first audience", parser = StringListParser.class),
+ *     @StringAttribute(name = JwtClaimNames.AUD, value = "second audience", parser = StringListParser.class),
+ *     @StringAttribute(name = JwtClaimNames.ISS, value = "https://test-issuer.org", parseTo = UrlParser.class),
+ *     @StringAttribute(name = "machin", value = "chose"),
+ *     @StringAttribute(name = "truc", value = "bidule", parser = YourFancyParserImpl.class)})
+ * 
+ * + * This would create + *
    + *
  • an {@code audience} claim with a value being a {@code List} with two + * entries
  • + *
  • an {@code issuer} claim with a value being a {@code java.net.URL} instance
  • + *
  • a {@code machin} claim with {@code chose} String as value (default parser is + * {@code NoOpParser})
  • + *
  • a {@code truc} claim whith an instance of what {@code YourFancyParserImpl} is + * designed to build from {@code bidule} string
  • + *
+ * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + * + */ +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface StringAttribute { + + /** + * @return the key in the {@link java.util.Map Map<String, Object>} + */ + String name(); + + /** + * @return a value to be transformed using "parser" before being put as value in + * {@link java.util.Map Map<String, Object>} + */ + String value(); + + /** + * @return an {@link AttributeValueParser} instance to deserialize {@link #value()} + * (turn it into an Object) + */ + Class> parser() default NoOpParser.class; + + public class BooleanParser implements AttributeValueParser { + @Override + public Boolean parse(final String value) { + return Boolean.valueOf(value); + } + } + + public class DoubleParser implements AttributeValueParser { + @Override + public Double parse(final String value) { + return isEmpty(value) ? null : Double.valueOf(value); + } + } + + public class FloatParser implements AttributeValueParser { + @Override + public Float parse(final String value) { + return isEmpty(value) ? null : Float.valueOf(value); + } + } + + public class InstantParser implements AttributeValueParser { + @Override + public Instant parse(final String value) { + return isEmpty(value) ? null : Instant.parse(value); + } + } + + public class IntegerParser implements AttributeValueParser { + @Override + public Integer parse(final String value) { + return isEmpty(value) ? null : Integer.valueOf(value); + } + } + + public class LongParser implements AttributeValueParser { + @Override + public Long parse(final String value) { + return isEmpty(value) ? null : Long.valueOf(value); + } + + } + + public class NoOpParser implements AttributeValueParser { + @Override + public String parse(final String value) { + return StringUtils.isEmpty(value) ? null : value; + } + } + + public class SpacedSeparatedStringsParser implements AttributeValueParser> { + @Override + public Set parse(final String value) { + return StringUtils.isEmpty(value) ? null : Stream.of(value.split(" ")).collect(Collectors.toSet()); + } + } + + public class StringListParser implements AttributeValueParser> { + @Override + public List parse(final String value) { + return StringUtils.isEmpty(value) ? null : Collections.singletonList(value); + } + } + + public class StringSetParser implements AttributeValueParser> { + @Override + public Set parse(final String value) { + return StringUtils.isEmpty(value) ? null : Collections.singleton(value); + } + } + + public class UrlParser implements AttributeValueParser { + @Override + public URL parse(final String value) { + try { + return StringUtils.isEmpty(value) ? null : new URL(value); + } catch (final MalformedURLException e) { + throw new RuntimeException(e); + } + } + } + +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/annotation/StringAttributeParserSupport.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/annotation/StringAttributeParserSupport.java new file mode 100644 index 00000000000..83d84b3b2ef --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/annotation/StringAttributeParserSupport.java @@ -0,0 +1,150 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.annotation; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.function.BinaryOperator; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.security.oauth2.core.OAuth2AccessToken; + +/** + * Helps turn a + * {@link org.springframework.security.test.oauth2.annotation.StringAttribute @StringAttribute} array + * into a {@link java.util.Map Map<String, Object>} + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + * + */ +class StringAttributeParserSupport { + private final Map>, AttributeValueParser> parsers = new HashMap<>(); + + private AttributeValueParser getParser(final Class> parserClass) { + if (!parsers.containsKey(parserClass)) { + register(parserClass); + } + return parsers.get(parserClass); + } + + private AttributeValueParser register(final Class> parserClass) { + try { + final AttributeValueParser parser = parserClass.getDeclaredConstructor().newInstance(); + this.parsers.put(parserClass, parser); + return parser; + } catch (final Exception e) { + throw new RuntimeException("Missing public no-arg constructor on " + parserClass.getName()); + } + } + + private ParsedAttribute parse(final StringAttribute stringAttribute) { + return new ParsedAttribute<>(stringAttribute.name(), getParser(stringAttribute.parser()).parse(stringAttribute.value())); + } + + /** + *

+ * Turns a {@link StringAttribute @StringAttribute} array into a {@link Map Map<String, + * Object>} as required for instance by + * {@link org.springframework.security.oauth2.jwt.Jwt JWT} headers and claims or + * {@link OAuth2AccessToken} attributes. + *

+ *

+ * Process highlights: + *

+ *
    + *
  • each {@link StringAttribute#value() value()} is parsed using + * {@link StringAttribute#parser()}
  • + *
  • obtained values are associated with {@link StringAttribute#name()}
  • + *
  • values with same name are accumulated in the same collection
  • + *
+ * + * @param properties to be transformed + * @return processed properties + */ + @SuppressWarnings("unchecked") + public Map parse(final StringAttribute... properties) { + return Stream.of(properties) + .map(this::parse) + .collect(Collectors.toMap(ParsedAttribute::getName, ParsedAttribute::getValue, (v1, v2) -> { + if (!(v1 instanceof Collection) || !(v2 instanceof Collection)) { + throw new UnsupportedOperationException( + "@StringAttribute values can be accumuleted only if instance of Collection"); + } + if (v1 instanceof Map) { + if (v2 instanceof Map) { + return MAP_ACCUMULATOR.apply((Map) v1, (Map) v2); + } + throw new UnsupportedOperationException( + "@StringAttribute \"Map\" values can only be accumulated with Maps"); + } + if (v2 instanceof Map) { + throw new UnsupportedOperationException( + "@StringAttribute \"Map\" values can only be accumulated with Maps"); + } + if (v1 instanceof List) { + return LIST_ACCUMULATOR.apply((List) v1, (Collection) v2); + } + return SET_ACCUMULATOR.apply((Collection) v1, (Collection) v2); + })); + } + + private static final class ParsedAttribute { + private final String name; + private final T value; + + public ParsedAttribute(final String name, final T value) { + super(); + this.name = name; + this.value = value; + } + + public String getName() { + return name; + } + + public T getValue() { + return value; + } + + } + + private static BinaryOperator> SET_ACCUMULATOR = (v1, v2) -> { + final HashSet all = new HashSet<>(v1.size() + v2.size()); + all.addAll(v1); + all.addAll(v2); + return all; + }; + + private static BinaryOperator> LIST_ACCUMULATOR = (v1, v2) -> { + final ArrayList all = new ArrayList<>(v1.size() + v2.size()); + all.addAll(v1); + all.addAll(v2); + return all; + }; + + private static BinaryOperator> MAP_ACCUMULATOR = (v1, v2) -> { + final HashMap all = new HashMap<>(v1.size() + v2.size()); + all.putAll(v1); + all.putAll(v2); + return all; + }; +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/annotation/WithMockJwt.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/annotation/WithMockJwt.java new file mode 100644 index 00000000000..252bb9be030 --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/annotation/WithMockJwt.java @@ -0,0 +1,200 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.annotation; + +import static org.springframework.util.StringUtils.isEmpty; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.stream.Stream; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.test.context.support.TestExecutionEvent; +import org.springframework.security.test.context.support.WithSecurityContext; +import org.springframework.security.test.context.support.WithSecurityContextFactory; +import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener; +import org.springframework.security.test.oauth2.annotation.WithMockJwt.WithMockJwtSecurityContextFactory; +import org.springframework.security.test.oauth2.support.JwtAuthenticationBuilder; +import org.springframework.test.context.TestContext; +import org.springframework.test.web.servlet.MockMvc; + +/** + *

+ * A lot like + * {@link org.springframework.security.test.context.support.WithMockUser @WithMockUser}: + * when used with {@link WithSecurityContextTestExecutionListener} this annotation can be + * added to a test method to emulate running with a mocked authentication created out of a + * {@link Jwt JWT}. + *

+ *

+ * Main steps are: + *

+ *
    + *
  • A {@link Jwt JWT} is created as per this annotation {@code name} (forces + * {@code subject} claim), {@code headers} and {@code claims}
  • + *
  • A {@link JwtAuthenticationToken JwtAuthenticationToken} is then created and fed + * with this new JWT token
  • + *
  • An empty {@link SecurityContext} is instantiated and populated with this + * {@code JwtAuthenticationToken}
  • + *
+ *

+ * As a result, the {@link Authentication} {@link MockMvc} gets from security context will + * have the following properties: + *

+ *
    + *
  • {@link Authentication#getPrincipal() getPrincipal()} returns a {@link Jwt}
  • + *
  • {@link Authentication#getName() getName()} returns the JWT {@code subject} claim, + * set from this annotation {@code name} value ({@code "user"} by default)
  • + *
  • {@link Authentication#getAuthorities() authorities} will be a collection of + * {@link SimpleGrantedAuthority} as defined by this annotation {@link #authorities()} + * ({@code "ROLE_USER" } by default)
  • + *
+ * + * Sample Usage: + * + *
+ * @WithMockJwt
+ * @Test
+ * public void testSomethingWithDefaultJwtAuthentication() {
+ *   //identified as {@code "user"} with {@code "ROLE_USER"}
+ *   //claims contain {@code "sub"} (subject) with {@code "ch4mpy"} as value
+ *   //headers can't be empty, so a default one is set
+ *   ...
+ * }
+ *
+ * @WithMockJwt(
+ *   authorities = {"ROLE_USER", "ROLE_ADMIN"},
+ *   name = "ch4mpy",
+ *   headers = { @StringAttribute(name = "foo", value = "bar") },
+ *   claims = { @StringAttribute(name = "machin", value = "chose") })
+ * @Test
+ * public void testSomethingWithCustomJwtAuthentication() {
+ *   //identified as {@code "ch4mpy"} with {@code "ROLE_USER"} and {@code "ROLE_ADMIN"}
+ *   //claims are {@code "machin"} with {@code "chose"} as value and {@code "sub"} (subject) with {@code "ch4mpy"} as value
+ *   //single {@code "foo"} header with {@code "bar"} as value
+ *   ...
+ * }
+ * 
+ * + * @see StringAttribute + * @see AttributeValueParser + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + * + */ +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +@WithSecurityContext(factory = WithMockJwtSecurityContextFactory.class) +public @interface WithMockJwt { + + /** + * Alias for authorities + * @return Authorities the client is to be granted + */ + @AliasFor("authorities") + String[] value() default { "ROLE_USER" }; + + /** + * Alias for value + * @return Authorities the client is to be granted + */ + @AliasFor("value") + String[] authorities() default { "ROLE_USER" }; + + String[] roles() default {}; + + /** + * @return Scopes the client is to be granted (added to "scope" claim, and authorities + * with "SCOPE_" prefix) + */ + String[] scopes() default {}; + + String scopesClaimeName() default JwtAuthenticationBuilder.DEFAULT_SCOPE_ATTRIBUTE_NAME; + + /** + * To be used both as authentication {@code Principal} name and token {@code username} + * attribute. + * @return Resource owner name + */ + String name() default JwtAuthenticationBuilder.DEFAULT_AUTH_NAME; + + /** + * @return JWT claims + */ + StringAttribute[] claims() default {}; + + /** + * Of little use at unit test time... + * @return JWT headers + */ + StringAttribute[] headers() default { + @StringAttribute( + name = JwtAuthenticationBuilder.DEFAULT_HEADER_NAME, + value = JwtAuthenticationBuilder.DEFAULT_HEADER_VALUE) }; + + /** + * Determines when the {@link SecurityContext} is setup. The default is before + * {@link TestExecutionEvent#TEST_METHOD} which occurs during + * {@link org.springframework.test.context.TestExecutionListener#beforeTestMethod(TestContext)} + * @return the {@link TestExecutionEvent} to initialize before + */ + @AliasFor(annotation = WithSecurityContext.class) + TestExecutionEvent setupBefore() default TestExecutionEvent.TEST_METHOD; + + public final class WithMockJwtSecurityContextFactory implements WithSecurityContextFactory { + @Override + public SecurityContext createSecurityContext(final WithMockJwt annotation) { + final SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(new AnnotationJwtAuthenticationBuilder(annotation).build()); + + return context; + } + + static class AnnotationJwtAuthenticationBuilder + extends + JwtAuthenticationBuilder { + + private final StringAttributeParserSupport parsingSupport = new StringAttributeParserSupport(); + + public AnnotationJwtAuthenticationBuilder(final WithMockJwt annotation) { + claims(parsingSupport.parse(annotation.claims())); + scopesClaimName(nonEmptyOrNull(annotation.scopesClaimeName())); + headers(parsingSupport.parse(annotation.headers())); + name(nonEmptyOrNull(annotation.name())); + Stream.of(annotation.authorities()).forEach(this::authority); + Stream.of(annotation.roles()).forEach(this::role); + Stream.of(annotation.scopes()).forEach(this::scope); + } + + private static String nonEmptyOrNull(final String value) { + return isEmpty(value) ? null : value; + } + } + } +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/reactive/server/AccessTokenMutator.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/reactive/server/AccessTokenMutator.java new file mode 100644 index 00000000000..745924a5c43 --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/reactive/server/AccessTokenMutator.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.reactive.server; + +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockAuthentication; + +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.lang.Nullable; +import org.springframework.security.test.oauth2.support.AccessTokenAuthenticationBuilder; +import org.springframework.test.web.reactive.server.MockServerConfigurer; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.reactive.server.WebTestClientConfigurer; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class AccessTokenMutator extends AccessTokenAuthenticationBuilder + implements + WebTestClientConfigurer, + MockServerConfigurer { + + @Override + public void beforeServerCreated(final WebHttpHandlerBuilder builder) { + configurer().beforeServerCreated(builder); + } + + @Override + public void afterConfigureAdded(final WebTestClient.MockServerSpec serverSpec) { + configurer().afterConfigureAdded(serverSpec); + } + + @Override + public void afterConfigurerAdded( + final WebTestClient.Builder builder, + @Nullable final WebHttpHandlerBuilder httpHandlerBuilder, + @Nullable final ClientHttpConnector connector) { + configurer().afterConfigurerAdded(builder, httpHandlerBuilder, connector); + } + + private T configurer() { + return mockAuthentication(build()); + } + +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/reactive/server/JwtMutator.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/reactive/server/JwtMutator.java new file mode 100644 index 00000000000..3245375e0ef --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/reactive/server/JwtMutator.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.reactive.server; + +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockAuthentication; + +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.lang.Nullable; +import org.springframework.security.test.oauth2.support.JwtAuthenticationBuilder; +import org.springframework.test.web.reactive.server.MockServerConfigurer; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.reactive.server.WebTestClientConfigurer; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class JwtMutator extends JwtAuthenticationBuilder + implements + WebTestClientConfigurer, + MockServerConfigurer { + + @Override + public void beforeServerCreated(final WebHttpHandlerBuilder builder) { + configurer().beforeServerCreated(builder); + } + + @Override + public void afterConfigureAdded(final WebTestClient.MockServerSpec serverSpec) { + configurer().afterConfigureAdded(serverSpec); + } + + @Override + public void afterConfigurerAdded( + final WebTestClient.Builder builder, + @Nullable final WebHttpHandlerBuilder httpHandlerBuilder, + @Nullable final ClientHttpConnector connector) { + configurer().afterConfigurerAdded(builder, httpHandlerBuilder, connector); + } + + private T configurer() { + return mockAuthentication(build()); + } + +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/reactive/server/OAuth2SecurityMockServerConfigurers.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/reactive/server/OAuth2SecurityMockServerConfigurers.java new file mode 100644 index 00000000000..ecf8131a588 --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/reactive/server/OAuth2SecurityMockServerConfigurers.java @@ -0,0 +1,62 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.reactive.server; + +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class OAuth2SecurityMockServerConfigurers { + + public static JwtMutator mockJwt() { + return new JwtMutator(); + } + + public static JwtMutator mockJwt(final Jwt jwt) { + return mockJwt().jwt(jwt); + } + + public static AccessTokenMutator mockAccessToken() { + return new AccessTokenMutator(); + } + + public static AccessTokenMutator mockAccessToken(final OAuth2AccessToken token) { + return mockAccessToken().accessToken(token); + } + + public static OidcIdTokenMutator mockOidcId(final AuthorizationGrantType authorizationRequestGrantType) { + return new OidcIdTokenMutator(authorizationRequestGrantType); + } + + public static OidcIdTokenMutator mockOidcId() { + return mockOidcId(AuthorizationGrantType.AUTHORIZATION_CODE); + } + + public static OidcIdTokenMutator + mockOidcId(final AuthorizationGrantType requestAuthorizationRequestGrantType, final OidcIdToken token) { + return mockOidcId(requestAuthorizationRequestGrantType).token(token); + } + + public static OidcIdTokenMutator mockOidcId(final OidcIdToken token) { + return mockOidcId().token(token); + } + +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/reactive/server/OidcIdTokenMutator.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/reactive/server/OidcIdTokenMutator.java new file mode 100644 index 00000000000..8cacfaf3ecd --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/reactive/server/OidcIdTokenMutator.java @@ -0,0 +1,146 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.reactive.server; + +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockAuthentication; + +import java.util.Map; + +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.lang.Nullable; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.test.oauth2.support.OidcIdTokenAuthenticationBuilder; +import org.springframework.test.web.reactive.server.MockServerConfigurer; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.reactive.server.WebTestClientConfigurer; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class OidcIdTokenMutator extends OidcIdTokenAuthenticationBuilder + implements + WebTestClientConfigurer, + MockServerConfigurer { + + private final ClientRegistrationMutator clientRegistrationMutator; + private final AuthorizationRequestMutator authorizationRequestMutator; + + public OidcIdTokenMutator(final AuthorizationGrantType authorizationGrantType) { + super(authorizationGrantType); + clientRegistrationMutator = new ClientRegistrationMutator(this, clientRegistrationBuilder); + authorizationRequestMutator = new AuthorizationRequestMutator(this, authorizationRequestBuilder, claims); + } + + public ClientRegistrationMutator clientRegistration() { + return clientRegistrationMutator; + } + + public AuthorizationRequestMutator authorizationRequest() { + return authorizationRequestMutator; + } + + @Override + public void beforeServerCreated(final WebHttpHandlerBuilder builder) { + configurer().beforeServerCreated(builder); + } + + @Override + public void afterConfigureAdded(final WebTestClient.MockServerSpec serverSpec) { + configurer().afterConfigureAdded(serverSpec); + } + + @Override + public void afterConfigurerAdded( + final WebTestClient.Builder builder, + @Nullable final WebHttpHandlerBuilder httpHandlerBuilder, + @Nullable final ClientHttpConnector connector) { + configurer().afterConfigurerAdded(builder, httpHandlerBuilder, connector); + } + + private T configurer() { + return mockAuthentication(build()); + } + + public final class ClientRegistrationMutator extends ClientRegistrationBuilder + implements + WebTestClientConfigurer, + MockServerConfigurer { + private final OidcIdTokenMutator root; + + public ClientRegistrationMutator(final OidcIdTokenMutator root, final ClientRegistration.Builder delegate) { + super(delegate); + this.root = root; + } + + @Override + public void beforeServerCreated(final WebHttpHandlerBuilder builder) { + root.beforeServerCreated(builder); + } + + @Override + public void afterConfigureAdded(final WebTestClient.MockServerSpec serverSpec) { + root.afterConfigureAdded(serverSpec); + } + + @Override + public void afterConfigurerAdded( + final WebTestClient.Builder builder, + @Nullable final WebHttpHandlerBuilder httpHandlerBuilder, + @Nullable final ClientHttpConnector connector) { + root.afterConfigurerAdded(builder, httpHandlerBuilder, connector); + } + + } + + public final class AuthorizationRequestMutator extends AuthorizationRequestBuilder + implements + WebTestClientConfigurer, + MockServerConfigurer { + private final OidcIdTokenMutator root; + + public AuthorizationRequestMutator( + final OidcIdTokenMutator root, + final OAuth2AuthorizationRequest.Builder delegate, + final Map additionalParameters) { + super(delegate, additionalParameters); + this.root = root; + } + + @Override + public void beforeServerCreated(final WebHttpHandlerBuilder builder) { + root.beforeServerCreated(builder); + } + + @Override + public void afterConfigureAdded(final WebTestClient.MockServerSpec serverSpec) { + root.afterConfigureAdded(serverSpec); + } + + @Override + public void afterConfigurerAdded( + final WebTestClient.Builder builder, + @Nullable final WebHttpHandlerBuilder httpHandlerBuilder, + @Nullable final ClientHttpConnector connector) { + root.afterConfigurerAdded(builder, httpHandlerBuilder, connector); + } + + } + +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/request/AccessTokenRequestPostProcessor.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/request/AccessTokenRequestPostProcessor.java new file mode 100644 index 00000000000..b4b0b974175 --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/request/AccessTokenRequestPostProcessor.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.request; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.test.oauth2.support.AccessTokenAuthenticationBuilder; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.SecurityContextRequestPostProcessorSupport; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class AccessTokenRequestPostProcessor extends AccessTokenAuthenticationBuilder + implements + RequestPostProcessor { + + @Override + public MockHttpServletRequest postProcessRequest(final MockHttpServletRequest request) { + SecurityContextRequestPostProcessorSupport.save(build(), request); + return request; + } + +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/request/JwtRequestPostProcessor.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/request/JwtRequestPostProcessor.java new file mode 100644 index 00000000000..566fdbe087b --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/request/JwtRequestPostProcessor.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.request; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.test.oauth2.support.JwtAuthenticationBuilder; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.SecurityContextRequestPostProcessorSupport; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class JwtRequestPostProcessor extends JwtAuthenticationBuilder + implements + RequestPostProcessor { + + @Override + public MockHttpServletRequest postProcessRequest(final MockHttpServletRequest request) { + SecurityContextRequestPostProcessorSupport.save(build(), request); + return request; + } + +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/request/OAuth2MockMvcRequestPostProcessors.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/request/OAuth2MockMvcRequestPostProcessors.java new file mode 100644 index 00000000000..8ece9d3860c --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/request/OAuth2MockMvcRequestPostProcessors.java @@ -0,0 +1,64 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.request; + +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public final class OAuth2MockMvcRequestPostProcessors { + public static final AuthorizationGrantType DEFAULT_REQUEST_AUTHORIZATION_GRANT_TYPE = + AuthorizationGrantType.AUTHORIZATION_CODE; + + public static JwtRequestPostProcessor mockJwt() { + return new JwtRequestPostProcessor(); + } + + public static JwtRequestPostProcessor mockJwt(final Jwt jwt) { + return mockJwt().jwt(jwt); + } + + public static AccessTokenRequestPostProcessor mockAccessToken() { + return new AccessTokenRequestPostProcessor(); + } + + public static AccessTokenRequestPostProcessor mockAccessToken(final OAuth2AccessToken token) { + return new AccessTokenRequestPostProcessor().accessToken(token); + } + + public static OidcIdTokenRequestPostProcessor mockOidcId(final AuthorizationGrantType authorizationGrantType) { + return new OidcIdTokenRequestPostProcessor(authorizationGrantType); + } + + public static OidcIdTokenRequestPostProcessor mockOidcId() { + return mockOidcId(DEFAULT_REQUEST_AUTHORIZATION_GRANT_TYPE); + } + + public static OidcIdTokenRequestPostProcessor + mockOidcId(final OidcIdToken token, final AuthorizationGrantType requestAuthorizationGrantType) { + return mockOidcId(requestAuthorizationGrantType).token(token); + } + + public static OidcIdTokenRequestPostProcessor mockOidcId(final OidcIdToken token) { + return mockOidcId().token(token); + } + +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/request/OidcIdTokenRequestPostProcessor.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/request/OidcIdTokenRequestPostProcessor.java new file mode 100644 index 00000000000..8d2d9dfb048 --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/request/OidcIdTokenRequestPostProcessor.java @@ -0,0 +1,117 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.request; + +import java.util.Map; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.test.oauth2.support.OidcIdTokenAuthenticationBuilder; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.SecurityContextRequestPostProcessorSupport; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class OidcIdTokenRequestPostProcessor extends OidcIdTokenAuthenticationBuilder + implements + RequestPostProcessor { + ClientRegistrationPostProcessor clientRegistrationPostProcessor; + AuthorizationRequestPostProcessor authorizationRequestPostProcessor; + + public OidcIdTokenRequestPostProcessor(final AuthorizationGrantType requestAuthorizationGrantType) { + super(requestAuthorizationGrantType); + clientRegistrationPostProcessor = new ClientRegistrationPostProcessor(this, clientRegistrationBuilder); + authorizationRequestPostProcessor = + new AuthorizationRequestPostProcessor(this, authorizationRequestBuilder, claims); + } + + @Override + public MockHttpServletRequest postProcessRequest(final MockHttpServletRequest request) { + final Authentication authentication = build(); + SecurityContextRequestPostProcessorSupport.save(authentication, request); + return request; + } + + public ClientRegistrationPostProcessor clientRegistration() { + return clientRegistrationPostProcessor; + } + + public AuthorizationRequestPostProcessor authorizationRequest() { + return authorizationRequestPostProcessor; + } + + interface Nested { + OidcIdTokenRequestPostProcessor and(); + } + + public final class ClientRegistrationPostProcessor + extends + ClientRegistrationBuilder + implements + OidcIdTokenRequestPostProcessor.Nested, + RequestPostProcessor { + private final OidcIdTokenRequestPostProcessor root; + + public ClientRegistrationPostProcessor( + final OidcIdTokenRequestPostProcessor root, + final ClientRegistration.Builder builder) { + super(builder); + this.root = root; + } + + @Override + public OidcIdTokenRequestPostProcessor and() { + return root; + } + + @Override + public MockHttpServletRequest postProcessRequest(final MockHttpServletRequest request) { + return root.postProcessRequest(request); + } + } + + public final class AuthorizationRequestPostProcessor + extends + AuthorizationRequestBuilder + implements + OidcIdTokenRequestPostProcessor.Nested, + RequestPostProcessor { + private final OidcIdTokenRequestPostProcessor root; + + public AuthorizationRequestPostProcessor( + final OidcIdTokenRequestPostProcessor root, + final OAuth2AuthorizationRequest.Builder builder, + final Map additionalParameters) { + super(builder, additionalParameters); + this.root = root; + } + + @Override + public OidcIdTokenRequestPostProcessor and() { + return root; + } + + @Override + public MockHttpServletRequest postProcessRequest(final MockHttpServletRequest request) { + return root.postProcessRequest(request); + } + } +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/support/AbstractAuthenticationBuilder.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/support/AbstractAuthenticationBuilder.java new file mode 100644 index 00000000000..a215f1e5619 --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/support/AbstractAuthenticationBuilder.java @@ -0,0 +1,188 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.support; + +import static org.springframework.security.test.oauth2.support.CollectionsSupport.asSet; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public abstract class AbstractAuthenticationBuilder> { + + public static final String DEFAULT_AUTH_NAME = "user"; + + public static final String[] DEFAULT_AUTHORITIES = { "ROLE_USER" }; + + public static final String DEFAULT_SCOPE_ATTRIBUTE_NAME = "scope"; + + private static final String ROLE_PREFIX = "ROLE_"; + + private static final String SCOPE_PREFIX = "SCOPE_"; + + protected String name; + + protected final Set authorities; + + private boolean isAuthoritiesSet = false; + + protected final Map claims = new HashMap<>(); + + protected String scopeClaimName = DEFAULT_SCOPE_ATTRIBUTE_NAME; + + public AbstractAuthenticationBuilder(final String defaultName, final String[] defaultAuthorities) { + this.name = defaultName; + this.authorities = new HashSet<>(asSet(defaultAuthorities)); + } + + public AbstractAuthenticationBuilder() { + this(DEFAULT_AUTH_NAME, DEFAULT_AUTHORITIES); + } + + public T name(final String name) { + this.name = name; + return downCast(); + } + + public T authority(final String authority) { + assert (authority != null); + if (!this.isAuthoritiesSet) { + this.authorities.clear(); + this.isAuthoritiesSet = true; + } + this.authorities.add(authority); + return downCast(); + } + + public T authorities(final Stream authorities) { + this.authorities.clear(); + authorities.forEach(this::authority); + return downCast(); + } + + public T authorities(final String... authorities) { + return authorities(Stream.of(authorities)); + } + + public T authorities(final Collection authorities) { + return authorities(authorities.stream()); + } + + public T role(final String role) { + assert (role != null); + assert (!role.startsWith(ROLE_PREFIX)); + return authority(ROLE_PREFIX + role); + } + + public T roles(final Stream roles) { + this.authorities.removeIf(a -> a.startsWith(ROLE_PREFIX)); + roles.forEach(this::role); + return downCast(); + } + + public T roles(final String... roles) { + return roles(Stream.of(roles)); + } + + public T roles(final Collection roles) { + return roles(roles.stream()); + } + + public T scope(final String scope) { + assert (scope != null); + assert (!scope.startsWith(SCOPE_PREFIX)); + return authority(SCOPE_PREFIX + scope); + } + + public T scopes(final Stream scopes) { + this.authorities.removeIf(a -> a.startsWith(SCOPE_PREFIX)); + scopes.forEach(this::scope); + return downCast(); + } + + public T scopes(final String... scopes) { + return scopes(Stream.of(scopes)); + } + + public T scopes(final Collection scopes) { + return scopes(scopes.stream()); + } + + public T claim(final String name, final Object value) { + assert (name != null); + this.claims.put(name, value); + return downCast(); + } + + public T claims(final Map attributes) { + assert (attributes != null); + this.claims.clear(); + attributes.entrySet().stream().forEach(e -> this.claim(e.getKey(), e.getValue())); + return downCast(); + } + + public T scopesClaimName(final String name) { + this.scopeClaimName = name; + return downCast(); + } + + public Set getAllAuthorities() { + return Stream.concat(authorities.stream(), getScopeAttributeStream().map(scope -> "SCOPE_" + scope)) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toSet()); + } + + public Set getAllScopes() { + return Stream + .concat( + getScopeAttributeStream(), + authorities.stream().filter(a -> a.startsWith("SCOPE_")).map(a -> a.substring(6))) + .collect(Collectors.toSet()); + } + + @SuppressWarnings("unchecked") + protected T downCast() { + return (T) this; + } + + private Stream getScopeAttributeStream() { + final Object scopeAttribute = claims.get(scopeClaimName); + if (scopeAttribute == null) { + return Stream.empty(); + } + + if (scopeAttribute instanceof Collection) { + return ((Collection) scopeAttribute).stream().map(Object::toString); + } else if (scopeAttribute instanceof String) { + return Stream.of(scopeAttribute.toString().split(" ")); + } else { + throw new RuntimeException( + "Only Collection or String are supported types for scopes. Was " + + scopeAttribute.getClass().getName()); + } + } + +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/support/AccessTokenAuthenticationBuilder.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/support/AccessTokenAuthenticationBuilder.java new file mode 100644 index 00000000000..3b68fb45814 --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/support/AccessTokenAuthenticationBuilder.java @@ -0,0 +1,91 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.support; + +import static org.springframework.security.test.oauth2.support.CollectionsSupport.nullIfEmpty; +import static org.springframework.security.test.oauth2.support.CollectionsSupport.putIfNotEmpty; + +import java.time.Instant; +import java.util.Map; +import java.util.Set; + +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType; +import org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class AccessTokenAuthenticationBuilder> + extends + AbstractAuthenticationBuilder { + + public static final String DEFAULT_TOKEN_VALUE = "Bearer test"; + + private String tokenValue = DEFAULT_TOKEN_VALUE; + + public T accessToken(final OAuth2AccessToken token) { + assert (TokenType.BEARER.equals(token.getTokenType())); + return tokenValue(token.getTokenValue()).scopes(token.getScopes()) + .claim(OAuth2IntrospectionClaimNames.ISSUED_AT, token.getIssuedAt()) + .claim(OAuth2IntrospectionClaimNames.EXPIRES_AT, token.getExpiresAt()); + } + + public T tokenValue(final String tokenValue) { + this.tokenValue = tokenValue; + return downCast(); + } + + public T attribute(final String name, final Object value) { + return claim(name, value); + } + + public T attributes(final Map attributes) { + return claims(attributes); + } + + public OAuth2IntrospectionAuthenticationToken build() { + if (claims.containsKey(OAuth2IntrospectionClaimNames.TOKEN_TYPE)) { + throw new RuntimeException( + OAuth2IntrospectionClaimNames.TOKEN_TYPE + + " claim is not configurable (forced to TokenType.BEARER)"); + } + if (claims.containsKey(OAuth2IntrospectionClaimNames.USERNAME)) { + throw new RuntimeException( + OAuth2IntrospectionClaimNames.USERNAME + + " claim is not configurable (forced to @WithMockAccessToken.name)"); + } + claims.put(OAuth2IntrospectionClaimNames.TOKEN_TYPE, TokenType.BEARER); + putIfNotEmpty(OAuth2IntrospectionClaimNames.USERNAME, name, claims); + + final Set allScopes = getAllScopes(); + putIfNotEmpty(scopeClaimName, allScopes, claims); + + return new OAuth2IntrospectionAuthenticationToken( + new OAuth2AccessToken( + TokenType.BEARER, + tokenValue, + (Instant) claims.get(OAuth2IntrospectionClaimNames.ISSUED_AT), + (Instant) claims.get(OAuth2IntrospectionClaimNames.EXPIRES_AT), + allScopes), + claims, + getAllAuthorities(), + nullIfEmpty(name)); + } + +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/support/CollectionsSupport.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/support/CollectionsSupport.java new file mode 100644 index 00000000000..40f1e3ff36d --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/support/CollectionsSupport.java @@ -0,0 +1,62 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.support; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.util.StringUtils; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class CollectionsSupport { + + public static String nullIfEmpty(final String str) { + return StringUtils.isEmpty(str) ? null : str; + } + + public static final Map + putIfNotEmpty(final String key, final String value, final Map map) { + if (value != null && !value.isEmpty()) { + map.put(key, value); + } + return map; + } + + public static final Map + putIfNotEmpty(final String key, final Collection value, final Map map) { + if (value != null && !value.isEmpty()) { + map.put(key, value); + } + return map; + } + + public static final Set asSet(final String... values) { + return values.length == 0 ? Collections.emptySet() : Stream.of(values).collect(Collectors.toSet()); + } + + public static final List asList(final String... values) { + return values.length == 0 ? Collections.emptyList() : Stream.of(values).collect(Collectors.toList()); + } + +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/support/JwtAuthenticationBuilder.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/support/JwtAuthenticationBuilder.java new file mode 100644 index 00000000000..38f1c8af5bf --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/support/JwtAuthenticationBuilder.java @@ -0,0 +1,122 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.support; + +import static org.springframework.security.test.oauth2.support.CollectionsSupport.putIfNotEmpty; + +import java.time.Instant; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class JwtAuthenticationBuilder> extends AbstractAuthenticationBuilder { + + public static final String DEFAULT_TOKEN_VALUE = "test.jwt.value"; + + public static final String DEFAULT_HEADER_NAME = "test-header"; + + public static final String DEFAULT_HEADER_VALUE = "abracadabra"; + + public static final Map DEFAULT_HEADERS = + Collections.singletonMap(DEFAULT_HEADER_NAME, DEFAULT_HEADER_VALUE); + + private String tokenValue = DEFAULT_TOKEN_VALUE; + + private Map headers = DEFAULT_HEADERS; + + private boolean isHeadersSet = false; + + /** + * Solely claims will be considered at build() time. token properties that can be + * stored as claims (audience, expiresAt, id, issuedAt, issuer, notBefore) are + * ignored. + * @param jwt fully configured JWT + * @return pre-configured builder + */ + public T jwt(final Jwt jwt) { + final Map claims = new HashMap<>(jwt.getClaims()); + if (jwt.getIssuedAt() != null) { + if (jwt.getClaims().containsKey(JwtClaimNames.IAT) + && !jwt.getIssuedAt().equals(jwt.getClaimAsInstant(JwtClaimNames.IAT))) { + throw new RuntimeException( + "Inconsistent issue instants: jwt.getIssuedAt() = " + jwt.getIssuedAt() + + " but jwt.getClaimAsInstant(JwtClaimNames.IAT) = " + + jwt.getClaimAsInstant(JwtClaimNames.IAT)); + } + claims.put(JwtClaimNames.IAT, jwt.getIssuedAt()); + } + if (jwt.getExpiresAt() != null) { + if (jwt.getClaims().containsKey(JwtClaimNames.EXP) + && !jwt.getExpiresAt().equals(jwt.getClaimAsInstant(JwtClaimNames.EXP))) { + throw new RuntimeException( + "Inconsistent expiry instants: jwt.getExpiresAt() = " + jwt.getExpiresAt() + + " but jwt.getClaimAsInstant(JwtClaimNames.EXP) = " + + jwt.getClaimAsInstant(JwtClaimNames.EXP)); + } + claims.put(JwtClaimNames.EXP, jwt.getExpiresAt()); + } + return tokenValue(jwt.getTokenValue()).name(jwt.getSubject()).claims(jwt.getClaims()).headers(jwt.getHeaders()); + } + + public T tokenValue(final String tokenValue) { + this.tokenValue = tokenValue; + return downCast(); + } + + public T header(final String name, final Object value) { + assert (name != null); + if (this.isHeadersSet == false) { + this.headers = new HashMap<>(); + this.isHeadersSet = true; + } + this.headers.put(name, value); + return downCast(); + } + + public T headers(final Map headers) { + assert (headers != null); + headers.entrySet().stream().forEach(e -> this.header(e.getKey(), e.getValue())); + return downCast(); + } + + public JwtAuthenticationToken build() { + if (claims.containsKey(JwtClaimNames.SUB) && !claims.get(JwtClaimNames.SUB).equals(name)) { + throw new RuntimeException(JwtClaimNames.SUB + " claim is not configurable (forced to \"name\")"); + } else { + putIfNotEmpty(JwtClaimNames.SUB, name, claims); + } + + putIfNotEmpty(scopeClaimName, getAllScopes(), claims); + + return new JwtAuthenticationToken( + new Jwt( + tokenValue, + (Instant) claims.get(JwtClaimNames.IAT), + (Instant) claims.get(JwtClaimNames.EXP), + headers, + claims), + getAllAuthorities()); + } + +} diff --git a/oauth2-test/src/main/java/org/springframework/security/test/oauth2/support/OidcIdTokenAuthenticationBuilder.java b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/support/OidcIdTokenAuthenticationBuilder.java new file mode 100644 index 00000000000..82661da3ce7 --- /dev/null +++ b/oauth2-test/src/main/java/org/springframework/security/test/oauth2/support/OidcIdTokenAuthenticationBuilder.java @@ -0,0 +1,314 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.support; + +import static org.springframework.security.test.oauth2.support.CollectionsSupport.putIfNotEmpty; + +import java.time.Instant; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthenticationMethod; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.util.StringUtils; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class OidcIdTokenAuthenticationBuilder> + extends + AbstractAuthenticationBuilder { + public static final String DEFAULT_TOKEN_VALUE = "Open ID test"; + public static final String DEFAULT_NAME_KEY = "sub"; + public static final String REQUEST_REDIRECT_URI = "https://localhost:8080/"; + public static final String REQUEST_AUTHORIZATION_URI = "https://localhost:8080/authorize"; + public static final String REQUEST_GRANT_TYPE = "authorization_code"; + public static final String CLIENT_TOKEN_URI = "https://localhost:8080/token"; + public static final String CLIENT_ID = "mocked-client"; + public static final String CLIENT_REGISTRATION_ID = "mocked-registration"; + public static final String CLIENT_GRANT_TYPE = "client_credentials"; + + protected String tokenValue = DEFAULT_TOKEN_VALUE; + protected String nameAttributeKey = DEFAULT_NAME_KEY; + protected final ClientRegistration.Builder clientRegistrationBuilder; + protected final OAuth2AuthorizationRequest.Builder authorizationRequestBuilder; + + public OidcIdTokenAuthenticationBuilder( + final ClientRegistration.Builder clientRegistration, + final OAuth2AuthorizationRequest.Builder authorizationRequest) { + super(); + this.clientRegistrationBuilder = clientRegistration; + this.authorizationRequestBuilder = authorizationRequest; + this.authorizationRequestBuilder.attributes(claims); + } + + public OidcIdTokenAuthenticationBuilder(final AuthorizationGrantType requestAuthorizationGrantType) { + this(defaultClientRegistration(), defaultAuthorizationRequest(requestAuthorizationGrantType)); + } + + public T token(final OidcIdToken token) { + final Map claims = new HashMap<>(token.getClaims()); + if (token.getIssuedAt() != null) { + if (token.getClaims().containsKey(IdTokenClaimNames.IAT) + && !token.getIssuedAt().equals(token.getClaimAsInstant(IdTokenClaimNames.IAT))) { + throw new RuntimeException( + "Inconsistent issue instants: token.getIssuedAt() = " + token.getIssuedAt() + + " but token.getClaimAsInstant(IdTokenClaimNames.IAT) = " + + token.getClaimAsInstant(IdTokenClaimNames.IAT)); + } + claims.put(IdTokenClaimNames.IAT, token.getIssuedAt()); + } + if (token.getExpiresAt() != null) { + if (token.getClaims().containsKey(IdTokenClaimNames.EXP) + && !token.getExpiresAt().equals(token.getClaimAsInstant(IdTokenClaimNames.EXP))) { + throw new RuntimeException( + "Inconsistent expiry instants: token.getExpiresAt() = " + token.getExpiresAt() + + " but token.getClaimAsInstant(IdTokenClaimNames.EXP) = " + + token.getClaimAsInstant(IdTokenClaimNames.EXP)); + } + claims.put(IdTokenClaimNames.EXP, token.getExpiresAt()); + } + tokenValue(token.getTokenValue()); + claims(claims); + return downCast(); + } + + public T tokenValue(final String tokenValue) { + this.tokenValue = tokenValue; + return downCast(); + } + + public T nameAttributeKey(final String nameAttributeKey) { + this.nameAttributeKey = nameAttributeKey; + return downCast(); + } + + public static ClientRegistration.Builder defaultClientRegistration() { + return ClientRegistration.withRegistrationId(CLIENT_REGISTRATION_ID) + .authorizationGrantType(new AuthorizationGrantType(CLIENT_GRANT_TYPE)) + .clientId(CLIENT_ID) + .tokenUri(CLIENT_TOKEN_URI); + } + + public static OAuth2AuthorizationRequest.Builder + defaultAuthorizationRequest(final AuthorizationGrantType authorizationGrantType) { + return authorizationRequestBuilder(authorizationGrantType).authorizationUri(REQUEST_AUTHORIZATION_URI) + .clientId(CLIENT_ID) + .redirectUri(REQUEST_REDIRECT_URI); + } + + public static OAuth2AuthorizationRequest.Builder + authorizationRequestBuilder(final AuthorizationGrantType authorizationGrantType) { + if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(authorizationGrantType)) { + return OAuth2AuthorizationRequest.authorizationCode(); + } + if (AuthorizationGrantType.IMPLICIT.equals(authorizationGrantType)) { + return OAuth2AuthorizationRequest.implicit(); + } + throw new UnsupportedOperationException( + "Only authorization_code and implicit grant types are supported for MockOAuth2AuthorizationRequest"); + } + + public OAuth2LoginAuthenticationToken build() { + assert (!StringUtils.isEmpty(nameAttributeKey)); + assert (!claims.containsKey(nameAttributeKey) || claims.get(nameAttributeKey).equals(name)); + + putIfNotEmpty(nameAttributeKey, name, claims); + + final Set scopes = getAllScopes(); + putIfNotEmpty(scopeClaimName, scopes, claims); + + final OidcIdToken token = new OidcIdToken( + tokenValue, + (Instant) claims.get(IdTokenClaimNames.IAT), + (Instant) claims.get(IdTokenClaimNames.EXP), + claims); + + final ClientRegistration clientRegistration = + clientRegistrationBuilder.scope(scopes).userNameAttributeName(nameAttributeKey).build(); + + final OAuth2AuthorizationRequest authorizationRequest = + authorizationRequestBuilder.attributes(claims).scopes(scopes).build(); + + final String redirectUri = + StringUtils.isEmpty(authorizationRequest.getRedirectUri()) ? clientRegistration.getRedirectUriTemplate() + : authorizationRequest.getRedirectUri(); + + final OAuth2AuthorizationExchange authorizationExchange = + new OAuth2AuthorizationExchange(authorizationRequest, auth2AuthorizationResponse(redirectUri)); + + final Collection authorities = getAllAuthorities(); + final DefaultOidcUser principal = new DefaultOidcUser(authorities, token, nameAttributeKey); + + final OAuth2AccessToken accessToken = new OAuth2AccessToken( + TokenType.BEARER, + tokenValue, + (Instant) claims.get(IdTokenClaimNames.IAT), + (Instant) claims.get(IdTokenClaimNames.EXP), + authorizationExchange.getAuthorizationRequest().getScopes()); + + return new OAuth2LoginAuthenticationToken( + clientRegistration, + authorizationExchange, + principal, + authorities, + accessToken); + } + + private static OAuth2AuthorizationResponse auth2AuthorizationResponse(final String redirectUri) { + final OAuth2AuthorizationResponse.Builder builder = + OAuth2AuthorizationResponse.success("test-authorization-success-code"); + builder.redirectUri(redirectUri); + return builder.build(); + } + + public static class ClientRegistrationBuilder> { + private final ClientRegistration.Builder delegate; + + public ClientRegistrationBuilder(final ClientRegistration.Builder delegate) { + this.delegate = delegate; + } + + public T authorizationGrantType(final AuthorizationGrantType authorizationGrantType) { + delegate.authorizationGrantType(authorizationGrantType); + return downcast(); + } + + public T authorizationUri(final String authorizationUri) { + delegate.authorizationUri(authorizationUri); + return downcast(); + } + + public T clientAuthenticationMethod(final ClientAuthenticationMethod clientAuthenticationMethod) { + delegate.clientAuthenticationMethod(clientAuthenticationMethod); + return downcast(); + } + + public T clientId(final String clientId) { + delegate.clientId(clientId); + return downcast(); + } + + public T clientName(final String clientName) { + delegate.clientName(clientName); + return downcast(); + } + + public T clientSecret(final String clientSecret) { + delegate.clientSecret(clientSecret); + return downcast(); + } + + public T jwkSetUri(final String jwkSetUri) { + delegate.jwkSetUri(jwkSetUri); + return downcast(); + } + + public T providerConfigurationMetadata(final Map configurationMetadata) { + delegate.providerConfigurationMetadata(configurationMetadata); + return downcast(); + } + + public T redirectUriTemplate(final String redirectUriTemplate) { + delegate.redirectUriTemplate(redirectUriTemplate); + return downcast(); + } + + public T registrationId(final String registrationId) { + delegate.registrationId(registrationId); + return downcast(); + } + + public T tokenUri(final String tokenUri) { + delegate.tokenUri(tokenUri); + return downcast(); + } + + public T userInfoAuthenticationMethod(final AuthenticationMethod userInfoAuthenticationMethod) { + delegate.userInfoAuthenticationMethod(userInfoAuthenticationMethod); + return downcast(); + } + + @SuppressWarnings("unchecked") + protected T downcast() { + return (T) this; + } + } + + public static class AuthorizationRequestBuilder> { + private final OAuth2AuthorizationRequest.Builder delegate; + private final Map additionalParameters; + + public AuthorizationRequestBuilder( + final OAuth2AuthorizationRequest.Builder builder, + final Map additionalParameters) { + this.additionalParameters = additionalParameters; + this.delegate = builder; + this.delegate.additionalParameters(additionalParameters); + } + + public T additionalParameter(final String name, final Object value) { + additionalParameters.put(name, value); + return downcast(); + } + + public T authorizationRequestUri(final String authorizationRequestUri) { + delegate.authorizationRequestUri(authorizationRequestUri); + return downcast(); + } + + public T authorizationUri(final String authorizationUri) { + delegate.authorizationUri(authorizationUri); + return downcast(); + } + + public T clientId(final String clientId) { + delegate.clientId(clientId); + return downcast(); + } + + public T redirectUri(final String redirectUri) { + delegate.redirectUri(redirectUri); + return downcast(); + } + + public T state(final String state) { + delegate.state(state); + return downcast(); + } + + @SuppressWarnings("unchecked") + protected T downcast() { + return (T) this; + } + } + +} diff --git a/oauth2-test/src/main/resources/META-INF/spring.factories b/oauth2-test/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000000..48be9c54768 --- /dev/null +++ b/oauth2-test/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +org.springframework.test.context.TestExecutionListener = \ + org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener,\ + org.springframework.security.test.context.support.ReactorContextTestExecutionListener diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/annotation/MessageServiceTest.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/annotation/MessageServiceTest.java new file mode 100644 index 00000000000..c64b9c7da98 --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/annotation/MessageServiceTest.java @@ -0,0 +1,105 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.annotation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.stereotype.Component; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = MessageServiceTest.SecurityConfiguration.class) +public class MessageServiceTest { + + @Autowired + private MessageService messageService; + + @Test(expected = AuthenticationCredentialsNotFoundException.class) + public void greetWitoutMockJwt() { + messageService.getGreeting(); + } + + @Test + @WithMockJwt(name = "ch4mpy") + public void greetWithMockJwt() { + assertThat(messageService.getGreeting()).isEqualTo("Hello, ch4mpy!"); + } + + @Test(expected = AccessDeniedException.class) + @WithMockJwt + public void secretWithoutMessageReadScope() { + assertThat(messageService.getSecret()).isEqualTo("Secret message"); + } + + @Test + @WithMockJwt("SCOPE_message:read") // same as: + // @WithMockJwt(scopes = "message:read") + // @WithMockJwt(claims = @StringAttribute(name = "scope", value = "message:read", parser = + // StringSetParser.class)) + public void secretWithScopeMessageReadAuthority() { + assertThat(messageService.getSecret()).isEqualTo("Secret message"); + } + + interface MessageService { + + @PreAuthorize("authenticated") + String getGreeting(); + + @PreAuthorize("hasAuthority('SCOPE_message:read')") + String getSecret(); + } + + @Component + static final class MessageServiceImpl implements MessageService { + + @Override + public String getGreeting() { + return String.format("Hello, %s!", SecurityContextHolder.getContext().getAuthentication().getName()); + } + + @Override + public String getSecret() { + return "Secret message"; + } + + } + + @EnableGlobalMethodSecurity(prePostEnabled = true) + @ComponentScan(basePackageClasses = MessageService.class) + static class SecurityConfiguration { + + @Bean + JwtDecoder jwtDecoder() { + return null; + } + } +} diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/annotation/StringAttributeParserSupportTest.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/annotation/StringAttributeParserSupportTest.java new file mode 100644 index 00000000000..5747e200e0a --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/annotation/StringAttributeParserSupportTest.java @@ -0,0 +1,125 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.annotation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.Test; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.security.test.oauth2.annotation.StringAttribute.StringListParser; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class StringAttributeParserSupportTest { + + @StringAttribute(name = "a", value = "bidule", parser = SomeTypeParser.class) + private static final class AProperty { + } + + @StringAttribute(name = "b", value = "chose", parser = SomeTypeParser.class) + private static final class BProperty { + } + + @Test + public void parsePropertiesWithDistinctNames() { + final StringAttributeParserSupport helper = new StringAttributeParserSupport(); + final StringAttribute propertyAnnotationA = + AnnotationUtils.findAnnotation(AProperty.class, StringAttribute.class); + final StringAttribute propertyAnnotationB = + AnnotationUtils.findAnnotation(BProperty.class, StringAttribute.class); + + final Map actual = helper.parse(propertyAnnotationA, propertyAnnotationB); + assertThat(actual).hasSize(2); + assertThat(actual.get("a")).isInstanceOf(String.class); + assertThat(actual.get("b")).isInstanceOf(String.class); + + } + + @StringAttribute(name = "a", value = "bidule", parser = StringListParser.class) + private static final class CProperty { + } + + @StringAttribute(name = "a", value = "chose", parser = StringListParser.class) + private static final class DProperty { + } + + @SuppressWarnings("unchecked") + @Test + public void parsePropertiesWithSameNameAccumulatesValues() { + final StringAttributeParserSupport helper = new StringAttributeParserSupport(); + final StringAttribute propertyAnnotationC = + AnnotationUtils.findAnnotation(CProperty.class, StringAttribute.class); + final StringAttribute propertyAnnotationD = + AnnotationUtils.findAnnotation(DProperty.class, StringAttribute.class); + + final Map actual = helper.parse(propertyAnnotationC, propertyAnnotationD); + assertThat(actual).hasSize(1); + assertThat(actual.get("a")).isInstanceOf(List.class); + assertThat((List) actual.get("a")).hasSize(2); + assertThat((List) actual.get("a")).contains("bidule", "chose"); + + } + + @StringAttribute(name = "instant-millis", value = "12345678", parser = InstantParser.class) + private static final class EProperty { + } + + @Test + public void parsePropertiesUsesParseroverrides() { + final StringAttributeParserSupport helper = new StringAttributeParserSupport(); + + final StringAttribute propertyAnnotation = + AnnotationUtils.findAnnotation(EProperty.class, StringAttribute.class); + + final Map actual = helper.parse(propertyAnnotation); + assertThat(actual).hasSize(1); + assertThat(actual.get("instant-millis")).isInstanceOf(Instant.class); + assertThat((Instant) actual.get("instant-millis")).isEqualTo(Instant.ofEpochMilli(12345678L)); + + } + + public static final class SomeTypeParser implements AttributeValueParser { + @Override + public String parse(final String value) { + return value; + } + } + + public static final class OtherTypeParser implements AttributeValueParser> { + @Override + public Collection parse(final String value) { + return Collections.singletonList(value); + } + } + + /** + * custom Instant mapper designed to override default one + */ + public static final class InstantParser implements AttributeValueParser { + @Override + public Instant parse(final String value) { + return Instant.ofEpochMilli(Long.valueOf(value)); + } + } +} diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/annotation/WithMockJwtSecurityContextFactoryTests.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/annotation/WithMockJwtSecurityContextFactoryTests.java new file mode 100644 index 00000000000..546ca40aef5 --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/annotation/WithMockJwtSecurityContextFactoryTests.java @@ -0,0 +1,276 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.annotation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URL; +import java.time.Instant; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.test.oauth2.annotation.StringAttribute.InstantParser; +import org.springframework.security.test.oauth2.annotation.StringAttribute.StringListParser; +import org.springframework.security.test.oauth2.annotation.StringAttribute.UrlParser; +import org.springframework.security.test.oauth2.annotation.WithMockJwt.WithMockJwtSecurityContextFactory; +import org.springframework.security.test.oauth2.support.AbstractAuthenticationBuilder; +import org.springframework.security.test.oauth2.support.JwtAuthenticationBuilder; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class WithMockJwtSecurityContextFactoryTests { + + private WithMockJwtSecurityContextFactory factory; + + @Before + public void setup() { + factory = new WithMockJwtSecurityContextFactory(); + } + + @WithMockJwt + private static class Default { + } + + @WithMockJwt("ROLE_ADMIN") + private static class CustomMini { + } + + @WithMockJwt(name = "Test User", authorities = { "ROLE_USER", "ROLE_ADMIN" }) + private static class CustomFrequent { + } + + @WithMockJwt( + name = "Test User", + authorities = { "ROLE_USER", "ROLE_ADMIN" }, + claims = { @StringAttribute(name = "custom-claim", value = "foo") }) + private static class CustomAdvanced { + } + + @WithMockJwt( + name = "truc", + authorities = { "machin", "chose" }, + headers = { @StringAttribute(name = "a", value = "1") }, + claims = { + @StringAttribute( + name = JwtClaimNames.AUD, + value = "test audience", + parser = StringListParser.class), + @StringAttribute( + name = JwtClaimNames.AUD, + value = "other audience", + parser = StringListParser.class), + @StringAttribute( + name = JwtClaimNames.ISS, + value = "https://test-issuer.org", + parser = UrlParser.class), + @StringAttribute( + name = JwtClaimNames.IAT, + value = "2019-03-03T22:35:00.0Z", + parser = InstantParser.class), + @StringAttribute(name = JwtClaimNames.JTI, value = "test ID"), + @StringAttribute(name = "custom-claim", value = "foo") }) + private static class CustomFull { + } + + @Test + public void defaults() { + final WithMockJwt annotation = AnnotationUtils.findAnnotation(Default.class, WithMockJwt.class); + + final Authentication auth = factory.createSecurityContext(annotation).getAuthentication(); + + assertThat(auth.getName()).isEqualTo(AbstractAuthenticationBuilder.DEFAULT_AUTH_NAME); + assertThat(auth.getAuthorities()).hasSize(1); + assertThat(auth.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_USER"))).isTrue(); + assertThat(auth.getPrincipal()).isInstanceOf(Jwt.class); + + final Jwt jwt = (Jwt) auth.getPrincipal(); + + assertThat(auth.getCredentials()).isEqualTo(jwt); + assertThat(auth.getDetails()).isNull(); + + assertThat(jwt.getTokenValue()).isEqualTo(JwtAuthenticationBuilder.DEFAULT_TOKEN_VALUE); + assertThat(jwt.getSubject()).isEqualTo(AbstractAuthenticationBuilder.DEFAULT_AUTH_NAME); + assertThat(jwt.getAudience()).isNull(); + assertThat(jwt.getIssuer()).isNull(); + assertThat(jwt.getIssuedAt()).isNull(); + assertThat(jwt.getExpiresAt()).isNull(); + assertThat(jwt.getNotBefore()).isNull(); + assertThat(jwt.getId()).isNull(); + + final Map headers = jwt.getHeaders(); + assertThat(headers).hasSize(1); + assertThat(headers.get(JwtAuthenticationBuilder.DEFAULT_HEADER_NAME)) + .isEqualTo(JwtAuthenticationBuilder.DEFAULT_HEADER_VALUE); + } + + @Test + public void customMini() { + final WithMockJwt annotation = AnnotationUtils.findAnnotation(CustomMini.class, WithMockJwt.class); + + final Authentication auth = factory.createSecurityContext(annotation).getAuthentication(); + + assertThat(auth.getName()).isEqualTo(AbstractAuthenticationBuilder.DEFAULT_AUTH_NAME); + assertThat(auth.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN"))).isTrue(); + assertThat(auth.getPrincipal()).isInstanceOf(Jwt.class); + + final Jwt jwt = (Jwt) auth.getPrincipal(); + + assertThat(auth.getCredentials()).isEqualTo(jwt); + assertThat(auth.getDetails()).isNull(); + + assertThat(jwt.getTokenValue()).isEqualTo(JwtAuthenticationBuilder.DEFAULT_TOKEN_VALUE); + assertThat(jwt.getSubject()).isEqualTo(AbstractAuthenticationBuilder.DEFAULT_AUTH_NAME); + assertThat(jwt.getAudience()).isNull(); + assertThat(jwt.getIssuer()).isNull(); + assertThat(jwt.getIssuedAt()).isNull(); + assertThat(jwt.getExpiresAt()).isNull(); + assertThat(jwt.getNotBefore()).isNull(); + assertThat(jwt.getId()).isNull(); + + final Map headers = jwt.getHeaders(); + assertThat(headers).hasSize(1); + assertThat(headers.get(JwtAuthenticationBuilder.DEFAULT_HEADER_NAME)) + .isEqualTo(JwtAuthenticationBuilder.DEFAULT_HEADER_VALUE); + } + + @Test + public void customFrequent() { + final WithMockJwt annotation = AnnotationUtils.findAnnotation(CustomFrequent.class, WithMockJwt.class); + + final Authentication auth = factory.createSecurityContext(annotation).getAuthentication(); + + assertThat(auth.getName()).isEqualTo("Test User"); + assertThat(auth.getAuthorities()).hasSize(2); + assertThat( + auth.getAuthorities() + .stream() + .allMatch( + a -> a.equals(new SimpleGrantedAuthority("ROLE_ADMIN")) + || a.equals(new SimpleGrantedAuthority("ROLE_USER")))).isTrue(); + assertThat(auth.getPrincipal()).isInstanceOf(Jwt.class); + + final Jwt jwt = (Jwt) auth.getPrincipal(); + + assertThat(auth.getCredentials()).isEqualTo(jwt); + assertThat(auth.getDetails()).isNull(); + + assertThat(jwt.getTokenValue()).isEqualTo(JwtAuthenticationBuilder.DEFAULT_TOKEN_VALUE); + assertThat(jwt.getSubject()).isEqualTo("Test User"); + assertThat(jwt.getAudience()).isNull(); + assertThat(jwt.getIssuer()).isNull(); + assertThat(jwt.getIssuedAt()).isNull(); + assertThat(jwt.getExpiresAt()).isNull(); + assertThat(jwt.getNotBefore()).isNull(); + assertThat(jwt.getId()).isNull(); + + final Map headers = jwt.getHeaders(); + assertThat(headers).hasSize(1); + assertThat(headers.get(JwtAuthenticationBuilder.DEFAULT_HEADER_NAME)) + .isEqualTo(JwtAuthenticationBuilder.DEFAULT_HEADER_VALUE); + } + + @Test + public void customAdvanced() { + final WithMockJwt annotation = AnnotationUtils.findAnnotation(CustomAdvanced.class, WithMockJwt.class); + + final Authentication auth = factory.createSecurityContext(annotation).getAuthentication(); + + assertThat(auth.getName()).isEqualTo("Test User"); + assertThat(auth.getAuthorities()).hasSize(2); + assertThat( + auth.getAuthorities() + .stream() + .allMatch( + a -> a.equals(new SimpleGrantedAuthority("ROLE_ADMIN")) + || a.equals(new SimpleGrantedAuthority("ROLE_USER")))).isTrue(); + assertThat(auth.getPrincipal()).isInstanceOf(Jwt.class); + + final Jwt jwt = (Jwt) auth.getPrincipal(); + + assertThat(auth.getCredentials()).isEqualTo(jwt); + assertThat(auth.getDetails()).isNull(); + + assertThat(jwt.getTokenValue()).isEqualTo(JwtAuthenticationBuilder.DEFAULT_TOKEN_VALUE); + assertThat(jwt.getSubject()).isEqualTo("Test User"); + assertThat(jwt.getAudience()).isNull(); + assertThat(jwt.getIssuer()).isNull(); + assertThat(jwt.getIssuedAt()).isNull(); + assertThat(jwt.getExpiresAt()).isNull(); + assertThat(jwt.getNotBefore()).isNull(); + assertThat(jwt.getId()).isNull(); + assertThat(jwt.getClaimAsString("custom-claim")).isEqualTo("foo"); + + final Map headers = jwt.getHeaders(); + assertThat(headers).hasSize(1); + assertThat(headers.get(JwtAuthenticationBuilder.DEFAULT_HEADER_NAME)) + .isEqualTo(JwtAuthenticationBuilder.DEFAULT_HEADER_VALUE); + } + + @Test + public void custom() throws Exception { + final SimpleGrantedAuthority machinAuthority = new SimpleGrantedAuthority("machin"); + final SimpleGrantedAuthority choseAuthority = new SimpleGrantedAuthority("chose"); + + final WithMockJwt annotation = AnnotationUtils.findAnnotation(CustomFull.class, WithMockJwt.class); + + final JwtAuthenticationToken auth = + (JwtAuthenticationToken) factory.createSecurityContext(annotation).getAuthentication(); + final Jwt principal = (Jwt) auth.getPrincipal(); + + assertThat(auth.getAuthorities()).hasSize(2); + assertThat(auth.getAuthorities().stream().allMatch(a -> a.equals(machinAuthority) || a.equals(choseAuthority))) + .isTrue(); + + assertThat(auth.getCredentials()).isEqualTo(principal); + + assertThat(auth.getDetails()).isNull(); + + assertThat(auth.getName()).isEqualTo("truc"); + + assertThat(principal.getAudience()).hasSize(2); + assertThat(principal.getAudience()).contains("test audience", "other audience"); + assertThat(principal.getExpiresAt()).isNull(); + assertThat(principal.getHeaders()).hasSize(1); + assertThat(principal.getHeaders().get("a")).isEqualTo("1"); + assertThat(principal.getId()).isEqualTo("test ID"); + assertThat(principal.getIssuedAt()).isEqualTo(Instant.parse("2019-03-03T22:35:00.0Z")); + assertThat(principal.getIssuer()).isEqualTo(new URL("https://test-issuer.org")); + assertThat(principal.getSubject()).isEqualTo("truc"); + assertThat(principal.getNotBefore()).isNull(); + assertThat(principal.getTokenValue()).isEqualTo(JwtAuthenticationBuilder.DEFAULT_TOKEN_VALUE); + assertThat(principal.getClaims().get("custom-claim")).isEqualTo("foo"); + + } + + public static final class FooParser implements AttributeValueParser { + + @Override + public String parse(final String value) { + return "foo"; + } + + } + +} diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/annotation/WithMockJwtTests.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/annotation/WithMockJwtTests.java new file mode 100644 index 00000000000..873cb99fbe2 --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/annotation/WithMockJwtTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.annotation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.security.test.context.support.TestExecutionEvent; +import org.springframework.security.test.context.support.WithSecurityContext; +import org.springframework.security.test.oauth2.support.AbstractAuthenticationBuilder; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class WithMockJwtTests { + + @Test + public void defaults() { + final WithMockJwt auth = AnnotationUtils.findAnnotation(Annotated.class, WithMockJwt.class); + assertThat(auth.name()).isEqualTo(AbstractAuthenticationBuilder.DEFAULT_AUTH_NAME); + assertThat(auth.authorities()).hasSize(1); + assertThat(auth.authorities()).contains("ROLE_USER"); + assertThat(auth.headers()).hasAtLeastOneElementOfType(StringAttribute.class); + assertThat(auth.claims()).isNotNull(); + + final WithSecurityContext context = + AnnotatedElementUtils.findMergedAnnotation(Annotated.class, WithSecurityContext.class); + + assertThat(context.setupBefore()).isEqualTo(TestExecutionEvent.TEST_METHOD); + } + + @WithMockJwt + private static class Annotated { + } + + @Test + public void findMergedAnnotationWhenSetupExplicitThenOverridden() { + final WithSecurityContext context = + AnnotatedElementUtils.findMergedAnnotation(SetupExplicit.class, WithSecurityContext.class); + + assertThat(context.setupBefore()).isEqualTo(TestExecutionEvent.TEST_METHOD); + } + + @WithMockJwt(setupBefore = TestExecutionEvent.TEST_METHOD) + private class SetupExplicit { + } + + @Test + public void findMergedAnnotationWhenSetupOverriddenThenOverridden() { + final WithSecurityContext context = + AnnotatedElementUtils.findMergedAnnotation(SetupOverridden.class, WithSecurityContext.class); + + assertThat(context.setupBefore()).isEqualTo(TestExecutionEvent.TEST_EXECUTION); + } + + @WithMockJwt(setupBefore = TestExecutionEvent.TEST_EXECUTION) + private class SetupOverridden { + } +} diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/reactive/server/AccessTokenMutatorTest.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/reactive/server/AccessTokenMutatorTest.java new file mode 100644 index 00000000000..e536dd8fa6f --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/reactive/server/AccessTokenMutatorTest.java @@ -0,0 +1,127 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.reactive.server; + +import static org.springframework.security.test.oauth2.reactive.server.OAuth2SecurityMockServerConfigurers.mockAccessToken; + +import org.junit.Test; +import org.springframework.security.test.oauth2.reactive.server.TestControllers.AccessTokenController; +import org.springframework.security.test.oauth2.reactive.server.TestControllers.AuthoritiesController; +import org.springframework.security.test.oauth2.reactive.server.TestControllers.GreetController; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class AccessTokenMutatorTest { + + @Test + public void testDefaultAccessTokenConfigurer() { + GreetController.clientBuilder() + .apply(mockAccessToken()) + .build() + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("Hello user!"); + + AuthoritiesController.clientBuilder() + .apply(mockAccessToken()) + .build() + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("[\"ROLE_USER\"]"); + } + + @Test + public void testCustomAccessTokenConfigurer() { + GreetController.clientBuilder() + .apply(mockAccessToken().name("ch4mpy").scopes("message:read")) + .build() + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("Hello ch4mpy!"); + + AuthoritiesController.clientBuilder() + .apply(mockAccessToken().name("ch4mpy").scopes("message:read")) + .build() + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("[\"SCOPE_message:read\"]"); + + AccessTokenController.clientBuilder() + .apply(mockAccessToken().name("ch4mpy").scopes("message:read")) + .build() + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals( + "Hello,ch4mpy! You are sucessfully authenticated and granted with [message:read] scopes using a JavaWebToken."); + } + + @Test + public void testCustomAccessTokenMutator() { + GreetController.client() + .mutateWith((mockAccessToken().name("ch4mpy").scopes("message:read"))) + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("Hello ch4mpy!"); + + AuthoritiesController.client() + .mutateWith((mockAccessToken().name("ch4mpy").scopes("message:read"))) + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("[\"SCOPE_message:read\"]"); + + AccessTokenController.client() + .mutateWith(mockAccessToken().name("ch4mpy").scopes("message:read")) + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals( + "Hello, ch4mpy! You are sucessfully authenticated and granted with [message:read] scopes using an OAuth2AccessToken."); + } + +} diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/reactive/server/JwtMutatorTest.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/reactive/server/JwtMutatorTest.java new file mode 100644 index 00000000000..31d6305cff5 --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/reactive/server/JwtMutatorTest.java @@ -0,0 +1,127 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.reactive.server; + +import static org.springframework.security.test.oauth2.reactive.server.OAuth2SecurityMockServerConfigurers.mockJwt; + +import org.junit.Test; +import org.springframework.security.test.oauth2.reactive.server.TestControllers.AuthoritiesController; +import org.springframework.security.test.oauth2.reactive.server.TestControllers.GreetController; +import org.springframework.security.test.oauth2.reactive.server.TestControllers.JwtController; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class JwtMutatorTest { + + @Test + public void testDefaultJwtConfigurer() { + GreetController.clientBuilder() + .apply(mockJwt()) + .build() + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("Hello user!"); + + AuthoritiesController.clientBuilder() + .apply(mockJwt()) + .build() + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("[\"ROLE_USER\"]"); + } + + @Test + public void testCustomJwtConfigurer() { + GreetController.clientBuilder() + .apply(mockJwt().name("ch4mpy").scope("message:read")) + .build() + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("Hello ch4mpy!"); + + AuthoritiesController.clientBuilder() + .apply(mockJwt().name("ch4mpy").scope("message:read")) + .build() + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("[\"SCOPE_message:read\"]"); + + JwtController.clientBuilder() + .apply(mockJwt().name("ch4mpy").scope("message:read")) + .build() + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals( + "Hello,ch4mpy! You are sucessfully authenticated and granted with [message:read] scopes using a JavaWebToken."); + } + + @Test + public void testCustomJwtMutator() { + GreetController.client() + .mutateWith((mockJwt().name("ch4mpy").scope("message:read"))) + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("Hello ch4mpy!"); + + AuthoritiesController.client() + .mutateWith((mockJwt().name("ch4mpy").scope("message:read"))) + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("[\"SCOPE_message:read\"]"); + + JwtController.client() + .mutateWith(mockJwt().name("ch4mpy").scope("message:read")) + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals( + "Hello,ch4mpy! You are sucessfully authenticated and granted with [message:read] scopes using a JavaWebToken."); + } + +} diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/reactive/server/OidcIdTokenMutatorTest.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/reactive/server/OidcIdTokenMutatorTest.java new file mode 100644 index 00000000000..8fb39baad21 --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/reactive/server/OidcIdTokenMutatorTest.java @@ -0,0 +1,127 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.reactive.server; + +import static org.springframework.security.test.oauth2.reactive.server.OAuth2SecurityMockServerConfigurers.mockOidcId; + +import org.junit.Test; +import org.springframework.security.test.oauth2.reactive.server.TestControllers.AuthoritiesController; +import org.springframework.security.test.oauth2.reactive.server.TestControllers.GreetController; +import org.springframework.security.test.oauth2.reactive.server.TestControllers.OidcIdTokenController; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class OidcIdTokenMutatorTest { + + @Test + public void testDefaultOidcIdTokenConfigurer() { + GreetController.clientBuilder() + .apply(mockOidcId()) + .build() + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("Hello user!"); + + AuthoritiesController.clientBuilder() + .apply(mockOidcId()) + .build() + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("[\"ROLE_USER\"]"); + } + + @Test + public void testCustomOidcIdTokenConfigurer() { + GreetController.clientBuilder() + .apply(mockOidcId().name("ch4mpy").scope("message:read")) + .build() + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("Hello ch4mpy!"); + + AuthoritiesController.clientBuilder() + .apply(mockOidcId().name("ch4mpy").scope("message:read")) + .build() + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("[\"SCOPE_message:read\"]"); + + OidcIdTokenController.clientBuilder() + .apply(mockOidcId().name("ch4mpy").scope("message:read")) + .build() + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals( + "Hello,ch4mpy! You are sucessfully authenticated and granted with [message:read] scopes using a JavaWebToken."); + } + + @Test + public void testCustomOidcIdTokenMutator() { + GreetController.client() + .mutateWith((mockOidcId().name("ch4mpy").scope("message:read"))) + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("Hello ch4mpy!"); + + AuthoritiesController.client() + .mutateWith((mockOidcId().name("ch4mpy").scope("message:read"))) + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals("[\"SCOPE_message:read\"]"); + + OidcIdTokenController.client() + .mutateWith(mockOidcId().name("ch4mpy").scope("message:read")) + .get() + .exchange() + .expectStatus() + .isOk() + .expectBody() + .toString() + .equals( + "Hello, ch4mpy! You are sucessfully authenticated and granted with [message:read] scopes using an OAuth2OidcIdToken."); + } + +} diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/reactive/server/TestControllers.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/reactive/server/TestControllers.java new file mode 100644 index 00000000000..29242ca80d7 --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/reactive/server/TestControllers.java @@ -0,0 +1,171 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.reactive.server; + +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; + +import java.security.Principal; +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter; +import org.springframework.security.web.server.csrf.CsrfWebFilter; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class TestControllers { + + @RestController + public static class GreetController { + @RequestMapping("/**") + public String index(final Principal authentication) { + return String.format("Hello, %s!", authentication.getName()); + } + + public static WebTestClient.Builder clientBuilder() { + return WebTestClient.bindToController(new TestControllers.GreetController()) + .webFilter(new CsrfWebFilter(), new SecurityContextServerWebExchangeWebFilter()) + .apply(springSecurity()) + .configureClient() + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); + } + + public static WebTestClient client() { + return clientBuilder().build(); + } + } + + @RestController + public static class AuthoritiesController { + @RequestMapping("/**") + public String index(final Authentication authentication) { + return authentication.getAuthorities() + .stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()) + .toString(); + } + + public static WebTestClient.Builder clientBuilder() { + return WebTestClient.bindToController(new TestControllers.AuthoritiesController()) + .webFilter(new CsrfWebFilter(), new SecurityContextServerWebExchangeWebFilter()) + .apply(springSecurity()) + .configureClient() + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); + } + + public static WebTestClient client() { + return clientBuilder().build(); + } + } + + @RestController + public static class JwtController { + @RequestMapping("/**") + // TODO: investigate why "@AuthenticationPrincipal Jwt token" does not work here + public String index(final Authentication authentication) { + final Jwt token = (Jwt) authentication.getPrincipal(); + @SuppressWarnings("unchecked") + final Collection scopes = (Collection) token.getClaims().get("scope"); + + return String.format( + "Hello, %s! You are sucessfully authenticated and granted with %s scopes using a JavaWebToken.", + token.getSubject(), + scopes.toString()); + } + + public static WebTestClient.Builder clientBuilder() { + return WebTestClient.bindToController(new TestControllers.JwtController()) + .webFilter(new CsrfWebFilter(), new SecurityContextServerWebExchangeWebFilter()) + .apply(springSecurity()) + .configureClient() + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); + } + + public static WebTestClient client() { + return clientBuilder().build(); + } + } + + @RestController + public static class AccessTokenController { + @RequestMapping("/**") + // TODO: investigate why "@AuthenticationPrincipal Map + // tokenAttributes" does not work here + public String index(final Authentication authentication) { + @SuppressWarnings("unchecked") + final Map tokenAttributes = (Map) authentication.getPrincipal(); + return String.format( + "Hello, %s! You are sucessfully authenticated and granted with %s scopes using an OAuth2AccessToken.", + tokenAttributes.get("username"), + tokenAttributes.get("scope").toString()); + } + + public static WebTestClient.Builder clientBuilder() { + return WebTestClient.bindToController(new TestControllers.AccessTokenController()) + .webFilter(new CsrfWebFilter(), new SecurityContextServerWebExchangeWebFilter()) + .apply(springSecurity()) + .configureClient() + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); + } + + public static WebTestClient client() { + return clientBuilder().build(); + } + } + + @RestController + public static class OidcIdTokenController { + @RequestMapping("/**") + // TODO: investigate why "@AuthenticationPrincipal OidcUser token" does not work + // here + public String index(final Authentication authentication) { + final OidcUser token = (OidcUser) authentication.getPrincipal(); + return String.format( + "Hello, %s! You are sucessfully authenticated and granted with %s authorities using an OidcId token.", + token.getName(), + token.getAuthorities() + .stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()) + .toString()); + } + + public static WebTestClient.Builder clientBuilder() { + return WebTestClient.bindToController(new TestControllers.OidcIdTokenController()) + .webFilter(new CsrfWebFilter(), new SecurityContextServerWebExchangeWebFilter()) + .apply(springSecurity()) + .configureClient() + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); + } + + public static WebTestClient client() { + return clientBuilder().build(); + } + } +} diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/request/AbstractRequestPostProcessorTest.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/request/AbstractRequestPostProcessorTest.java new file mode 100644 index 00000000000..ba456a4d670 --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/request/AbstractRequestPostProcessorTest.java @@ -0,0 +1,59 @@ +/* Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.request; + +import java.util.Collections; +import java.util.Enumeration; +import java.util.Map; + +import org.junit.Before; +import org.mockito.Mock; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public abstract class AbstractRequestPostProcessorTest { + + @Mock + MockHttpServletRequest request; + + final static String TEST_NAME = "ch4mpy"; + final static String[] TEST_AUTHORITIES = new String[] { "TEST_AUTHORITY" }; + final static String[] TEST_SCOPES = new String[] { "test:collection" }; + final static String SCOPE_CLAIM_NAME = "scp"; + final static Map TEST_CLAIMS = + Collections.singletonMap(SCOPE_CLAIM_NAME, Collections.singleton("test:claim")); + + @Before + public void setup() throws Exception { + request = new MockHttpServletRequest(); + } + + static Authentication authentication(final MockHttpServletRequest req) { + for (final Enumeration names = req.getAttributeNames(); names.hasMoreElements();) { + final String name = names.nextElement(); + if (name.contains("SecurityContext")) { + final SecurityContext securityContext = (SecurityContext) req.getAttribute(name); + return securityContext.getAuthentication(); + } + } + return null; + } + +} diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/request/AccessTokenRequestPostProcessorTest.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/request/AccessTokenRequestPostProcessorTest.java new file mode 100644 index 00000000000..adde466bc24 --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/request/AccessTokenRequestPostProcessorTest.java @@ -0,0 +1,53 @@ +/* Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.oauth2.request.OAuth2MockMvcRequestPostProcessors.mockAccessToken; + +import java.util.Collection; + +import org.junit.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionAuthenticationToken; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class AccessTokenRequestPostProcessorTest extends AbstractRequestPostProcessorTest { + + @Test + @SuppressWarnings("unchecked") + public void test() { + final AccessTokenRequestPostProcessor rpp = mockAccessToken().name(TEST_NAME) + .authorities(TEST_AUTHORITIES) + .scopes(TEST_SCOPES) + .claims(TEST_CLAIMS) + .scopesClaimName(SCOPE_CLAIM_NAME); + + final OAuth2IntrospectionAuthenticationToken actual = + (OAuth2IntrospectionAuthenticationToken) authentication(rpp.postProcessRequest(request)); + + assertThat(actual.getName()).isEqualTo(TEST_NAME); + assertThat(actual.getAuthorities()).containsExactlyInAnyOrder( + new SimpleGrantedAuthority("TEST_AUTHORITY"), + new SimpleGrantedAuthority("SCOPE_test:collection"), + new SimpleGrantedAuthority("SCOPE_test:claim")); + assertThat((Collection) actual.getTokenAttributes().get(SCOPE_CLAIM_NAME)) + .containsExactlyInAnyOrder("test:collection", "test:claim"); + } + +} diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/request/JwtRequestPostProcessorTest.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/request/JwtRequestPostProcessorTest.java new file mode 100644 index 00000000000..4650c98da84 --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/request/JwtRequestPostProcessorTest.java @@ -0,0 +1,52 @@ +/* Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.oauth2.request.OAuth2MockMvcRequestPostProcessors.mockJwt; + +import java.util.Collection; + +import org.junit.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class JwtRequestPostProcessorTest extends AbstractRequestPostProcessorTest { + + @Test + @SuppressWarnings("unchecked") + public void test() { + final JwtRequestPostProcessor rpp = mockJwt().name(TEST_NAME) + .authorities(TEST_AUTHORITIES) + .scopes(TEST_SCOPES) + .claims(TEST_CLAIMS) + .scopesClaimName(SCOPE_CLAIM_NAME); + + final JwtAuthenticationToken actual = (JwtAuthenticationToken) authentication(rpp.postProcessRequest(request)); + + assertThat(actual.getName()).isEqualTo(TEST_NAME); + assertThat(actual.getAuthorities()).containsExactlyInAnyOrder( + new SimpleGrantedAuthority("TEST_AUTHORITY"), + new SimpleGrantedAuthority("SCOPE_test:collection"), + new SimpleGrantedAuthority("SCOPE_test:claim")); + assertThat((Collection) actual.getTokenAttributes().get("scp")) + .containsExactlyInAnyOrder("test:collection", "test:claim"); + } + +} diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/request/OidcIdTokenRequestPostProcessorTest.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/request/OidcIdTokenRequestPostProcessorTest.java new file mode 100644 index 00000000000..44d16d822c9 --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/request/OidcIdTokenRequestPostProcessorTest.java @@ -0,0 +1,54 @@ +/* Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.oauth2.request.OAuth2MockMvcRequestPostProcessors.mockOidcId; + +import java.util.Collection; + +import org.junit.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class OidcIdTokenRequestPostProcessorTest extends AbstractRequestPostProcessorTest { + + @SuppressWarnings("unchecked") + @Test + public void test() { + final OidcIdTokenRequestPostProcessor rpp = mockOidcId().name(TEST_NAME) + .authorities(TEST_AUTHORITIES) + .scopes(TEST_SCOPES) + .claims(TEST_CLAIMS) + .scopesClaimName(SCOPE_CLAIM_NAME); + + final OAuth2LoginAuthenticationToken actual = + (OAuth2LoginAuthenticationToken) authentication(rpp.postProcessRequest(request)); + + assertThat(actual.getName()).isEqualTo(TEST_NAME); + assertThat(actual.getAuthorities()).containsExactlyInAnyOrder( + new SimpleGrantedAuthority("TEST_AUTHORITY"), + new SimpleGrantedAuthority("SCOPE_test:collection"), + new SimpleGrantedAuthority("SCOPE_test:claim")); + assertThat(actual.getAccessToken().getScopes()).containsExactlyInAnyOrder("test:collection", "test:claim"); + assertThat((Collection) actual.getPrincipal().getAttributes().get("scp")) + .containsExactlyInAnyOrder("test:collection", "test:claim"); + } + +} diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/support/AccessTokenAuthenticationBuilderTest.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/support/AccessTokenAuthenticationBuilderTest.java new file mode 100644 index 00000000000..15629ab6b3a --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/support/AccessTokenAuthenticationBuilderTest.java @@ -0,0 +1,116 @@ +/* Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; + +import org.junit.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class AccessTokenAuthenticationBuilderTest { + static class TestAccessTokenAuthenticationBuilder + extends + AccessTokenAuthenticationBuilder { + } + + @Test + public void authenticationNameAndTokenSubjectClaimAreSet() { + final OAuth2IntrospectionAuthenticationToken actual = + new TestAccessTokenAuthenticationBuilder().name("ch4mpy").build(); + + assertThat(actual.getName()).isEqualTo("ch4mpy"); + assertThat(actual.getTokenAttributes().get(OAuth2IntrospectionClaimNames.USERNAME)).isEqualTo("ch4mpy"); + } + + @Test + public void tokenIatIsSetFromClaims() { + final OAuth2AccessToken actual = new TestAccessTokenAuthenticationBuilder().name("ch4mpy") + .claim(OAuth2IntrospectionClaimNames.ISSUED_AT, Instant.parse("2019-03-21T13:52:25Z")) + .build() + .getToken(); + + assertThat(actual.getIssuedAt()).isEqualTo(Instant.parse("2019-03-21T13:52:25Z")); + assertThat(actual.getExpiresAt()).isNull(); + } + + @Test + public void tokenExpIsSetFromClaims() { + final OAuth2AccessToken actual = new TestAccessTokenAuthenticationBuilder().name("ch4mpy") + .claim(OAuth2IntrospectionClaimNames.EXPIRES_AT, Instant.parse("2019-03-21T13:52:25Z")) + .build() + .getToken(); + + assertThat(actual.getIssuedAt()).isNull(); + assertThat(actual.getExpiresAt()).isEqualTo(Instant.parse("2019-03-21T13:52:25Z")); + } + + @Test + public void scopesCollectionAndScopeClaimAreAddedToAuthorities() { + final OAuth2IntrospectionAuthenticationToken actual = new TestAccessTokenAuthenticationBuilder().name("ch4mpy") + .authorities("TEST_AUTHORITY") + .scopes("scope:collection") + .claim("scope", Collections.singleton("scope:claim")) + .build(); + + assertThat(actual.getAuthorities()).containsExactlyInAnyOrder( + new SimpleGrantedAuthority("TEST_AUTHORITY"), + new SimpleGrantedAuthority("SCOPE_scope:collection"), + new SimpleGrantedAuthority("SCOPE_scope:claim")); + } + + @SuppressWarnings("unchecked") + @Test + public void scopesCollectionAndScopeAuthoritiesAreAddedToScopeClaim() { + final OAuth2IntrospectionAuthenticationToken actual = new TestAccessTokenAuthenticationBuilder().name("ch4mpy") + .authorities("SCOPE_scope:authority") + .scope("scope:collection") + .claim("scope", Collections.singleton("scope:claim")) + .build(); + + assertThat((Collection) actual.getTokenAttributes().get("scope")) + .containsExactlyInAnyOrder("scope:authority", "scope:collection", "scope:claim"); + } + + /** + * "scp" is the an usual name for "scope" claim + */ + + @Test + public void scopesCollectionAndScpClaimAreAddedToAuthorities() { + final OAuth2IntrospectionAuthenticationToken actual = new TestAccessTokenAuthenticationBuilder().name("ch4mpy") + .authorities("TEST_AUTHORITY") + .scopes("scope:collection") + .claim("scp", Collections.singleton("scope:claim")) + .scopesClaimName("scp") + .build(); + + assertThat(actual.getAuthorities()).containsExactlyInAnyOrder( + new SimpleGrantedAuthority("TEST_AUTHORITY"), + new SimpleGrantedAuthority("SCOPE_scope:collection"), + new SimpleGrantedAuthority("SCOPE_scope:claim")); + } + +} diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/support/CollectionsSupportTest.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/support/CollectionsSupportTest.java new file mode 100644 index 00000000000..f8e5219dfe0 --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/support/CollectionsSupportTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.support; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.oauth2.support.CollectionsSupport.nullIfEmpty; +import static org.springframework.security.test.oauth2.support.CollectionsSupport.putIfNotEmpty; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import org.junit.Test; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class CollectionsSupportTest { + + @Test + public void nullIfEmptyReturnsNullForNullString() { + assertThat(nullIfEmpty(null)).isNull(); + } + + @Test + public void nullIfEmptyReturnsNullForEmptyString() { + assertThat(nullIfEmpty("")).isNull(); + } + + @Test + public void nullIfEmptyReturnsNonNullForSpace() { + assertThat(nullIfEmpty(" ")).isEqualTo(" "); + } + + @Test + public void nullIfEmptyReturnsNonNullForToto() { + assertThat(nullIfEmpty("Toto")).isEqualTo("Toto"); + } + + @Test + public void putIfNotEmptyDoesNothingForNullString() { + assertThat(putIfNotEmpty("foo", (String) null, new HashMap<>())).isEmpty(); + } + + @Test + public void putIfNotEmptyDoesNothingForEmptyString() { + assertThat(putIfNotEmpty("foo", "", new HashMap<>())).isEmpty(); + } + + @Test + public void putIfNotEmptyInsertsSpace() { + assertThat(putIfNotEmpty("foo", " ", new HashMap<>()).get("foo")).isEqualTo(" "); + } + + @Test + public void putIfNotEmptyInsertsToto() { + assertThat(putIfNotEmpty("foo", "Toto", new HashMap<>()).get("foo")).isEqualTo("Toto"); + } + + @Test + public void putIfNotEmptyDoesNothingForNullList() { + assertThat(putIfNotEmpty("foo", (List) null, new HashMap<>())).isEmpty(); + } + + @Test + public void putIfNotEmptyDoesNothingForEmptyList() { + assertThat(putIfNotEmpty("foo", Collections.emptyList(), new HashMap<>())).isEmpty(); + } + + @Test + public void putIfNotEmptyInsertsNonEmptyList() { + @SuppressWarnings("unchecked") + final List actual = + (List) (putIfNotEmpty("foo", Collections.singletonList("Toto"), new HashMap<>()).get("foo")); + assertThat(actual).hasSize(1); + assertThat(actual).contains("Toto"); + } + +} diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/support/JwtAuthenticationBuilderTest.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/support/JwtAuthenticationBuilderTest.java new file mode 100644 index 00000000000..49fa92c2465 --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/support/JwtAuthenticationBuilderTest.java @@ -0,0 +1,169 @@ +/* Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class JwtAuthenticationBuilderTest { + static class TestJwtAuthenticationBuilder extends JwtAuthenticationBuilder { + } + + @Test + public void defaultNameAndAuthority() { + final JwtAuthenticationToken actual = new TestJwtAuthenticationBuilder().build(); + + assertThat(actual.getName()).isEqualTo("user"); + assertThat(actual.getAuthorities()).containsExactly(new SimpleGrantedAuthority("ROLE_USER")); + } + + @Test + public void defaultNameAndRoleOverides() { + assertThat(new TestJwtAuthenticationBuilder().name("ch4mpy").build().getName()).isEqualTo("ch4mpy"); + assertThat(new TestJwtAuthenticationBuilder().authority("TEST").build().getAuthorities()) + .containsExactly(new SimpleGrantedAuthority("TEST")); + assertThat(new TestJwtAuthenticationBuilder().role("TEST").build().getAuthorities()) + .containsExactly(new SimpleGrantedAuthority("ROLE_TEST")); + assertThat(new TestJwtAuthenticationBuilder().scope("TEST").build().getAuthorities()) + .containsExactly(new SimpleGrantedAuthority("SCOPE_TEST")); + } + + @Test + public void authenticationNameAndTokenSubjectClaimAreSet() { + final JwtAuthenticationToken actual = new TestJwtAuthenticationBuilder().name("ch4mpy").build(); + + assertThat(actual.getName()).isEqualTo("ch4mpy"); + assertThat(actual.getTokenAttributes().get(JwtClaimNames.SUB)).isEqualTo("ch4mpy"); + } + + @Test + public void tokenIatIsSetFromClaims() { + final Jwt actual = new TestJwtAuthenticationBuilder().name("ch4mpy") + .claim(JwtClaimNames.IAT, Instant.parse("2019-03-21T13:52:25Z")) + .build() + .getToken(); + + assertThat(actual.getIssuedAt()).isEqualTo(Instant.parse("2019-03-21T13:52:25Z")); + assertThat(actual.getExpiresAt()).isNull(); + assertThat(actual.getClaimAsInstant(JwtClaimNames.IAT)).isEqualTo(Instant.parse("2019-03-21T13:52:25Z")); + assertThat(actual.getClaimAsInstant(JwtClaimNames.EXP)).isNull(); + } + + @Test + public void tokenExpIsSetFromClaims() { + final Jwt actual = new TestJwtAuthenticationBuilder().name("ch4mpy") + .claim(JwtClaimNames.EXP, Instant.parse("2019-03-21T13:52:25Z")) + .build() + .getToken(); + + assertThat(actual.getIssuedAt()).isNull(); + assertThat(actual.getExpiresAt()).isEqualTo(Instant.parse("2019-03-21T13:52:25Z")); + assertThat(actual.getClaimAsInstant(JwtClaimNames.IAT)).isNull(); + assertThat(actual.getClaimAsInstant(JwtClaimNames.EXP)).isEqualTo(Instant.parse("2019-03-21T13:52:25Z")); + } + + @Test + public void scopesCollectionAndScopeClaimAreAddedToAuthorities() { + final JwtAuthenticationToken actual = new TestJwtAuthenticationBuilder().name("ch4mpy") + .authority("TEST_AUTHORITY") + .scope("scope:collection") + .claim("scope", Collections.singleton("scope:claim")) + .build(); + + assertThat(actual.getAuthorities()).containsExactlyInAnyOrder( + new SimpleGrantedAuthority("TEST_AUTHORITY"), + new SimpleGrantedAuthority("SCOPE_scope:collection"), + new SimpleGrantedAuthority("SCOPE_scope:claim")); + } + + @SuppressWarnings("unchecked") + @Test + public void scopesCollectionAndScopeAuthoritiesAreAddedToScopeClaim() { + final JwtAuthenticationToken actual = new TestJwtAuthenticationBuilder().name("ch4mpy") + .authorities("SCOPE_scope:authority") + .scope("scope:collection") + .claim("scope", Collections.singleton("scope:claim")) + .build(); + + assertThat((Collection) actual.getToken().getClaims().get("scope")) + .containsExactlyInAnyOrder("scope:authority", "scope:collection", "scope:claim"); + } + + /** + * "scp" is the an usual name for "scope" claim + */ + + @Test + public void scopesCollectionAndScpClaimAreAddedToAuthorities() { + final JwtAuthenticationToken actual = new TestJwtAuthenticationBuilder().name("ch4mpy") + .authorities("TEST_AUTHORITY") + .scopes("scope:collection") + .claim("scp", Collections.singleton("scope:claim")) + .scopesClaimName("scp") + .build(); + + assertThat(actual.getAuthorities()).containsExactlyInAnyOrder( + new SimpleGrantedAuthority("TEST_AUTHORITY"), + new SimpleGrantedAuthority("SCOPE_scope:collection"), + new SimpleGrantedAuthority("SCOPE_scope:claim")); + } + + @Test + public void fromJwt() { + final Jwt jwt = new Jwt( + "test-token", + null, + null, + Collections.singletonMap("test-header", "test"), + Collections.singletonMap(JwtClaimNames.SUB, "ch4mpy")); + final JwtAuthenticationToken actual = new TestJwtAuthenticationBuilder().jwt(jwt).build(); + assertThat(actual.getAuthorities()).containsExactly(new SimpleGrantedAuthority("ROLE_USER")); + assertThat(actual.getName()).isEqualTo("ch4mpy"); + assertThat(actual.getTokenAttributes()).hasSize(1); + assertThat(actual.getTokenAttributes().get(JwtClaimNames.SUB)).isEqualTo("ch4mpy"); + } + + @Test(expected = RuntimeException.class) + public void fromInconsistentJwtInstants() { + final Map claims = new HashMap<>(); + claims.put(JwtClaimNames.SUB, "ch4mpy"); + claims.put(JwtClaimNames.IAT, Instant.parse("2018-01-01T01:01:01Z")); + claims.put(JwtClaimNames.EXP, Instant.parse("2018-02-02T02:02:02Z")); + final Jwt jwt = new Jwt( + "test-token", + Instant.parse("2019-01-01T01:01:01Z"), + Instant.parse("2019-02-02T02:02:02Z"), + Collections.singletonMap("test-header", "test"), + claims); + + final JwtAuthenticationToken actual = new TestJwtAuthenticationBuilder().jwt(jwt).build(); + } + +} diff --git a/oauth2-test/src/test/java/org/springframework/security/test/oauth2/support/OidcIdSupportTest.java b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/support/OidcIdSupportTest.java new file mode 100644 index 00000000000..774aefa4a97 --- /dev/null +++ b/oauth2-test/src/test/java/org/springframework/security/test/oauth2/support/OidcIdSupportTest.java @@ -0,0 +1,99 @@ +/* Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.oauth2.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Collections; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + */ +public class OidcIdSupportTest { + private static final String CLIENT_ID = "test-client"; + TestOidcIdAuthenticationBuilder builder; + + static class TestOidcIdAuthenticationBuilder + extends + OidcIdTokenAuthenticationBuilder { + public TestOidcIdAuthenticationBuilder() { + super(AuthorizationGrantType.AUTHORIZATION_CODE); + } + } + + @Before + public void setUp() { + builder = new TestOidcIdAuthenticationBuilder().name("ch4mpy").nameAttributeKey("userName").role("USER"); + builder.clientRegistrationBuilder.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .clientId(CLIENT_ID) + .tokenUri("https://stub"); + builder.authorizationRequestBuilder.authorizationUri("https://stub") + .clientId(CLIENT_ID) + .redirectUri("https://stub"); + } + + @Test + public void authenticationNameIsSet() { + final OAuth2LoginAuthenticationToken actual = builder.build(); + + assertThat(actual.getName()).isEqualTo("ch4mpy"); + } + + @Test + public void tokenIatIsSetFromClaims() { + final OAuth2AccessToken actual = + builder.claim(OAuth2IntrospectionClaimNames.ISSUED_AT, Instant.parse("2019-03-21T13:52:25Z")) + .build() + .getAccessToken(); + + assertThat(actual.getIssuedAt()).isEqualTo(Instant.parse("2019-03-21T13:52:25Z")); + assertThat(actual.getExpiresAt()).isNull(); + } + + @Test + public void tokenExpIsSetFromClaims() { + final OAuth2AccessToken actual = + builder.claim(OAuth2IntrospectionClaimNames.EXPIRES_AT, Instant.parse("2019-03-21T13:52:25Z")) + .build() + .getAccessToken(); + + assertThat(actual.getIssuedAt()).isNull(); + assertThat(actual.getExpiresAt()).isEqualTo(Instant.parse("2019-03-21T13:52:25Z")); + } + + @Test + public void scopesCollectionAndScopeClaimAreAddedToAuthorities() { + final OAuth2LoginAuthenticationToken actual = builder.authorities("TEST_AUTHORITY") + .scopes("scope:collection") + .claim("scope", Collections.singleton("scope:claim")) + .build(); + + assertThat(actual.getAuthorities()).containsExactlyInAnyOrder( + new SimpleGrantedAuthority("TEST_AUTHORITY"), + new SimpleGrantedAuthority("SCOPE_scope:collection"), + new SimpleGrantedAuthority("SCOPE_scope:claim")); + } + +} diff --git a/oauth2-test/template.mf b/oauth2-test/template.mf new file mode 100644 index 00000000000..01a76852085 --- /dev/null +++ b/oauth2-test/template.mf @@ -0,0 +1,19 @@ +Implementation-Title: org.springframework.security.oauth2.test +Implementation-Version: ${version} +Bundle-SymbolicName: org.springframework.security.oauth2.test +Bundle-Name: Spring Security OAuth2 Test +Bundle-Vendor: SpringSource +Bundle-Version: ${version} +Bundle-ManifestVersion: 2 +Ignored-Existing-Headers: + Import-Package, + Export-Package +Import-Template: + org.apache.commons.logging.*;version="${cloggingRange}", + org.springframework.security.core.*;version="${secRange}", + org.springframework.security.authentication.*;version="${secRange}", + org.springframework.security.oauth2.server.resource.*;version="${secRange}", + org.springframework.security.web.*;version="${secRange}", + org.springframework.beans.factory;version="${springRange}", + org.springframework.util;version="${springRange}", + javax.servlet.*;version="0" diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionClaimNames.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionClaimNames.java index 178f08f33e4..f5d3095b39a 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionClaimNames.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionClaimNames.java @@ -17,12 +17,13 @@ /** * The names of the "Introspection Claims" defined by an - * Introspection Response. + * Introspection + * Response. * * @author Josh Cummings * @since 5.2 */ -interface OAuth2IntrospectionClaimNames { +public interface OAuth2IntrospectionClaimNames { /** * {@code active} - Indicator whether or not the token is currently active @@ -40,7 +41,8 @@ interface OAuth2IntrospectionClaimNames { String CLIENT_ID = "client_id"; /** - * {@code username} - A human-readable identifier for the resource owner that authorized the token + * {@code username} - A human-readable identifier for the resource owner that + * authorized the token */ String USERNAME = "username"; @@ -65,7 +67,8 @@ interface OAuth2IntrospectionClaimNames { String NOT_BEFORE = "nbf"; /** - * {@code sub} - Usually a machine-readable identifier of the resource owner who authorized the token + * {@code sub} - Usually a machine-readable identifier of the resource owner who + * authorized the token */ String SUBJECT = "sub"; diff --git a/samples/boot/oauth2resourceserver-opaque/spring-security-samples-boot-oauth2resourceserver-opaque.gradle b/samples/boot/oauth2resourceserver-opaque/spring-security-samples-boot-oauth2resourceserver-opaque.gradle index 9074842b18a..0905d5b64d6 100644 --- a/samples/boot/oauth2resourceserver-opaque/spring-security-samples-boot-oauth2resourceserver-opaque.gradle +++ b/samples/boot/oauth2resourceserver-opaque/spring-security-samples-boot-oauth2resourceserver-opaque.gradle @@ -10,5 +10,6 @@ dependencies { compile 'com.squareup.okhttp3:mockwebserver' testCompile project(':spring-security-test') + testCompile project(':spring-security-oauth2-resource-server-test') testCompile 'org.springframework.boot:spring-boot-starter-test' } diff --git a/samples/boot/oauth2resourceserver-opaque/src/test/java/sample/OAuth2ResourceServerControllerTest.java b/samples/boot/oauth2resourceserver-opaque/src/test/java/sample/OAuth2ResourceServerControllerTest.java new file mode 100644 index 00000000000..756c1a07449 --- /dev/null +++ b/samples/boot/oauth2resourceserver-opaque/src/test/java/sample/OAuth2ResourceServerControllerTest.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package sample; + +import static org.hamcrest.CoreMatchers.is; +import static org.springframework.security.test.oauth2.request.OAuth2MockMvcRequestPostProcessors.mockAccessToken; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +/** + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + * + */ +@RunWith(SpringRunner.class) +@WebMvcTest(OAuth2ResourceServerController.class) +public class OAuth2ResourceServerControllerTest { + + @Autowired + MockMvc mockMvc; + + @Test + public void testRequestPostProcessor() throws Exception { + // No authentication request post-processor => no authentication => unauthorized + mockMvc.perform(get("/message")).andDo(print()).andExpect(status().isUnauthorized()); + + mockMvc.perform(get("/").with(mockAccessToken().name(null).attribute("sub", "ch4mpy"))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().string(is("Hello, ch4mpy!"))); + + mockMvc.perform(get("/message").with(mockAccessToken().scope("message:read"))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().string(is("secret message"))); + + mockMvc.perform(get("/message").with(mockAccessToken().authority("SCOPE_message:read"))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().string(is("secret message"))); + + mockMvc.perform(get("/message").with(mockAccessToken().attribute("scope", Collections.singleton("message:read")))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().string(is("secret message"))); + + mockMvc.perform(get("/message").with(mockAccessToken().name(null).attribute("sub", "ch4mpy"))) + .andDo(print()) + .andExpect(status().isForbidden()); + + } + +} diff --git a/samples/boot/oauth2resourceserver/spring-security-samples-boot-oauth2resourceserver.gradle b/samples/boot/oauth2resourceserver/spring-security-samples-boot-oauth2resourceserver.gradle index 2135bb0af66..1ba15ac36cf 100644 --- a/samples/boot/oauth2resourceserver/spring-security-samples-boot-oauth2resourceserver.gradle +++ b/samples/boot/oauth2resourceserver/spring-security-samples-boot-oauth2resourceserver.gradle @@ -9,5 +9,6 @@ dependencies { compile 'com.squareup.okhttp3:mockwebserver' testCompile project(':spring-security-test') + testCompile project(':spring-security-oauth2-resource-server-test') testCompile 'org.springframework.boot:spring-boot-starter-test' } diff --git a/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java b/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java index ee13abffa4b..2b2d2eea639 100644 --- a/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java +++ b/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java @@ -555,7 +555,7 @@ private static String md5Hex(String a2) { * Support class for {@link RequestPostProcessor}'s that establish a Spring Security * context */ - private static abstract class SecurityContextRequestPostProcessorSupport { + public static class SecurityContextRequestPostProcessorSupport { /** * Saves the specified {@link Authentication} into an empty @@ -564,7 +564,7 @@ private static abstract class SecurityContextRequestPostProcessorSupport { * @param authentication the {@link Authentication} to save * @param request the {@link HttpServletRequest} to use */ - final void save(Authentication authentication, HttpServletRequest request) { + public static final void save(Authentication authentication, HttpServletRequest request) { SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); securityContext.setAuthentication(authentication); save(securityContext, request); @@ -576,7 +576,7 @@ final void save(Authentication authentication, HttpServletRequest request) { * @param securityContext the {@link SecurityContext} to save * @param request the {@link HttpServletRequest} to use */ - final void save(SecurityContext securityContext, HttpServletRequest request) { + public static final void save(SecurityContext securityContext, HttpServletRequest request) { SecurityContextRepository securityContextRepository = WebTestUtils .getSecurityContextRepository(request); boolean isTestRepository = securityContextRepository instanceof TestSecurityContextRepository; @@ -653,14 +653,13 @@ private static SecurityContext getContext(HttpServletRequest request) { * @author Rob Winch * @since 4.0 */ - private final static class TestSecurityContextHolderPostProcessor extends - SecurityContextRequestPostProcessorSupport implements RequestPostProcessor { + private final static class TestSecurityContextHolderPostProcessor implements RequestPostProcessor { private SecurityContext EMPTY = SecurityContextHolder.createEmptyContext(); @Override public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { // TestSecurityContextHolder is only a default value - SecurityContext existingContext = TestSecurityContextRepository + SecurityContext existingContext = SecurityContextRequestPostProcessorSupport.TestSecurityContextRepository .getContext(request); if (existingContext != null) { return request; @@ -668,7 +667,7 @@ public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) SecurityContext context = TestSecurityContextHolder.getContext(); if (!this.EMPTY.equals(context)) { - save(context, request); + SecurityContextRequestPostProcessorSupport.save(context, request); } return request; @@ -682,8 +681,7 @@ public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) * @author Rob Winch * @since 4.0 */ - private final static class SecurityContextRequestPostProcessor extends - SecurityContextRequestPostProcessorSupport implements RequestPostProcessor { + private final static class SecurityContextRequestPostProcessor implements RequestPostProcessor { private final SecurityContext securityContext; @@ -693,7 +691,7 @@ private SecurityContextRequestPostProcessor(SecurityContext securityContext) { @Override public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { - save(this.securityContext, request); + SecurityContextRequestPostProcessorSupport.save(this.securityContext, request); return request; } } @@ -706,8 +704,7 @@ public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) * @since 4.0 * */ - private final static class AuthenticationRequestPostProcessor extends - SecurityContextRequestPostProcessorSupport implements RequestPostProcessor { + private final static class AuthenticationRequestPostProcessor implements RequestPostProcessor { private final Authentication authentication; private AuthenticationRequestPostProcessor(Authentication authentication) { @@ -716,9 +713,7 @@ private AuthenticationRequestPostProcessor(Authentication authentication) { @Override public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(this.authentication); - save(this.authentication, request); + SecurityContextRequestPostProcessorSupport.save(this.authentication, request); return request; } } @@ -866,8 +861,7 @@ private User createUser() { } } - private static class AnonymousRequestPostProcessor extends - SecurityContextRequestPostProcessorSupport implements RequestPostProcessor { + private static class AnonymousRequestPostProcessor implements RequestPostProcessor { private AuthenticationRequestPostProcessor delegate = new AuthenticationRequestPostProcessor( new AnonymousAuthenticationToken("key", "anonymous", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")));