feat(auth): add DingTalk as a public login provider

Adds DingTalk (钉钉) as a public sign-in option: it authenticates a SkillHub
platform account and nothing more. No Organization membership, no directory
sync, no Namespace grants.

DingTalk deviates from standard OAuth at all three stages, one strategy each:

- authorize: its endpoint wants scope=openid, but declaring that scope in
  configuration makes Spring treat the registration as OIDC and attach a
  nonce, which DingTalk rejects. The scope is added by
  DingTalkAuthorizationRequestCustomizer instead, keeping this a plain OAuth2
  client. A test asserts the scope is present and the nonce is not.
- token: credentials go in a JSON body rather than a form, handled by
  DingTalkTokenResponseClient.
- userinfo: the token travels in x-acs-dingtalk-access-token rather than
  Authorization: Bearer.

Subject and email semantics, which decide whether a login can reach an
existing account:

- unionId is the only accepted subject. DingTalk also returns openId and
  userId, but they must not act as fallbacks: openId is scoped per app and
  userId per organization, so a login falling back to either would bind a
  different identity than a later login carrying unionId, splitting one
  person across two platform accounts.
- A blank or missing unionId fails the login.
- emailVerified is always false. DingTalk returns the email an organization
  admin recorded without attesting the user controls it.

The userinfo service only fetches attributes; account matching, provisioning
and session creation stay with the unified identity core. The reference
implementation called OAuthLoginFlowService.authenticate() from inside
loadUser, which decided the account before the core's gate ran.

Operational bounds match the Feishu adapter: connect and read timeouts, a
64 KB response cap, error descriptions and logs carrying only the exception
class or provider error code, and no logging in the claims extractor.
Unused PII is dropped rather than carried into the principal -- notably
mobile and stateCode.

Adds ProviderStrategyWiringTest, which loads the real application context.
The unit tests call package-visible constructors and so cannot catch Spring
wiring faults; a component with two constructors and no @Autowired marker
unit-tests green and then fails at startup. That happened during this work.

Adapted from the implementation in #467 by @konglong87, re-extracted onto
current main with the subject, structure and bounds changes above.

Part of R1-A2 (public Provider adapters) per
openspec/changes/enterprise-identity-platform/rollout-plan.md.

Co-authored-by: konglong87 <konglong87@users.noreply.github.com>
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-18 16:56:58 +08:00
parent 5c92c9eeed
commit 96f244b416
12 changed files with 1094 additions and 0 deletions

View file

@ -80,6 +80,18 @@ spring:
client-authentication-method: client_secret_post
redirect-uri: "${OAUTH2_FEISHU_REDIRECT_URI:{baseUrl}/login/oauth2/code/{registrationId}}"
client-name: ${OAUTH2_FEISHU_DISPLAY_NAME:飞书}
dingtalk:
client-id: ${OAUTH2_DINGTALK_CLIENT_ID:placeholder}
client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:placeholder}
# No scope is declared on purpose. DingTalk's authorize endpoint wants scope=openid,
# but declaring it here makes Spring treat the registration as OIDC and attach a
# nonce, which DingTalk rejects. DingTalkAuthorizationRequestCustomizer adds the
# scope back to the outgoing URI without turning this into an OIDC flow.
authorization-grant-type: authorization_code
# DingTalk sends credentials in a JSON body, handled by DingTalkTokenResponseClient.
client-authentication-method: none
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:钉钉}
provider:
github:
api-base-url: ${OAUTH2_GITHUB_API_BASE_URL:https://api.github.com}
@ -97,6 +109,11 @@ spring:
token-uri: ${OAUTH2_FEISHU_TOKEN_URI:https://accounts.feishu.cn/oauth/v3/token}
user-info-uri: ${OAUTH2_FEISHU_USER_INFO_URI:${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v1/user_info}
user-name-attribute: open_id
dingtalk:
authorization-uri: ${OAUTH2_DINGTALK_AUTHORIZE_URI:https://login.dingtalk.com}/oauth2/auth
token-uri: ${OAUTH2_DINGTALK_BASE_URI:https://api.dingtalk.com}/v1.0/oauth2/userAccessToken
user-info-uri: ${OAUTH2_DINGTALK_BASE_URI:https://api.dingtalk.com}/v1.0/contact/users/me
user-name-attribute: unionId
servlet:
multipart:
max-file-size: 100MB

View file

@ -0,0 +1,88 @@
package com.iflytek.skillhub.auth.oauth;
import static org.assertj.core.api.Assertions.assertThat;
import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.oauth.DingTalkOAuth2Constants;
import com.iflytek.skillhub.auth.oauth.DispatchingTokenResponseClient;
import com.iflytek.skillhub.auth.oauth.OAuthClaimsExtractor;
import com.iflytek.skillhub.auth.oauth.ProviderAuthorizationRequestCustomizer;
import com.iflytek.skillhub.auth.oauth.ProviderOAuth2UserService;
import com.iflytek.skillhub.auth.oauth.ProviderTokenResponseClient;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
/**
* Loads the real application context to prove the provider strategy beans are constructible.
*
* <p>The unit tests for these classes call their package-visible constructors directly, so they
* cannot catch Spring wiring faults: a component with two constructors and no {@code @Autowired}
* marker compiles and unit-tests green, then fails at startup with "No default constructor found".
* This test is the guard for that class of failure.
*/
@SpringBootTest
@ActiveProfiles("test")
@Import(TestRedisConfig.class)
class ProviderStrategyWiringTest {
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@Autowired
private DispatchingTokenResponseClient dispatchingTokenResponseClient;
@Autowired
private List<ProviderTokenResponseClient> tokenResponseClients;
@Autowired
private List<ProviderOAuth2UserService> userServices;
@Autowired
private List<ProviderAuthorizationRequestCustomizer> authorizationCustomizers;
@Autowired
private List<OAuthClaimsExtractor> claimsExtractors;
@Test
void dispatcherAndEveryProviderStrategyAreConstructible() {
assertThat(dispatchingTokenResponseClient).isNotNull();
// DingTalk needs all three strategy hooks; a missing bean would silently fall back to the
// standard OAuth2 behaviour its endpoints reject.
assertThat(tokenResponseClients)
.extracting(ProviderTokenResponseClient::getProvider)
.contains(DingTalkOAuth2Constants.REGISTRATION_ID);
assertThat(authorizationCustomizers)
.extracting(ProviderAuthorizationRequestCustomizer::getProvider)
.contains(DingTalkOAuth2Constants.REGISTRATION_ID);
assertThat(userServices)
.extracting(ProviderOAuth2UserService::getProvider)
.contains(DingTalkOAuth2Constants.REGISTRATION_ID, "feishu");
assertThat(claimsExtractors)
.extracting(OAuthClaimsExtractor::getProvider)
.contains(DingTalkOAuth2Constants.REGISTRATION_ID, "feishu", "github");
}
@Test
void providerKeysAreUniqueSoDispatchMapsCannotCollide() {
// Collectors.toMap in the dispatchers throws on duplicate keys, which would break startup.
assertThat(tokenResponseClients).extracting(ProviderTokenResponseClient::getProvider)
.doesNotHaveDuplicates();
assertThat(userServices).extracting(ProviderOAuth2UserService::getProvider)
.doesNotHaveDuplicates();
assertThat(authorizationCustomizers).extracting(ProviderAuthorizationRequestCustomizer::getProvider)
.doesNotHaveDuplicates();
assertThat(claimsExtractors).extracting(OAuthClaimsExtractor::getProvider)
.doesNotHaveDuplicates();
}
}

View file

@ -0,0 +1,30 @@
package com.iflytek.skillhub.auth.oauth;
import java.util.LinkedHashSet;
import java.util.Set;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.stereotype.Component;
/**
* Adds the {@code openid} scope DingTalk's authorize endpoint requires.
*
* <p>The scope cannot simply be declared in {@code application.yml}: Spring Security treats a
* registration carrying {@code openid} as an OIDC client and attaches a {@code nonce} parameter,
* which DingTalk rejects. Adding the scope here keeps the registration a plain OAuth2 client while
* still sending the parameter DingTalk expects.
*/
@Component
public class DingTalkAuthorizationRequestCustomizer implements ProviderAuthorizationRequestCustomizer {
@Override
public String getProvider() {
return DingTalkOAuth2Constants.REGISTRATION_ID;
}
@Override
public void customize(OAuth2AuthorizationRequest.Builder builder) {
Set<String> scopes = new LinkedHashSet<>(builder.build().getScopes());
scopes.add(DingTalkOAuth2Constants.AUTHORIZATION_SCOPE);
builder.scopes(scopes);
}
}

View file

@ -0,0 +1,76 @@
package com.iflytek.skillhub.auth.oauth;
import java.util.Map;
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;
/**
* Provider-specific claims extractor for DingTalk (钉钉). Attributes are already fetched by
* {@link DingTalkOAuth2UserService}, which reads them from DingTalk's non-standard user info
* endpoint.
*
* <p>Like the GitHub and Feishu extractors, this class logs nothing: the subject, display name and
* email it handles are exactly the values that must stay out of the logs.
*/
@Component
public class DingTalkClaimsExtractor implements OAuthClaimsExtractor {
@Override
public String getProvider() {
return DingTalkOAuth2Constants.REGISTRATION_ID;
}
@Override
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) {
Map<String, Object> attrs = oAuth2User.getAttributes();
String subject = requireText(
attrs.get(DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME),
DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME
);
String email = text(attrs.get("email"));
// DingTalk's user info endpoint returns the email recorded by the organization admin and
// does not attest that the user controls it, so it carries no verification signal and
// cannot be used to join an existing account.
boolean emailVerified = false;
// nick -> name and stop. Falling back to the subject would write it into
// UserAccount.displayName and into UserActivatedEvent, pushing the external subject
// somewhere event consumers may log it.
String providerLogin = text(attrs.get("nick"));
if (providerLogin == null) {
providerLogin = text(attrs.get("name"));
}
return new OAuthClaims(
DingTalkOAuth2Constants.REGISTRATION_ID,
subject,
email,
emailVerified,
providerLogin,
attrs
);
}
private static String requireText(Object value, String attribute) {
String text = text(value);
if (text == null) {
throw new OAuth2AuthenticationException(
new OAuth2Error("missing_subject", "DingTalk user info is missing " + attribute, null)
);
}
return text;
}
private static String text(Object value) {
if (value == null) {
return null;
}
String text = String.valueOf(value).trim();
return text.isEmpty() ? null : text;
}
}

View file

@ -0,0 +1,23 @@
package com.iflytek.skillhub.auth.oauth;
/** Shared protocol constants for the DingTalk OAuth2 adapter. */
public final class DingTalkOAuth2Constants {
public static final String REGISTRATION_ID = "dingtalk";
public static final String AUTHORIZATION_SCOPE = "openid";
public static final String ACCESS_TOKEN_HEADER = "x-acs-dingtalk-access-token";
/**
* The only accepted subject claim. DingTalk also returns {@code openId} and {@code userId}, but
* they must not act as fallbacks: {@code openId} is scoped per app and {@code userId} per
* organization, so a login that fell back to either would bind a different identity than a
* later login carrying {@code unionId}, splitting one person across two platform accounts.
* Promoting another claim later needs an explicit alias migration.
*/
static final String SUBJECT_CLAIM_NAME = "unionId";
public static final String SUBJECT_ATTRIBUTE = "dingtalkSubject";
private DingTalkOAuth2Constants() {
}
}

View file

@ -0,0 +1,162 @@
package com.iflytek.skillhub.auth.oauth;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
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.core.authority.SimpleGrantedAuthority;
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.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
/**
* Loads DingTalk (钉钉) user info, which deviates from standard OAuth: the access token travels in
* a custom {@code x-acs-dingtalk-access-token} header rather than {@code Authorization: Bearer}.
*
* <p>This service only fetches attributes. Account matching, provisioning and session creation
* stay with the unified identity core reached through {@link OAuthLoginFlowService}, so DingTalk
* cannot decide who a login resolves to.
*/
@Component
public class DingTalkOAuth2UserService implements ProviderOAuth2UserService {
private static final Logger log = LoggerFactory.getLogger(DingTalkOAuth2UserService.class);
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
private static final Duration READ_TIMEOUT = Duration.ofSeconds(10);
/** A DingTalk contact payload is well under 1 KB; this only needs to stop an unbounded body. */
private static final int MAX_RESPONSE_BYTES = 64 * 1024;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final RestClient restClient;
/**
* Uses an external-service client that is intentionally not customized with application
* tracing. Trace context must not be propagated to the external DingTalk service.
*/
@Autowired
public DingTalkOAuth2UserService() {
this(RestClient.builder().requestFactory(defaultRequestFactory()));
}
public DingTalkOAuth2UserService(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
}
/**
* Bounds the userinfo call so an unresponsive DingTalk endpoint cannot hold a login thread. The
* timeouts apply to this provider client only and do not change the shared HTTP defaults.
*/
private static ClientHttpRequestFactory defaultRequestFactory() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(CONNECT_TIMEOUT);
factory.setReadTimeout(READ_TIMEOUT);
return factory;
}
@Override
public String getProvider() {
return DingTalkOAuth2Constants.REGISTRATION_ID;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
String userInfoUri = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUri();
Map<String, Object> payload;
try {
payload = restClient.get()
.uri(userInfoUri)
.header(
DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER,
userRequest.getAccessToken().getTokenValue()
)
.exchange((request, clientResponse) -> readBounded(clientResponse.getBody()));
} catch (Exception e) {
// Exception class only: the message can quote the request URI, which holds the token.
log.warn("DingTalk user info request failed with {}", e.getClass().getSimpleName());
throw new OAuth2AuthenticationException(
new OAuth2Error("dingtalk_userinfo_error", "Failed to load DingTalk user info", null),
e
);
}
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
normalize(payload),
DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME
);
}
/**
* Reads at most {@link #MAX_RESPONSE_BYTES} before parsing, so a misconfigured or hostile
* {@code OAUTH2_DINGTALK_BASE_URI} cannot stream an unbounded body into the parser. Reading one
* byte past the cap is what distinguishes an oversized payload from one that exactly fills it.
*/
private static Map<String, Object> readBounded(InputStream body) throws IOException {
byte[] bytes = body.readNBytes(MAX_RESPONSE_BYTES + 1);
if (bytes.length > MAX_RESPONSE_BYTES) {
throw new IOException("DingTalk user info response exceeds " + MAX_RESPONSE_BYTES + " bytes");
}
return OBJECT_MAPPER.readValue(bytes, new com.fasterxml.jackson.core.type.TypeReference<>() {
});
}
/**
* Copies through only the attributes the platform consumes, and aliases DingTalk's
* {@code avatarUrl} to the {@code avatar_url} key the identity core reads. Attributes the
* platform does not use -- notably {@code mobile} and {@code stateCode} -- are dropped rather
* than carried into the principal, keeping unused PII out of claims and logs.
*/
private static Map<String, Object> normalize(Map<String, Object> payload) {
Map<String, Object> attributes = new LinkedHashMap<>();
copyIfPresent(attributes, payload, DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME);
copyIfPresent(attributes, payload, "nick");
copyIfPresent(attributes, payload, "name");
copyIfPresent(attributes, payload, "email");
Object avatar = payload.get("avatarUrl");
if (avatar != null && !String.valueOf(avatar).isBlank()) {
attributes.put("avatar_url", avatar);
}
if (!attributes.containsKey(DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME)) {
throw new OAuth2AuthenticationException(
new OAuth2Error(
"dingtalk_userinfo_error",
"DingTalk user info missing " + DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME,
null
)
);
}
return attributes;
}
private static void copyIfPresent(
Map<String, Object> target,
Map<String, Object> source,
String key
) {
Object value = source.get(key);
if (value != null && !String.valueOf(value).isBlank()) {
target.put(key, value);
}
}
}

View file

@ -0,0 +1,145 @@
package com.iflytek.skillhub.auth.oauth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
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;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
/**
* Custom token response client for DingTalk (钉钉).
*
* <p>DingTalk requires a JSON body for token exchange instead of the standard
* form-urlencoded format. This client adapts the request accordingly.
*
* <p>Request body format:
* <pre>{ "clientId": "...", "clientSecret": "...", "code": "...", "grantType": "authorization_code" }</pre>
*/
@Component
public class DingTalkTokenResponseClient implements ProviderTokenResponseClient {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final RestTemplate restTemplate;
@Autowired
public DingTalkTokenResponseClient() {
this.restTemplate = buildRestTemplate();
}
/** Package-visible constructor for unit testing with a mock RestTemplate. */
DingTalkTokenResponseClient(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@Override
public String getProvider() {
return DingTalkOAuth2Constants.REGISTRATION_ID;
}
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)
throws OAuth2AuthenticationException {
String tokenUri = authorizationCodeGrantRequest.getClientRegistration().getProviderDetails().getTokenUri();
String clientId = authorizationCodeGrantRequest.getClientRegistration().getClientId();
String clientSecret = authorizationCodeGrantRequest.getClientRegistration().getClientSecret();
String code = authorizationCodeGrantRequest.getAuthorizationExchange()
.getAuthorizationResponse()
.getCode();
Map<String, Object> tokenRequest = Map.of(
"clientId", clientId,
"clientSecret", clientSecret,
"code", code,
"grantType", "authorization_code"
);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<String> response;
try {
response = restTemplate.postForEntity(tokenUri, new HttpEntity<>(tokenRequest, headers), String.class);
} catch (RestClientResponseException e) {
throw new OAuth2AuthenticationException(
new OAuth2Error("token_exchange_io_error",
"DingTalk token exchange failed with HTTP " + e.getStatusCode().value(), null));
} catch (RestClientException e) {
throw new OAuth2AuthenticationException(
new OAuth2Error("token_exchange_io_error",
"DingTalk token exchange request failed", null));
}
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
try {
JsonNode json = MAPPER.readTree(response.getBody());
JsonNode accessTokenNode = json.get("accessToken");
if (accessTokenNode == null || accessTokenNode.isNull()) {
throw new OAuth2AuthenticationException(
new OAuth2Error("token_response_missing_field",
"DingTalk token response missing accessToken field", null));
}
String accessToken = accessTokenNode.asText();
if (accessToken.isBlank()) {
throw new OAuth2AuthenticationException(
new OAuth2Error("token_response_missing_field",
"DingTalk token response has empty accessToken", null));
}
JsonNode expireInNode = json.get("expireIn");
if (expireInNode == null || !expireInNode.isIntegralNumber() || !expireInNode.canConvertToLong()) {
throw new OAuth2AuthenticationException(
new OAuth2Error("token_response_invalid_expiry",
"DingTalk token response has invalid expireIn field", null));
}
long expireInSeconds = expireInNode.longValue();
if (expireInSeconds <= 0) {
throw new OAuth2AuthenticationException(
new OAuth2Error("token_response_invalid_expiry",
"DingTalk token response has non-positive expireIn field", null));
}
// Only include non-sensitive fields in additional parameters.
Map<String, Object> safeParams = Map.of("expireIn", expireInSeconds);
return OAuth2AccessTokenResponse.withToken(accessToken)
.tokenType(OAuth2AccessToken.TokenType.BEARER)
.expiresIn(expireInSeconds)
.additionalParameters(safeParams)
.build();
} catch (OAuth2AuthenticationException e) {
throw e;
} catch (Exception e) {
throw new OAuth2AuthenticationException(
new OAuth2Error("token_parse_error",
"Failed to parse DingTalk token response", null));
}
}
throw new OAuth2AuthenticationException(
new OAuth2Error("token_exchange_failed",
"DingTalk token exchange failed: HTTP " + response.getStatusCode(), null));
}
}

View file

@ -0,0 +1,122 @@
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.HashMap;
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.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
class DingTalkClaimsExtractorTest {
private final DingTalkClaimsExtractor extractor = new DingTalkClaimsExtractor();
@Test
void extract_mapsUnionIdAndNick() {
Map<String, Object> attrs = new HashMap<>(Map.of(
"unionId", "un_123",
"nick", "张三",
"email", "zhangsan@corp.example"
));
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
assertThat(claims.provider()).isEqualTo("dingtalk");
assertThat(claims.subject()).isEqualTo("un_123");
assertThat(claims.providerLogin()).isEqualTo("张三");
assertThat(claims.email()).isEqualTo("zhangsan@corp.example");
// DingTalk's contact endpoint does not attest email ownership.
assertThat(claims.emailVerified()).isFalse();
}
@Test
void extract_neverAcceptsOpenIdOrUserIdAsSubject() {
// openId is per-app and userId per-organization. Accepting either as a fallback would bind
// a different identity than a later login carrying unionId, splitting one person across
// two platform accounts.
Map<String, Object> attrs = new HashMap<>(Map.of(
"openId", "op_456",
"userId", "usr_789",
"nick", "张三"
));
assertThatThrownBy(() -> extractor.extract(userRequest(), user(attrs)))
.isInstanceOf(OAuth2AuthenticationException.class)
.hasMessageContaining("unionId");
}
@Test
void extract_rejectsBlankUnionId() {
Map<String, Object> attrs = new HashMap<>();
attrs.put("unionId", " ");
attrs.put("nick", "张三");
assertThatThrownBy(() -> extractor.extract(userRequest(), user(attrs)))
.isInstanceOf(OAuth2AuthenticationException.class)
.hasMessageContaining("unionId");
}
@Test
void extract_fallsBackToNameThenLeavesDisplayNameUnset() {
Map<String, Object> withName = new HashMap<>(Map.of("unionId", "un_1", "name", "Alice"));
assertThat(extractor.extract(userRequest(), user(withName)).providerLogin()).isEqualTo("Alice");
// Must not synthesize from the subject: providerLogin is written to displayName and into
// UserActivatedEvent, so a synthesized value would carry the subject to event consumers.
Map<String, Object> bare = new HashMap<>(Map.of("unionId", "un_2"));
OAuthClaims claims = extractor.extract(userRequest(), user(bare));
assertThat(claims.providerLogin()).isNull();
assertThat(claims.subject()).isEqualTo("un_2");
}
/** Does not enforce the name attribute, unlike DefaultOAuth2User. */
private OAuth2User user(Map<String, Object> attrs) {
return new OAuth2User() {
@Override
public Map<String, Object> getAttributes() {
return attrs;
}
@Override
public java.util.Collection<? extends org.springframework.security.core.GrantedAuthority>
getAuthorities() {
return java.util.List.of();
}
@Override
public String getName() {
return String.valueOf(attrs.get("unionId"));
}
};
}
private OAuth2UserRequest userRequest() {
ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk")
.clientId("dingoauth_test")
.clientSecret("client-secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.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,
"token-123",
Instant.now(),
Instant.now().plusSeconds(3600)
);
return new OAuth2UserRequest(registration, accessToken);
}
}

View file

@ -0,0 +1,140 @@
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.header;
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 org.junit.jupiter.api.Test;
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.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
class DingTalkOAuth2UserServiceTest {
@Test
void loadUser_sendsCustomTokenHeaderAndNormalizesAttributes() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me"))
// DingTalk reads the token from its own header, not Authorization: Bearer.
.andExpect(header(DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER, "token-123"))
.andRespond(withSuccess(
"""
{
"unionId": "un_123",
"openId": "op_456",
"nick": "张三",
"avatarUrl": "https://avatar.example/z.png",
"email": "zhangsan@corp.example",
"mobile": "13800000000",
"stateCode": "86"
}
""",
MediaType.APPLICATION_JSON
));
DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder);
OAuth2User user = service.loadUser(userRequest());
assertThat(user.getName()).isEqualTo("un_123");
assertThat(user.getAttributes())
.containsEntry("unionId", "un_123")
.containsEntry("nick", "张三")
.containsEntry("email", "zhangsan@corp.example")
// avatarUrl is aliased to the key the identity core reads.
.containsEntry("avatar_url", "https://avatar.example/z.png");
// Unused PII must not travel into the principal or claims.
assertThat(user.getAttributes()).doesNotContainKeys("mobile", "stateCode", "avatarUrl");
// openId must not survive as a usable subject candidate.
assertThat(user.getAttributes()).doesNotContainKey("openId");
server.verify();
}
@Test
void loadUser_rejectsResponseWithoutUnionId() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me"))
.andRespond(withSuccess(
"""
{"openId": "op_456", "nick": "张三"}
""",
MediaType.APPLICATION_JSON
));
DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder);
assertThatThrownBy(() -> service.loadUser(userRequest()))
.isInstanceOf(OAuth2AuthenticationException.class)
.hasMessageContaining("unionId");
server.verify();
}
@Test
void loadUser_rejectsOversizedResponseBody() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
// 64 KB cap; pad a structurally valid payload past it so the size check fires, not the parser.
String padding = "x".repeat(70 * 1024);
server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me"))
.andRespond(withSuccess(
"{\"unionId\":\"un_123\",\"nick\":\"" + padding + "\"}",
MediaType.APPLICATION_JSON
));
DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder);
assertThatThrownBy(() -> service.loadUser(userRequest()))
.isInstanceOf(OAuth2AuthenticationException.class)
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode())
.isEqualTo("dingtalk_userinfo_error"));
server.verify();
}
@Test
void loadUser_errorDescriptionDoesNotEchoUpstreamTextOrToken() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me"))
.andRespond(withSuccess("not json at all: token-123", MediaType.APPLICATION_JSON));
DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder);
assertThatThrownBy(() -> service.loadUser(userRequest()))
.isInstanceOf(OAuth2AuthenticationException.class)
.satisfies(ex -> {
String description = ((OAuth2AuthenticationException) ex).getError().getDescription();
assertThat(description).doesNotContain("token-123");
});
server.verify();
}
private OAuth2UserRequest userRequest() {
ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk")
.clientId("dingoauth_test")
.clientSecret("client-secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.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,
"token-123",
Instant.now(),
Instant.now().plusSeconds(3600)
);
return new OAuth2UserRequest(registration, accessToken);
}
}

View file

@ -0,0 +1,201 @@
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 java.time.Duration;
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.getAccessToken().getIssuedAt()).isNotNull();
assertThat(response.getAccessToken().getExpiresAt()).isNotNull();
assertThat(Duration.between(
response.getAccessToken().getIssuedAt(),
response.getAccessToken().getExpiresAt())).isEqualTo(Duration.ofSeconds(7200));
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().body("sensitive-upstream-response"));
assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest()))
.isInstanceOf(OAuth2AuthenticationException.class)
.satisfies(ex -> {
OAuth2AuthenticationException oauthException = (OAuth2AuthenticationException) ex;
assertThat(oauthException.getError().getErrorCode()).isEqualTo("token_exchange_io_error");
assertThat(oauthException.getMessage()).doesNotContain("sensitive-upstream-response");
});
}
@Test
void getTokenResponse_throwsWhenExpireInIsMissing() {
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
.andRespond(withSuccess(
"""
{
"accessToken": "dt_access_token_123"
}
""",
MediaType.APPLICATION_JSON
));
assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest()))
.isInstanceOf(OAuth2AuthenticationException.class)
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex)
.getError().getErrorCode()).isEqualTo("token_response_invalid_expiry"));
}
@Test
void getTokenResponse_throwsWhenExpireInIsNonPositive() {
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
.andRespond(withSuccess(
"""
{
"accessToken": "dt_access_token_123",
"expireIn": 0
}
""",
MediaType.APPLICATION_JSON
));
assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest()))
.isInstanceOf(OAuth2AuthenticationException.class)
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex)
.getError().getErrorCode()).isEqualTo("token_response_invalid_expiry"));
}
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)
);
}
}

View file

@ -84,4 +84,91 @@ class OAuth2AuthorizationRequestResolverTest {
assertThat(session).isNotNull();
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
}
@Test
void resolve_addsDingTalkScopeWithoutTurningTheRequestIntoOidc() {
SkillHubOAuth2AuthorizationRequestResolver dingTalkResolver = resolverFor(
dingTalkRegistration(),
new DingTalkAuthorizationRequestCustomizer()
);
MockHttpServletRequest request =
new MockHttpServletRequest("GET", "/oauth2/authorization/dingtalk");
var authorizationRequest = dingTalkResolver.resolve(request, "dingtalk");
assertThat(authorizationRequest).isNotNull();
// DingTalk's authorize endpoint requires scope=openid...
assertThat(authorizationRequest.getScopes()).contains("openid");
assertThat(authorizationRequest.getAuthorizationRequestUri()).contains("scope=openid");
// ...but rejects the nonce Spring attaches when a registration declares openid in config.
// Declaring no scope there and adding it here is what keeps the nonce away.
assertThat(authorizationRequest.getAdditionalParameters()).doesNotContainKey("nonce");
assertThat(authorizationRequest.getAttributes()).doesNotContainKey("nonce");
assertThat(authorizationRequest.getAuthorizationRequestUri()).doesNotContain("nonce=");
}
@Test
void resolve_leavesOtherProvidersUntouchedWhenADingTalkCustomizerIsRegistered() {
SkillHubOAuth2AuthorizationRequestResolver mixedResolver = resolverFor(
githubRegistration(),
new DingTalkAuthorizationRequestCustomizer()
);
MockHttpServletRequest request =
new MockHttpServletRequest("GET", "/oauth2/authorization/github");
var authorizationRequest = mixedResolver.resolve(request, "github");
assertThat(authorizationRequest).isNotNull();
assertThat(authorizationRequest.getScopes()).containsExactly("read:user");
}
private static SkillHubOAuth2AuthorizationRequestResolver resolverFor(
ClientRegistration registration,
ProviderAuthorizationRequestCustomizer customizer
) {
OAuthLoginFlowService flowService = new OAuthLoginFlowService(
java.util.List.of(),
mock(AccessPolicy.class),
mock(IdentityBindingService.class)
);
return new SkillHubOAuth2AuthorizationRequestResolver(
new InMemoryClientRegistrationRepository(registration),
flowService,
java.util.List.of(customizer)
);
}
private static ClientRegistration githubRegistration() {
return ClientRegistration.withRegistrationId("github")
.clientId("client")
.clientSecret("secret")
.authorizationUri("https://example.test/oauth/authorize")
.tokenUri("https://example.test/oauth/token")
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.userInfoUri("https://example.test/user")
.userNameAttributeName("id")
.authorizationGrantType(
org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE)
.scope("read:user")
.clientName("GitHub")
.build();
}
private static ClientRegistration dingTalkRegistration() {
// Mirrors application.yml: no scope declared, so Spring keeps this a plain OAuth2 client.
return ClientRegistration.withRegistrationId("dingtalk")
.clientId("dingoauth_test")
.clientSecret("secret")
.authorizationUri("https://login.dingtalk.com/oauth2/auth")
.tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.userInfoUri("https://api.dingtalk.com/v1.0/contact/users/me")
.userNameAttributeName("unionId")
.authorizationGrantType(
org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE)
.clientAuthenticationMethod(
org.springframework.security.oauth2.core.ClientAuthenticationMethod.NONE)
.clientName("钉钉")
.build();
}
}

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path fill="#118EE9" d="M573.7 252.5C422.5 197.4 201.3 96.7 201.3 96.7c-15.7-4.1-17.9 11.1-17.9 11.1c-5 61.1 33.6 160.5 53.6 182.8c19.9 22.3 319.1 113.7 319.1 113.7S326 357.9 270.5 341.9c-55.6-16-37.9 17.8-37.9 17.8c11.4 61.7 64.9 131.8 107.2 138.4c42.2 6.6 220.1 4 220.1 4s-35.5 4.1-93.2 11.9c-42.7 5.8-97 12.5-111.1 17.8c-33.1 12.5 24 62.6 24 62.6c84.7 76.8 129.7 50.5 129.7 50.5c33.3-10.7 61.4-18.5 85.2-24.2L565 743.1h84.6L603 928l205.3-271.9H700.8l22.3-38.7c.3.5.4.8.4.8S799.8 496.1 829 433.8l.6-1h-.1c5-10.8 8.6-19.7 10-25.8c17-71.3-114.5-99.4-265.8-154.5"/>
</svg>

After

Width:  |  Height:  |  Size: 639 B