feat(auth): let providers override OAuth userinfo loading

Some providers do not return a flat, standard userinfo payload, so
DefaultOAuth2UserService cannot read them. Add ProviderOAuth2UserService
so a provider can claim its own registration id and supply the loading
step, while everything after it stays shared.

The override runs inside the RemoteIdentityIoExecutor boundary added in
R1-A, so a provider's HTTP call does not hold the surrounding
transaction open. Registrations without an override keep using the
default user service unchanged.

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

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-18 15:12:30 +08:00
parent be46c547d1
commit 6e6cf19e00
3 changed files with 192 additions and 3 deletions

View file

@ -36,6 +36,7 @@ import org.springframework.stereotype.Service;
public class OAuthLoginFlowService {
private final Map<String, OAuthClaimsExtractor> extractors;
private final Map<String, ProviderOAuth2UserService> userServiceOverrides;
private final AccessPolicy accessPolicy;
private final IdentityBindingService identityBindingService;
private final LegacyPlatformIdentityCore identityCore;
@ -44,12 +45,14 @@ public class OAuthLoginFlowService {
@Autowired
public OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
List<ProviderOAuth2UserService> userServiceList,
AccessPolicy accessPolicy,
IdentityBindingService identityBindingService,
LegacyPlatformIdentityCore identityCore,
RemoteIdentityIoExecutor remoteIdentityIo) {
this(
extractorList,
userServiceList,
accessPolicy,
identityBindingService,
identityCore,
@ -59,6 +62,7 @@ public class OAuthLoginFlowService {
}
OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
List<ProviderOAuth2UserService> userServiceList,
AccessPolicy accessPolicy,
IdentityBindingService identityBindingService,
LegacyPlatformIdentityCore identityCore,
@ -66,6 +70,8 @@ public class OAuthLoginFlowService {
RemoteIdentityIoExecutor remoteIdentityIo) {
this.extractors = extractorList.stream()
.collect(Collectors.toMap(OAuthClaimsExtractor::getProvider, Function.identity()));
this.userServiceOverrides = userServiceList.stream()
.collect(Collectors.toMap(ProviderOAuth2UserService::getProvider, Function.identity()));
this.accessPolicy = accessPolicy;
this.identityBindingService = identityBindingService;
this.identityCore = identityCore;
@ -73,12 +79,30 @@ public class OAuthLoginFlowService {
this.remoteIdentityIo = remoteIdentityIo;
}
OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
AccessPolicy accessPolicy,
IdentityBindingService identityBindingService,
LegacyPlatformIdentityCore identityCore,
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate,
RemoteIdentityIoExecutor remoteIdentityIo) {
this(
extractorList,
List.of(),
accessPolicy,
identityBindingService,
identityCore,
delegate,
remoteIdentityIo
);
}
OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
AccessPolicy accessPolicy,
IdentityBindingService identityBindingService,
LegacyPlatformIdentityCore identityCore) {
this(
extractorList,
List.of(),
accessPolicy,
identityBindingService,
identityCore,
@ -95,8 +119,9 @@ public class OAuthLoginFlowService {
public AuthenticatedLoginContext loadLoginContext(OAuth2UserRequest request) {
LoadedProviderIdentity loadedIdentity = remoteIdentityIo.execute(() -> {
OAuth2User upstreamUser = delegate.loadUser(request);
String registrationId = request.getClientRegistration().getRegistrationId();
ProviderOAuth2UserService override = userServiceOverrides.get(registrationId);
OAuth2User upstreamUser = (override != null ? override : delegate).loadUser(request);
OAuthClaimsExtractor extractor = extractors.get(registrationId);
if (extractor == null) {
throw new OAuth2AuthenticationException(

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.auth.oauth;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.user.OAuth2User;
/**
* Strategy interface for provider-specific OAuth user loading. Implementations override the
* default user info loading for providers whose endpoints deviate from the standard
* flat-attribute response format.
*/
public interface ProviderOAuth2UserService extends OAuth2UserService<OAuth2UserRequest, OAuth2User> {
String getProvider();
}

View file

@ -102,6 +102,148 @@ class OAuthLoginFlowServiceTest {
verify(delegate).loadUser(request);
}
@Test
void loadLoginContext_prefersProviderUserServiceOverrideInsideRemoteIoBoundary() {
OAuthClaims claims = claims("feishu", "ou_1");
OAuthClaimsExtractor extractor = new OAuthClaimsExtractor() {
@Override
public String getProvider() {
return "feishu";
}
@Override
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User user) {
return claims;
}
};
OAuth2User overrideUser = new DefaultOAuth2User(
List.of(new SimpleGrantedAuthority("OAUTH_USER")),
Map.of("open_id", "ou_1"),
"open_id"
);
AtomicInteger boundaryCalls = new AtomicInteger();
AtomicInteger overrideCallsInsideBoundary = new AtomicInteger();
RemoteIdentityIoExecutor remoteIdentityIo = new RemoteIdentityIoExecutor() {
@Override
public <T> T execute(java.util.function.Supplier<T> operation) {
boundaryCalls.incrementAndGet();
return operation.get();
}
};
ProviderOAuth2UserService override = new ProviderOAuth2UserService() {
@Override
public String getProvider() {
return "feishu";
}
@Override
public OAuth2User loadUser(OAuth2UserRequest request) {
// Records the boundary state at call time: a provider override must run inside the
// remote-IO boundary, otherwise its HTTP call would hold the surrounding transaction.
if (boundaryCalls.get() == 1) {
overrideCallsInsideBoundary.incrementAndGet();
}
return overrideUser;
}
};
AccessPolicy accessPolicy = mock(AccessPolicy.class);
IdentityBindingService identityBindingService = mock(IdentityBindingService.class);
LegacyPlatformIdentityCore identityCore = mock(LegacyPlatformIdentityCore.class);
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = mock();
PlatformPrincipal principal = new PlatformPrincipal(
"usr_2", "zhangsan", null, null, "feishu", Set.of("USER")
);
OAuthLoginFlowService service = new OAuthLoginFlowService(
List.of(extractor),
List.of(override),
accessPolicy,
identityBindingService,
identityCore,
delegate,
remoteIdentityIo
);
OAuth2UserRequest request = oauthUserRequest("feishu");
when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.ALLOW);
when(identityCore.evaluate(claims)).thenReturn(LegacyPlatformIdentityDecision.legacy());
when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(principal);
OAuthLoginFlowService.AuthenticatedLoginContext result = service.loadLoginContext(request);
assertThat(result.upstreamUser()).isSameAs(overrideUser);
assertThat(result.principal()).isSameAs(principal);
assertThat(boundaryCalls).hasValue(1);
assertThat(overrideCallsInsideBoundary).hasValue(1);
// The default user service must not be consulted when an override claims the registration.
verify(delegate, never()).loadUser(request);
}
@Test
void loadLoginContext_fallsBackToDefaultUserServiceForUnclaimedProviders() {
OAuthClaims claims = claims();
OAuthClaimsExtractor extractor = new OAuthClaimsExtractor() {
@Override
public String getProvider() {
return "github";
}
@Override
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User user) {
return claims;
}
};
ProviderOAuth2UserService unrelatedOverride = new ProviderOAuth2UserService() {
@Override
public String getProvider() {
return "feishu";
}
@Override
public OAuth2User loadUser(OAuth2UserRequest request) {
throw new AssertionError("Feishu override must not handle a GitHub login");
}
};
AccessPolicy accessPolicy = mock(AccessPolicy.class);
IdentityBindingService identityBindingService = mock(IdentityBindingService.class);
LegacyPlatformIdentityCore identityCore = mock(LegacyPlatformIdentityCore.class);
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = mock();
OAuth2User upstreamUser = new DefaultOAuth2User(
List.of(new SimpleGrantedAuthority("OAUTH_USER")),
Map.of("id", "gh_1"),
"id"
);
PlatformPrincipal principal = new PlatformPrincipal(
"usr_1", "alice", "alice@example.com", null, "github", Set.of("USER")
);
OAuthLoginFlowService service = new OAuthLoginFlowService(
List.of(extractor),
List.of(unrelatedOverride),
accessPolicy,
identityBindingService,
identityCore,
delegate,
directRemoteIo()
);
OAuth2UserRequest request = oauthUserRequest();
when(delegate.loadUser(request)).thenReturn(upstreamUser);
when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.ALLOW);
when(identityCore.evaluate(claims)).thenReturn(LegacyPlatformIdentityDecision.legacy());
when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(principal);
OAuthLoginFlowService.AuthenticatedLoginContext result = service.loadLoginContext(request);
assertThat(result.upstreamUser()).isSameAs(upstreamUser);
verify(delegate).loadUser(request);
}
private static RemoteIdentityIoExecutor directRemoteIo() {
return new RemoteIdentityIoExecutor() {
@Override
public <T> T execute(java.util.function.Supplier<T> operation) {
return operation.get();
}
};
}
@ParameterizedTest
@EnumSource(IdentityCoreMode.class)
void authenticate_preservesPrincipalAcrossLegacyShadowAndActiveModes(IdentityCoreMode mode) {
@ -320,12 +462,20 @@ class OAuthLoginFlowServiceTest {
);
}
private static OAuthClaims claims(String provider, String subject) {
return new OAuthClaims(provider, subject, null, false, subject, Map.of());
}
private static OAuth2UserRequest oauthUserRequest() {
ClientRegistration registration = ClientRegistration.withRegistrationId("github")
return oauthUserRequest("github");
}
private static OAuth2UserRequest oauthUserRequest(String registrationId) {
ClientRegistration registration = ClientRegistration.withRegistrationId(registrationId)
.clientId("client")
.clientSecret("secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("https://skillhub.example/login/oauth2/code/github")
.redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId)
.authorizationUri("https://github.example/oauth/authorize")
.tokenUri("https://github.example/oauth/token")
.userInfoUri("https://github.example/user")