feat(auth): add Feishu OAuth2 login provider

Feishu deviates from standard OAuth2: the authorize endpoint requires
app_id instead of client_id, userinfo returns a {code, msg, data}
envelope with errors reported as HTTP 200, and the token endpoint uses
client_secret_post. Reuse the Spring oauth2Login pipeline and override
only the userinfo loading step via a ProviderOAuth2UserService extension
point, keeping GitHub/GitLab behavior unchanged. Bindings use open_id
as subject; union_id is kept in extra for future cross-app migration.

Signed-off-by: yhd <yhd4711499@live.com>
This commit is contained in:
yhd 2026-08-07 14:19:04 +08:00
parent d2403bb591
commit b4388ee2a5
13 changed files with 537 additions and 8 deletions

View file

@ -112,6 +112,17 @@ OAUTH2_GITLAB_CLIENT_SECRET=
OAUTH2_GITLAB_BASE_URI=https://gitlab.com
OAUTH2_GITLAB_DISPLAY_NAME=GitLab
# Optional: configure Feishu (Lark) OAuth. Create a self-built app (企业自建应用) on the
# Feishu Open Platform, grant the contact:user.base:readonly and contact:user.email:readonly
# scopes, publish a version, and add <base-url>/login/oauth2/code/feishu to the app's
# redirect URLs (安全设置 -> 重定向 URL).
# Note: users without an email are denied when EMAIL_DOMAIN access policy is enabled;
# SUBJECT_WHITELIST entries must use the Feishu open_id (ou_...).
OAUTH2_FEISHU_CLIENT_ID=
OAUTH2_FEISHU_CLIENT_SECRET=
OAUTH2_FEISHU_BASE_URI=https://open.feishu.cn
OAUTH2_FEISHU_DISPLAY_NAME=飞书
# Optional: OIDC login (e.g. Keycloak, Okta, Azure AD).
# Replace "OIDC" in variable names with your registration id (uppercase).
# The registration id becomes identity_binding.provider_code — keep it stable.

View file

@ -279,9 +279,23 @@ spring:
```
Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider 只需:
1. `application.yml` 添加 registration 配置
2. `CustomOAuth2UserService` 中按 `registrationId` 分支处理用户属性映射
3. 前端登录页增加对应按钮(通过 `/api/v1/auth/providers` 自动发现)
1. `application.yml` 添加 registration 配置client-id 默认 `placeholder` 时登录页自动隐藏该入口)
2. 新增一个 `OAuthClaimsExtractor` 实现(`@Component`,按 `registrationId` 自动注册),完成用户属性到标准 claims 的映射
3. 前端无需改动:登录按钮通过 `/api/v1/auth/methods` 自动发现,图标约定 `web/public/{provider}-logo.svg`
### 非标准 Provider 接入样板飞书Feishu
飞书 OAuth 与标准 OAuth2 存在偏差,接入时做了以下定制,可作为后续非标准 Provider 的参考:
1. **授权端点参数**:飞书要求 `app_id` 而非 `client_id`,且不接受 `scope` 参数(权限在开放平台应用内配置)。
`SkillHubOAuth2AuthorizationRequestResolver``feishu` registration 重建授权 URI。
2. **userinfo 响应包裹**:响应为 `{code, msg, data}` 结构且错误以 HTTP 200 返回。
通过 `ProviderOAuth2UserService` 扩展点实现 `FeishuOAuth2UserService`,覆盖默认的 user info 加载并解包 `data`
`OAuthLoginFlowService` 按 registrationId 选择 loader其余 Provider 仍走 `DefaultOAuth2UserService`
3. **token 端点认证**:使用 `client_secret_post`(表单传 client_id/client_secret
4. **subject 选择**:绑定主体使用 `open_id`(应用内唯一);`union_id` 保留在 extra 中,
未来若同一部署接入多个飞书应用可基于它做身份归并。
5. **准入策略注意**邮箱域名策略EMAIL_DOMAIN模式下未绑定邮箱的飞书用户会被拒绝。
## 4. 核心接口设计

View file

@ -69,6 +69,15 @@ spring:
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab}
feishu:
client-id: ${OAUTH2_FEISHU_CLIENT_ID:placeholder}
client-secret: ${OAUTH2_FEISHU_CLIENT_SECRET:placeholder}
# Feishu scopes are configured on the open platform app itself
# (contact:user.base:readonly, contact:user.email:readonly).
authorization-grant-type: authorization_code
client-authentication-method: client_secret_post
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: ${OAUTH2_FEISHU_DISPLAY_NAME:飞书}
provider:
github:
user-info-uri: https://api.github.com/user
@ -77,6 +86,11 @@ spring:
token-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/token
user-info-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/api/v4/user
user-name-attribute: username
feishu:
authorization-uri: ${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v1/authorize
token-uri: ${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v2/oauth/token
user-info-uri: ${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v1/user_info
user-name-attribute: open_id
servlet:
multipart:
max-file-size: 100MB

View file

@ -0,0 +1,58 @@
package com.iflytek.skillhub.auth.oauth;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
/**
* Provider-specific claims extractor for Feishu (Lark) OAuth users. Attributes are already
* unwrapped from the Feishu response envelope by {@link FeishuOAuth2UserService}.
*/
@Component
public class FeishuClaimsExtractor implements OAuthClaimsExtractor {
private static final Logger log = LoggerFactory.getLogger(FeishuClaimsExtractor.class);
@Override
public String getProvider() {
return FeishuOAuth2UserService.PROVIDER;
}
@Override
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) {
Map<String, Object> attrs = oAuth2User.getAttributes();
// open_id is unique within the Feishu app; union_id is kept in extra for potential
// cross-app identity migration later.
String subject = String.valueOf(attrs.get("open_id"));
String email = (String) attrs.get("enterprise_email");
if (email == null) {
email = (String) attrs.get("email");
}
boolean emailVerified = email != null;
String username = (String) attrs.get("name");
if (username == null || username.isBlank()) {
username = (String) attrs.get("en_name");
}
if (username == null || username.isBlank()) {
username = "feishu-" + subject;
}
log.info("Feishu OAuth claims extracted - subject: {}, username: {}, email present: {}",
subject, username, email != null);
return new OAuthClaims(
FeishuOAuth2UserService.PROVIDER,
subject,
email,
emailVerified,
username,
attrs
);
}
}

View file

@ -0,0 +1,127 @@
package com.iflytek.skillhub.auth.oauth;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
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 Feishu (Lark) user info, which deviates from the standard OAuth format: the response is
* wrapped in a {@code {code, msg, data}} envelope and errors are reported with HTTP 200.
*/
@Component
public class FeishuOAuth2UserService implements ProviderOAuth2UserService {
static final String PROVIDER = "feishu";
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 Feishu service.
*/
@Autowired
public FeishuOAuth2UserService() {
this(RestClient.builder());
}
public FeishuOAuth2UserService(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
}
@Override
public String getProvider() {
return PROVIDER;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
String userInfoUri = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUri();
FeishuUserResponse response;
try {
response = restClient.get()
.uri(userInfoUri)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userRequest.getAccessToken().getTokenValue())
.retrieve()
.body(new ParameterizedTypeReference<FeishuUserResponse>() {});
} catch (Exception e) {
throw new OAuth2AuthenticationException(
new OAuth2Error("feishu_userinfo_error", "Failed to load Feishu user info: " + e.getMessage(), null),
e
);
}
if (response == null || response.code() != 0 || response.data() == null) {
String msg = response != null ? response.msg() : "empty response";
throw new OAuth2AuthenticationException(
new OAuth2Error("feishu_userinfo_error", "Feishu user info error: " + msg, null)
);
}
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
Map<String, Object> attributes = flatten(response.data(), userNameAttributeName);
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
attributes,
userNameAttributeName
);
}
private Map<String, Object> flatten(FeishuUserData data, String userNameAttributeName) {
Map<String, Object> attributes = new LinkedHashMap<>();
putIfPresent(attributes, "open_id", data.openId());
putIfPresent(attributes, "union_id", data.unionId());
putIfPresent(attributes, "name", data.name());
putIfPresent(attributes, "en_name", data.enName());
putIfPresent(attributes, "avatar_url", data.avatarUrl());
putIfPresent(attributes, "email", data.email());
putIfPresent(attributes, "enterprise_email", data.enterpriseEmail());
putIfPresent(attributes, "mobile", data.mobile());
if (!attributes.containsKey(userNameAttributeName)) {
throw new OAuth2AuthenticationException(
new OAuth2Error("feishu_userinfo_error", "Feishu user info missing " + userNameAttributeName, null)
);
}
return attributes;
}
private void putIfPresent(Map<String, Object> attributes, String key, String value) {
if (value != null && !value.isBlank()) {
attributes.put(key, value);
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
record FeishuUserResponse(int code, String msg, @JsonProperty("data") FeishuUserData data) {}
@JsonIgnoreProperties(ignoreUnknown = true)
record FeishuUserData(
@JsonProperty("open_id") String openId,
@JsonProperty("union_id") String unionId,
@JsonProperty("name") String name,
@JsonProperty("en_name") String enName,
@JsonProperty("avatar_url") String avatarUrl,
@JsonProperty("email") String email,
@JsonProperty("enterprise_email") String enterpriseEmail,
@JsonProperty("mobile") String mobile
) {}
}

View file

@ -16,6 +16,7 @@ import java.util.stream.Collectors;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.OAuth2User;
@ -29,23 +30,31 @@ import org.springframework.stereotype.Service;
@Service
public class OAuthLoginFlowService {
private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
private final OAuth2UserService<OAuth2UserRequest, OAuth2User> defaultUserService = new DefaultOAuth2UserService();
private final Map<String, OAuthClaimsExtractor> extractors;
private final Map<String, ProviderOAuth2UserService> userServiceOverrides;
private final AccessPolicy accessPolicy;
private final IdentityBindingService identityBindingService;
public OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
List<ProviderOAuth2UserService> userServiceList,
AccessPolicy accessPolicy,
IdentityBindingService identityBindingService) {
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;
}
public AuthenticatedLoginContext loadLoginContext(OAuth2UserRequest request) {
OAuth2User upstreamUser = delegate.loadUser(request);
String registrationId = request.getClientRegistration().getRegistrationId();
OAuth2UserService<OAuth2UserRequest, OAuth2User> userService = userServiceOverrides.get(registrationId);
if (userService == null) {
userService = defaultUserService;
}
OAuth2User upstreamUser = userService.loadUser(request);
OAuthClaimsExtractor extractor = extractors.get(registrationId);
if (extractor == null) {

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

@ -5,6 +5,7 @@ import org.springframework.security.oauth2.client.registration.ClientRegistratio
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
/**
* OAuth2 authorization request resolver that preserves a sanitized post-login redirect target in
@ -14,6 +15,9 @@ import org.springframework.stereotype.Component;
public class SkillHubOAuth2AuthorizationRequestResolver
implements org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver {
private static final String FEISHU_REGISTRATION_ID = "feishu";
private static final String AUTHORIZATION_BASE_PATH = "/oauth2/authorization/";
private final DefaultOAuth2AuthorizationRequestResolver delegate;
private final OAuthLoginFlowService oauthLoginFlowService;
@ -30,13 +34,51 @@ public class SkillHubOAuth2AuthorizationRequestResolver
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request);
oauthLoginFlowService.rememberReturnTo(request);
return authorizationRequest;
return customizeFeishu(authorizationRequest, registrationIdFrom(request));
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request, clientRegistrationId);
oauthLoginFlowService.rememberReturnTo(request);
return authorizationRequest;
return customizeFeishu(authorizationRequest, clientRegistrationId);
}
private String registrationIdFrom(HttpServletRequest request) {
String uri = request.getRequestURI();
int index = uri.indexOf(AUTHORIZATION_BASE_PATH);
if (index < 0) {
return null;
}
return uri.substring(index + AUTHORIZATION_BASE_PATH.length());
}
/**
* Feishu's authorize endpoint identifies the client with {@code app_id} rather than
* {@code client_id}, and scopes are controlled by the app's permission configuration rather
* than a {@code scope} request parameter.
*/
private OAuth2AuthorizationRequest customizeFeishu(OAuth2AuthorizationRequest authorizationRequest,
String registrationId) {
if (authorizationRequest == null || !FEISHU_REGISTRATION_ID.equals(registrationId)) {
return authorizationRequest;
}
String authorizationUri = UriComponentsBuilder
.fromUriString(authorizationRequest.getAuthorizationUri())
.queryParam("app_id", authorizationRequest.getClientId())
.queryParam("redirect_uri", authorizationRequest.getRedirectUri())
.queryParam("response_type", "code")
.queryParam("state", authorizationRequest.getState())
.build()
.toUriString();
return OAuth2AuthorizationRequest.authorizationCode()
.authorizationUri(authorizationRequest.getAuthorizationUri())
.clientId(authorizationRequest.getClientId())
.redirectUri(authorizationRequest.getRedirectUri())
.scopes(authorizationRequest.getScopes())
.state(authorizationRequest.getState())
.attributes(attributes -> attributes.putAll(authorizationRequest.getAttributes()))
.authorizationRequestUri(authorizationUri)
.build();
}
}

View file

@ -0,0 +1,87 @@
package com.iflytek.skillhub.auth.oauth;
import static org.assertj.core.api.Assertions.assertThat;
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.user.DefaultOAuth2User;
class FeishuClaimsExtractorTest {
private final FeishuClaimsExtractor extractor = new FeishuClaimsExtractor();
@Test
void extract_prefersEnterpriseEmailOverPersonalEmail() {
Map<String, Object> attrs = new HashMap<>(Map.of(
"open_id", "ou_123",
"name", "张三",
"email", "zhangsan@personal.example",
"enterprise_email", "zhangsan@corp.example"
));
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
assertThat(claims.provider()).isEqualTo("feishu");
assertThat(claims.subject()).isEqualTo("ou_123");
assertThat(claims.email()).isEqualTo("zhangsan@corp.example");
assertThat(claims.emailVerified()).isTrue();
assertThat(claims.providerLogin()).isEqualTo("张三");
}
@Test
void extract_allowsNullEmailAndFallsBackUsername() {
Map<String, Object> attrs = new HashMap<>(Map.of("open_id", "ou_456"));
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
assertThat(claims.subject()).isEqualTo("ou_456");
assertThat(claims.email()).isNull();
assertThat(claims.emailVerified()).isFalse();
assertThat(claims.providerLogin()).isEqualTo("feishu-ou_456");
}
@Test
void extract_fallsBackToEnglishNameWhenChineseNameBlank() {
Map<String, Object> attrs = new HashMap<>(Map.of(
"open_id", "ou_789",
"en_name", "Alice"
));
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
assertThat(claims.providerLogin()).isEqualTo("Alice");
}
private DefaultOAuth2User user(Map<String, Object> attrs) {
return new DefaultOAuth2User(java.util.List.of(), attrs, "open_id");
}
private OAuth2UserRequest userRequest() {
ClientRegistration registration = ClientRegistration.withRegistrationId("feishu")
.clientId("cli_test123")
.clientSecret("client-secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.authorizationUri("https://open.feishu.cn/open-apis/authen/v1/authorize")
.tokenUri("https://open.feishu.cn/open-apis/authen/v2/oauth/token")
.userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info")
.userNameAttributeName("open_id")
.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,105 @@
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.HttpHeaders;
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 FeishuOAuth2UserServiceTest {
@Test
void loadUser_unwrapsFeishuEnvelopeIntoFlatAttributes() {
RestClient.Builder restClientBuilder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info"))
.andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123"))
.andRespond(withSuccess(
"""
{
"code": 0,
"msg": "success",
"data": {
"open_id": "ou_123",
"union_id": "on_456",
"name": "张三",
"avatar_url": "https://avatar.example/zhangsan.png",
"enterprise_email": "zhangsan@corp.example",
"email": "zhangsan@personal.example"
}
}
""",
MediaType.APPLICATION_JSON
));
FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
OAuth2User user = service.loadUser(userRequest());
assertThat(user.getName()).isEqualTo("ou_123");
assertThat(user.getAttributes())
.containsEntry("open_id", "ou_123")
.containsEntry("union_id", "on_456")
.containsEntry("name", "张三")
.containsEntry("avatar_url", "https://avatar.example/zhangsan.png")
.containsEntry("enterprise_email", "zhangsan@corp.example")
.doesNotContainKey("code")
.doesNotContainKey("data");
server.verify();
}
@Test
void loadUser_throwsWhenFeishuReportsErrorCode() {
RestClient.Builder restClientBuilder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info"))
.andRespond(withSuccess(
"""
{"code": 99991663, "msg": "invalid access token"}
""",
MediaType.APPLICATION_JSON
));
FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
assertThatThrownBy(() -> service.loadUser(userRequest()))
.isInstanceOf(OAuth2AuthenticationException.class)
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode())
.isEqualTo("feishu_userinfo_error"));
server.verify();
}
private OAuth2UserRequest userRequest() {
ClientRegistration registration = ClientRegistration.withRegistrationId("feishu")
.clientId("cli_test123")
.clientSecret("client-secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.authorizationUri("https://open.feishu.cn/open-apis/authen/v1/authorize")
.tokenUri("https://open.feishu.cn/open-apis/authen/v2/oauth/token")
.userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info")
.userNameAttributeName("open_id")
.clientName("飞书")
.build();
OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
"token-123",
Instant.now(),
Instant.now().plusSeconds(3600)
);
return new OAuth2UserRequest(registration, accessToken);
}
}

View file

@ -30,13 +30,26 @@ class OAuth2AuthorizationRequestResolverTest {
.scope("read:user")
.clientName("GitHub")
.build();
ClientRegistration feishu = ClientRegistration.withRegistrationId("feishu")
.clientId("cli_test123")
.clientSecret("secret")
.authorizationUri("https://open.feishu.cn/open-apis/authen/v1/authorize")
.tokenUri("https://open.feishu.cn/open-apis/authen/v2/oauth/token")
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info")
.userNameAttributeName("open_id")
.authorizationGrantType(org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE)
.clientAuthenticationMethod(org.springframework.security.oauth2.core.ClientAuthenticationMethod.CLIENT_SECRET_POST)
.clientName("飞书")
.build();
OAuthLoginFlowService oauthLoginFlowService = new OAuthLoginFlowService(
java.util.List.of(),
java.util.List.of(),
mock(AccessPolicy.class),
mock(IdentityBindingService.class)
);
resolver = new SkillHubOAuth2AuthorizationRequestResolver(
new InMemoryClientRegistrationRepository(github),
new InMemoryClientRegistrationRepository(github, feishu),
oauthLoginFlowService
);
}
@ -65,4 +78,32 @@ class OAuth2AuthorizationRequestResolverTest {
assertThat(session).isNotNull();
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
}
@Test
void resolve_feishu_usesAppIdInsteadOfClientId() {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/feishu");
var authorizationRequest = resolver.resolve(request, "feishu");
assertThat(authorizationRequest).isNotNull();
String uri = authorizationRequest.getAuthorizationRequestUri();
assertThat(uri).contains("app_id=cli_test123");
assertThat(uri).contains("response_type=code");
assertThat(uri).contains("state=");
assertThat(uri).doesNotContain("client_id=");
assertThat(uri).doesNotContain("scope=");
}
@Test
void resolve_github_keepsStandardParameters() {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github");
var authorizationRequest = resolver.resolve(request, "github");
assertThat(authorizationRequest).isNotNull();
String uri = authorizationRequest.getAuthorizationRequestUri();
assertThat(uri).contains("client_id=client");
assertThat(uri).contains("scope=read:user");
assertThat(uri).doesNotContain("app_id=");
}
}

View file

@ -17,6 +17,7 @@ class OAuthLoginFlowServiceTest {
@Test
void rememberReturnTo_stores_sanitized_return_target() {
OAuthLoginFlowService service = new OAuthLoginFlowService(
List.of(),
List.of(),
mock(AccessPolicy.class),
mock(IdentityBindingService.class)
@ -35,6 +36,7 @@ class OAuthLoginFlowServiceTest {
@Test
void resolveFailureRedirect_maps_access_denied_to_user_facing_page() {
OAuthLoginFlowService service = new OAuthLoginFlowService(
List.of(),
List.of(),
mock(AccessPolicy.class),
mock(IdentityBindingService.class)
@ -51,6 +53,7 @@ class OAuthLoginFlowServiceTest {
@Test
void consumeReturnTo_clearsUnsafeSessionValue() {
OAuthLoginFlowService service = new OAuthLoginFlowService(
List.of(),
List.of(),
mock(AccessPolicy.class),
mock(IdentityBindingService.class)

View file

@ -0,0 +1,4 @@
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none">
<path fill="#3370FF" d="M21.2 7.9c-1.6-2.6-4.7-3.9-7.7-3.2L4.6 6.7c-.5.1-.6.7-.2 1l3.5 2.7-3 1.7c-.4.2-.4.8 0 1l4.4 2.4c.7.4 1.5.5 2.3.3l7.6-1.7c.8-.2 1.4-.8 1.6-1.6.2-1 .1-2.1.4-4.6z"/>
<path fill="#7EA6FF" d="M13 13.2l8 1.4c-.5 2.6-2.6 4.6-5.2 4.9l-5 .5c-.4 0-.6-.5-.3-.8l2.5-6z"/>
</svg>

After

Width:  |  Height:  |  Size: 397 B