providerClients) {
- this(providerClients, new DefaultAuthorizationCodeTokenResponseClient());
+ this(providerClients, OAuth2TokenResponseClients.standard());
}
DispatchingTokenResponseClient(
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClient.java
index 5a03639a..d33e023e 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClient.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClient.java
@@ -16,7 +16,6 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
-import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
@@ -55,12 +54,12 @@ public class FeishuOAuth2AccessTokenResponseClient
public FeishuOAuth2AccessTokenResponseClient(
@Value("${OAUTH2_FEISHU_PROTOCOL_VERSION:v3}") String protocolVersion) {
this(RestClient.builder().requestFactory(defaultRequestFactory()),
- new DefaultAuthorizationCodeTokenResponseClient(), protocolVersion);
+ OAuth2TokenResponseClients.standard(), protocolVersion);
}
FeishuOAuth2AccessTokenResponseClient(
RestClient.Builder restClientBuilder) {
- this(restClientBuilder, new DefaultAuthorizationCodeTokenResponseClient(), V3);
+ this(restClientBuilder, OAuth2TokenResponseClients.standard(), V3);
}
FeishuOAuth2AccessTokenResponseClient(
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java
index 6f0a87b4..dcdfba39 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java
@@ -58,7 +58,8 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
boolean emailVerified = isConfirmed(attrs.get("confirmed_at"));
- log.debug("Initial email from GitLab: {}, verified: {}", email, emailVerified);
+ log.debug("Initial GitLab email state: emailPresent={}, emailVerified={}",
+ email != null && !email.isBlank(), emailVerified);
// If email is not verified or not present, try to fetch from emails API
if (email == null || !emailVerified) {
@@ -67,7 +68,7 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
if (primaryEmail != null) {
email = primaryEmail.email();
emailVerified = true;
- log.debug("Found verified email from GitLab API: {}", email);
+ log.debug("Found verified email from GitLab API: emailPresent=true");
} else {
log.debug("No verified email found from GitLab emails API");
}
@@ -80,8 +81,11 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
}
String subject = String.valueOf(attrs.get("id"));
- log.info("GitLab OAuth claims extracted - subject: {}, username: {}, email: {}, emailVerified: {}",
- subject, username, email, emailVerified);
+ log.info("GitLab OAuth claims extracted: subjectPresent={}, usernamePresent={}, emailPresent={}, emailVerified={}",
+ subject != null && !subject.isBlank(),
+ username != null && !username.isBlank(),
+ email != null && !email.isBlank(),
+ emailVerified);
return new OAuthClaims(
"gitlab",
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2TokenResponseClients.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2TokenResponseClients.java
new file mode 100644
index 00000000..8d252a2e
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2TokenResponseClients.java
@@ -0,0 +1,96 @@
+package com.iflytek.skillhub.auth.oauth;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.Duration;
+import java.util.List;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.client.ClientHttpResponse;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.http.converter.FormHttpMessageConverter;
+import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient;
+import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler;
+import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * Factory for the standard OAuth2 authorization-code token client used by public providers.
+ *
+ * Spring's default client does not cap response size. Public provider endpoints should still be
+ * treated as untrusted remote IO, so the shared client keeps the standard parser but adds timeouts
+ * and a bounded response body before any converter sees the payload.
+ */
+final class OAuth2TokenResponseClients {
+
+ static final int MAX_RESPONSE_BYTES = 64 * 1024;
+
+ private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
+ private static final Duration READ_TIMEOUT = Duration.ofSeconds(10);
+
+ private OAuth2TokenResponseClients() {
+ }
+
+ static DefaultAuthorizationCodeTokenResponseClient standard() {
+ DefaultAuthorizationCodeTokenResponseClient client =
+ new DefaultAuthorizationCodeTokenResponseClient();
+ client.setRestOperations(standardRestTemplate());
+ return client;
+ }
+
+ /** Package-visible so tests can exercise the production HTTP policy directly. */
+ static RestTemplate standardRestTemplate() {
+ SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+ factory.setConnectTimeout(CONNECT_TIMEOUT);
+ factory.setReadTimeout(READ_TIMEOUT);
+
+ RestTemplate template = new RestTemplate(List.of(
+ new FormHttpMessageConverter(),
+ new OAuth2AccessTokenResponseHttpMessageConverter()
+ ));
+ template.setRequestFactory(factory);
+ template.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
+ template.getInterceptors().add((request, body, execution) -> {
+ ClientHttpResponse response = execution.execute(request, body);
+ byte[] bytes = response.getBody().readNBytes(MAX_RESPONSE_BYTES + 1);
+ if (bytes.length > MAX_RESPONSE_BYTES) {
+ response.close();
+ throw new IOException("OAuth2 token response exceeds "
+ + MAX_RESPONSE_BYTES + " bytes");
+ }
+ return new BoundedClientHttpResponse(response, bytes);
+ });
+ return template;
+ }
+
+ /** Replays the already-read, size-checked body so Spring's converters can parse it normally. */
+ private record BoundedClientHttpResponse(ClientHttpResponse delegate, byte[] body)
+ implements ClientHttpResponse {
+
+ @Override
+ public HttpStatusCode getStatusCode() throws IOException {
+ return delegate.getStatusCode();
+ }
+
+ @Override
+ public String getStatusText() throws IOException {
+ return delegate.getStatusText();
+ }
+
+ @Override
+ public void close() {
+ delegate.close();
+ }
+
+ @Override
+ public InputStream getBody() {
+ return new ByteArrayInputStream(body);
+ }
+
+ @Override
+ public HttpHeaders getHeaders() {
+ return delegate.getHeaders();
+ }
+ }
+}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java
index d32fc6ae..eb4a4d89 100644
--- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java
@@ -1,5 +1,9 @@
package com.iflytek.skillhub.auth.oauth;
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import java.time.Instant;
import java.util.List;
@@ -7,6 +11,7 @@ import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
+import org.slf4j.LoggerFactory;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
@@ -15,6 +20,7 @@ import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
@@ -188,6 +194,49 @@ class CustomOidcUserServiceTest {
assertThat(captured.subject()).isEqualTo("oidc-sub-unverified");
}
+ @Test
+ void loadUser_logsPresenceFlagsWithoutSubjectEmailOrFailureDescription() {
+ OAuthLoginFlowService loginFlowService = mock(OAuthLoginFlowService.class);
+ OAuth2UserService delegate = mock();
+ CustomOidcUserService service = new CustomOidcUserService(loginFlowService, delegate);
+ OidcUserRequest request = oidcRequest();
+ OidcUser upstreamUser = oidcUser(Map.of(
+ IdTokenClaimNames.SUB, "sensitive-oidc-subject",
+ "email", "sensitive@example.com",
+ "email_verified", true,
+ "preferred_username", "sensitive-user"
+ ));
+ when(delegate.loadUser(request)).thenReturn(upstreamUser);
+ when(loginFlowService.authenticate(any()))
+ .thenThrow(new OAuth2AuthenticationException(
+ new OAuth2Error("access_denied",
+ "subject sensitive-oidc-subject email sensitive@example.com", null)));
+
+ Logger logger = (Logger) LoggerFactory.getLogger(CustomOidcUserService.class);
+ Level previousLevel = logger.getLevel();
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ logger.setLevel(Level.DEBUG);
+ logger.addAppender(appender);
+ try {
+ assertThatThrownBy(() -> service.loadUser(request))
+ .isInstanceOf(OAuth2AuthenticationException.class);
+ } finally {
+ logger.detachAppender(appender);
+ logger.setLevel(previousLevel);
+ appender.stop();
+ }
+
+ String logged = appender.list.stream()
+ .map(ILoggingEvent::getFormattedMessage)
+ .collect(java.util.stream.Collectors.joining("\n"));
+ assertThat(logged).contains("subjectPresent=true", "emailPresent=true", "errorCode=access_denied");
+ assertThat(logged)
+ .doesNotContain("sensitive-oidc-subject")
+ .doesNotContain("sensitive@example.com")
+ .doesNotContain("sensitive-user");
+ }
+
private static OidcUserRequest oidcRequest() {
Instant issuedAt = Instant.parse("2026-04-24T00:00:00Z");
OidcIdToken idToken = new OidcIdToken(
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java
index 643be583..c94c9b09 100644
--- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java
@@ -1,5 +1,9 @@
package com.iflytek.skillhub.auth.oauth;
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
@@ -8,6 +12,7 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat
import java.time.Instant;
import java.util.Map;
import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
@@ -79,6 +84,46 @@ class GitLabClaimsExtractorTest {
server.verify();
}
+ @Test
+ void extract_logsPresenceFlagsWithoutSubjectUsernameOrEmailValues() {
+ RestClient.Builder restClientBuilder = RestClient.builder();
+ GitLabClaimsExtractor extractor = new GitLabClaimsExtractor(restClientBuilder);
+ Logger logger = (Logger) LoggerFactory.getLogger(GitLabClaimsExtractor.class);
+ Level previousLevel = logger.getLevel();
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ logger.setLevel(Level.DEBUG);
+ logger.addAppender(appender);
+ try {
+ extractor.extract(
+ userRequest(),
+ new DefaultOAuth2User(
+ java.util.List.of(),
+ Map.of(
+ "id", "gitlab-subject-42",
+ "username", "alice-sensitive",
+ "email", "alice@gitlab.example",
+ "confirmed_at", "2026-04-16T08:00:00Z"
+ ),
+ "username"
+ )
+ );
+ } finally {
+ logger.detachAppender(appender);
+ logger.setLevel(previousLevel);
+ appender.stop();
+ }
+
+ String logged = appender.list.stream()
+ .map(ILoggingEvent::getFormattedMessage)
+ .collect(java.util.stream.Collectors.joining("\n"));
+ assertThat(logged).contains("subjectPresent=true", "usernamePresent=true", "emailPresent=true");
+ assertThat(logged)
+ .doesNotContain("gitlab-subject-42")
+ .doesNotContain("alice-sensitive")
+ .doesNotContain("alice@gitlab.example");
+ }
+
private OAuth2UserRequest userRequest() {
ClientRegistration registration = ClientRegistration.withRegistrationId("gitlab")
.clientId("client-id")
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2TokenResponseClientsTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2TokenResponseClientsTest.java
new file mode 100644
index 00000000..cfd38da6
--- /dev/null
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2TokenResponseClientsTest.java
@@ -0,0 +1,98 @@
+package com.iflytek.skillhub.auth.oauth;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.http.MediaType;
+import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient;
+import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestTemplate;
+
+class OAuth2TokenResponseClientsTest {
+
+ @Test
+ void standardClientParsesNormalJsonTokenResponse() {
+ RestTemplate restTemplate = OAuth2TokenResponseClients.standardRestTemplate();
+ MockRestServiceServer server = MockRestServiceServer.createServer(restTemplate);
+ server.expect(requestTo("https://provider.example/token"))
+ .andRespond(withSuccess(
+ """
+ {
+ "access_token": "access-token",
+ "token_type": "Bearer",
+ "expires_in": 3600
+ }
+ """,
+ MediaType.APPLICATION_JSON
+ ));
+ DefaultAuthorizationCodeTokenResponseClient client =
+ new DefaultAuthorizationCodeTokenResponseClient();
+ client.setRestOperations(restTemplate);
+
+ var response = client.getTokenResponse(grantRequest("github"));
+
+ assertThat(response.getAccessToken().getTokenValue()).isEqualTo("access-token");
+ assertThat(response.getAccessToken().getTokenType()).isEqualTo(OAuth2AccessToken.TokenType.BEARER);
+ server.verify();
+ }
+
+ @Test
+ void standardClientRejectsOversizedTokenResponseWithoutEchoingBody() {
+ RestTemplate restTemplate = OAuth2TokenResponseClients.standardRestTemplate();
+ MockRestServiceServer server = MockRestServiceServer.createServer(restTemplate);
+ String sensitivePadding = "secret-response-body-".repeat(4 * 1024);
+ server.expect(requestTo("https://provider.example/token"))
+ .andRespond(withSuccess(
+ "{\"access_token\":\"" + sensitivePadding + "\",\"token_type\":\"Bearer\",\"expires_in\":3600}",
+ MediaType.APPLICATION_JSON
+ ));
+ DefaultAuthorizationCodeTokenResponseClient client =
+ new DefaultAuthorizationCodeTokenResponseClient();
+ client.setRestOperations(restTemplate);
+
+ assertThatThrownBy(() -> client.getTokenResponse(grantRequest("gitlab")))
+ .satisfies(error -> assertThat(error.getMessage())
+ .contains("OAuth2 token response exceeds")
+ .doesNotContain("secret-response-body"));
+ server.verify();
+ }
+
+ private static OAuth2AuthorizationCodeGrantRequest grantRequest(String registrationId) {
+ ClientRegistration registration = ClientRegistration.withRegistrationId(registrationId)
+ .clientId("client-id")
+ .clientSecret("client-secret")
+ .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+ .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+ .redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId)
+ .authorizationUri("https://provider.example/authorize")
+ .tokenUri("https://provider.example/token")
+ .userInfoUri("https://provider.example/userinfo")
+ .userNameAttributeName("id")
+ .build();
+ OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode()
+ .authorizationUri("https://provider.example/authorize")
+ .clientId("client-id")
+ .redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId)
+ .state("state-1")
+ .build();
+ OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponse.success("code-1")
+ .redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId)
+ .state("state-1")
+ .build();
+ return new OAuth2AuthorizationCodeGrantRequest(
+ registration,
+ new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse)
+ );
+ }
+}