diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospector.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospector.java index 20ae00337d..6e6b21ad8f 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospector.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospector.java @@ -22,6 +22,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.List; import java.util.Map; import org.apache.commons.logging.Log; @@ -69,7 +70,7 @@ public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector { private Converter> requestEntityConverter; - private Converter authenticationConverter; + private Converter authenticationConverter = this::defaultAuthenticationConverter; /** * Creates a {@code OpaqueTokenAuthenticationProvider} with the provided parameters @@ -85,7 +86,6 @@ public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector { RestTemplate restTemplate = new RestTemplate(); restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(clientId, clientSecret)); this.restOperations = restTemplate; - this.authenticationConverter = this.defaultAuthenticationConverter(); } /** @@ -100,7 +100,6 @@ public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector { Assert.notNull(restOperations, "restOperations cannot be null"); this.requestEntityConverter = this.defaultRequestEntityConverter(URI.create(introspectionUri)); this.restOperations = restOperations; - this.authenticationConverter = this.defaultAuthenticationConverter(); } private Converter> defaultRequestEntityConverter(URI introspectionUri) { @@ -131,8 +130,8 @@ public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector { } ResponseEntity> responseEntity = makeRequest(requestEntity); Map claims = adaptToNimbusResponse(responseEntity); - convertClaimsSet(claims); - return this.authenticationConverter.convert(() -> claims); + OAuth2TokenIntrospectionClaimAccessor accessor = convertClaimsSet(claims); + return this.authenticationConverter.convert(accessor); } /** @@ -183,7 +182,7 @@ public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector { return claims; } - private Map convertClaimsSet(Map claims) { + private OAuth2TokenIntrospectionClaimAccessor convertClaimsSet(Map claims) { claims.computeIfPresent(OAuth2TokenIntrospectionClaimNames.AUD, (k, v) -> { if (v instanceof String) { return Collections.singletonList(v); @@ -216,7 +215,28 @@ public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector { claims.computeIfPresent(OAuth2TokenIntrospectionClaimNames.ISS, (k, v) -> v.toString()); claims.computeIfPresent(OAuth2TokenIntrospectionClaimNames.NBF, (k, v) -> Instant.ofEpochSecond(((Number) v).longValue())); - return claims; + claims.computeIfPresent(OAuth2TokenIntrospectionClaimNames.SCOPE, + (k, v) -> (v instanceof String s) ? new ArrayListFromString(s.split(" ")) : v); + return () -> claims; + } + + /** + *

+ * Sets the {@link Converter Converter<OAuth2TokenIntrospectionClaimAccessor, + * OAuth2AuthenticatedPrincipal>} to use. Defaults to + * {@link SpringOpaqueTokenIntrospector#defaultAuthenticationConverter}. + *

+ *

+ * Use if you need a custom mapping of OAuth 2.0 token claims to the authenticated + * principal. + *

+ * @param authenticationConverter the converter + * @since 6.3 + */ + public void setAuthenticationConverter( + Converter authenticationConverter) { + Assert.notNull(authenticationConverter, "converter cannot be null"); + this.authenticationConverter = authenticationConverter; } /** @@ -229,43 +249,30 @@ public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector { * OAuth2AuthenticatedPrincipal>} * @since 6.3 */ - private Converter defaultAuthenticationConverter() { - return (accessor) -> { - Map claims = accessor.getClaims(); - Collection authorities = new ArrayList<>(); - - claims.computeIfPresent(OAuth2TokenIntrospectionClaimNames.SCOPE, (k, v) -> { - if (v instanceof String) { - Collection scopes = Arrays.asList(((String) v).split(" ")); - for (String scope : scopes) { - authorities.add(new SimpleGrantedAuthority(AUTHORITY_PREFIX + scope)); - } - return scopes; - } - return v; - }); - - return new OAuth2IntrospectionAuthenticatedPrincipal(claims, authorities); - }; + private OAuth2IntrospectionAuthenticatedPrincipal defaultAuthenticationConverter( + OAuth2TokenIntrospectionClaimAccessor accessor) { + Collection authorities = authorities(accessor.getScopes()); + return new OAuth2IntrospectionAuthenticatedPrincipal(accessor.getClaims(), authorities); } - /** - *

- * Sets the {@link Converter Converter<OAuth2TokenIntrospectionClaimAccessor, - * OAuth2AuthenticatedPrincipal>} to use. Defaults to - * {@link SpringOpaqueTokenIntrospector#defaultAuthenticationConverter()}. - *

- *

- * Use if you need a custom mapping of OAuth 2.0 token claims to the authenticated - * principal. - *

- * @param authenticationConverter the converter - * @since 6.3 - */ - public void setAuthenticationConverter( - Converter authenticationConverter) { - Assert.notNull(authenticationConverter, "converter cannot be null"); - this.authenticationConverter = authenticationConverter; + private Collection authorities(List scopes) { + if (!(scopes instanceof ArrayListFromString)) { + return Collections.emptyList(); + } + Collection authorities = new ArrayList<>(); + for (String scope : scopes) { + authorities.add(new SimpleGrantedAuthority(AUTHORITY_PREFIX + scope)); + } + return authorities; + } + + // gh-7563 + private static final class ArrayListFromString extends ArrayList { + + ArrayListFromString(String... elements) { + super(Arrays.asList(elements)); + } + } } diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospector.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospector.java index 6eb86f7076..6db7b92dfa 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospector.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospector.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2023 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. @@ -22,11 +22,13 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.List; import java.util.Map; import reactor.core.publisher.Mono; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.convert.converter.Converter; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.HttpHeaders; @@ -35,6 +37,7 @@ import org.springframework.http.MediaType; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimAccessor; import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames; import org.springframework.util.Assert; import org.springframework.web.reactive.function.BodyInserters; @@ -61,6 +64,8 @@ public class SpringReactiveOpaqueTokenIntrospector implements ReactiveOpaqueToke private final WebClient webClient; + private Converter> authenticationConverter = this::defaultAuthenticationConverter; + /** * Creates a {@code OpaqueTokenReactiveAuthenticationManager} with the provided * parameters @@ -96,6 +101,8 @@ public class SpringReactiveOpaqueTokenIntrospector implements ReactiveOpaqueToke .flatMap(this::makeRequest) .flatMap(this::adaptToNimbusResponse) .map(this::convertClaimsSet) + .flatMap(this.authenticationConverter::convert) + .cast(OAuth2AuthenticatedPrincipal.class) .onErrorMap((e) -> !(e instanceof OAuth2IntrospectionException), this::onError); // @formatter:on } @@ -135,7 +142,7 @@ public class SpringReactiveOpaqueTokenIntrospector implements ReactiveOpaqueToke .switchIfEmpty(Mono.error(() -> new BadOpaqueTokenException("Provided token isn't active"))); } - private OAuth2AuthenticatedPrincipal convertClaimsSet(Map claims) { + private OAuth2TokenIntrospectionClaimAccessor convertClaimsSet(Map claims) { claims.computeIfPresent(OAuth2TokenIntrospectionClaimNames.AUD, (k, v) -> { if (v instanceof String) { return Collections.singletonList(v); @@ -168,22 +175,58 @@ public class SpringReactiveOpaqueTokenIntrospector implements ReactiveOpaqueToke claims.computeIfPresent(OAuth2TokenIntrospectionClaimNames.ISS, (k, v) -> v.toString()); claims.computeIfPresent(OAuth2TokenIntrospectionClaimNames.NBF, (k, v) -> Instant.ofEpochSecond(((Number) v).longValue())); - Collection authorities = new ArrayList<>(); - claims.computeIfPresent(OAuth2TokenIntrospectionClaimNames.SCOPE, (k, v) -> { - if (v instanceof String) { - Collection scopes = Arrays.asList(((String) v).split(" ")); - for (String scope : scopes) { - authorities.add(new SimpleGrantedAuthority(AUTHORITY_PREFIX + scope)); - } - return scopes; - } - return v; - }); - return new OAuth2IntrospectionAuthenticatedPrincipal(claims, authorities); + claims.computeIfPresent(OAuth2TokenIntrospectionClaimNames.SCOPE, + (k, v) -> (v instanceof String s) ? new ArrayListFromString(s.split(" ")) : v); + return () -> claims; } private OAuth2IntrospectionException onError(Throwable ex) { return new OAuth2IntrospectionException(ex.getMessage(), ex); } + /** + *

+ * Sets the {@link Converter Converter<OAuth2TokenIntrospectionClaimAccessor, + * OAuth2AuthenticatedPrincipal>} to use. Defaults to + * {@link SpringReactiveOpaqueTokenIntrospector#defaultAuthenticationConverter}. + *

+ *

+ * Use if you need a custom mapping of OAuth 2.0 token claims to the authenticated + * principal. + *

+ * @param authenticationConverter the converter + * @since 6.3 + */ + public void setAuthenticationConverter( + Converter> authenticationConverter) { + Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); + this.authenticationConverter = authenticationConverter; + } + + private Mono defaultAuthenticationConverter( + OAuth2TokenIntrospectionClaimAccessor accessor) { + Collection authorities = authorities(accessor.getScopes()); + return Mono.just(new OAuth2IntrospectionAuthenticatedPrincipal(accessor.getClaims(), authorities)); + } + + private Collection authorities(List scopes) { + if (!(scopes instanceof ArrayListFromString)) { + return Collections.emptyList(); + } + Collection authorities = new ArrayList<>(); + for (String scope : scopes) { + authorities.add(new SimpleGrantedAuthority(AUTHORITY_PREFIX + scope)); + } + return authorities; + } + + // gh-7563 + private static final class ArrayListFromString extends ArrayList { + + ArrayListFromString(String... elements) { + super(Arrays.asList(elements)); + } + + } + } diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospectorTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospectorTests.java index f395fd6326..9b5f0386dd 100644 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospectorTests.java +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospectorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2023 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. @@ -33,10 +33,12 @@ import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimAccessor; import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames; import org.springframework.web.reactive.function.client.ClientResponse; import org.springframework.web.reactive.function.client.WebClient; @@ -44,9 +46,11 @@ import org.springframework.web.reactive.function.client.WebClient; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; /** * Tests for {@link SpringReactiveOpaqueTokenIntrospector} @@ -193,6 +197,30 @@ public class SpringReactiveOpaqueTokenIntrospectorTests { // @formatter:on } + @Test + public void setAuthenticationConverterWhenConverterIsNullThenExceptionIsThrown() { + WebClient web = mock(WebClient.class); + SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector( + INTROSPECTION_URL, web); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> introspectionClient.setAuthenticationConverter(null)); + } + + @Test + public void setAuthenticationConverterWhenNonNullConverterGivenThenConverterUsed() { + WebClient web = mockResponse(ACTIVE_RESPONSE); + Converter> authenticationConverter = mock( + Converter.class); + OAuth2AuthenticatedPrincipal oAuth2AuthenticatedPrincipal = mock(OAuth2AuthenticatedPrincipal.class); + String tokenToIntrospect = "some token"; + given(authenticationConverter.convert(any())).willReturn((Mono) Mono.just(oAuth2AuthenticatedPrincipal)); + SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector( + INTROSPECTION_URL, web); + introspectionClient.setAuthenticationConverter(authenticationConverter); + introspectionClient.introspect(tokenToIntrospect).block(); + verify(authenticationConverter).convert(any()); + } + @Test public void constructorWhenIntrospectionUriIsEmptyThenIllegalArgumentException() { assertThatIllegalArgumentException()