diff --git a/.env.release.example b/.env.release.example index 5b2fbc9d..126c0035 100644 --- a/.env.release.example +++ b/.env.release.example @@ -94,6 +94,8 @@ OAUTH2_GITLAB_DISPLAY_NAME=GitLab # Optional: configure DingTalk (钉钉) OAuth2 login. # Register your app at https://open-dev.dingtalk.com and request the Contact.User.Read scope. +# The scope must be "openid" (not "dingtalk") — DingTalk uses openid for OAuth2 authorization. +# Add "openid corpid" if you also need corporate identity information. OAUTH2_DINGTALK_CLIENT_ID= OAUTH2_DINGTALK_CLIENT_SECRET= OAUTH2_DINGTALK_DISPLAY_NAME=钉钉 diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 5621c8c9..3f85d0e4 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -73,7 +73,7 @@ spring: client-id: ${OAUTH2_DINGTALK_CLIENT_ID:placeholder} client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:placeholder} scope: - - dingtalk + - openid authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:钉钉} @@ -89,7 +89,7 @@ spring: authorization-uri: https://login.dingtalk.com/oauth2/auth token-uri: https://api.dingtalk.com/v1.0/oauth2/userAccessToken user-info-uri: https://api.dingtalk.com/v1.0/contact/users/me - user-name-attribute: openId + user-name-attribute: unionId servlet: multipart: max-file-size: 100MB diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java index 51f6d27b..1a5956f3 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.auth.oauth; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Component; @@ -14,11 +16,16 @@ import java.util.Map; * *

Field mapping: *

+ * + *

Note: unionId is used instead of openId because openId is only unique within + * a single DingTalk application. If a user logs in through different DingTalk apps + * under the same developer account, openId would differ, causing duplicate accounts. + * unionId remains stable across all apps under the same developer. */ @Component public class DingTalkClaimsExtractor implements OAuthClaimsExtractor { @@ -32,18 +39,25 @@ public class DingTalkClaimsExtractor implements OAuthClaimsExtractor { public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) { Map attrs = oAuth2User.getAttributes(); - String openId = (String) attrs.get("openId"); String unionId = (String) attrs.get("unionId"); + String openId = (String) attrs.get("openId"); String nick = (String) attrs.get("nick"); + // unionId is required — it is the cross-app stable identity for DingTalk users + if (unionId == null || unionId.isEmpty()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("missing_union_id", + "DingTalk response missing required unionId field. " + + "Ensure the 'openid' scope is configured and the DingTalk app " + + "has the Contact.User.Read permission.", null)); + } + // DingTalk users may not have email; synthesize one for downstream compatibility - String syntheticEmail = (unionId != null && !unionId.isEmpty()) - ? unionId + "@dingtalk.local" - : (openId != null ? openId + "@dingtalk.local" : null); + String syntheticEmail = unionId + "@dingtalk.local"; return new OAuthClaims( "dingtalk", - openId, + unionId, // Use unionId (cross-app unique) instead of openId (single-app only) syntheticEmail, true, nick, diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java index 6280972a..ba929aac 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.auth.oauth; +import java.time.Duration; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.Map; @@ -10,6 +11,7 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; @@ -41,14 +43,32 @@ public class DingTalkOAuth2UserService implements OAuth2UserService requestEntity = new HttpEntity<>(headers); ResponseEntity response = restTemplate.exchange( - "https://api.dingtalk.com/v1.0/contact/users/me", + userInfoUri, HttpMethod.GET, requestEntity, Map.class @@ -70,9 +90,9 @@ public class DingTalkOAuth2UserService implements OAuth2UserService { private static final ObjectMapper MAPPER = new ObjectMapper(); - private final RestTemplate restTemplate = new RestTemplate(); + private final RestTemplate restTemplate; + + public DingTalkTokenResponseClient() { + this.restTemplate = buildRestTemplate(); + } + + /** Package-visible constructor for unit testing with a mock RestTemplate. */ + DingTalkTokenResponseClient(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + private static RestTemplate buildRestTemplate() { + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(5)); + factory.setReadTimeout(Duration.ofSeconds(10)); + return new RestTemplate(factory); + } @Override public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) @@ -65,15 +83,30 @@ public class DingTalkTokenResponseClient implements OAuth2AccessTokenResponseCli if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { try { JsonNode json = MAPPER.readTree(response.getBody()); - String accessToken = json.get("accessToken").asText(); - if (accessToken == null || accessToken.isEmpty()) { + + JsonNode accessTokenNode = json.get("accessToken"); + if (accessTokenNode == null || accessTokenNode.isNull()) { throw new OAuth2AuthenticationException( new OAuth2Error("token_response_missing_field", - "DingTalk token response missing accessToken", null)); + "DingTalk token response missing accessToken field", null)); } + String accessToken = accessTokenNode.asText(); + if (accessToken.isEmpty()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_response_missing_field", + "DingTalk token response has empty accessToken", null)); + } + + // Only include non-sensitive fields in additional parameters + Map safeParams = new java.util.LinkedHashMap<>(); + JsonNode expireInNode = json.get("expireIn"); + if (expireInNode != null && !expireInNode.isNull()) { + safeParams.put("expireIn", expireInNode.asLong()); + } + return OAuth2AccessTokenResponse.withToken(accessToken) .tokenType(OAuth2AccessToken.TokenType.BEARER) - .additionalParameters(Collections.singletonMap("raw_response", response.getBody())) + .additionalParameters(safeParams) .build(); } catch (OAuth2AuthenticationException e) { throw e; diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java new file mode 100644 index 00000000..645851e1 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java @@ -0,0 +1,101 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +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.user.DefaultOAuth2User; + +class DingTalkClaimsExtractorTest { + + private final DingTalkClaimsExtractor extractor = new DingTalkClaimsExtractor(); + + @Test + void extract_usesUnionIdAsSubject() { + OAuthClaims claims = extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of( + "unionId", "union123", + "openId", "open456", + "nick", "测试用户" + ), + "unionId" + ) + ); + + assertThat(claims.provider()).isEqualTo("dingtalk"); + assertThat(claims.subject()).isEqualTo("union123"); + assertThat(claims.email()).isEqualTo("union123@dingtalk.local"); + assertThat(claims.emailVerified()).isTrue(); + assertThat(claims.providerLogin()).isEqualTo("测试用户"); + } + + @Test + void extract_throwsWhenUnionIdIsMissing() { + assertThatThrownBy(() -> extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of( + "openId", "open456", + "nick", "测试用户" + ), + "openId" + ) + )).isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("missing_union_id")); + } + + @Test + void extract_throwsWhenUnionIdIsEmpty() { + assertThatThrownBy(() -> extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of( + "unionId", "", + "openId", "open456", + "nick", "测试用户" + ), + "openId" + ) + )).isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("missing_union_id")); + } + + @Test + void getProvider_returnsDingtalk() { + assertThat(extractor.getProvider()).isEqualTo("dingtalk"); + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk") + .clientId("dingzgzf3b9k7jv74iq2") + .clientSecret("test-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("openid") + .authorizationUri("https://login.dingtalk.com/oauth2/auth") + .tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken") + .userInfoUri("https://api.dingtalk.com/v1.0/contact/users/me") + .userNameAttributeName("unionId") + .clientName("钉钉") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "test-access-token", + Instant.now(), + Instant.now().plusSeconds(3600) + ); + return new OAuth2UserRequest(registration, accessToken); + } +} \ No newline at end of file diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java new file mode 100644 index 00000000..40b2c44b --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java @@ -0,0 +1,155 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; + +class DingTalkOAuth2UserServiceTest { + + private DingTalkOAuth2UserService service; + private DingTalkClaimsExtractor claimsExtractor; + private OAuthLoginFlowService oauthLoginFlowService; + private MockRestServiceServer mockServer; + private RestTemplate restTemplate; + + @BeforeEach + void setUp() { + claimsExtractor = new DingTalkClaimsExtractor(); + oauthLoginFlowService = mock(OAuthLoginFlowService.class); + restTemplate = new RestTemplate(); + mockServer = MockRestServiceServer.createServer(restTemplate); + service = new DingTalkOAuth2UserService(claimsExtractor, oauthLoginFlowService, restTemplate); + } + + @Test + void loadUser_fetchesUserInfoWithCustomHeaderAndReturnsOAuth2User() { + // Mock DingTalk user info API response + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header("x-acs-dingtalk-access-token", "test-access-token")) + .andRespond(withSuccess( + """ + { + "unionId": "union123", + "openId": "open456", + "nick": "测试用户", + "avatarUrl": "https://example.com/avatar.jpg" + } + """, + MediaType.APPLICATION_JSON + )); + + // Mock OAuthLoginFlowService to return a principal + PlatformPrincipal principal = new PlatformPrincipal( + "user-union123", "测试用户", "union123@dingtalk.local", + "https://example.com/avatar.jpg", "dingtalk", Set.of("USER") + ); + when(oauthLoginFlowService.authenticate(any(OAuthClaims.class))).thenReturn(principal); + + OAuth2User oauth2User = service.loadUser(userRequest()); + + assertThat(oauth2User.getName()).isEqualTo("user-union123"); + assertThat(oauth2User.getAttributes().get("unionId")).isEqualTo("union123"); + assertThat(oauth2User.getAttributes().get("platformPrincipal")).isEqualTo(principal); + assertThat(oauth2User.getAttributes().get("providerLogin")).isEqualTo("user-union123"); + assertThat(oauth2User.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_USER"))).isTrue(); + mockServer.verify(); + } + + @Test + void loadUser_readsUserInfoUriFromClientRegistration() { + // Use a custom userInfoUri to verify it's read from config, not hardcoded + String customUri = "https://custom-api.example.com/v1.0/contact/users/me"; + + mockServer.expect(requestTo(customUri)) + .andExpect(method(HttpMethod.GET)) + .andExpect(header("x-acs-dingtalk-access-token", "test-access-token")) + .andRespond(withSuccess( + """ + { + "unionId": "union789", + "openId": "open012", + "nick": "自定义用户" + } + """, + MediaType.APPLICATION_JSON + )); + + PlatformPrincipal principal = new PlatformPrincipal( + "user-union789", "自定义用户", "union789@dingtalk.local", + null, "dingtalk", Set.of("USER") + ); + when(oauthLoginFlowService.authenticate(any(OAuthClaims.class))).thenReturn(principal); + + OAuth2User oauth2User = service.loadUser(userRequestWithCustomUri(customUri)); + + assertThat(oauth2User.getName()).isEqualTo("user-union789"); + mockServer.verify(); + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk") + .clientId("dingzgzf3b9k7jv74iq2") + .clientSecret("test-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("openid") + .authorizationUri("https://login.dingtalk.com/oauth2/auth") + .tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken") + .userInfoUri("https://api.dingtalk.com/v1.0/contact/users/me") + .userNameAttributeName("unionId") + .clientName("钉钉") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "test-access-token", + Instant.now(), + Instant.now().plusSeconds(3600) + ); + return new OAuth2UserRequest(registration, accessToken); + } + + private OAuth2UserRequest userRequestWithCustomUri(String userInfoUri) { + ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk") + .clientId("dingzgzf3b9k7jv74iq2") + .clientSecret("test-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("openid") + .authorizationUri("https://login.dingtalk.com/oauth2/auth") + .tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken") + .userInfoUri(userInfoUri) + .userNameAttributeName("unionId") + .clientName("钉钉") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "test-access-token", + Instant.now(), + Instant.now().plusSeconds(3600) + ); + return new OAuth2UserRequest(registration, accessToken); + } +} \ No newline at end of file diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java new file mode 100644 index 00000000..47dbb09c --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java @@ -0,0 +1,172 @@ +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 static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +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.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +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 DingTalkTokenResponseClientTest { + + private DingTalkTokenResponseClient client; + private MockRestServiceServer mockServer; + + @BeforeEach + void setUp() { + RestTemplate restTemplate = new RestTemplate(); + mockServer = MockRestServiceServer.createServer(restTemplate); + client = new DingTalkTokenResponseClient(restTemplate); + } + + @Test + void getTokenResponse_returnsAccessTokenOnSuccess() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andRespond(withSuccess( + """ + { + "accessToken": "dt_access_token_123", + "expireIn": 7200 + } + """, + MediaType.APPLICATION_JSON + )); + + OAuth2AccessTokenResponse response = client.getTokenResponse(authorizationCodeGrantRequest()); + + assertThat(response.getAccessToken().getTokenValue()).isEqualTo("dt_access_token_123"); + assertThat(response.getAccessToken().getTokenType()).isEqualTo(OAuth2AccessToken.TokenType.BEARER); + assertThat(response.getAdditionalParameters().get("expireIn")).isEqualTo(7200L); + // Verify raw_response is NOT included (sensitive data leak fix) + assertThat(response.getAdditionalParameters().containsKey("raw_response")).isFalse(); + mockServer.verify(); + } + + @Test + void getTokenResponse_throwsWhenAccessTokenFieldMissing() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andRespond(withSuccess( + """ + { + "expireIn": 7200 + } + """, + MediaType.APPLICATION_JSON + )); + + assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("token_response_missing_field")); + } + + @Test + void getTokenResponse_throwsWhenAccessTokenIsNull() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andRespond(withSuccess( + """ + { + "accessToken": null, + "expireIn": 7200 + } + """, + MediaType.APPLICATION_JSON + )); + + assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("token_response_missing_field")); + } + + @Test + void getTokenResponse_throwsWhenAccessTokenIsEmpty() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andRespond(withSuccess( + """ + { + "accessToken": "", + "expireIn": 7200 + } + """, + MediaType.APPLICATION_JSON + )); + + assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("token_response_missing_field")); + } + + @Test + void getTokenResponse_throwsOnHttpError() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andRespond(withServerError()); + + assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + @Test + void getTokenResponse_doesNotIncludeExpireInWhenMissing() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andRespond(withSuccess( + """ + { + "accessToken": "dt_access_token_123" + } + """, + MediaType.APPLICATION_JSON + )); + + OAuth2AccessTokenResponse response = client.getTokenResponse(authorizationCodeGrantRequest()); + + assertThat(response.getAccessToken().getTokenValue()).isEqualTo("dt_access_token_123"); + assertThat(response.getAdditionalParameters().containsKey("expireIn")).isFalse(); + assertThat(response.getAdditionalParameters().containsKey("raw_response")).isFalse(); + } + + private OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk") + .clientId("dingzgzf3b9k7jv74iq2") + .clientSecret("test-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("openid") + .authorizationUri("https://login.dingtalk.com/oauth2/auth") + .tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken") + .userInfoUri("https://api.dingtalk.com/v1.0/contact/users/me") + .userNameAttributeName("unionId") + .clientName("钉钉") + .build(); + + OAuth2AuthorizationRequest authRequest = OAuth2AuthorizationRequest.authorizationCode() + .clientId(registration.getClientId()) + .authorizationUri(registration.getProviderDetails().getAuthorizationUri()) + .redirectUri(registration.getRedirectUri()) + .scopes(registration.getScopes()) + .state("test-state") + .build(); + + OAuth2AuthorizationResponse authResponse = OAuth2AuthorizationResponse.success("test-code") + .redirectUri(registration.getRedirectUri()) + .state("test-state") + .build(); + + return new OAuth2AuthorizationCodeGrantRequest( + registration, + new OAuth2AuthorizationExchange(authRequest, authResponse) + ); + } +} \ No newline at end of file