Skip to content

Commit 6de1c6e

Browse files
committed
Implement OAuth 2.0 Server Metadata (RFC 8414)
See See https://tools.ietf.org/html/rfc8414 Closes gh-54
1 parent 17c20e9 commit 6de1c6e

File tree

26 files changed

+2298
-421
lines changed

26 files changed

+2298
-421
lines changed

oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
import org.springframework.security.oauth2.server.authorization.oidc.web.OidcProviderConfigurationEndpointFilter;
5050
import org.springframework.security.oauth2.server.authorization.web.NimbusJwkSetEndpointFilter;
5151
import org.springframework.security.oauth2.server.authorization.web.OAuth2AuthorizationEndpointFilter;
52+
import org.springframework.security.oauth2.server.authorization.web.OAuth2AuthorizationServerMetadataEndpointFilter;
5253
import org.springframework.security.oauth2.server.authorization.web.OAuth2ClientAuthenticationFilter;
5354
import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter;
5455
import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenRevocationEndpointFilter;
@@ -99,6 +100,8 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
99100
NimbusJwkSetEndpointFilter.DEFAULT_JWK_SET_ENDPOINT_URI, HttpMethod.GET.name());
100101
private final RequestMatcher oidcProviderConfigurationEndpointMatcher = new AntPathRequestMatcher(
101102
OidcProviderConfigurationEndpointFilter.DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI, HttpMethod.GET.name());
103+
private final RequestMatcher oauth2ServerMetadataEndpointMatcher = new AntPathRequestMatcher(
104+
OAuth2AuthorizationServerMetadataEndpointFilter.DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI, HttpMethod.GET.name());
102105

103106
/**
104107
* Sets the repository of registered clients.
@@ -143,9 +146,13 @@ public OAuth2AuthorizationServerConfigurer<B> providerSettings(ProviderSettings
143146
*/
144147
public List<RequestMatcher> getEndpointMatchers() {
145148
// TODO Initialize matchers using URI's from ProviderSettings
146-
return Arrays.asList(this.authorizationEndpointMatcher, this.tokenEndpointMatcher,
147-
this.tokenRevocationEndpointMatcher, this.jwkSetEndpointMatcher,
148-
this.oidcProviderConfigurationEndpointMatcher);
149+
return Arrays.asList(
150+
this.authorizationEndpointMatcher,
151+
this.tokenEndpointMatcher,
152+
this.tokenRevocationEndpointMatcher,
153+
this.jwkSetEndpointMatcher,
154+
this.oidcProviderConfigurationEndpointMatcher,
155+
this.oauth2ServerMetadataEndpointMatcher);
149156
}
150157

151158
@Override
@@ -209,6 +216,10 @@ public void configure(B builder) {
209216
OidcProviderConfigurationEndpointFilter oidcProviderConfigurationEndpointFilter =
210217
new OidcProviderConfigurationEndpointFilter(providerSettings);
211218
builder.addFilterBefore(postProcess(oidcProviderConfigurationEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class);
219+
220+
OAuth2AuthorizationServerMetadataEndpointFilter authorizationServerMetadataFilter
221+
= new OAuth2AuthorizationServerMetadataEndpointFilter(providerSettings);
222+
builder.addFilterBefore(postProcess(authorizationServerMetadataFilter), AbstractPreAuthenticatedProcessingFilter.class);
212223
}
213224

214225
JWKSource<SecurityContext> jwkSource = getJwkSource(builder);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
/*
2+
* Copyright 2020 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+
package org.springframework.security.oauth2.core;
17+
18+
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
19+
import org.springframework.security.oauth2.core.oidc.OidcScopes;
20+
import org.springframework.security.oauth2.server.authorization.Version;
21+
import org.springframework.util.Assert;
22+
23+
import java.io.Serializable;
24+
import java.net.URI;
25+
import java.net.URL;
26+
import java.util.Arrays;
27+
import java.util.Collection;
28+
import java.util.Collections;
29+
import java.util.LinkedHashMap;
30+
import java.util.LinkedList;
31+
import java.util.List;
32+
import java.util.Map;
33+
import java.util.Set;
34+
import java.util.function.Consumer;
35+
import java.util.stream.Collectors;
36+
37+
38+
/**
39+
* A base representation of a Provider Metadata response, returned by an endpoint defined
40+
* either in OpenID Connect Discovery 1.0 or OAuth 2.0 Authorization Server Metadata.
41+
* It contains a set of claims about the Provider's configuration.
42+
*
43+
* @author Daniel Garnier-Moiroux
44+
* @since 0.1.0
45+
* @see AuthorizationServerMetadataClaimAccessor
46+
* @see <a target="_blank" href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">4.2. OpenID Provider Configuration Response</a>
47+
* @see <a target="_blank" href="https://tools.ietf.org/html/rfc8414#section-3.2">3.2. Authorization Server Metadata Response</a>
48+
*/
49+
public abstract class AbstractAuthorizationServerMetadata implements AuthorizationServerMetadataClaimAccessor, Serializable {
50+
private static final long serialVersionUID = Version.SERIAL_VERSION_UID;
51+
52+
protected final Map<String, Object> claims;
53+
54+
protected AbstractAuthorizationServerMetadata(Map<String, Object> claims) {
55+
Assert.notEmpty(claims, "claims cannot be empty");
56+
this.claims = Collections.unmodifiableMap(new LinkedHashMap<>(claims));
57+
}
58+
59+
/**
60+
* Returns the Authorization Server metadata.
61+
*
62+
* @return a {@code Map} of the metadata values
63+
*/
64+
@Override
65+
public Map<String, Object> getClaims() {
66+
return this.claims;
67+
}
68+
69+
protected static Map<String, Object> defaultClaims() {
70+
LinkedHashMap<String, Object> claims = new LinkedHashMap<>();
71+
claims.put(
72+
AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED,
73+
Arrays.asList(
74+
"client_secret_basic", // TODO: Use ClientAuthenticationMethod.CLIENT_SECRET_BASIC in Spring Security 5.5.0
75+
"client_secret_post" // TODO: Use ClientAuthenticationMethod.CLIENT_SECRET_POST in Spring Security 5.5.0
76+
)
77+
);
78+
claims.put(
79+
AuthorizationServerMetadataClaimNames.GRANT_TYPES_SUPPORTED,
80+
Arrays.asList(
81+
AuthorizationGrantType.AUTHORIZATION_CODE.getValue(),
82+
AuthorizationGrantType.CLIENT_CREDENTIALS.getValue(),
83+
AuthorizationGrantType.REFRESH_TOKEN.getValue()
84+
)
85+
);
86+
claims.put(
87+
AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED,
88+
Collections.singletonList(OAuth2AuthorizationResponseType.CODE.getValue())
89+
);
90+
claims.put(
91+
AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED,
92+
Collections.singletonList(OidcScopes.OPENID)
93+
);
94+
return claims;
95+
}
96+
97+
/**
98+
* An abstract builder for subclasses of {@link AbstractAuthorizationServerMetadata}.
99+
*/
100+
public static abstract class AbstractBuilder<T extends AbstractAuthorizationServerMetadata, B extends AbstractBuilder<T, B>> {
101+
102+
protected final Map<String, Object> claims = new LinkedHashMap<>();
103+
104+
protected AbstractBuilder() { }
105+
106+
protected abstract B getThis(); // avoid unchecked casts in subclasses by using "getThis()" instead of "(B) this"
107+
108+
/**
109+
* Use this {@code issuer} in the resulting {@link AbstractAuthorizationServerMetadata}, REQUIRED.
110+
*
111+
* @param issuer the {@code URL} of the Authorization Server's Issuer Identifier
112+
* @return the {@link AbstractBuilder} for further configuration
113+
*/
114+
public B issuer(String issuer) {
115+
return claim(AuthorizationServerMetadataClaimNames.ISSUER, issuer);
116+
}
117+
118+
/**
119+
* Use this {@code authorization_endpoint} in the resulting {@link AbstractAuthorizationServerMetadata}, REQUIRED.
120+
*
121+
* @param authorizationEndpoint the {@code URL} of the Authorization Server's OAuth 2.0 Authorization Endpoint
122+
* @return the {@link AbstractBuilder} for further configuration
123+
*/
124+
public B authorizationEndpoint(String authorizationEndpoint) {
125+
return claim(AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT, authorizationEndpoint);
126+
}
127+
128+
/**
129+
* Use this {@code token_endpoint} in the resulting {@link AbstractAuthorizationServerMetadata}, REQUIRED.
130+
*
131+
* @param tokenEndpoint the {@code URL} of the Authorization Server's OAuth 2.0 Token Endpoint
132+
* @return the {@link AbstractBuilder} for further configuration
133+
*/
134+
public B tokenEndpoint(String tokenEndpoint) {
135+
return claim(AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT, tokenEndpoint);
136+
}
137+
138+
/**
139+
* Add this Authentication Method to the collection of {@code token_endpoint_auth_methods_supported}
140+
* in the resulting {@link AbstractAuthorizationServerMetadata}, OPTIONAL.
141+
*
142+
* @param authenticationMethod the OAuth 2.0 Authentication Method supported by the Token Endpoint
143+
* @return the {@link AbstractBuilder} for further configuration
144+
*/
145+
public B tokenEndpointAuthenticationMethod(String authenticationMethod) {
146+
addClaimToClaimList(AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, authenticationMethod);
147+
return getThis();
148+
}
149+
150+
/**
151+
* A {@code Consumer} of the Token Endpoint Authentication Method(s) allowing the ability to add, replace, or remove.
152+
*
153+
* @param authenticationMethodsConsumer a {@code Consumer} of the Token Endpoint Authentication Method(s)
154+
* @return the {@link AbstractBuilder} for further configuration
155+
*/
156+
public B tokenEndpointAuthenticationMethods(Consumer<List<String>> authenticationMethodsConsumer) {
157+
acceptClaimValues(AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, authenticationMethodsConsumer);
158+
return getThis();
159+
}
160+
161+
/**
162+
* Use this {@code jwks_uri} in the resulting {@link AbstractAuthorizationServerMetadata}, REQUIRED.
163+
*
164+
* @param jwkSetUri the {@code URL} of the Authorization Server's JSON Web Key Set document
165+
* @return the {@link AbstractBuilder} for further configuration
166+
*/
167+
public B jwkSetUri(String jwkSetUri) {
168+
return claim(AuthorizationServerMetadataClaimNames.JWKS_URI, jwkSetUri);
169+
}
170+
171+
/**
172+
* Add this Response Type to the collection of {@code response_types_supported} in the resulting
173+
* {@link AbstractAuthorizationServerMetadata}.
174+
*
175+
* @param responseType the OAuth 2.0 {@code response_type} value that the Authorization Server supports
176+
* @return the {@link AbstractBuilder} for further configuration
177+
*/
178+
public B responseType(String responseType) {
179+
addClaimToClaimList(AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, responseType);
180+
return getThis();
181+
}
182+
183+
/**
184+
* A {@code Consumer} of the Response Type(s) allowing the ability to add, replace, or remove.
185+
*
186+
* @param responseTypesConsumer a {@code Consumer} of the Response Type(s)
187+
* @return the {@link AbstractBuilder} for further configuration
188+
*/
189+
public B responseTypes(Consumer<List<String>> responseTypesConsumer) {
190+
acceptClaimValues(AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, responseTypesConsumer);
191+
return getThis();
192+
}
193+
194+
/**
195+
* Add this Grant Type to the collection of {@code grant_types_supported} in the resulting
196+
* {@link AbstractAuthorizationServerMetadata}, OPTIONAL.
197+
*
198+
* @param grantType the OAuth 2.0 {@code grant_type} value that the Authorization Server supports
199+
* @return the {@link AbstractBuilder} for further configuration
200+
*/
201+
public B grantType(String grantType) {
202+
addClaimToClaimList(AuthorizationServerMetadataClaimNames.GRANT_TYPES_SUPPORTED, grantType);
203+
return getThis();
204+
}
205+
206+
/**
207+
* A {@code Consumer} of the Grant Type(s) allowing the ability to add, replace, or remove.
208+
*
209+
* @param grantTypesConsumer a {@code Consumer} of the Grant Type(s)
210+
* @return the {@link AbstractBuilder} for further configuration
211+
*/
212+
public B grantTypes(Consumer<List<String>> grantTypesConsumer) {
213+
acceptClaimValues(AuthorizationServerMetadataClaimNames.GRANT_TYPES_SUPPORTED, grantTypesConsumer);
214+
return getThis();
215+
}
216+
217+
/**
218+
* Add this Scope to the collection of {@code scopes_supported} in the resulting
219+
* {@link AbstractAuthorizationServerMetadata}, RECOMMENDED.
220+
*
221+
* @param scope the OAuth 2.0 {@code scope} value that the Authorization Server supports
222+
* @return the {@link AbstractBuilder} for further configuration
223+
*/
224+
public B scope(String scope) {
225+
addClaimToClaimList(AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED, scope);
226+
return getThis();
227+
}
228+
229+
/**
230+
* A {@code Consumer} of the Scopes(s) allowing the ability to add, replace, or remove.
231+
*
232+
* @param scopesConsumer a {@code Consumer} of the Scopes(s)
233+
* @return the {@link AbstractBuilder} for further configuration
234+
*/
235+
public B scopes(Consumer<List<String>> scopesConsumer) {
236+
acceptClaimValues(AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED, scopesConsumer);
237+
return getThis();
238+
}
239+
240+
/**
241+
* Use this claim in the resulting {@link AbstractAuthorizationServerMetadata}
242+
*
243+
* @param name the claim name
244+
* @param value the claim value
245+
* @return the {@link AbstractBuilder} for further configuration
246+
*/
247+
public B claim(String name, Object value) {
248+
Assert.hasText(name, "name cannot be empty");
249+
Assert.notNull(value, "value cannot be null");
250+
this.claims.put(name, value);
251+
return getThis();
252+
}
253+
254+
/**
255+
* Provides access to every {@link #claim(String, Object)} declared so far with
256+
* the possibility to add, replace, or remove.
257+
*
258+
* @param claimsConsumer a {@code Consumer} of the claims
259+
* @return the {@link AbstractBuilder} for further configurations
260+
*/
261+
public B claims(Consumer<Map<String, Object>> claimsConsumer) {
262+
claimsConsumer.accept(this.claims);
263+
return getThis();
264+
}
265+
266+
/**
267+
* Creates the {@link AbstractAuthorizationServerMetadata}.
268+
*
269+
* @return the {@link AbstractAuthorizationServerMetadata}
270+
*/
271+
public abstract T build();
272+
273+
protected void validateCommonClaims() {
274+
Assert.notNull(this.claims.get(AuthorizationServerMetadataClaimNames.ISSUER), "issuer cannot be null");
275+
validateURL(this.claims.get(AuthorizationServerMetadataClaimNames.ISSUER), "issuer must be a valid URL");
276+
Assert.notNull(this.claims.get(AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT), "authorizationEndpoint cannot be null");
277+
validateURL(this.claims.get(AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT), "authorizationEndpoint must be a valid URL");
278+
Assert.notNull(this.claims.get(AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT), "tokenEndpoint cannot be null");
279+
validateURL(this.claims.get(AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT), "tokenEndpoint must be a valid URL");
280+
Assert.notNull(this.claims.get(AuthorizationServerMetadataClaimNames.JWKS_URI), "jwksUri cannot be null");
281+
validateURL(this.claims.get(AuthorizationServerMetadataClaimNames.JWKS_URI), "jwksUri must be a valid URL");
282+
Assert.notNull(this.claims.get(AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED), "responseTypes cannot be null");
283+
Assert.isInstanceOf(List.class, this.claims.get(AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED), "responseTypes must be of type List");
284+
Assert.notEmpty((List<?>) this.claims.get(AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED), "responseTypes cannot be empty");
285+
}
286+
287+
/**
288+
* Remove claims of type Collection that have a size of zero.
289+
* <p>
290+
* Both <a target="_blank" href="https://tools.ietf.org/html/rfc8414#section-3.2">3.2. Authorization Server Metadata Response</a>
291+
* and <a target="_blank" href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">4.2. OpenID Provider Configuration Response</a>
292+
* state "Claims with zero elements MUST be omitted from the response."
293+
*/
294+
protected void removeEmptyClaims() {
295+
Set<String> claimsToRemove = this.claims.entrySet()
296+
.stream()
297+
.filter(entry -> entry.getValue() != null)
298+
.filter(entry -> Collection.class.isAssignableFrom(entry.getValue().getClass()))
299+
.filter(entry -> ((Collection<?>) entry.getValue()).size() == 0)
300+
.map(Map.Entry::getKey)
301+
.collect(Collectors.toSet());
302+
303+
for (String claimToRemove : claimsToRemove) {
304+
this.claims.remove(claimToRemove);
305+
}
306+
}
307+
308+
protected static void validateURL(Object url, String errorMessage) {
309+
if (URL.class.isAssignableFrom(url.getClass())) {
310+
return;
311+
}
312+
313+
try {
314+
new URI(url.toString()).toURL();
315+
} catch (Exception ex) {
316+
throw new IllegalArgumentException(errorMessage, ex);
317+
}
318+
}
319+
320+
@SuppressWarnings("unchecked")
321+
protected void addClaimToClaimList(String name, String value) {
322+
Assert.hasText(name, "name cannot be empty");
323+
Assert.notNull(value, "value cannot be null");
324+
this.claims.computeIfAbsent(name, k -> new LinkedList<String>());
325+
((List<String>) this.claims.get(name)).add(value);
326+
}
327+
328+
@SuppressWarnings("unchecked")
329+
protected void acceptClaimValues(String name, Consumer<List<String>> valuesConsumer) {
330+
Assert.hasText(name, "name cannot be empty");
331+
Assert.notNull(valuesConsumer, "valuesConsumer cannot be null");
332+
this.claims.computeIfAbsent(name, k -> new LinkedList<String>());
333+
List<String> values = (List<String>) this.claims.get(name);
334+
valuesConsumer.accept(values);
335+
}
336+
}
337+
}

0 commit comments

Comments
 (0)