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>
This commit is contained in:
XiaoSeS 2026-09-18 16:56:33 +08:00
parent 934cfa6ded
commit 5c92c9eeed
6 changed files with 216 additions and 8 deletions

View file

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

View file

@ -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.
*
* <p>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<OAuth2AuthorizationCodeGrantRequest> {
private final Map<String, ProviderTokenResponseClient> overrides;
private final OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> delegate;
@Autowired
public DispatchingTokenResponseClient(List<ProviderTokenResponseClient> providerClients) {
this(providerClients, new DefaultAuthorizationCodeTokenResponseClient());
}
DispatchingTokenResponseClient(
List<ProviderTokenResponseClient> providerClients,
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> 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);
}
}

View file

@ -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.
*
* <p>The token and userinfo counterparts are {@link ProviderTokenResponseClient} and
* {@link ProviderOAuth2UserService}.
*/
public interface ProviderAuthorizationRequestCustomizer {
String getProvider();
void customize(OAuth2AuthorizationRequest.Builder builder);
}

View file

@ -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.
*
* <p>The userinfo counterpart is {@link ProviderOAuth2UserService}.
*/
public interface ProviderTokenResponseClient
extends OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
String getProvider();
}

View file

@ -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<ProviderAuthorizationRequestCustomizer> customizers) {
this.delegate = new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository,
"/oauth2/authorization"
);
this.oauthLoginFlowService = oauthLoginFlowService;
Map<String, ProviderAuthorizationRequestCustomizer> 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

View file

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