Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,11 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(
metadata.tokenEndpointAuthenticationMethod("none");
}))
.userInfoEndpoint(userInfo -> userInfo
.userInfoMapper(userInfoMapper))))
.userInfoMapper(userInfoMapper)))
.authorizationServerMetadataEndpoint(metadata ->
metadata.authorizationServerMetadataCustomizer(builder ->
builder.tokenEndpointAuthenticationMethod(
ClientAuthenticationMethod.NONE.getValue()))))
.exceptionHandling(exceptions -> exceptions.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.jobtracker.config;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* RFC 8414 path-aware Authorization Server Metadata.
*
* Spring Authorization Server only serves /.well-known/oauth-authorization-server at the
* root (no path suffix). ChatGPT follows the path-aware variant from RFC 8414 §3.1 and
* appends the protected-resource path suffix, so it requests:
* GET /.well-known/oauth-authorization-server/mcp
* which the built-in endpoint never matches. This controller fills that gap and also
* ensures "none" appears in token_endpoint_auth_methods_supported so public PKCE clients
* are not rejected before the flow starts.
*/
@RestController
public class OAuthAuthorizationServerMetadataController {

private static final String WELL_KNOWN_PREFIX = "/.well-known/oauth-authorization-server";

private final AuthorizationServerSettings settings;
private final McpOAuthProperties mcpOAuthProperties;

public OAuthAuthorizationServerMetadataController(
AuthorizationServerSettings settings,
McpOAuthProperties mcpOAuthProperties) {
this.settings = settings;
this.mcpOAuthProperties = mcpOAuthProperties;
}

@GetMapping(
value = {WELL_KNOWN_PREFIX, WELL_KNOWN_PREFIX + "/**"},
produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> authorizationServerMetadata(HttpServletRequest request) {
String issuer = settings.getIssuer();

Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("issuer", issuer);
metadata.put("authorization_endpoint", issuer + settings.getAuthorizationEndpoint());
metadata.put("token_endpoint", issuer + settings.getTokenEndpoint());
metadata.put("jwks_uri", issuer + settings.getJwkSetEndpoint());
metadata.put("registration_endpoint", issuer + registrationPath());
metadata.put("scopes_supported", mcpOAuthProperties.getScopes());
metadata.put("response_types_supported", List.of("code"));
metadata.put("grant_types_supported", List.of("authorization_code", "refresh_token"));
metadata.put("token_endpoint_auth_methods_supported", List.of(
ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue(),
ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue(),
ClientAuthenticationMethod.NONE.getValue()
));
metadata.put("revocation_endpoint", issuer + settings.getTokenRevocationEndpoint());
metadata.put("revocation_endpoint_auth_methods_supported", List.of(
ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue(),
ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue(),
ClientAuthenticationMethod.NONE.getValue()
));
metadata.put("introspection_endpoint", issuer + settings.getTokenIntrospectionEndpoint());
metadata.put("code_challenge_methods_supported", List.of("S256"));

return metadata;
}

private String registrationPath() {
String path = settings.getOidcClientRegistrationEndpoint();
return (path != null && !path.isBlank()) ? path : "/connect/register";
}
}
3 changes: 2 additions & 1 deletion src/main/java/com/jobtracker/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ public SecurityFilterChain securityFilterChain(
.requestMatchers("/actuator/**").permitAll()
.requestMatchers(
"/.well-known/oauth-protected-resource",
"/.well-known/oauth-protected-resource/**").permitAll()
"/.well-known/oauth-protected-resource/**",
"/.well-known/oauth-authorization-server/**").permitAll()
.requestMatchers("/connect/register").permitAll()
.requestMatchers("/mcp", "/mcp/**").authenticated()
.requestMatchers("/api/v1/**").authenticated()
Expand Down
14 changes: 14 additions & 0 deletions src/test/java/com/jobtracker/integration/mcp/McpAuthIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@
import com.jobtracker.dto.auth.AuthResponse;
import com.jobtracker.dto.auth.RegisterRequest;
import com.jobtracker.integration.AbstractIntegrationTest;
import com.jobtracker.repository.ApplicationRepository;
import com.jobtracker.repository.GoogleDriveConnectionRepository;
import com.jobtracker.repository.InterviewEventRepository;
import com.jobtracker.repository.PasswordResetTokenRepository;
import com.jobtracker.repository.RefreshTokenRepository;
import com.jobtracker.repository.UserAchievementRepository;
import com.jobtracker.repository.UserGamificationRepository;
import com.jobtracker.repository.UserInterviewMetricsRepository;
import com.jobtracker.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -50,13 +56,21 @@ class McpAuthIT extends AbstractIntegrationTest {
@Autowired private UserRepository userRepository;
@Autowired private RefreshTokenRepository refreshTokenRepository;
@Autowired private GoogleDriveConnectionRepository googleDriveConnectionRepository;
@Autowired private InterviewEventRepository interviewEventRepository;
@Autowired private UserInterviewMetricsRepository userInterviewMetricsRepository;
@Autowired private ApplicationRepository applicationRepository;
@Autowired private PasswordResetTokenRepository passwordResetTokenRepository;
@Autowired private UserAchievementRepository userAchievementRepository;
@Autowired private UserGamificationRepository userGamificationRepository;

private String accessToken;

@BeforeEach
void setUp() throws Exception {
googleDriveConnectionRepository.deleteAll();
refreshTokenRepository.deleteAll();
interviewEventRepository.deleteAll();
userInterviewMetricsRepository.deleteAll();
userRepository.deleteAll();

RegisterRequest reg = new RegisterRequest("MCP Test User", "mcp-test@example.com", "pass1234", "pass1234", true);
Expand Down
Loading