fix: 钉钉OAuth2安全加固与身份标识优化

1. 身份标识: openId → unionId (跨应用唯一)
   - DingTalkClaimsExtractor: subject使用unionId, 新增null校验
   - DingTalkOAuth2UserService: nameAttribute改为unionId
   - application.yml: user-name-attribute改为unionId

2. Scope配置: dingtalk → openid (钉钉OAuth2正确scope)
   - application.yml: scope改为openid
   - .env.release.example: 新增scope说明注释

3. RestTemplate超时配置 (5s connect + 10s read)
   - DingTalkTokenResponseClient: 防止无限阻塞
   - DingTalkOAuth2UserService: 同步配置

4. NPE风险修复
   - DingTalkTokenResponseClient: JsonNode null检查

5. 敏感信息泄露修复
   - DingTalkTokenResponseClient: 移除raw_response, 只保留expireIn

6. 硬编码URL修复
   - DingTalkOAuth2UserService: userInfoUri从ClientRegistration配置读取

7. 单元测试覆盖 (12个测试全部通过)
   - DingTalkClaimsExtractorTest: 4个
   - DingTalkTokenResponseClientTest: 6个
   - DingTalkOAuth2UserServiceTest: 2个

Signed-off-by: konglong87 <38234954+konglong87@users.noreply.github.com>
This commit is contained in:
konglong87 2026-06-02 14:49:40 +08:00
parent cf746827d9
commit 0216cdfdee
8 changed files with 514 additions and 17 deletions

View file

@ -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=钉钉

View file

@ -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

View file

@ -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;
*
* <p>Field mapping:
* <ul>
* <li>subject → openId</li>
* <li>subject → unionId (unique across all apps under the same developer account)</li>
* <li>email → unionId@dingtalk.local (synthetic, DingTalk users may not have email)</li>
* <li>emailVerified → true (synthetic)</li>
* <li>providerLogin → nick</li>
* </ul>
*
* <p>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<String, Object> 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,

View file

@ -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<OAuth2UserRe
public DingTalkOAuth2UserService(DingTalkClaimsExtractor claimsExtractor,
OAuthLoginFlowService oauthLoginFlowService) {
this.restTemplate = new RestTemplate();
this.restTemplate = buildRestTemplate();
this.claimsExtractor = claimsExtractor;
this.oauthLoginFlowService = oauthLoginFlowService;
}
/** Package-visible constructor for unit testing with a mock RestTemplate. */
DingTalkOAuth2UserService(DingTalkClaimsExtractor claimsExtractor,
OAuthLoginFlowService oauthLoginFlowService,
RestTemplate restTemplate) {
this.restTemplate = restTemplate;
this.claimsExtractor = claimsExtractor;
this.oauthLoginFlowService = oauthLoginFlowService;
}
private static RestTemplate buildRestTemplate() {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(5));
factory.setReadTimeout(Duration.ofSeconds(10));
return new RestTemplate(factory);
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) {
String accessToken = userRequest.getAccessToken().getTokenValue();
String userInfoUri = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUri();
// Fetch user info using DingTalk's custom header
HttpHeaders headers = new HttpHeaders();
@ -56,7 +76,7 @@ public class DingTalkOAuth2UserService implements OAuth2UserService<OAuth2UserRe
HttpEntity<Void> requestEntity = new HttpEntity<>(headers);
ResponseEntity<Map> 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<OAuth2UserRe
userAttributes.putIfAbsent("nickName", attributes.get("nick"));
userAttributes.putIfAbsent("avatarUrl", attributes.get("avatarUrl"));
// Extract claims
// Extract claims — use unionId as the name attribute (cross-app unique identity)
OAuthClaims claims = claimsExtractor.extract(userRequest, new DefaultOAuth2User(
java.util.Collections.emptyList(), userAttributes, "openId"));
java.util.Collections.emptyList(), userAttributes, "unionId"));
log.info("DingTalk OAuth2 login: subject={}, providerLogin={}", claims.subject(), claims.providerLogin());

View file

@ -6,6 +6,7 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
@ -15,6 +16,7 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenRespon
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.util.Collections;
import java.util.Map;
@ -31,7 +33,23 @@ import java.util.Map;
public class DingTalkTokenResponseClient implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
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<String, Object> 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;

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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)
);
}
}