Skip to content

Commit 54aae36

Browse files
committed
Add support for OAuth 2.0 Protected Resource Metadata
Closes gh-17244
1 parent 64c9e3e commit 54aae36

File tree

13 files changed

+1061
-19
lines changed

13 files changed

+1061
-19
lines changed

config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.util.Collections;
2121
import java.util.LinkedHashMap;
2222
import java.util.Map;
23+
import java.util.function.Consumer;
2324
import java.util.function.Supplier;
2425

2526
import jakarta.servlet.http.HttpServletRequest;
@@ -43,6 +44,7 @@
4344
import org.springframework.security.oauth2.jwt.Jwt;
4445
import org.springframework.security.oauth2.jwt.JwtDecoder;
4546
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
47+
import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata;
4648
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
4749
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider;
4850
import org.springframework.security.oauth2.server.resource.authentication.OpaqueTokenAuthenticationProvider;
@@ -51,6 +53,7 @@
5153
import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector;
5254
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
5355
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
56+
import org.springframework.security.oauth2.server.resource.web.OAuth2ProtectedResourceMetadataFilter;
5457
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
5558
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationConverter;
5659
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
@@ -59,6 +62,7 @@
5962
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
6063
import org.springframework.security.web.access.DelegatingAccessDeniedHandler;
6164
import org.springframework.security.web.authentication.AuthenticationConverter;
65+
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
6266
import org.springframework.security.web.csrf.CsrfException;
6367
import org.springframework.security.web.util.matcher.AndRequestMatcher;
6468
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
@@ -172,6 +176,8 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
172176

173177
private OpaqueTokenConfigurer opaqueTokenConfigurer;
174178

179+
private final ProtectedResourceMetadataConfigurer protectedResourceMetadataConfigurer = new ProtectedResourceMetadataConfigurer();
180+
175181
private AccessDeniedHandler accessDeniedHandler = new DelegatingAccessDeniedHandler(
176182
new LinkedHashMap<>(Map.of(CsrfException.class, new AccessDeniedHandlerImpl())),
177183
new BearerTokenAccessDeniedHandler());
@@ -250,6 +256,18 @@ public OAuth2ResourceServerConfigurer<H> opaqueToken(Customizer<OpaqueTokenConfi
250256
return this;
251257
}
252258

259+
/**
260+
* Configure OAuth 2.0 Protected Resource Metadata.
261+
* @param protectedResourceMetadataCustomizer the {@link Customizer} to provide more
262+
* options for the {@link ProtectedResourceMetadataConfigurer}
263+
* @return the {@link OAuth2ResourceServerConfigurer} for further customizations
264+
*/
265+
public OAuth2ResourceServerConfigurer<H> protectedResourceMetadata(
266+
Customizer<ProtectedResourceMetadataConfigurer> protectedResourceMetadataCustomizer) {
267+
protectedResourceMetadataCustomizer.customize(this.protectedResourceMetadataConfigurer);
268+
return this;
269+
}
270+
253271
@Override
254272
public void init(H http) {
255273
validateConfiguration();
@@ -277,10 +295,19 @@ public void configure(H http) {
277295
filter.setSecurityContextHolderStrategy(getSecurityContextHolderStrategy());
278296
filter = postProcess(filter);
279297
http.addFilter(filter);
298+
280299
if (dPoPAuthenticationAvailable) {
281300
DPoPAuthenticationConfigurer<H> dPoPAuthenticationConfigurer = new DPoPAuthenticationConfigurer<>();
282301
dPoPAuthenticationConfigurer.configure(http);
283302
}
303+
304+
OAuth2ProtectedResourceMetadataFilter protectedResourceMetadataFilter = new OAuth2ProtectedResourceMetadataFilter();
305+
if (this.protectedResourceMetadataConfigurer.protectedResourceMetadataCustomizer != null) {
306+
protectedResourceMetadataFilter.setProtectedResourceMetadataCustomizer(
307+
this.protectedResourceMetadataConfigurer.protectedResourceMetadataCustomizer);
308+
}
309+
protectedResourceMetadataFilter = postProcess(protectedResourceMetadataFilter);
310+
http.addFilterBefore(protectedResourceMetadataFilter, AbstractPreAuthenticatedProcessingFilter.class);
284311
}
285312

286313
private void validateConfiguration() {
@@ -562,6 +589,30 @@ AuthenticationManager getAuthenticationManager(H http) {
562589

563590
}
564591

592+
public static final class ProtectedResourceMetadataConfigurer {
593+
594+
private Consumer<OAuth2ProtectedResourceMetadata.Builder> protectedResourceMetadataCustomizer;
595+
596+
private ProtectedResourceMetadataConfigurer() {
597+
}
598+
599+
/**
600+
* Sets the {@code Consumer} providing access to the
601+
* {@link OAuth2ProtectedResourceMetadata.Builder} allowing the ability to
602+
* customize the claims of the Resource Server's configuration.
603+
* @param protectedResourceMetadataCustomizer the {@code Consumer} providing
604+
* access to the {@link OAuth2ProtectedResourceMetadata.Builder}
605+
* @return the {@link ProtectedResourceMetadataConfigurer} for further
606+
* configuration
607+
*/
608+
public ProtectedResourceMetadataConfigurer protectedResourceMetadataCustomizer(
609+
Consumer<OAuth2ProtectedResourceMetadata.Builder> protectedResourceMetadataCustomizer) {
610+
this.protectedResourceMetadataCustomizer = protectedResourceMetadataCustomizer;
611+
return this;
612+
}
613+
614+
}
615+
565616
private static final class BearerTokenRequestMatcher implements RequestMatcher {
566617

567618
private AuthenticationConverter authenticationConverter;

config/src/test/java/org/springframework/security/SerializationSamples.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@
192192
import org.springframework.security.oauth2.server.resource.BearerTokenError;
193193
import org.springframework.security.oauth2.server.resource.BearerTokenErrors;
194194
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
195+
import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata;
195196
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication;
196197
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken;
197198
import org.springframework.security.oauth2.server.resource.authentication.DPoPAuthenticationToken;
@@ -406,6 +407,15 @@ final class SerializationSamples {
406407
(r) -> new OAuth2IntrospectionException("message", new RuntimeException()));
407408
generatorByClassName.put(DPoPAuthenticationToken.class,
408409
(r) -> applyDetails(new DPoPAuthenticationToken("token", "proof", "method", "uri")));
410+
generatorByClassName.put(OAuth2ProtectedResourceMetadata.class,
411+
(r) -> OAuth2ProtectedResourceMetadata.builder()
412+
.resource("https://localhost/resource")
413+
.authorizationServer("https://localhost/authorizationServer")
414+
.scope("scope")
415+
.bearerMethod("bearerMethod")
416+
.resourceName("resourceName")
417+
.tlsClientCertificateBoundAccessTokens(true)
418+
.build());
409419

410420
// oauth2-authorization-server
411421
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/*
2+
* Copyright 2004-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.config.annotation.web.configurers.oauth2.server.resource;
18+
19+
import java.util.function.Consumer;
20+
21+
import org.junit.jupiter.api.Test;
22+
import org.junit.jupiter.api.extension.ExtendWith;
23+
24+
import org.springframework.beans.factory.annotation.Autowired;
25+
import org.springframework.context.annotation.Bean;
26+
import org.springframework.context.annotation.Configuration;
27+
import org.springframework.security.config.Customizer;
28+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
29+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
30+
import org.springframework.security.config.test.SpringTestContext;
31+
import org.springframework.security.config.test.SpringTestContextExtension;
32+
import org.springframework.security.oauth2.jose.TestKeys;
33+
import org.springframework.security.oauth2.jwt.JwtDecoder;
34+
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
35+
import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata;
36+
import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadataClaimNames;
37+
import org.springframework.security.web.SecurityFilterChain;
38+
import org.springframework.test.web.servlet.MockMvc;
39+
40+
import static org.hamcrest.Matchers.hasItem;
41+
import static org.hamcrest.Matchers.hasSize;
42+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
43+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
44+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
45+
46+
/**
47+
* Integration tests for OAuth 2.0 Protected Resource Metadata Requests.
48+
*
49+
* @author Joe Grandja
50+
*/
51+
@ExtendWith(SpringTestContextExtension.class)
52+
public class OAuth2ProtectedResourceMetadataTests {
53+
54+
private static final String DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI = "/.well-known/oauth-protected-resource";
55+
56+
private static final String RESOURCE = "https://resource.com:8443";
57+
58+
private static final String ISSUER_1 = "https://provider1.com";
59+
60+
private static final String ISSUER_2 = "https://provider2.com";
61+
62+
public final SpringTestContext spring = new SpringTestContext(this);
63+
64+
@Autowired
65+
private MockMvc mvc;
66+
67+
@Test
68+
public void requestWhenProtectedResourceMetadataRequestThenReturnMetadataResponse() throws Exception {
69+
this.spring.register(ResourceServerConfiguration.class).autowire();
70+
71+
this.mvc.perform(get(RESOURCE.concat(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI)))
72+
.andExpect(status().is2xxSuccessful())
73+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(RESOURCE))
74+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).isArray())
75+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).value(hasSize(1)))
76+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED)
77+
.value(hasItem("header")))
78+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS)
79+
.value(true))
80+
.andReturn();
81+
}
82+
83+
@Test
84+
public void requestWhenProtectedResourceMetadataRequestIncludesResourcePathThenMetadataResponseHasResourcePath()
85+
throws Exception {
86+
this.spring.register(ResourceServerConfiguration.class).autowire();
87+
88+
String host = RESOURCE;
89+
90+
String resourcePath = "/resource1";
91+
String resource = host.concat(resourcePath);
92+
this.mvc.perform(get(host.concat(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI).concat(resourcePath)))
93+
.andExpect(status().is2xxSuccessful())
94+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(resource))
95+
.andReturn();
96+
97+
resourcePath = "/path1/resource2";
98+
resource = host.concat(resourcePath);
99+
this.mvc.perform(get(host.concat(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI).concat(resourcePath)))
100+
.andExpect(status().is2xxSuccessful())
101+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(resource))
102+
.andReturn();
103+
104+
resourcePath = "/path1/path2/resource3";
105+
resource = host.concat(resourcePath);
106+
this.mvc.perform(get(host.concat(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI).concat(resourcePath)))
107+
.andExpect(status().is2xxSuccessful())
108+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(resource))
109+
.andReturn();
110+
}
111+
112+
@Test
113+
public void requestWhenProtectedResourceMetadataRequestAndMetadataCustomizerSetThenReturnCustomMetadataResponse()
114+
throws Exception {
115+
this.spring.register(ResourceServerConfigurationWithMetadataCustomizer.class).autowire();
116+
117+
this.mvc.perform(get(RESOURCE.concat(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI)))
118+
.andExpect(status().is2xxSuccessful())
119+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(RESOURCE))
120+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).isArray())
121+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).value(hasSize(2)))
122+
.andExpect(
123+
jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).value(hasItem(ISSUER_1)))
124+
.andExpect(
125+
jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).value(hasItem(ISSUER_2)))
126+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).isArray())
127+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).value(hasSize(2)))
128+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).value(hasItem("scope1")))
129+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).value(hasItem("scope2")))
130+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).isArray())
131+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).value(hasSize(1)))
132+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED)
133+
.value(hasItem("header")))
134+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE_NAME).value("resourceName"))
135+
.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS)
136+
.value(true))
137+
.andReturn();
138+
}
139+
140+
@EnableWebSecurity
141+
@Configuration(proxyBeanMethods = false)
142+
static class ResourceServerConfiguration {
143+
144+
@Bean
145+
SecurityFilterChain securityFilterChain(HttpSecurity http) {
146+
// @formatter:off
147+
http
148+
.authorizeHttpRequests((authorize) ->
149+
authorize
150+
.anyRequest().authenticated()
151+
)
152+
.oauth2ResourceServer((oauth2) ->
153+
oauth2
154+
.jwt(Customizer.withDefaults())
155+
);
156+
// @formatter:on
157+
return http.build();
158+
}
159+
160+
@Bean
161+
JwtDecoder jwtDecoder() {
162+
return NimbusJwtDecoder.withPublicKey(TestKeys.DEFAULT_PUBLIC_KEY).build();
163+
}
164+
165+
}
166+
167+
@EnableWebSecurity
168+
@Configuration(proxyBeanMethods = false)
169+
static class ResourceServerConfigurationWithMetadataCustomizer extends ResourceServerConfiguration {
170+
171+
@Bean
172+
SecurityFilterChain securityFilterChain(HttpSecurity http) {
173+
// @formatter:off
174+
http
175+
.authorizeHttpRequests((authorize) ->
176+
authorize
177+
.anyRequest().authenticated()
178+
)
179+
.oauth2ResourceServer((oauth2) ->
180+
oauth2
181+
.jwt(Customizer.withDefaults())
182+
.protectedResourceMetadata((metadata) ->
183+
metadata.protectedResourceMetadataCustomizer(protectedResourceMetadataCustomizer())
184+
)
185+
);
186+
// @formatter:on
187+
return http.build();
188+
}
189+
190+
private Consumer<OAuth2ProtectedResourceMetadata.Builder> protectedResourceMetadataCustomizer() {
191+
return (protectedResourceMetadata) -> protectedResourceMetadata.authorizationServer(ISSUER_1)
192+
.authorizationServer(ISSUER_2)
193+
.scope("scope1")
194+
.scope("scope2")
195+
.resourceName("resourceName");
196+
}
197+
198+
}
199+
200+
}

config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ public void postWhenCsrfDisabledWithBearerTokenAsFormParameterThenIgnoresToken()
415415
// @formatter:off
416416
this.mvc.perform(post("/").header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE).with(bearerToken("token").asParam()))
417417
.andExpect(status().isUnauthorized())
418-
.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer"));
418+
.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\""));
419419
// @formatter:on
420420
}
421421

@@ -437,7 +437,7 @@ public void getWhenUsingDefaultsWithNoBearerTokenThenUnauthorized() throws Excep
437437
// @formatter:off
438438
this.mvc.perform(get("/"))
439439
.andExpect(status().isUnauthorized())
440-
.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer"));
440+
.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\""));
441441
// @formatter:on
442442
}
443443

@@ -1472,14 +1472,18 @@ private static ResultMatcher invalidRequestHeader(String message) {
14721472
return header().string(HttpHeaders.WWW_AUTHENTICATE,
14731473
AllOf.allOf(new StringStartsWith("Bearer " + "error=\"invalid_request\", " + "error_description=\""),
14741474
new StringContains(message),
1475-
new StringEndsWith(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\"")));
1475+
new StringContains(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""),
1476+
new StringEndsWith(
1477+
", " + "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")));
14761478
}
14771479

14781480
private static ResultMatcher invalidTokenHeader(String message) {
14791481
return header().string(HttpHeaders.WWW_AUTHENTICATE,
14801482
AllOf.allOf(new StringStartsWith("Bearer " + "error=\"invalid_token\", " + "error_description=\""),
14811483
new StringContains(message),
1482-
new StringEndsWith(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\"")));
1484+
new StringContains(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""),
1485+
new StringEndsWith(
1486+
", " + "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")));
14831487
}
14841488

14851489
private static ResultMatcher insufficientScopeHeader() {

0 commit comments

Comments
 (0)