From 5c92c9eeede51b64aa026267158f4f54862f12b0 Mon Sep 17 00:00:00 2001
From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Date: Fri, 18 Sep 2026 16:56:33 +0800
Subject: [PATCH] feat(auth): let providers override token exchange and
authorization params
Extends the per-provider strategy pattern from the userinfo step to the two
earlier stages of the authorization-code flow, so a provider whose endpoints
deviate from the standard contract needs no branch in shared code:
- ProviderTokenResponseClient for a non-standard token exchange, dispatched
by DispatchingTokenResponseClient because Spring's tokenEndpoint accepts
only one client
- ProviderAuthorizationRequestCustomizer for authorization parameters,
dispatched through the resolver's existing customizer hook
Registrations without an override keep the standard Spring behaviour.
Together with ProviderOAuth2UserService this covers all three stages where
a provider can deviate: authorize, token, userinfo. Account decisions stay
outside these hooks, in the unified identity core.
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---
.../skillhub/auth/config/SecurityConfig.java | 11 +--
.../oauth/DispatchingTokenResponseClient.java | 48 +++++++++
...roviderAuthorizationRequestCustomizer.java | 17 ++++
.../oauth/ProviderTokenResponseClient.java | 16 +++
...HubOAuth2AuthorizationRequestResolver.java | 33 ++++++-
.../DispatchingTokenResponseClientTest.java | 99 +++++++++++++++++++
6 files changed, 216 insertions(+), 8 deletions(-)
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClient.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAuthorizationRequestCustomizer.java
create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderTokenResponseClient.java
create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClientTest.java
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java
index 5880f58a..3039de42 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java
@@ -2,7 +2,7 @@ package com.iflytek.skillhub.auth.config;
import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService;
import com.iflytek.skillhub.auth.oauth.CustomOidcUserService;
-import com.iflytek.skillhub.auth.oauth.FeishuOAuth2AccessTokenResponseClient;
+import com.iflytek.skillhub.auth.oauth.DispatchingTokenResponseClient;
import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler;
import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler;
import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver;
@@ -62,7 +62,7 @@ public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final CustomOidcUserService customOidcUserService;
- private final FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient;
+ private final DispatchingTokenResponseClient tokenResponseClient;
private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver;
private final OAuth2LoginSuccessHandler successHandler;
private final OAuth2LoginFailureHandler failureHandler;
@@ -77,7 +77,7 @@ public class SecurityConfig {
public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
CustomOidcUserService customOidcUserService,
- FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient,
+ DispatchingTokenResponseClient tokenResponseClient,
SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver,
OAuth2LoginSuccessHandler successHandler,
OAuth2LoginFailureHandler failureHandler,
@@ -91,7 +91,7 @@ public class SecurityConfig {
@Value("${server.servlet.session.cookie.name:SESSION}") String sessionCookieName) {
this.customOAuth2UserService = customOAuth2UserService;
this.customOidcUserService = customOidcUserService;
- this.feishuOAuth2AccessTokenResponseClient = feishuOAuth2AccessTokenResponseClient;
+ this.tokenResponseClient = tokenResponseClient;
this.authorizationRequestResolver = authorizationRequestResolver;
this.successHandler = successHandler;
this.failureHandler = failureHandler;
@@ -136,8 +136,7 @@ public class SecurityConfig {
})
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver))
- .tokenEndpoint(tokenEndpoint -> tokenEndpoint
- .accessTokenResponseClient(feishuOAuth2AccessTokenResponseClient))
+ .tokenEndpoint(token -> token.accessTokenResponseClient(tokenResponseClient))
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
.oidcUserService(customOidcUserService))
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClient.java
new file mode 100644
index 00000000..934e89c7
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClient.java
@@ -0,0 +1,48 @@
+package com.iflytek.skillhub.auth.oauth;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
+import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
+import org.springframework.stereotype.Component;
+
+/**
+ * Routes the authorization-code token exchange to a {@link ProviderTokenResponseClient} when one
+ * claims the registration, and to the standard Spring client otherwise.
+ *
+ *
Spring's {@code tokenEndpoint} accepts a single client, so per-provider exchange needs one
+ * dispatcher rather than a branch inside the security configuration.
+ */
+@Component
+public class DispatchingTokenResponseClient
+ implements OAuth2AccessTokenResponseClient {
+
+ private final Map overrides;
+ private final OAuth2AccessTokenResponseClient delegate;
+
+ @Autowired
+ public DispatchingTokenResponseClient(List providerClients) {
+ this(providerClients, new DefaultAuthorizationCodeTokenResponseClient());
+ }
+
+ DispatchingTokenResponseClient(
+ List providerClients,
+ OAuth2AccessTokenResponseClient delegate
+ ) {
+ this.overrides = providerClients.stream()
+ .collect(Collectors.toMap(ProviderTokenResponseClient::getProvider, Function.identity()));
+ this.delegate = delegate;
+ }
+
+ @Override
+ public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest request) {
+ String registrationId = request.getClientRegistration().getRegistrationId();
+ ProviderTokenResponseClient override = overrides.get(registrationId);
+ return (override != null ? override : delegate).getTokenResponse(request);
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAuthorizationRequestCustomizer.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAuthorizationRequestCustomizer.java
new file mode 100644
index 00000000..a8504181
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAuthorizationRequestCustomizer.java
@@ -0,0 +1,17 @@
+package com.iflytek.skillhub.auth.oauth;
+
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
+
+/**
+ * Strategy interface for provider-specific authorization request tweaks, for providers whose
+ * authorize endpoint deviates from the standard parameter contract.
+ *
+ * The token and userinfo counterparts are {@link ProviderTokenResponseClient} and
+ * {@link ProviderOAuth2UserService}.
+ */
+public interface ProviderAuthorizationRequestCustomizer {
+
+ String getProvider();
+
+ void customize(OAuth2AuthorizationRequest.Builder builder);
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderTokenResponseClient.java
new file mode 100644
index 00000000..9dc85aef
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderTokenResponseClient.java
@@ -0,0 +1,16 @@
+package com.iflytek.skillhub.auth.oauth;
+
+import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
+import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
+
+/**
+ * Strategy interface for provider-specific token exchange. Implementations override the default
+ * exchange for providers whose token endpoints deviate from the standard form-urlencoded contract.
+ *
+ *
The userinfo counterpart is {@link ProviderOAuth2UserService}.
+ */
+public interface ProviderTokenResponseClient
+ extends OAuth2AccessTokenResponseClient {
+
+ String getProvider();
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java
index df985f8f..04b387d1 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java
@@ -1,11 +1,17 @@
package com.iflytek.skillhub.auth.oauth;
import jakarta.servlet.http.HttpServletRequest;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.springframework.beans.factory.annotation.Autowired;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.stereotype.Component;
/**
@@ -21,13 +27,36 @@ public class SkillHubOAuth2AuthorizationRequestResolver
private final DefaultOAuth2AuthorizationRequestResolver delegate;
private final OAuthLoginFlowService oauthLoginFlowService;
- public SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository,
- OAuthLoginFlowService oauthLoginFlowService) {
+ SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository,
+ OAuthLoginFlowService oauthLoginFlowService) {
+ this(clientRegistrationRepository, oauthLoginFlowService, List.of());
+ }
+
+ @Autowired
+ public SkillHubOAuth2AuthorizationRequestResolver(
+ ClientRegistrationRepository clientRegistrationRepository,
+ OAuthLoginFlowService oauthLoginFlowService,
+ List customizers) {
this.delegate = new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository,
"/oauth2/authorization"
);
this.oauthLoginFlowService = oauthLoginFlowService;
+ Map byProvider = customizers.stream()
+ .collect(Collectors.toMap(
+ ProviderAuthorizationRequestCustomizer::getProvider,
+ Function.identity()
+ ));
+ // Spring resolves the registration id into the builder attributes, so one customizer hook
+ // can dispatch per provider instead of this class knowing about any of them.
+ this.delegate.setAuthorizationRequestCustomizer(builder -> {
+ OAuth2AuthorizationRequest probe = builder.build();
+ String registrationId = probe.getAttribute(OAuth2ParameterNames.REGISTRATION_ID);
+ ProviderAuthorizationRequestCustomizer customizer = byProvider.get(registrationId);
+ if (customizer != null) {
+ customizer.customize(builder);
+ }
+ });
}
@Override
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClientTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClientTest.java
new file mode 100644
index 00000000..d2c18586
--- /dev/null
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClientTest.java
@@ -0,0 +1,99 @@
+package com.iflytek.skillhub.auth.oauth;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.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;
+
+class DispatchingTokenResponseClientTest {
+
+ @Test
+ void routesToProviderOverrideWhenOneClaimsTheRegistration() {
+ OAuth2AccessTokenResponse overrideResponse = response("from-override");
+ OAuth2AccessTokenResponse defaultResponse = response("from-default");
+ DispatchingTokenResponseClient client = new DispatchingTokenResponseClient(
+ List.of(stubProvider("dingtalk", overrideResponse)),
+ request -> defaultResponse
+ );
+
+ OAuth2AccessTokenResponse result = client.getTokenResponse(grantRequest("dingtalk"));
+
+ assertThat(result.getAccessToken().getTokenValue()).isEqualTo("from-override");
+ }
+
+ @Test
+ void fallsBackToDefaultClientForUnclaimedRegistrations() {
+ OAuth2AccessTokenResponse overrideResponse = response("from-override");
+ OAuth2AccessTokenResponse defaultResponse = response("from-default");
+ DispatchingTokenResponseClient client = new DispatchingTokenResponseClient(
+ List.of(stubProvider("dingtalk", overrideResponse)),
+ request -> defaultResponse
+ );
+
+ // GitHub must keep the standard exchange even while a DingTalk override is registered.
+ OAuth2AccessTokenResponse result = client.getTokenResponse(grantRequest("github"));
+
+ assertThat(result.getAccessToken().getTokenValue()).isEqualTo("from-default");
+ }
+
+ private static ProviderTokenResponseClient stubProvider(
+ String provider,
+ OAuth2AccessTokenResponse response
+ ) {
+ return new ProviderTokenResponseClient() {
+ @Override
+ public String getProvider() {
+ return provider;
+ }
+
+ @Override
+ public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest request) {
+ return response;
+ }
+ };
+ }
+
+ private static OAuth2AccessTokenResponse response(String tokenValue) {
+ return OAuth2AccessTokenResponse.withToken(tokenValue)
+ .tokenType(org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType.BEARER)
+ .expiresIn(3600)
+ .build();
+ }
+
+ private static OAuth2AuthorizationCodeGrantRequest grantRequest(String registrationId) {
+ ClientRegistration registration = ClientRegistration.withRegistrationId(registrationId)
+ .clientId("client")
+ .clientSecret("secret")
+ .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+ .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+ .redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId)
+ .authorizationUri("https://provider.example/authorize")
+ .tokenUri("https://provider.example/token")
+ .userInfoUri("https://provider.example/me")
+ .userNameAttributeName("id")
+ .build();
+ OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode()
+ .authorizationUri("https://provider.example/authorize")
+ .clientId("client")
+ .redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId)
+ .state("state-1")
+ .build();
+ OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponse.success("code-1")
+ .redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId)
+ .state("state-1")
+ .build();
+ return new OAuth2AuthorizationCodeGrantRequest(
+ registration,
+ new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse)
+ );
+ }
+
+}