Add support for OAuth 2.0 Protected Resource Metadata
Closes gh-17244
This commit is contained in:
+51
@@ -20,6 +20,7 @@ import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -43,6 +44,7 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.OpaqueTokenAuthenticationProvider;
|
||||
@@ -51,6 +53,7 @@ import org.springframework.security.oauth2.server.resource.introspection.OpaqueT
|
||||
import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector;
|
||||
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
|
||||
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
|
||||
import org.springframework.security.oauth2.server.resource.web.OAuth2ProtectedResourceMetadataFilter;
|
||||
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
|
||||
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
|
||||
@@ -59,6 +62,7 @@ import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
|
||||
import org.springframework.security.web.access.DelegatingAccessDeniedHandler;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
|
||||
import org.springframework.security.web.csrf.CsrfException;
|
||||
import org.springframework.security.web.util.matcher.AndRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
|
||||
@@ -172,6 +176,8 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
|
||||
|
||||
private OpaqueTokenConfigurer opaqueTokenConfigurer;
|
||||
|
||||
private final ProtectedResourceMetadataConfigurer protectedResourceMetadataConfigurer = new ProtectedResourceMetadataConfigurer();
|
||||
|
||||
private AccessDeniedHandler accessDeniedHandler = new DelegatingAccessDeniedHandler(
|
||||
new LinkedHashMap<>(Map.of(CsrfException.class, new AccessDeniedHandlerImpl())),
|
||||
new BearerTokenAccessDeniedHandler());
|
||||
@@ -250,6 +256,18 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure OAuth 2.0 Protected Resource Metadata.
|
||||
* @param protectedResourceMetadataCustomizer the {@link Customizer} to provide more
|
||||
* options for the {@link ProtectedResourceMetadataConfigurer}
|
||||
* @return the {@link OAuth2ResourceServerConfigurer} for further customizations
|
||||
*/
|
||||
public OAuth2ResourceServerConfigurer<H> protectedResourceMetadata(
|
||||
Customizer<ProtectedResourceMetadataConfigurer> protectedResourceMetadataCustomizer) {
|
||||
protectedResourceMetadataCustomizer.customize(this.protectedResourceMetadataConfigurer);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(H http) {
|
||||
validateConfiguration();
|
||||
@@ -277,10 +295,19 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
|
||||
filter.setSecurityContextHolderStrategy(getSecurityContextHolderStrategy());
|
||||
filter = postProcess(filter);
|
||||
http.addFilter(filter);
|
||||
|
||||
if (dPoPAuthenticationAvailable) {
|
||||
DPoPAuthenticationConfigurer<H> dPoPAuthenticationConfigurer = new DPoPAuthenticationConfigurer<>();
|
||||
dPoPAuthenticationConfigurer.configure(http);
|
||||
}
|
||||
|
||||
OAuth2ProtectedResourceMetadataFilter protectedResourceMetadataFilter = new OAuth2ProtectedResourceMetadataFilter();
|
||||
if (this.protectedResourceMetadataConfigurer.protectedResourceMetadataCustomizer != null) {
|
||||
protectedResourceMetadataFilter.setProtectedResourceMetadataCustomizer(
|
||||
this.protectedResourceMetadataConfigurer.protectedResourceMetadataCustomizer);
|
||||
}
|
||||
protectedResourceMetadataFilter = postProcess(protectedResourceMetadataFilter);
|
||||
http.addFilterBefore(protectedResourceMetadataFilter, AbstractPreAuthenticatedProcessingFilter.class);
|
||||
}
|
||||
|
||||
private void validateConfiguration() {
|
||||
@@ -562,6 +589,30 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
|
||||
|
||||
}
|
||||
|
||||
public static final class ProtectedResourceMetadataConfigurer {
|
||||
|
||||
private Consumer<OAuth2ProtectedResourceMetadata.Builder> protectedResourceMetadataCustomizer;
|
||||
|
||||
private ProtectedResourceMetadataConfigurer() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@code Consumer} providing access to the
|
||||
* {@link OAuth2ProtectedResourceMetadata.Builder} allowing the ability to
|
||||
* customize the claims of the Resource Server's configuration.
|
||||
* @param protectedResourceMetadataCustomizer the {@code Consumer} providing
|
||||
* access to the {@link OAuth2ProtectedResourceMetadata.Builder}
|
||||
* @return the {@link ProtectedResourceMetadataConfigurer} for further
|
||||
* configuration
|
||||
*/
|
||||
public ProtectedResourceMetadataConfigurer protectedResourceMetadataCustomizer(
|
||||
Consumer<OAuth2ProtectedResourceMetadata.Builder> protectedResourceMetadataCustomizer) {
|
||||
this.protectedResourceMetadataCustomizer = protectedResourceMetadataCustomizer;
|
||||
return this;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static final class BearerTokenRequestMatcher implements RequestMatcher {
|
||||
|
||||
private AuthenticationConverter authenticationConverter;
|
||||
|
||||
@@ -192,6 +192,7 @@ import org.springframework.security.oauth2.server.authorization.settings.TokenSe
|
||||
import org.springframework.security.oauth2.server.resource.BearerTokenError;
|
||||
import org.springframework.security.oauth2.server.resource.BearerTokenErrors;
|
||||
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
|
||||
import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.DPoPAuthenticationToken;
|
||||
@@ -406,6 +407,15 @@ final class SerializationSamples {
|
||||
(r) -> new OAuth2IntrospectionException("message", new RuntimeException()));
|
||||
generatorByClassName.put(DPoPAuthenticationToken.class,
|
||||
(r) -> applyDetails(new DPoPAuthenticationToken("token", "proof", "method", "uri")));
|
||||
generatorByClassName.put(OAuth2ProtectedResourceMetadata.class,
|
||||
(r) -> OAuth2ProtectedResourceMetadata.builder()
|
||||
.resource("https://localhost/resource")
|
||||
.authorizationServer("https://localhost/authorizationServer")
|
||||
.scope("scope")
|
||||
.bearerMethod("bearerMethod")
|
||||
.resourceName("resourceName")
|
||||
.tlsClientCertificateBoundAccessTokens(true)
|
||||
.build());
|
||||
|
||||
// oauth2-authorization-server
|
||||
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* Copyright 2004-present 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.config.annotation.web.configurers.oauth2.server.resource;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.oauth2.jose.TestKeys;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata;
|
||||
import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadataClaimNames;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.hamcrest.Matchers.hasItem;
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Integration tests for OAuth 2.0 Protected Resource Metadata Requests.
|
||||
*
|
||||
* @author Joe Grandja
|
||||
*/
|
||||
@ExtendWith(SpringTestContextExtension.class)
|
||||
public class OAuth2ProtectedResourceMetadataTests {
|
||||
|
||||
private static final String DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI = "/.well-known/oauth-protected-resource";
|
||||
|
||||
private static final String RESOURCE = "https://resource.com:8443";
|
||||
|
||||
private static final String ISSUER_1 = "https://provider1.com";
|
||||
|
||||
private static final String ISSUER_2 = "https://provider2.com";
|
||||
|
||||
public final SpringTestContext spring = new SpringTestContext(this);
|
||||
|
||||
@Autowired
|
||||
private MockMvc mvc;
|
||||
|
||||
@Test
|
||||
public void requestWhenProtectedResourceMetadataRequestThenReturnMetadataResponse() throws Exception {
|
||||
this.spring.register(ResourceServerConfiguration.class).autowire();
|
||||
|
||||
this.mvc.perform(get(RESOURCE.concat(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI)))
|
||||
.andExpect(status().is2xxSuccessful())
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(RESOURCE))
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).isArray())
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).value(hasSize(1)))
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED)
|
||||
.value(hasItem("header")))
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS)
|
||||
.value(true))
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void requestWhenProtectedResourceMetadataRequestIncludesResourcePathThenMetadataResponseHasResourcePath()
|
||||
throws Exception {
|
||||
this.spring.register(ResourceServerConfiguration.class).autowire();
|
||||
|
||||
String host = RESOURCE;
|
||||
|
||||
String resourcePath = "/resource1";
|
||||
String resource = host.concat(resourcePath);
|
||||
this.mvc.perform(get(host.concat(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI).concat(resourcePath)))
|
||||
.andExpect(status().is2xxSuccessful())
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(resource))
|
||||
.andReturn();
|
||||
|
||||
resourcePath = "/path1/resource2";
|
||||
resource = host.concat(resourcePath);
|
||||
this.mvc.perform(get(host.concat(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI).concat(resourcePath)))
|
||||
.andExpect(status().is2xxSuccessful())
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(resource))
|
||||
.andReturn();
|
||||
|
||||
resourcePath = "/path1/path2/resource3";
|
||||
resource = host.concat(resourcePath);
|
||||
this.mvc.perform(get(host.concat(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI).concat(resourcePath)))
|
||||
.andExpect(status().is2xxSuccessful())
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(resource))
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void requestWhenProtectedResourceMetadataRequestAndMetadataCustomizerSetThenReturnCustomMetadataResponse()
|
||||
throws Exception {
|
||||
this.spring.register(ResourceServerConfigurationWithMetadataCustomizer.class).autowire();
|
||||
|
||||
this.mvc.perform(get(RESOURCE.concat(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI)))
|
||||
.andExpect(status().is2xxSuccessful())
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(RESOURCE))
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).isArray())
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).value(hasSize(2)))
|
||||
.andExpect(
|
||||
jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).value(hasItem(ISSUER_1)))
|
||||
.andExpect(
|
||||
jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).value(hasItem(ISSUER_2)))
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).isArray())
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).value(hasSize(2)))
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).value(hasItem("scope1")))
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).value(hasItem("scope2")))
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).isArray())
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).value(hasSize(1)))
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED)
|
||||
.value(hasItem("header")))
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE_NAME).value("resourceName"))
|
||||
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS)
|
||||
.value(true))
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@EnableWebSecurity
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class ResourceServerConfiguration {
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http) {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authorize) ->
|
||||
authorize
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2ResourceServer((oauth2) ->
|
||||
oauth2
|
||||
.jwt(Customizer.withDefaults())
|
||||
);
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
JwtDecoder jwtDecoder() {
|
||||
return NimbusJwtDecoder.withPublicKey(TestKeys.DEFAULT_PUBLIC_KEY).build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@EnableWebSecurity
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class ResourceServerConfigurationWithMetadataCustomizer extends ResourceServerConfiguration {
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http) {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authorize) ->
|
||||
authorize
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2ResourceServer((oauth2) ->
|
||||
oauth2
|
||||
.jwt(Customizer.withDefaults())
|
||||
.protectedResourceMetadata((metadata) ->
|
||||
metadata.protectedResourceMetadataCustomizer(protectedResourceMetadataCustomizer())
|
||||
)
|
||||
);
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
private Consumer<OAuth2ProtectedResourceMetadata.Builder> protectedResourceMetadataCustomizer() {
|
||||
return (protectedResourceMetadata) -> protectedResourceMetadata.authorizationServer(ISSUER_1)
|
||||
.authorizationServer(ISSUER_2)
|
||||
.scope("scope1")
|
||||
.scope("scope2")
|
||||
.resourceName("resourceName");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+8
-4
@@ -415,7 +415,7 @@ public class OAuth2ResourceServerConfigurerTests {
|
||||
// @formatter:off
|
||||
this.mvc.perform(post("/").header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE).with(bearerToken("token").asParam()))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer"));
|
||||
.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\""));
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@@ -437,7 +437,7 @@ public class OAuth2ResourceServerConfigurerTests {
|
||||
// @formatter:off
|
||||
this.mvc.perform(get("/"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer"));
|
||||
.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\""));
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@@ -1472,14 +1472,18 @@ public class OAuth2ResourceServerConfigurerTests {
|
||||
return header().string(HttpHeaders.WWW_AUTHENTICATE,
|
||||
AllOf.allOf(new StringStartsWith("Bearer " + "error=\"invalid_request\", " + "error_description=\""),
|
||||
new StringContains(message),
|
||||
new StringEndsWith(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\"")));
|
||||
new StringContains(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""),
|
||||
new StringEndsWith(
|
||||
", " + "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")));
|
||||
}
|
||||
|
||||
private static ResultMatcher invalidTokenHeader(String message) {
|
||||
return header().string(HttpHeaders.WWW_AUTHENTICATE,
|
||||
AllOf.allOf(new StringStartsWith("Bearer " + "error=\"invalid_token\", " + "error_description=\""),
|
||||
new StringContains(message),
|
||||
new StringEndsWith(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\"")));
|
||||
new StringContains(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""),
|
||||
new StringEndsWith(
|
||||
", " + "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")));
|
||||
}
|
||||
|
||||
private static ResultMatcher insufficientScopeHeader() {
|
||||
|
||||
+7
-3
@@ -294,7 +294,7 @@ public class OAuth2ResourceServerBeanDefinitionParserTests {
|
||||
// @formatter:off
|
||||
this.mvc.perform(get("/"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer"));
|
||||
.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\""));
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@@ -928,14 +928,18 @@ public class OAuth2ResourceServerBeanDefinitionParserTests {
|
||||
return header().string(HttpHeaders.WWW_AUTHENTICATE,
|
||||
AllOf.allOf(new StringStartsWith("Bearer " + "error=\"invalid_request\", " + "error_description=\""),
|
||||
new StringContains(message),
|
||||
new StringEndsWith(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\"")));
|
||||
new StringContains(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""),
|
||||
new StringEndsWith(
|
||||
", " + "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")));
|
||||
}
|
||||
|
||||
private static ResultMatcher invalidTokenHeader(String message) {
|
||||
return header().string(HttpHeaders.WWW_AUTHENTICATE,
|
||||
AllOf.allOf(new StringStartsWith("Bearer " + "error=\"invalid_token\", " + "error_description=\""),
|
||||
new StringContains(message),
|
||||
new StringEndsWith(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\"")));
|
||||
new StringContains(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""),
|
||||
new StringEndsWith(
|
||||
", " + "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")));
|
||||
}
|
||||
|
||||
private static ResultMatcher insufficientScopeHeader() {
|
||||
|
||||
BIN
Binary file not shown.
+289
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* Copyright 2004-present 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.oauth2.server.resource;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* A representation of an OAuth 2.0 Protected Resource Metadata response, which is
|
||||
* returned from an OAuth 2.0 Resource Server's Metadata Endpoint, and contains a set of
|
||||
* claims about the Resource Server's configuration. The claims are defined by the OAuth
|
||||
* 2.0 Protected Resource Metadata specification (RFC 9728).
|
||||
*
|
||||
* @author Joe Grandja
|
||||
* @since 7.0
|
||||
* @see OAuth2ProtectedResourceMetadataClaimAccessor
|
||||
* @see <a target="_blank" href="https://www.rfc-editor.org/rfc/rfc9728.html#section-2">2.
|
||||
* Protected Resource Metadata</a>
|
||||
*/
|
||||
public final class OAuth2ProtectedResourceMetadata
|
||||
implements OAuth2ProtectedResourceMetadataClaimAccessor, Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = -18589911827039000L;
|
||||
|
||||
private final Map<String, Object> claims;
|
||||
|
||||
private OAuth2ProtectedResourceMetadata(Map<String, Object> claims) {
|
||||
Assert.notEmpty(claims, "claims cannot be empty");
|
||||
this.claims = Collections.unmodifiableMap(new LinkedHashMap<>(claims));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the metadata as claims.
|
||||
* @return a {@code Map} of the metadata as claims
|
||||
*/
|
||||
public Map<String, Object> getClaims() {
|
||||
return this.claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new {@link Builder} with empty claims.
|
||||
* @return the {@link Builder}
|
||||
*/
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helps configure an {@link OAuth2ProtectedResourceMetadata}.
|
||||
*/
|
||||
public static final class Builder {
|
||||
|
||||
private final Map<String, Object> claims = new LinkedHashMap<>();
|
||||
|
||||
private Builder() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the resource identifier for the protected resource, REQUIRED.
|
||||
* @param resource the resource identifier {@code URL} for the protected resource
|
||||
* @return the {@link Builder} for further configuration
|
||||
*/
|
||||
public Builder resource(String resource) {
|
||||
return claim(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE, resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the issuer identifier for an authorization server, OPTIONAL.
|
||||
* @param authorizationServer the issuer identifier {@code URL} for an
|
||||
* authorization server
|
||||
* @return the {@link Builder} for further configuration
|
||||
*/
|
||||
public Builder authorizationServer(String authorizationServer) {
|
||||
addClaimToClaimList(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS, authorizationServer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@code Consumer} of the issuer identifier values for the authorization
|
||||
* servers, allowing the ability to add, replace, or remove, OPTIONAL.
|
||||
* @param authorizationServersConsumer a {@code Consumer} of the issuer identifier
|
||||
* values for the authorization servers
|
||||
* @return the {@link Builder} for further configuration
|
||||
*/
|
||||
public Builder authorizationServers(Consumer<List<String>> authorizationServersConsumer) {
|
||||
acceptClaimValues(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS,
|
||||
authorizationServersConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a {@code scope} supported in authorization requests to the protected
|
||||
* resource, RECOMMENDED.
|
||||
* @param scope a {@code scope} supported in authorization requests to the
|
||||
* protected resource
|
||||
* @return the {@link Builder} for further configuration
|
||||
*/
|
||||
public Builder scope(String scope) {
|
||||
addClaimToClaimList(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED, scope);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@code Consumer} of the {@code scope} values supported in authorization
|
||||
* requests to the protected resource, allowing the ability to add, replace, or
|
||||
* remove, RECOMMENDED.
|
||||
* @param scopesConsumer a {@code Consumer} of the {@code scope} values supported
|
||||
* in authorization requests to the protected resource
|
||||
* @return the {@link Builder} for further configuration
|
||||
*/
|
||||
public Builder scopes(Consumer<List<String>> scopesConsumer) {
|
||||
acceptClaimValues(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED, scopesConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a supported method for sending an OAuth 2.0 bearer token to the protected
|
||||
* resource, OPTIONAL. Defined values are "header", "body" and "query".
|
||||
* @param bearerMethod a supported method for sending an OAuth 2.0 bearer token to
|
||||
* the protected resource
|
||||
* @return the {@link Builder} for further configuration
|
||||
*/
|
||||
public Builder bearerMethod(String bearerMethod) {
|
||||
addClaimToClaimList(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED, bearerMethod);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@code Consumer} of the supported methods for sending an OAuth 2.0 bearer
|
||||
* token to the protected resource, allowing the ability to add, replace, or
|
||||
* remove, OPTIONAL.
|
||||
* @param bearerMethodsConsumer a {@code Consumer} of the supported methods for
|
||||
* sending an OAuth 2.0 bearer token to the protected resource
|
||||
* @return the {@link Builder} for further configuration
|
||||
*/
|
||||
public Builder bearerMethods(Consumer<List<String>> bearerMethodsConsumer) {
|
||||
acceptClaimValues(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED,
|
||||
bearerMethodsConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the name of the protected resource intended for display to the end user,
|
||||
* RECOMMENDED.
|
||||
* @param resourceName the name of the protected resource intended for display to
|
||||
* the end user
|
||||
* @return the {@link Builder} for further configuration
|
||||
*/
|
||||
public Builder resourceName(String resourceName) {
|
||||
return claim(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE_NAME, resourceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set to {@code true} to indicate protected resource support for mutual-TLS
|
||||
* client certificate-bound access tokens, OPTIONAL.
|
||||
* @param tlsClientCertificateBoundAccessTokens {@code true} to indicate protected
|
||||
* resource support for mutual-TLS client certificate-bound access tokens
|
||||
* @return the {@link Builder} for further configuration
|
||||
*/
|
||||
public Builder tlsClientCertificateBoundAccessTokens(boolean tlsClientCertificateBoundAccessTokens) {
|
||||
return claim(OAuth2ProtectedResourceMetadataClaimNames.TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS,
|
||||
tlsClientCertificateBoundAccessTokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the claim.
|
||||
* @param name the claim name
|
||||
* @param value the claim value
|
||||
* @return the {@link Builder} for further configuration
|
||||
*/
|
||||
public Builder claim(String name, Object value) {
|
||||
Assert.hasText(name, "name cannot be empty");
|
||||
Assert.notNull(value, "value cannot be null");
|
||||
this.claims.put(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides access to every {@link #claim(String, Object)} declared so far
|
||||
* allowing the ability to add, replace, or remove.
|
||||
* @param claimsConsumer a {@code Consumer} of the claims
|
||||
* @return the {@link Builder} for further configurations
|
||||
*/
|
||||
public Builder claims(Consumer<Map<String, Object>> claimsConsumer) {
|
||||
claimsConsumer.accept(this.claims);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the claims and build the {@link OAuth2ProtectedResourceMetadata}.
|
||||
* @return the {@link OAuth2ProtectedResourceMetadata}
|
||||
*/
|
||||
public OAuth2ProtectedResourceMetadata build() {
|
||||
validate();
|
||||
return new OAuth2ProtectedResourceMetadata(this.claims);
|
||||
}
|
||||
|
||||
private void validate() {
|
||||
Assert.notNull(this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE),
|
||||
"resource cannot be null");
|
||||
validateURL(this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE),
|
||||
"resource must be a valid URL");
|
||||
if (this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS) != null) {
|
||||
Assert.isInstanceOf(List.class,
|
||||
this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS),
|
||||
"authorization_servers must be of type List");
|
||||
Assert.notEmpty(
|
||||
(List<?>) this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS),
|
||||
"authorization_servers cannot be empty");
|
||||
List<?> authorizationServers = (List<?>) this.claims
|
||||
.get(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS);
|
||||
authorizationServers.forEach((authorizationServer) -> validateURL(authorizationServer,
|
||||
"authorization_server must be a valid URL"));
|
||||
}
|
||||
if (this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED) != null) {
|
||||
Assert.isInstanceOf(List.class,
|
||||
this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED),
|
||||
"scopes must be of type List");
|
||||
Assert.notEmpty((List<?>) this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED),
|
||||
"scopes cannot be empty");
|
||||
}
|
||||
if (this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED) != null) {
|
||||
Assert.isInstanceOf(List.class,
|
||||
this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED),
|
||||
"bearer methods must be of type List");
|
||||
Assert.notEmpty(
|
||||
(List<?>) this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED),
|
||||
"bearer methods cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void addClaimToClaimList(String name, String value) {
|
||||
Assert.hasText(name, "name cannot be empty");
|
||||
Assert.notNull(value, "value cannot be null");
|
||||
this.claims.computeIfAbsent(name, (k) -> new LinkedList<String>());
|
||||
((List<String>) this.claims.get(name)).add(value);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void acceptClaimValues(String name, Consumer<List<String>> valuesConsumer) {
|
||||
Assert.hasText(name, "name cannot be empty");
|
||||
Assert.notNull(valuesConsumer, "valuesConsumer cannot be null");
|
||||
this.claims.computeIfAbsent(name, (k) -> new LinkedList<String>());
|
||||
List<String> values = (List<String>) this.claims.get(name);
|
||||
valuesConsumer.accept(values);
|
||||
}
|
||||
|
||||
private static void validateURL(Object url, String errorMessage) {
|
||||
if (URL.class.isAssignableFrom(url.getClass())) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new URI(url.toString()).toURL();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new IllegalArgumentException(errorMessage, ex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright 2004-present 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.oauth2.server.resource;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.security.oauth2.core.ClaimAccessor;
|
||||
|
||||
/**
|
||||
* A {@link ClaimAccessor} for the claims a Resource Server describes about its
|
||||
* configuration, used in OAuth 2.0 Protected Resource Metadata.
|
||||
*
|
||||
* @author Joe Grandja
|
||||
* @since 7.0
|
||||
* @see ClaimAccessor
|
||||
* @see OAuth2ProtectedResourceMetadataClaimNames
|
||||
* @see <a target="_blank" href="https://www.rfc-editor.org/rfc/rfc9728.html#section-2">2.
|
||||
* Protected Resource Metadata</a>
|
||||
*/
|
||||
public interface OAuth2ProtectedResourceMetadataClaimAccessor extends ClaimAccessor {
|
||||
|
||||
/**
|
||||
* Returns the {@code URL} the protected resource asserts as its resource identifier
|
||||
* {@code (resource)}.
|
||||
* @return the {@code URL} the protected resource asserts as its resource identifier
|
||||
*/
|
||||
default URL getResource() {
|
||||
return getClaimAsURL(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of {@code issuer} identifier {@code URL}'s, for authorization
|
||||
* servers that can be used with this protected resource
|
||||
* {@code (authorization_servers)}.
|
||||
* @return a list of {@code issuer} identifier {@code URL}'s, for authorization
|
||||
* servers that can be used with this protected resource
|
||||
*/
|
||||
default List<URL> getAuthorizationServers() {
|
||||
List<String> authorizationServers = getClaimAsStringList(
|
||||
OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS);
|
||||
List<URL> urls = new ArrayList<>();
|
||||
authorizationServers.forEach((authorizationServer) -> {
|
||||
try {
|
||||
urls.add(new URI(authorizationServer).toURL());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new IllegalArgumentException("Failed to convert authorization_server to URL", ex);
|
||||
}
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of {@code scope} values supported, that are used in authorization
|
||||
* requests to request access to this protected resource {@code (scopes_supported)}.
|
||||
* @return a list of {@code scope} values supported, that are used in authorization
|
||||
* requests to request access to this protected resource
|
||||
*/
|
||||
default List<String> getScopes() {
|
||||
return getClaimAsStringList(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of the supported methods for sending an OAuth 2.0 bearer token to
|
||||
* the protected resource. Defined values are "header", "body" and "query".
|
||||
* {@code (bearer_methods_supported)}.
|
||||
* @return a list of the supported methods for sending an OAuth 2.0 bearer token to
|
||||
* the protected resource
|
||||
*/
|
||||
default List<String> getBearerMethodsSupported() {
|
||||
return getClaimAsStringList(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the protected resource intended for display to the end user
|
||||
* {@code (resource_name)}.
|
||||
* @return the name of the protected resource intended for display to the end user
|
||||
*/
|
||||
default String getResourceName() {
|
||||
return getClaimAsString(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} to indicate protected resource support for mutual-TLS client
|
||||
* certificate-bound access tokens
|
||||
* {@code (tls_client_certificate_bound_access_tokens)}.
|
||||
* @return {@code true} to indicate protected resource support for mutual-TLS client
|
||||
* certificate-bound access tokens
|
||||
*/
|
||||
default boolean isTlsClientCertificateBoundAccessTokens() {
|
||||
return Boolean.TRUE.equals(getClaimAsBoolean(
|
||||
OAuth2ProtectedResourceMetadataClaimNames.TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS));
|
||||
}
|
||||
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2004-present 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.oauth2.server.resource;
|
||||
|
||||
/**
|
||||
* The names of the claims a Resource Server describes about its configuration, used in
|
||||
* OAuth 2.0 Protected Resource Metadata.
|
||||
*
|
||||
* @author Joe Grandja
|
||||
* @since 7.0
|
||||
* @see <a target="_blank" href="https://www.rfc-editor.org/rfc/rfc9728.html#section-2">2.
|
||||
* Protected Resource Metadata</a>
|
||||
*/
|
||||
public final class OAuth2ProtectedResourceMetadataClaimNames {
|
||||
|
||||
/**
|
||||
* {@code resource} - the {@code URL} the protected resource asserts as its resource
|
||||
* identifier
|
||||
*/
|
||||
public static final String RESOURCE = "resource";
|
||||
|
||||
/**
|
||||
* {@code authorization_servers} - a list of {@code issuer} identifier {@code URL}'s,
|
||||
* for authorization servers that can be used with this protected resource
|
||||
*/
|
||||
public static final String AUTHORIZATION_SERVERS = "authorization_servers";
|
||||
|
||||
/**
|
||||
* {@code scopes_supported} - a list of {@code scope} values supported, that are used
|
||||
* in authorization requests to request access to this protected resource
|
||||
*/
|
||||
public static final String SCOPES_SUPPORTED = "scopes_supported";
|
||||
|
||||
/**
|
||||
* {@code bearer_methods_supported} - a list of the supported methods for sending an
|
||||
* OAuth 2.0 bearer token to the protected resource. Defined values are "header",
|
||||
* "body" and "query".
|
||||
*/
|
||||
public static final String BEARER_METHODS_SUPPORTED = "bearer_methods_supported";
|
||||
|
||||
/**
|
||||
* {@code resource_name} - the name of the protected resource intended for display to
|
||||
* the end user
|
||||
*/
|
||||
public static final String RESOURCE_NAME = "resource_name";
|
||||
|
||||
/**
|
||||
* {@code tls_client_certificate_bound_access_tokens} - {@code true} to indicate
|
||||
* protected resource support for mutual-TLS client certificate-bound access tokens
|
||||
*/
|
||||
public static final String TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS = "tls_client_certificate_bound_access_tokens";
|
||||
|
||||
private OAuth2ProtectedResourceMetadataClaimNames() {
|
||||
}
|
||||
|
||||
}
|
||||
+14
@@ -30,7 +30,9 @@ import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.server.resource.BearerTokenError;
|
||||
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.util.UrlUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* An {@link AuthenticationEntryPoint} implementation used to commence authentication of
|
||||
@@ -81,6 +83,7 @@ public final class BearerTokenAuthenticationEntryPoint implements Authentication
|
||||
status = bearerTokenError.getHttpStatus();
|
||||
}
|
||||
}
|
||||
parameters.put("resource_metadata", getResourceMetadataParameter(request));
|
||||
String wwwAuthenticate = computeWWWAuthenticateHeaderValue(parameters);
|
||||
response.addHeader(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticate);
|
||||
response.setStatus(status.value());
|
||||
@@ -94,6 +97,17 @@ public final class BearerTokenAuthenticationEntryPoint implements Authentication
|
||||
this.realmName = realmName;
|
||||
}
|
||||
|
||||
private static String getResourceMetadataParameter(HttpServletRequest request) {
|
||||
// @formatter:off
|
||||
return UriComponentsBuilder.fromUriString(UrlUtils.buildFullRequestUrl(request))
|
||||
.replacePath(OAuth2ProtectedResourceMetadataFilter.DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI)
|
||||
.replaceQuery(null)
|
||||
.fragment(null)
|
||||
.build()
|
||||
.toUriString();
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
private static String computeWWWAuthenticateHeaderValue(Map<String, String> parameters) {
|
||||
StringBuilder wwwAuthenticate = new StringBuilder();
|
||||
wwwAuthenticate.append("Bearer");
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright 2004-present 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.oauth2.server.resource.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.GenericHttpMessageConverter;
|
||||
import org.springframework.http.converter.HttpMessageNotWritableException;
|
||||
import org.springframework.http.converter.json.GsonHttpMessageConverter;
|
||||
import org.springframework.http.converter.json.JsonbHttpMessageConverter;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import org.springframework.http.server.ServletServerHttpResponse;
|
||||
import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata;
|
||||
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
|
||||
import org.springframework.security.web.util.UrlUtils;
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ClassUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* A {@code Filter} that processes OAuth 2.0 Protected Resource Metadata Requests.
|
||||
*
|
||||
* @author Joe Grandja
|
||||
* @since 7.0
|
||||
* @see OAuth2ProtectedResourceMetadata
|
||||
* @see <a target="_blank" href=
|
||||
* "https://www.rfc-editor.org/rfc/rfc9728.html#section-3.1">3.1. Protected Resource
|
||||
* Metadata Request</a>
|
||||
*/
|
||||
public final class OAuth2ProtectedResourceMetadataFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() {
|
||||
};
|
||||
|
||||
private static final GenericHttpMessageConverter<Object> JSON_MESSAGE_CONVERTER = HttpMessageConverters
|
||||
.getJsonMessageConverter();
|
||||
|
||||
/**
|
||||
* The default endpoint {@code URI} for OAuth 2.0 Protected Resource Metadata
|
||||
* requests.
|
||||
*/
|
||||
static final String DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI = "/.well-known/oauth-protected-resource";
|
||||
|
||||
private final RequestMatcher requestMatcher = PathPatternRequestMatcher.withDefaults()
|
||||
.matcher(HttpMethod.GET, DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI.concat("/**"));
|
||||
|
||||
private Consumer<OAuth2ProtectedResourceMetadata.Builder> protectedResourceMetadataCustomizer = (
|
||||
protectedResourceMetadata) -> {
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the {@code Consumer} providing access to the
|
||||
* {@link OAuth2ProtectedResourceMetadata.Builder} allowing the ability to customize
|
||||
* the claims of the Resource Server's configuration.
|
||||
* @param protectedResourceMetadataCustomizer the {@code Consumer} providing access to
|
||||
* the {@link OAuth2ProtectedResourceMetadata.Builder}
|
||||
*/
|
||||
public void setProtectedResourceMetadataCustomizer(
|
||||
Consumer<OAuth2ProtectedResourceMetadata.Builder> protectedResourceMetadataCustomizer) {
|
||||
Assert.notNull(protectedResourceMetadataCustomizer, "protectedResourceMetadataCustomizer cannot be null");
|
||||
this.protectedResourceMetadataCustomizer = protectedResourceMetadataCustomizer;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
if (!this.requestMatcher.matches(request)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
OAuth2ProtectedResourceMetadata.Builder builder = OAuth2ProtectedResourceMetadata.builder()
|
||||
.resource(resolveResourceIdentifier(request))
|
||||
.bearerMethod("header")
|
||||
.tlsClientCertificateBoundAccessTokens(true);
|
||||
|
||||
this.protectedResourceMetadataCustomizer.accept(builder);
|
||||
|
||||
OAuth2ProtectedResourceMetadata protectedResourceMetadata = builder.build();
|
||||
|
||||
try {
|
||||
ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
|
||||
JSON_MESSAGE_CONVERTER.write(protectedResourceMetadata.getClaims(), STRING_OBJECT_MAP.getType(),
|
||||
MediaType.APPLICATION_JSON, httpResponse);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new HttpMessageNotWritableException(
|
||||
"An error occurred writing the OAuth 2.0 Protected Resource Metadata: " + ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveResourceIdentifier(HttpServletRequest request) {
|
||||
// Resolve Resource Identifier dynamically from request
|
||||
String path = request.getRequestURI();
|
||||
if (!StringUtils.hasText(path)) {
|
||||
path = "";
|
||||
}
|
||||
else {
|
||||
path = path.replace(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI, "");
|
||||
}
|
||||
|
||||
// @formatter:off
|
||||
return UriComponentsBuilder.fromUriString(UrlUtils.buildFullRequestUrl(request))
|
||||
.replacePath(path)
|
||||
.replaceQuery(null)
|
||||
.fragment(null)
|
||||
.build()
|
||||
.toUriString();
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
private static final class HttpMessageConverters {
|
||||
|
||||
private static final boolean jackson2Present;
|
||||
|
||||
private static final boolean gsonPresent;
|
||||
|
||||
private static final boolean jsonbPresent;
|
||||
|
||||
static {
|
||||
ClassLoader classLoader = HttpMessageConverters.class.getClassLoader();
|
||||
jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader)
|
||||
&& ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader);
|
||||
gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader);
|
||||
jsonbPresent = ClassUtils.isPresent("jakarta.json.bind.Jsonb", classLoader);
|
||||
}
|
||||
|
||||
private HttpMessageConverters() {
|
||||
}
|
||||
|
||||
private static GenericHttpMessageConverter<Object> getJsonMessageConverter() {
|
||||
if (jackson2Present) {
|
||||
return new MappingJackson2HttpMessageConverter();
|
||||
}
|
||||
if (gsonPresent) {
|
||||
return new GsonHttpMessageConverter();
|
||||
}
|
||||
if (jsonbPresent) {
|
||||
return new JsonbHttpMessageConverter();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+17
-12
@@ -50,7 +50,8 @@ public class BearerTokenAuthenticationEntryPointTests {
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
this.authenticationEntryPoint.commence(request, response, new BadCredentialsException("test"));
|
||||
assertThat(response.getStatus()).isEqualTo(401);
|
||||
assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer");
|
||||
assertThat(response.getHeader("WWW-Authenticate"))
|
||||
.isEqualTo("Bearer resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -60,7 +61,8 @@ public class BearerTokenAuthenticationEntryPointTests {
|
||||
this.authenticationEntryPoint.setRealmName("test");
|
||||
this.authenticationEntryPoint.commence(request, response, new BadCredentialsException("test"));
|
||||
assertThat(response.getStatus()).isEqualTo(401);
|
||||
assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer realm=\"test\"");
|
||||
assertThat(response.getHeader("WWW-Authenticate")).isEqualTo(
|
||||
"Bearer realm=\"test\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -71,7 +73,8 @@ public class BearerTokenAuthenticationEntryPointTests {
|
||||
null, null);
|
||||
this.authenticationEntryPoint.commence(request, response, new OAuth2AuthenticationException(error));
|
||||
assertThat(response.getStatus()).isEqualTo(400);
|
||||
assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer error=\"invalid_request\"");
|
||||
assertThat(response.getHeader("WWW-Authenticate")).isEqualTo(
|
||||
"Bearer error=\"invalid_request\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -82,8 +85,8 @@ public class BearerTokenAuthenticationEntryPointTests {
|
||||
"The access token expired", null, null);
|
||||
this.authenticationEntryPoint.commence(request, response, new OAuth2AuthenticationException(error));
|
||||
assertThat(response.getStatus()).isEqualTo(400);
|
||||
assertThat(response.getHeader("WWW-Authenticate"))
|
||||
.isEqualTo("Bearer error=\"invalid_request\", error_description=\"The access token expired\"");
|
||||
assertThat(response.getHeader("WWW-Authenticate")).isEqualTo(
|
||||
"Bearer error=\"invalid_request\", error_description=\"The access token expired\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -94,8 +97,8 @@ public class BearerTokenAuthenticationEntryPointTests {
|
||||
null, "https://example.com", null);
|
||||
this.authenticationEntryPoint.commence(request, response, new OAuth2AuthenticationException(error));
|
||||
assertThat(response.getStatus()).isEqualTo(400);
|
||||
assertThat(response.getHeader("WWW-Authenticate"))
|
||||
.isEqualTo("Bearer error=\"invalid_request\", error_uri=\"https://example.com\"");
|
||||
assertThat(response.getHeader("WWW-Authenticate")).isEqualTo(
|
||||
"Bearer error=\"invalid_request\", error_uri=\"https://example.com\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -106,7 +109,8 @@ public class BearerTokenAuthenticationEntryPointTests {
|
||||
null, null);
|
||||
this.authenticationEntryPoint.commence(request, response, new OAuth2AuthenticationException(error));
|
||||
assertThat(response.getStatus()).isEqualTo(401);
|
||||
assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer error=\"invalid_token\"");
|
||||
assertThat(response.getHeader("WWW-Authenticate")).isEqualTo(
|
||||
"Bearer error=\"invalid_token\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -117,7 +121,8 @@ public class BearerTokenAuthenticationEntryPointTests {
|
||||
null, null);
|
||||
this.authenticationEntryPoint.commence(request, response, new OAuth2AuthenticationException(error));
|
||||
assertThat(response.getStatus()).isEqualTo(403);
|
||||
assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer error=\"insufficient_scope\"");
|
||||
assertThat(response.getHeader("WWW-Authenticate")).isEqualTo(
|
||||
"Bearer error=\"insufficient_scope\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -128,8 +133,8 @@ public class BearerTokenAuthenticationEntryPointTests {
|
||||
null, null, "test.read test.write");
|
||||
this.authenticationEntryPoint.commence(request, response, new OAuth2AuthenticationException(error));
|
||||
assertThat(response.getStatus()).isEqualTo(403);
|
||||
assertThat(response.getHeader("WWW-Authenticate"))
|
||||
.isEqualTo("Bearer error=\"insufficient_scope\", scope=\"test.read test.write\"");
|
||||
assertThat(response.getHeader("WWW-Authenticate")).isEqualTo(
|
||||
"Bearer error=\"insufficient_scope\", scope=\"test.read test.write\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -144,7 +149,7 @@ public class BearerTokenAuthenticationEntryPointTests {
|
||||
assertThat(response.getStatus()).isEqualTo(403);
|
||||
assertThat(response.getHeader("WWW-Authenticate"))
|
||||
.isEqualTo("Bearer realm=\"test\", error=\"insufficient_scope\", error_description=\"Insufficient scope\", "
|
||||
+ "error_uri=\"https://example.com\", scope=\"test.read test.write\"");
|
||||
+ "error_uri=\"https://example.com\", scope=\"test.read test.write\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright 2004-present 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.oauth2.server.resource.web;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
|
||||
/**
|
||||
* Tests for {@link OAuth2ProtectedResourceMetadataFilter}.
|
||||
*
|
||||
* @author Joe Grandja
|
||||
*/
|
||||
public class OAuth2ProtectedResourceMetadataFilterTests {
|
||||
|
||||
private static final String DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI = "/.well-known/oauth-protected-resource";
|
||||
|
||||
private final OAuth2ProtectedResourceMetadataFilter filter = new OAuth2ProtectedResourceMetadataFilter();
|
||||
|
||||
@Test
|
||||
public void setProtectedResourceMetadataCustomizerWhenNullThenThrowIllegalArgumentException() {
|
||||
assertThatExceptionOfType(IllegalArgumentException.class)
|
||||
.isThrownBy(() -> this.filter.setProtectedResourceMetadataCustomizer(null))
|
||||
.withMessage("protectedResourceMetadataCustomizer cannot be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void doFilterWhenNotProtectedResourceMetadataRequestThenNotProcessed() throws Exception {
|
||||
String requestUri = "/path";
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
|
||||
request.setServletPath(requestUri);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain filterChain = mock(FilterChain.class);
|
||||
|
||||
this.filter.doFilter(request, response, filterChain);
|
||||
|
||||
verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void doFilterWhenProtectedResourceMetadataRequestPostThenNotProcessed() throws Exception {
|
||||
String requestUri = DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI;
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
|
||||
request.setServletPath(requestUri);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain filterChain = mock(FilterChain.class);
|
||||
|
||||
this.filter.doFilter(request, response, filterChain);
|
||||
|
||||
verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void doFilterWhenProtectedResourceMetadataRequestThenMetadataResponse() throws Exception {
|
||||
this.filter.setProtectedResourceMetadataCustomizer(
|
||||
(protectedResourceMetadata) -> protectedResourceMetadata.authorizationServer("https://provider1.com")
|
||||
.authorizationServer("https://provider2.com")
|
||||
.scope("scope1")
|
||||
.scope("scope2")
|
||||
.resourceName("resourceName"));
|
||||
|
||||
String requestUri = DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI;
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
|
||||
request.setServletPath(requestUri);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain filterChain = mock(FilterChain.class);
|
||||
|
||||
this.filter.doFilter(request, response, filterChain);
|
||||
|
||||
verifyNoInteractions(filterChain);
|
||||
|
||||
assertThat(response.getContentType()).isEqualTo(MediaType.APPLICATION_JSON_VALUE);
|
||||
String protectedResourceMetadataResponse = response.getContentAsString();
|
||||
assertThat(protectedResourceMetadataResponse).contains("\"resource\":\"http://localhost\"");
|
||||
assertThat(protectedResourceMetadataResponse)
|
||||
.contains("\"authorization_servers\":[\"https://provider1.com\",\"https://provider2.com\"]");
|
||||
assertThat(protectedResourceMetadataResponse).contains("\"scopes_supported\":[\"scope1\",\"scope2\"]");
|
||||
assertThat(protectedResourceMetadataResponse).contains("\"bearer_methods_supported\":[\"header\"]");
|
||||
assertThat(protectedResourceMetadataResponse).contains("\"resource_name\":\"resourceName\"");
|
||||
assertThat(protectedResourceMetadataResponse).contains("\"tls_client_certificate_bound_access_tokens\":true");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user