Skip to content

Commit 04ac7d3

Browse files
committed
authorization-server: accept empty scope claim in DCR request
- The MCP inspector sends an empty scope claim by default Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 79bacd0 commit 04ac7d3

5 files changed

Lines changed: 302 additions & 41 deletions

File tree

‎mcp-authorization-server/src/main/java/org/springaicommunity/mcp/security/authorizationserver/config/McpAuthorizationServerConfigurer.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ public void init(HttpSecurity http) {
160160
OAuth2TokenGenerator<?> tokenGenerator = getTokenGenerator(http);
161161
authServer.tokenGenerator(tokenGenerator);
162162
if (this.supportDynamicClientRegistration) {
163+
authServer.addObjectPostProcessor(new McpClientRegistrationConverterPostProcessor());
163164
authServer.clientRegistrationEndpoint(cr -> cr.openRegistrationAllowed(true));
164165
}
165166
this.authServerCustomizer.forEach(c -> c.customize(authServer));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright 2026-2026 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.springaicommunity.mcp.security.authorizationserver.config;
18+
19+
import jakarta.servlet.http.HttpServletRequest;
20+
21+
import org.springframework.http.converter.HttpMessageConverter;
22+
import org.springframework.http.server.ServletServerHttpRequest;
23+
import org.springframework.security.config.ObjectPostProcessor;
24+
import org.springframework.security.core.Authentication;
25+
import org.springframework.security.core.context.SecurityContextHolder;
26+
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
27+
import org.springframework.security.oauth2.core.OAuth2Error;
28+
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
29+
import org.springframework.security.oauth2.server.authorization.OAuth2ClientRegistration;
30+
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientRegistrationAuthenticationToken;
31+
import org.springframework.security.oauth2.server.authorization.http.converter.OAuth2ClientRegistrationHttpMessageConverter;
32+
import org.springframework.security.oauth2.server.authorization.web.OAuth2ClientRegistrationEndpointFilter;
33+
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ClientRegistrationAuthenticationConverter;
34+
import org.springframework.security.web.authentication.AuthenticationConverter;
35+
36+
/**
37+
* Post-processor to set an {@link AuthenticationConverter} that tolerates empty scopes on
38+
* {@link OAuth2ClientRegistrationEndpointFilter}.
39+
* <p>
40+
* The converter is lifted from {@link OAuth2ClientRegistrationAuthenticationConverter},
41+
* which is {@code final} and does not allow customizing the underlying
42+
* {@link OAuth2ClientRegistrationHttpMessageConverter}. The only difference is the
43+
* injected {@link McpEmptyScopeClientRegistrationConverter}.
44+
* <p>
45+
* For internal use only.
46+
*
47+
* @author Daniel Garnier-Moiroux
48+
* @see <a href="https://github.com/spring-projects/spring-security/pull/19765">Spring
49+
* Security #19765</a>
50+
*/
51+
class McpClientRegistrationConverterPostProcessor
52+
implements ObjectPostProcessor<OAuth2ClientRegistrationEndpointFilter> {
53+
54+
private final HttpMessageConverter<OAuth2ClientRegistration> clientRegistrationHttpMessageConverter = clientRegistrationHttpMessageConverter();
55+
56+
@Override
57+
public OAuth2ClientRegistrationEndpointFilter postProcess(OAuth2ClientRegistrationEndpointFilter filter) {
58+
filter.setAuthenticationConverter(this::convert);
59+
return filter;
60+
}
61+
62+
private Authentication convert(HttpServletRequest request) {
63+
Authentication principal = SecurityContextHolder.getContext().getAuthentication();
64+
65+
OAuth2ClientRegistration clientRegistration;
66+
try {
67+
clientRegistration = this.clientRegistrationHttpMessageConverter.read(OAuth2ClientRegistration.class,
68+
new ServletServerHttpRequest(request));
69+
}
70+
catch (Exception ex) {
71+
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST,
72+
"OAuth 2.0 Client Registration Error: " + ex.getMessage(),
73+
"https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.2");
74+
throw new OAuth2AuthenticationException(error, ex);
75+
}
76+
77+
return new OAuth2ClientRegistrationAuthenticationToken(principal, clientRegistration);
78+
}
79+
80+
private static HttpMessageConverter<OAuth2ClientRegistration> clientRegistrationHttpMessageConverter() {
81+
var messageConverter = new OAuth2ClientRegistrationHttpMessageConverter();
82+
messageConverter.setClientRegistrationConverter(new McpEmptyScopeClientRegistrationConverter());
83+
return messageConverter;
84+
}
85+
86+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Copyright 2026-2026 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.springaicommunity.mcp.security.authorizationserver.config;
18+
19+
import java.net.URL;
20+
import java.time.Instant;
21+
import java.util.Collection;
22+
import java.util.HashMap;
23+
import java.util.List;
24+
import java.util.Map;
25+
26+
import org.jspecify.annotations.Nullable;
27+
28+
import org.springframework.core.convert.TypeDescriptor;
29+
import org.springframework.core.convert.converter.Converter;
30+
import org.springframework.security.oauth2.core.converter.ClaimConversionService;
31+
import org.springframework.security.oauth2.core.converter.ClaimTypeConverter;
32+
import org.springframework.security.oauth2.server.authorization.OAuth2ClientMetadataClaimNames;
33+
import org.springframework.security.oauth2.server.authorization.OAuth2ClientRegistration;
34+
import org.springframework.security.oauth2.server.authorization.http.converter.OAuth2ClientRegistrationHttpMessageConverter;
35+
import org.springframework.util.StringUtils;
36+
37+
/**
38+
* Converts dynamic client registration parameters into an
39+
* {@link OAuth2ClientRegistration}, ignoring an empty {@code scope} parameter, e.g.
40+
* {@code "scope": ""}. OAuth2 specifies that the "scope" claim should have at least 1
41+
* character, but some clients, including the MCP Inspector 2.7.0, sends an empty scope
42+
* string.
43+
* <p>
44+
* It is lifted from
45+
* {@code OAuth2ClientRegistrationHttpMessageConverter.MapOAuth2ClientRegistrationConverter}.
46+
* <p>
47+
* For internal use only.
48+
*
49+
* @author Daniel Garnier-Moiroux
50+
* @see OAuth2ClientRegistrationHttpMessageConverter
51+
* @see <a href="https://github.com/spring-projects/spring-security/pull/19765">Spring
52+
* Security #19765</a>
53+
*/
54+
class McpEmptyScopeClientRegistrationConverter implements Converter<Map<String, Object>, OAuth2ClientRegistration> {
55+
56+
private static final ClaimConversionService CLAIM_CONVERSION_SERVICE = ClaimConversionService.getSharedInstance();
57+
58+
private static final TypeDescriptor OBJECT_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(Object.class);
59+
60+
private static final TypeDescriptor STRING_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(String.class);
61+
62+
private static final TypeDescriptor INSTANT_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(Instant.class);
63+
64+
private static final TypeDescriptor URL_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(URL.class);
65+
66+
private static final Converter<Object, ?> INSTANT_CONVERTER = getConverter(INSTANT_TYPE_DESCRIPTOR);
67+
68+
private final ClaimTypeConverter claimTypeConverter;
69+
70+
@SuppressWarnings("NullAway")
71+
McpEmptyScopeClientRegistrationConverter() {
72+
Converter<Object, ?> stringConverter = getConverter(STRING_TYPE_DESCRIPTOR);
73+
Converter<Object, ?> collectionStringConverter = getConverter(
74+
TypeDescriptor.collection(Collection.class, STRING_TYPE_DESCRIPTOR));
75+
Converter<Object, ?> urlConverter = getConverter(URL_TYPE_DESCRIPTOR);
76+
77+
Map<String, Converter<Object, ?>> claimConverters = new HashMap<>();
78+
claimConverters.put(OAuth2ClientMetadataClaimNames.CLIENT_ID, stringConverter);
79+
claimConverters.put(OAuth2ClientMetadataClaimNames.CLIENT_ID_ISSUED_AT, INSTANT_CONVERTER);
80+
claimConverters.put(OAuth2ClientMetadataClaimNames.CLIENT_SECRET, stringConverter);
81+
claimConverters.put(OAuth2ClientMetadataClaimNames.CLIENT_SECRET_EXPIRES_AT,
82+
McpEmptyScopeClientRegistrationConverter::convertClientSecretExpiresAt);
83+
claimConverters.put(OAuth2ClientMetadataClaimNames.CLIENT_NAME, stringConverter);
84+
claimConverters.put(OAuth2ClientMetadataClaimNames.REDIRECT_URIS, collectionStringConverter);
85+
claimConverters.put(OAuth2ClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD, stringConverter);
86+
claimConverters.put(OAuth2ClientMetadataClaimNames.GRANT_TYPES, collectionStringConverter);
87+
claimConverters.put(OAuth2ClientMetadataClaimNames.RESPONSE_TYPES, collectionStringConverter);
88+
claimConverters.put(OAuth2ClientMetadataClaimNames.SCOPE,
89+
McpEmptyScopeClientRegistrationConverter::convertScope);
90+
claimConverters.put(OAuth2ClientMetadataClaimNames.JWKS_URI, urlConverter);
91+
this.claimTypeConverter = new ClaimTypeConverter(claimConverters);
92+
}
93+
94+
@Override
95+
public OAuth2ClientRegistration convert(Map<String, Object> source) {
96+
Map<String, Object> parsedClaims = this.claimTypeConverter.convert(source);
97+
Object clientSecretExpiresAt = parsedClaims.get(OAuth2ClientMetadataClaimNames.CLIENT_SECRET_EXPIRES_AT);
98+
if (clientSecretExpiresAt instanceof Number && clientSecretExpiresAt.equals(0)) {
99+
parsedClaims.remove(OAuth2ClientMetadataClaimNames.CLIENT_SECRET_EXPIRES_AT);
100+
}
101+
// Update matching https://github.com/spring-projects/spring-security/pull/19765
102+
Object scope = parsedClaims.get(OAuth2ClientMetadataClaimNames.SCOPE);
103+
if (scope instanceof Collection<?> scopes && scopes.isEmpty()) {
104+
parsedClaims.remove(OAuth2ClientMetadataClaimNames.SCOPE);
105+
}
106+
return OAuth2ClientRegistration.withClaims(parsedClaims).build();
107+
}
108+
109+
@SuppressWarnings("NullAway")
110+
private static Converter<Object, ?> getConverter(TypeDescriptor targetDescriptor) {
111+
return (source) -> CLAIM_CONVERSION_SERVICE.convert(source, OBJECT_TYPE_DESCRIPTOR, targetDescriptor);
112+
}
113+
114+
private static @Nullable Instant convertClientSecretExpiresAt(Object clientSecretExpiresAt) {
115+
if (clientSecretExpiresAt != null && String.valueOf(clientSecretExpiresAt).equals("0")) {
116+
// 0 indicates that client_secret_expires_at does not expire
117+
return null;
118+
}
119+
return (Instant) INSTANT_CONVERTER.convert(clientSecretExpiresAt);
120+
}
121+
122+
private static List<String> convertScope(@Nullable Object scope) {
123+
if (scope == null) {
124+
return List.of();
125+
}
126+
return List.of(StringUtils.delimitedListToStringArray(scope.toString(), " "));
127+
}
128+
129+
}

‎mcp-authorization-server/src/test/java/org/springaicommunity/mcp/security/authorizationserver/config/McpAuthorizationServerConfigurerTest.java‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import com.nimbusds.jose.jwk.JWKSet;
1414
import com.nimbusds.jose.jwk.RSAKey;
1515
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
16-
import com.nimbusds.jose.jwk.source.ImmutableSecret;
1716
import com.nimbusds.jose.jwk.source.JWKSource;
1817
import com.nimbusds.jose.proc.SecurityContext;
1918
import org.junit.jupiter.api.BeforeEach;
@@ -158,6 +157,24 @@ void dynamicClientRegistrationSucceeds() {
158157
assertThat(resp).hasStatus(HttpStatus.CREATED);
159158
}
160159

160+
@Test
161+
void dynamicClientRegistrationIgnoresEmptyScope() {
162+
var emptyScope = """
163+
{
164+
"redirect_uris": ["https://example.com/callback"],
165+
"scope": ""
166+
}
167+
""";
168+
var nullScope = """
169+
{
170+
"redirect_uris": ["https://example.com/callback"],
171+
"scope": null
172+
}
173+
""";
174+
assertThat(registerClient(emptyScope)).hasStatus(HttpStatus.CREATED);
175+
assertThat(registerClient(nullScope)).hasStatus(HttpStatus.CREATED);
176+
}
177+
161178
@Test
162179
void dynamicClientRegistrationFailsValidation() {
163180
var invalidRedirectUri = """

0 commit comments

Comments
 (0)