diff --git a/src/main/java/com/jobtracker/config/AuthorizationServerConfig.java b/src/main/java/com/jobtracker/config/AuthorizationServerConfig.java index 360804c..480576e 100644 --- a/src/main/java/com/jobtracker/config/AuthorizationServerConfig.java +++ b/src/main/java/com/jobtracker/config/AuthorizationServerConfig.java @@ -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))) diff --git a/src/main/java/com/jobtracker/config/OAuthAuthorizationServerMetadataController.java b/src/main/java/com/jobtracker/config/OAuthAuthorizationServerMetadataController.java new file mode 100644 index 0000000..9e0cc59 --- /dev/null +++ b/src/main/java/com/jobtracker/config/OAuthAuthorizationServerMetadataController.java @@ -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 authorizationServerMetadata(HttpServletRequest request) { + String issuer = settings.getIssuer(); + + Map 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"; + } +} diff --git a/src/main/java/com/jobtracker/config/SecurityConfig.java b/src/main/java/com/jobtracker/config/SecurityConfig.java index 51b003e..4c94348 100644 --- a/src/main/java/com/jobtracker/config/SecurityConfig.java +++ b/src/main/java/com/jobtracker/config/SecurityConfig.java @@ -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() diff --git a/src/test/java/com/jobtracker/integration/mcp/McpAuthIT.java b/src/test/java/com/jobtracker/integration/mcp/McpAuthIT.java index 6904cf7..34b109f 100644 --- a/src/test/java/com/jobtracker/integration/mcp/McpAuthIT.java +++ b/src/test/java/com/jobtracker/integration/mcp/McpAuthIT.java @@ -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; @@ -50,6 +56,12 @@ 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; @@ -57,6 +69,8 @@ class McpAuthIT extends AbstractIntegrationTest { 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);