OAuth2ProtectedResourceMetadata.java
/*
* 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);
}
}
}
}