diff --git a/src/main/java/com/jobtracker/config/AuthorizationServerConfig.java b/src/main/java/com/jobtracker/config/AuthorizationServerConfig.java index 2762831..360804c 100644 --- a/src/main/java/com/jobtracker/config/AuthorizationServerConfig.java +++ b/src/main/java/com/jobtracker/config/AuthorizationServerConfig.java @@ -6,15 +6,19 @@ import com.nimbusds.jose.jwk.source.ImmutableJWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.web.client.RestClient; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -56,6 +60,7 @@ import java.security.SecureRandom; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; +import java.time.Duration; import java.time.Instant; import java.util.LinkedHashSet; import java.util.UUID; @@ -122,11 +127,37 @@ public SecurityFilterChain authorizationServerSecurityFilterChain( return http.build(); } - @Bean - public RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) { + @Bean(name = "jdbcRegisteredClientRepository") + public RegisteredClientRepository jdbcRegisteredClientRepository(JdbcOperations jdbcOperations) { return new JdbcRegisteredClientRepository(jdbcOperations); } + /** + * Primary {@link RegisteredClientRepository}: adds CIMD (Client ID Metadata Document) support + * on top of the persistent JDBC repository. URL client IDs are resolved as ephemeral CIMD + * clients; all other lookups (bootstrap clients, DCR-registered clients) delegate to JDBC. + */ + @Bean + @Primary + public RegisteredClientRepository registeredClientRepository( + @Qualifier("jdbcRegisteredClientRepository") RegisteredClientRepository jdbcRegisteredClientRepository, + McpOAuthProperties mcpOAuthProperties, + @Qualifier("cimdRestClient") RestClient cimdRestClient) { + return new CimdRegisteredClientRepository(jdbcRegisteredClientRepository, mcpOAuthProperties, cimdRestClient); + } + + /** + * Dedicated {@link RestClient} for fetching CIMD documents, with tight timeouts + * (3s connect, 5s read) so a slow or hostile client_id URL cannot stall the authorization flow. + */ + @Bean(name = "cimdRestClient") + public RestClient cimdRestClient() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout((int) Duration.ofSeconds(3).toMillis()); + requestFactory.setReadTimeout((int) Duration.ofSeconds(5).toMillis()); + return RestClient.builder().requestFactory(requestFactory).build(); + } + @Bean public OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations, RegisteredClientRepository registeredClientRepository) { return new JdbcOAuth2AuthorizationService(jdbcOperations, registeredClientRepository); diff --git a/src/main/java/com/jobtracker/config/CimdRegisteredClientRepository.java b/src/main/java/com/jobtracker/config/CimdRegisteredClientRepository.java new file mode 100644 index 0000000..f4bc298 --- /dev/null +++ b/src/main/java/com/jobtracker/config/CimdRegisteredClientRepository.java @@ -0,0 +1,258 @@ +package com.jobtracker.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; +import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; +import org.springframework.web.client.RestClient; + +import java.net.InetAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * {@link RegisteredClientRepository} that adds support for CIMD (Client ID Metadata Documents, + * draft-ietf-oauth-client-id-metadata-document) on top of the persistent JDBC repository. + * + *

With CIMD, a client (e.g. ChatGPT) does not register ahead of time. Instead it presents the + * URL of its metadata document as the {@code client_id} — for example + * {@code https://chatgpt.com/oauth/.../client.json}. When the authorization server looks up such a + * client, this repository fetches that document over HTTPS, validates it, and builds an + * ephemeral {@link RegisteredClient} (never persisted) so the authorization-code flow can + * proceed. + * + *

All other (non-URL) client IDs — the bootstrapped GPT Actions / MCP clients and any + * DCR-registered clients — are delegated unchanged to the wrapped JDBC repository. + * + *

SSRF protection: a CIMD {@code client_id} URL must use HTTPS and must not resolve to a + * loopback, link-local, site-local/private, or any-local address. Fetch/parse failures are + * swallowed and surfaced as a {@code null} client so Spring Security returns a standard OAuth + * error rather than a 500. + */ +public class CimdRegisteredClientRepository implements RegisteredClientRepository { + + private static final Logger log = LoggerFactory.getLogger(CimdRegisteredClientRepository.class); + + private static final Duration ACCESS_TOKEN_TTL = Duration.ofHours(1); + private static final Duration REFRESH_TOKEN_TTL = Duration.ofDays(30); + + private final RegisteredClientRepository delegate; + private final McpOAuthProperties mcpOAuthProperties; + private final RestClient restClient; + + // Ephemeral CIMD clients keyed by their generated id (SHA-256 of the client_id URL). The token + // endpoint reloads a client via findById() after the authorization step, but ephemeral clients + // are not persisted — caching them here keeps a single CIMD authorization-code flow working + // end to end without a database round-trip. + private final ConcurrentHashMap ephemeralClients = new ConcurrentHashMap<>(); + + public CimdRegisteredClientRepository( + RegisteredClientRepository delegate, + McpOAuthProperties mcpOAuthProperties, + RestClient cimdRestClient) { + this.delegate = delegate; + this.mcpOAuthProperties = mcpOAuthProperties; + this.restClient = cimdRestClient; + } + + @Override + public void save(RegisteredClient registeredClient) { + // Ephemeral CIMD clients are never saved; everything else persists via JDBC. + delegate.save(registeredClient); + } + + @Override + public RegisteredClient findById(String id) { + RegisteredClient ephemeral = ephemeralClients.get(id); + if (ephemeral != null) { + return ephemeral; + } + return delegate.findById(id); + } + + @Override + public RegisteredClient findByClientId(String clientId) { + if (clientId != null && clientId.startsWith("https://")) { + return resolveCimdClient(clientId); + } + return delegate.findByClientId(clientId); + } + + private RegisteredClient resolveCimdClient(String clientIdUrl) { + if (!isSafeCimdUrl(clientIdUrl)) { + log.warn("event=CIMD_REJECTED reason=unsafe_url client_id={}", clientIdUrl); + return null; + } + + Map document = fetchCimdDocument(clientIdUrl); + if (document == null) { + return null; + } + + List redirectUris = stringList(document.get("redirect_uris")); + if (redirectUris.isEmpty()) { + log.warn("event=CIMD_REJECTED reason=missing_redirect_uris client_id={}", clientIdUrl); + return null; + } + + Set scopes = resolveScopes(document.get("scope")); + Set grantTypes = resolveGrantTypes(document.get("grant_types")); + + RegisteredClient client = RegisteredClient.withId(sha256(clientIdUrl)) + .clientId(clientIdUrl) + .clientName(clientName(document, clientIdUrl)) + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .authorizationGrantTypes(set -> set.addAll(grantTypes)) + .redirectUris(set -> set.addAll(redirectUris)) + .scopes(set -> set.addAll(scopes)) + .clientSettings(ClientSettings.builder() + .requireProofKey(true) // PKCE is mandatory per the MCP spec + .requireAuthorizationConsent(false) + .build()) + .tokenSettings(TokenSettings.builder() + .accessTokenTimeToLive(ACCESS_TOKEN_TTL) + .refreshTokenTimeToLive(REFRESH_TOKEN_TTL) + .reuseRefreshTokens(false) + .build()) + .build(); + + ephemeralClients.put(client.getId(), client); + return client; + } + + private Map fetchCimdDocument(String url) { + try { + @SuppressWarnings("unchecked") + Map body = restClient.get() + .uri(URI.create(url)) + .retrieve() + .body(Map.class); + if (body == null || body.isEmpty()) { + log.warn("event=CIMD_FETCH_FAILED reason=empty_document client_id={}", url); + return null; + } + return body; + } catch (Exception ex) { + log.warn("event=CIMD_FETCH_FAILED client_id={} message={}", url, ex.getMessage()); + return null; + } + } + + /** + * SSRF guard: only HTTPS URLs whose host resolves exclusively to public addresses are allowed. + */ + private boolean isSafeCimdUrl(String url) { + try { + URI uri = URI.create(url); + if (!"https".equalsIgnoreCase(uri.getScheme())) { + return false; + } + String host = uri.getHost(); + if (host == null || host.isBlank()) { + return false; + } + InetAddress[] addresses = InetAddress.getAllByName(host); + if (addresses.length == 0) { + return false; + } + for (InetAddress address : addresses) { + if (address.isLoopbackAddress() + || address.isAnyLocalAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isMulticastAddress()) { + return false; + } + } + return true; + } catch (Exception ex) { + log.warn("event=CIMD_URL_VALIDATION_FAILED client_id={} message={}", url, ex.getMessage()); + return false; + } + } + + private Set resolveScopes(Object rawScope) { + List allowed = mcpOAuthProperties.getScopes(); + if (allowed.isEmpty()) { + allowed = List.of("openid", "read:profile", "read:applications", "write:applications", + "read:resume", "read:google-drive", "read:metrics"); + } + Set result = new LinkedHashSet<>(); + if (rawScope instanceof String scopeStr && !scopeStr.isBlank()) { + Set requested = Set.of(scopeStr.trim().split("\\s+")); + for (String scope : allowed) { + if (requested.contains(scope)) { + result.add(scope); + } + } + } + if (result.isEmpty()) { + result.addAll(allowed); + } + return result; + } + + private Set resolveGrantTypes(Object rawGrantTypes) { + Set grantTypes = new LinkedHashSet<>(); + for (String grant : stringList(rawGrantTypes)) { + if (AuthorizationGrantType.AUTHORIZATION_CODE.getValue().equals(grant)) { + grantTypes.add(AuthorizationGrantType.AUTHORIZATION_CODE); + } else if (AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(grant)) { + grantTypes.add(AuthorizationGrantType.REFRESH_TOKEN); + } + } + // The MCP authorization-code flow always needs these two; default when the doc omits them. + grantTypes.add(AuthorizationGrantType.AUTHORIZATION_CODE); + grantTypes.add(AuthorizationGrantType.REFRESH_TOKEN); + return grantTypes; + } + + private String clientName(Map document, String fallback) { + Object name = document.get("client_name"); + if (name instanceof String s && !s.isBlank()) { + String trimmed = s.trim(); + return trimmed.length() > 100 ? trimmed.substring(0, 100) : trimmed; + } + return fallback; + } + + private static List stringList(Object raw) { + if (raw instanceof List list) { + return list.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .filter(value -> !value.isBlank()) + .distinct() + .toList(); + } + return List.of(); + } + + private static String sha256(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte b : digest) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)); + hex.append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException ex) { + // SHA-256 is mandated by the JLS; this is unreachable on any supported JVM. + throw new IllegalStateException("SHA-256 not available", ex); + } + } +} diff --git a/src/main/java/com/jobtracker/config/OAuthProtectedResourceMetadataController.java b/src/main/java/com/jobtracker/config/OAuthProtectedResourceMetadataController.java index df4bb95..ac56fe5 100644 --- a/src/main/java/com/jobtracker/config/OAuthProtectedResourceMetadataController.java +++ b/src/main/java/com/jobtracker/config/OAuthProtectedResourceMetadataController.java @@ -6,6 +6,7 @@ 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; @@ -51,11 +52,33 @@ public Map protectedResourceMetadata(HttpServletRequest request) : ""; String resource = issuer + suffix; - return Map.of( - "resource", resource, - "authorization_servers", List.of(issuer), - "bearer_methods_supported", List.of("header"), - "scopes_supported", mcpOAuthProperties.getScopes() - ); + // LinkedHashMap (not Map.of) so the JSON field order is stable and readable. + Map metadata = new LinkedHashMap<>(); + metadata.put("resource", resource); + metadata.put("authorization_servers", List.of(issuer)); + metadata.put("bearer_methods_supported", List.of("header")); + metadata.put("scopes_supported", mcpOAuthProperties.getScopes()); + + // Advertise the Dynamic Client Registration endpoint (RFC 7591). ChatGPT reads the + // protected-resource metadata first and only enables DCR when it finds a + // "registration_endpoint" here — it does not fall through to /.well-known/openid-configuration. + metadata.put("registration_endpoint", registrationEndpoint(issuer)); + + // Advertise CIMD (Client ID Metadata Documents, draft-ietf-oauth-client-id-metadata-document) + // so ChatGPT can present a metadata-document URL as its client_id instead of registering first. + // "automatic" means the AS will fetch the CIMD document on demand from the client_id URL. + metadata.put("client_registration_types_supported", List.of("automatic")); + + return metadata; + } + + // The DCR endpoint URL, derived from AuthorizationServerSettings (the path defaults to + // "/connect/register") rather than hardcoded, so it tracks any issuer/path reconfiguration. + private String registrationEndpoint(String issuer) { + String path = authorizationServerSettings.getOidcClientRegistrationEndpoint(); + if (path == null || path.isBlank()) { + path = "/connect/register"; + } + return issuer + path; } } diff --git a/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java b/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java index 3bfad2c..f482d83 100644 --- a/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java @@ -11,6 +11,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -87,6 +88,18 @@ public ResponseEntity> handleValidation(MethodArgumentNotVal return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); } + // Map an unsupported HTTP method to 405 (not 500). Without this, the generic Exception + // handler below catches HttpRequestMethodNotSupportedException and returns 500 — e.g. a + // GET /connect/register (DCR is POST-only) would surface as a server error to clients. + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleMethodNotSupported(HttpRequestMethodNotSupportedException ex) { + log.warn("event=METHOD_NOT_ALLOWED message={}", ex.getMessage()); + Map body = new HashMap<>(); + body.put("error", "method_not_allowed"); + body.put("message", ex.getMessage()); + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(body); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleGeneral(Exception ex) { log.error("event=UNEXPECTED_ERROR message={}", ex.getMessage(), ex); diff --git a/src/test/java/com/jobtracker/integration/CimdRegisteredClientIT.java b/src/test/java/com/jobtracker/integration/CimdRegisteredClientIT.java new file mode 100644 index 0000000..23c85be --- /dev/null +++ b/src/test/java/com/jobtracker/integration/CimdRegisteredClientIT.java @@ -0,0 +1,155 @@ +package com.jobtracker.integration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.client.ExpectedCount; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.client.RestClient; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifies CIMD (Client ID Metadata Document) support in the authorization server. + * + * When ChatGPT presents the URL of its metadata document as the {@code client_id}, the + * {@link com.jobtracker.config.CimdRegisteredClientRepository} fetches that document, builds an + * ephemeral {@link RegisteredClient}, and the authorization-code flow proceeds as usual. + * + *

The CIMD fetch {@code RestClient} is replaced with one backed by {@link MockRestServiceServer} + * so no real network call is made. A raw public-IP host is used as the client_id so the SSRF guard + * passes without a DNS lookup. + */ +@Import(CimdRegisteredClientIT.CimdTestConfig.class) +@TestPropertySource(properties = "spring.main.allow-bean-definition-overriding=true") +class CimdRegisteredClientIT extends AbstractIntegrationTest { + + private static final String CLIENT_ID_URL = "https://93.184.216.34/oauth/test/client.json"; + private static final String REDIRECT_URI = "https://chat.openai.com/aip/test/callback"; + + @Autowired private MockMvc mockMvc; + @Autowired private RegisteredClientRepository registeredClientRepository; + + @BeforeEach + void resetMockServer() { + CimdTestConfig.SERVER.get().reset(); + } + + @Test + void authorizationRequest_withCimdUrlAsClientId_shouldFetchDocumentAndRedirectWithCode() throws Exception { + CimdTestConfig.SERVER.get() + .expect(ExpectedCount.manyTimes(), requestTo(CLIENT_ID_URL)) + .andRespond(withSuccess(""" + { + "client_id": "%s", + "client_name": "ChatGPT CIMD Test", + "redirect_uris": ["%s"], + "scope": "openid read:profile read:applications", + "grant_types": ["authorization_code"], + "token_endpoint_auth_method": "none" + } + """.formatted(CLIENT_ID_URL, REDIRECT_URI), MediaType.APPLICATION_JSON)); + + PkcePair pkcePair = generatePkcePair(); + + MvcResult result = mockMvc.perform(get("/oauth2/authorize") + .with(user("cimd-user@example.com").roles("USER")) + .queryParam("response_type", "code") + .queryParam("client_id", CLIENT_ID_URL) + .queryParam("redirect_uri", REDIRECT_URI) + .queryParam("scope", "openid read:profile") + .queryParam("state", "cimd-state") + .queryParam("code_challenge", pkcePair.challenge()) + .queryParam("code_challenge_method", "S256")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrlPattern(REDIRECT_URI + "?*")) + .andReturn(); + + String location = result.getResponse().getHeader(HttpHeaders.LOCATION); + assertThat(location).contains("code="); + assertThat(location).contains("state=cimd-state"); + } + + @Test + void authorizationRequest_withCimdUrlNotListingRequestedRedirect_shouldBeRejected() throws Exception { + CimdTestConfig.SERVER.get() + .expect(ExpectedCount.manyTimes(), requestTo(CLIENT_ID_URL)) + .andRespond(withSuccess(""" + { + "client_id": "%s", + "redirect_uris": ["https://other.example.com/callback"], + "scope": "openid read:profile" + } + """.formatted(CLIENT_ID_URL), MediaType.APPLICATION_JSON)); + + PkcePair pkcePair = generatePkcePair(); + + // The requested redirect_uri is not in the CIMD document, so the AS must not redirect to it. + mockMvc.perform(get("/oauth2/authorize") + .with(user("cimd-user@example.com").roles("USER")) + .queryParam("response_type", "code") + .queryParam("client_id", CLIENT_ID_URL) + .queryParam("redirect_uri", REDIRECT_URI) + .queryParam("scope", "openid read:profile") + .queryParam("state", "cimd-state") + .queryParam("code_challenge", pkcePair.challenge()) + .queryParam("code_challenge_method", "S256")) + .andExpect(status().isBadRequest()); + } + + @Test + void nonUrlClientId_shouldStillResolveViaJdbcRepository() { + // The bootstrapped GPT Actions client (configured in application-test.yml) must keep working + // through the JDBC delegate even though the primary repository is the CIMD wrapper. + RegisteredClient client = registeredClientRepository.findByClientId("test-openai-client-id"); + assertThat(client).isNotNull(); + assertThat(client.getClientId()).isEqualTo("test-openai-client-id"); + } + + private PkcePair generatePkcePair() throws Exception { + String verifier = Base64.getUrlEncoder().withoutPadding() + .encodeToString("test-code-verifier-1234567890".getBytes(StandardCharsets.US_ASCII)); + byte[] digest = MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)); + String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + return new PkcePair(verifier, challenge); + } + + private record PkcePair(String verifier, String challenge) { + } + + @TestConfiguration + static class CimdTestConfig { + + static final AtomicReference SERVER = new AtomicReference<>(); + + // Overrides the production cimdRestClient bean with one bound to MockRestServiceServer so + // CIMD document fetches are intercepted instead of hitting the network. + @Bean(name = "cimdRestClient") + RestClient cimdRestClient() { + RestClient.Builder builder = RestClient.builder(); + SERVER.set(MockRestServiceServer.bindTo(builder).build()); + return builder.build(); + } + } +} diff --git a/src/test/java/com/jobtracker/integration/DynamicClientRegistrationIT.java b/src/test/java/com/jobtracker/integration/DynamicClientRegistrationIT.java index 6e43007..5c0c27d 100644 --- a/src/test/java/com/jobtracker/integration/DynamicClientRegistrationIT.java +++ b/src/test/java/com/jobtracker/integration/DynamicClientRegistrationIT.java @@ -65,6 +65,26 @@ void oidcDiscovery_shouldAdvertiseRegistrationEndpointAndNoneAuthMethod() throws assertThat(hasNone).as("token_endpoint_auth_methods_supported must include 'none'").isTrue(); } + @Test + void protectedResourceMetadata_shouldAdvertiseRegistrationEndpointAndCimd() throws Exception { + mockMvc.perform(get("/.well-known/oauth-protected-resource/mcp")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.registration_endpoint") + .value("https://jobapply-api.hugojava.dev/connect/register")) + .andExpect(jsonPath("$.client_registration_types_supported").isArray()) + .andExpect(jsonPath("$.client_registration_types_supported[0]").value("automatic")) + .andExpect(jsonPath("$.authorization_servers[0]") + .value("https://jobapply-api.hugojava.dev")); + } + + @Test + void getConnectRegister_shouldReturnMethodNotAllowed() throws Exception { + // DCR is POST-only; a GET must surface as 405 (not 500 from the generic handler). + mockMvc.perform(get("/connect/register")) + .andExpect(status().isMethodNotAllowed()) + .andExpect(jsonPath("$.error").value("method_not_allowed")); + } + @Test void dcrEndpoint_shouldBePublic() throws Exception { mockMvc.perform(post("/connect/register")