Skip to content

Commit 349017f

Browse files
arnabnandy7Kehrlann
authored andcommitted
Authorization Server: customize authorization code request validation
Signed-off-by: Arnab Nandy <arnab_nandy7@yahoo.com>
1 parent 5e79137 commit 349017f

6 files changed

Lines changed: 267 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,6 +1131,28 @@ ClientIdMetadataDocumentRegisteredClientRepository cimdClientRepository() {
11311131
}
11321132
```
11331133

1134+
The authorization server validates the redirect URI in an authorization request against
1135+
the redirect URIs from the client metadata document. Some clients, such as Claude Code, use a dynamic port with
1136+
the `localhost` host. To support those clients, configure `LocalhostWildcardPortValidator`
1137+
alongside Spring Authorization Server's default scope validator:
1138+
1139+
```java
1140+
@Bean
1141+
SecurityFilterChain securityFilterChain(
1142+
HttpSecurity http) {
1143+
return http
1144+
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
1145+
.with(McpAuthorizationServerConfigurer.mcpAuthorizationServer(), mcp -> {
1146+
mcp.cimd(true);
1147+
mcp.authorizationCodeRequestValidator(
1148+
new LocalhostWildcardPortValidator()
1149+
.andThen(DEFAULT_SCOPE_VALIDATOR));
1150+
})
1151+
.formLogin(withDefaults())
1152+
.build();
1153+
}
1154+
```
1155+
11341156
### Known limitations
11351157

11361158
- Spring WebFlux servers are not supported.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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.util.function.Consumer;
20+
21+
import org.jspecify.annotations.Nullable;
22+
23+
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationContext;
24+
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken;
25+
import org.springframework.web.util.UriComponents;
26+
import org.springframework.web.util.UriComponentsBuilder;
27+
28+
import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationValidator.DEFAULT_REDIRECT_URI_VALIDATOR;
29+
30+
/**
31+
* Validates authorization code request redirect URIs, allowing the port to vary when both
32+
* the requested and registered redirect URI use the {@code localhost} host.
33+
* <p>
34+
* <strong>WARNING:</strong> OAuth 2.1 does not recommend using {@code localhost} for
35+
* loopback redirection. Some clients use it, however, and require this compatibility
36+
* behavior. Requests that do not qualify for localhost wildcard port matching are
37+
* delegated to Spring Authorization Server's default redirect URI validator.
38+
*
39+
* @author Arnab Nandy
40+
* @see <a href=
41+
* "https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-15.html#section-8.4.2">OAuth
42+
* 2.1, Loopback Interface Redirection</a>
43+
*/
44+
public final class LocalhostWildcardPortValidator
45+
implements Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> {
46+
47+
private static final String LOCALHOST = "localhost";
48+
49+
@Override
50+
public void accept(OAuth2AuthorizationCodeRequestAuthenticationContext context) {
51+
OAuth2AuthorizationCodeRequestAuthenticationToken authentication = context.getAuthentication();
52+
@Nullable String requestedRedirectUri = authentication.getRedirectUri();
53+
@Nullable UriComponents requested = parseLocalhostRedirectUri(requestedRedirectUri);
54+
if (requested != null && context.getRegisteredClient()
55+
.getRedirectUris()
56+
.stream()
57+
.anyMatch(registeredRedirectUri -> matchesExceptPort(requested, registeredRedirectUri))) {
58+
return;
59+
}
60+
DEFAULT_REDIRECT_URI_VALIDATOR.accept(context);
61+
}
62+
63+
@Nullable private static UriComponents parseLocalhostRedirectUri(@Nullable String redirectUri) {
64+
if (redirectUri == null) {
65+
return null;
66+
}
67+
try {
68+
UriComponents uri = UriComponentsBuilder.fromUriString(redirectUri).build();
69+
return LOCALHOST.equalsIgnoreCase(uri.getHost()) ? uri : null;
70+
}
71+
catch (IllegalArgumentException ex) {
72+
return null;
73+
}
74+
}
75+
76+
private static boolean matchesExceptPort(UriComponents requested, String registeredRedirectUri) {
77+
try {
78+
UriComponentsBuilder registered = UriComponentsBuilder.fromUriString(registeredRedirectUri);
79+
registered.port(requested.getPort());
80+
return registered.build().toString().equals(requested.toString());
81+
}
82+
catch (IllegalArgumentException ex) {
83+
return false;
84+
}
85+
}
86+
87+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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.util.function.Consumer;
20+
21+
import org.springframework.security.config.ObjectPostProcessor;
22+
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationContext;
23+
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationProvider;
24+
25+
/**
26+
* Post-processor to set the authorization code request validator on
27+
* {@link OAuth2AuthorizationCodeRequestAuthenticationProvider}.
28+
* <p>
29+
* For internal use only.
30+
*
31+
* @author Arnab Nandy
32+
*/
33+
class McpAuthorizationCodeRequestValidatorPostProcessor
34+
implements ObjectPostProcessor<OAuth2AuthorizationCodeRequestAuthenticationProvider> {
35+
36+
private final Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> validator;
37+
38+
McpAuthorizationCodeRequestValidatorPostProcessor(
39+
Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> validator) {
40+
this.validator = validator;
41+
}
42+
43+
@Override
44+
public OAuth2AuthorizationCodeRequestAuthenticationProvider postProcess(
45+
OAuth2AuthorizationCodeRequestAuthenticationProvider object) {
46+
object.setAuthenticationValidator(this.validator);
47+
return object;
48+
}
49+
50+
}

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
3939
import org.springframework.security.oauth2.core.OAuth2Token;
4040
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
41+
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationContext;
42+
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationValidator;
4143
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientRegistrationAuthenticationContext;
4244
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientRegistrationAuthenticationValidator;
4345
import org.springframework.security.oauth2.server.authorization.mcp.token.ResourceIdentifierAudienceTokenCustomizer;
@@ -70,6 +72,8 @@ public class McpAuthorizationServerConfigurer
7072

7173
private Consumer<OAuth2ClientRegistrationAuthenticationContext> clientRegistrationValidator = new OAuth2ClientRegistrationAuthenticationValidator();
7274

75+
private Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> authorizationCodeRequestValidator = new OAuth2AuthorizationCodeRequestAuthenticationValidator();
76+
7377
public static McpAuthorizationServerConfigurer mcpAuthorizationServer() {
7478
return new McpAuthorizationServerConfigurer();
7579
}
@@ -122,6 +126,19 @@ public McpAuthorizationServerConfigurer dynamicClientRegistrationValidator(
122126
return this;
123127
}
124128

129+
/**
130+
* Update the validator for incoming authorization code requests.
131+
* @param authorizationCodeRequestValidator the validator. Defaults to
132+
* {@link OAuth2AuthorizationCodeRequestAuthenticationValidator};
133+
* @return The {@link McpAuthorizationServerConfigurer} for further configuration.
134+
*/
135+
public McpAuthorizationServerConfigurer authorizationCodeRequestValidator(
136+
Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> authorizationCodeRequestValidator) {
137+
Assert.notNull(authorizationCodeRequestValidator, "authorizationCodeRequestValidator cannot be null");
138+
this.authorizationCodeRequestValidator = authorizationCodeRequestValidator;
139+
return this;
140+
}
141+
125142
@Override
126143
public void init(HttpSecurity http) {
127144
http.authorizeHttpRequests(authz -> {
@@ -130,6 +147,8 @@ public void init(HttpSecurity http) {
130147
}
131148
}).oauth2AuthorizationServer(authServer -> {
132149
authServer.addObjectPostProcessor(McpNoScopeClientConsentNotRequired.postProcessor());
150+
authServer.addObjectPostProcessor(
151+
new McpAuthorizationCodeRequestValidatorPostProcessor(this.authorizationCodeRequestValidator));
133152
authServer.addObjectPostProcessor(
134153
new McpClientRegistrationValidatorPostProcessor(this.clientRegistrationValidator));
135154
authServer.authorizationServerMetadataEndpoint(metadataEndpoint -> {
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 java.util.Map;
20+
import java.util.Set;
21+
22+
import org.junit.jupiter.api.Test;
23+
24+
import org.springframework.security.core.Authentication;
25+
import org.springframework.security.oauth2.core.AuthorizationGrantType;
26+
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
27+
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationContext;
28+
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationException;
29+
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken;
30+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
31+
32+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
33+
import static org.assertj.core.api.Assertions.assertThatNoException;
34+
import static org.mockito.Mockito.mock;
35+
36+
class LocalhostWildcardPortValidatorTests {
37+
38+
private final LocalhostWildcardPortValidator validator = new LocalhostWildcardPortValidator();
39+
40+
@Test
41+
void allowsDifferentPortForLocalhost() {
42+
var context = context("http://localhost:54321/callback", "http://localhost:8080/callback");
43+
44+
assertThatNoException().isThrownBy(() -> this.validator.accept(context));
45+
}
46+
47+
@Test
48+
void allowsImplicitDefaultPortForLocalhost() {
49+
var context = context("http://localhost/callback", "http://localhost:8080/callback");
50+
51+
assertThatNoException().isThrownBy(() -> this.validator.accept(context));
52+
}
53+
54+
@Test
55+
void rejectsDifferentPathForLocalhost() {
56+
var context = context("http://localhost:54321/other-callback", "http://localhost:8080/callback");
57+
58+
assertThatExceptionOfType(OAuth2AuthorizationCodeRequestAuthenticationException.class)
59+
.isThrownBy(() -> this.validator.accept(context));
60+
}
61+
62+
@Test
63+
void delegatesNonLocalhostRedirectUriToDefaultValidator() {
64+
var context = context("https://example.com/callback", "https://registered.example.com/callback");
65+
66+
assertThatExceptionOfType(OAuth2AuthorizationCodeRequestAuthenticationException.class)
67+
.isThrownBy(() -> this.validator.accept(context));
68+
}
69+
70+
private static OAuth2AuthorizationCodeRequestAuthenticationContext context(String requestedRedirectUri,
71+
String registeredRedirectUri) {
72+
Authentication principal = mock(Authentication.class);
73+
var authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken("https://authorization-server.test",
74+
"client-id", principal, requestedRedirectUri, "state", Set.of(), Map.of());
75+
var registeredClient = RegisteredClient.withId("client-id")
76+
.clientId("client-id")
77+
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
78+
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
79+
.redirectUri(registeredRedirectUri)
80+
.build();
81+
return OAuth2AuthorizationCodeRequestAuthenticationContext.with(authentication)
82+
.registeredClient(registeredClient)
83+
.build();
84+
}
85+
86+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
6161
import static org.assertj.core.api.Assertions.assertThat;
6262
import static org.springaicommunity.mcp.security.authorizationserver.config.McpAuthorizationServerConfigurer.mcpAuthorizationServer;
63+
import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationValidator.DEFAULT_SCOPE_VALIDATOR;
6364
import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientRegistrationAuthenticationValidator.DEFAULT_REDIRECT_URI_VALIDATOR;
6465

6566
@ExtendWith(SpringExtension.class)
@@ -224,6 +225,8 @@ SecurityFilterChain filterChain(HttpSecurity http, AtomicInteger authzServerCust
224225
mcpAuthzServer.authorizationServer(authzServer -> authzServerCustomizationCount.incrementAndGet());
225226
mcpAuthzServer.cimd(true);
226227
mcpAuthzServer.dynamicClientRegistrationValidator(clientRegistrationValidator);
228+
mcpAuthzServer.authorizationCodeRequestValidator(
229+
new LocalhostWildcardPortValidator().andThen(DEFAULT_SCOPE_VALIDATOR));
227230
});
228231
return http.build();
229232
}

0 commit comments

Comments
 (0)