diff --git a/.env.release.example b/.env.release.example
index c5ac104a..f11c0eb2 100644
--- a/.env.release.example
+++ b/.env.release.example
@@ -117,6 +117,17 @@ OAUTH2_GITLAB_CLIENT_SECRET=
OAUTH2_GITLAB_BASE_URI=https://gitlab.com
OAUTH2_GITLAB_DISPLAY_NAME=GitLab
+# Optional: Feishu (Lark) login as a public sign-in provider. Leaving the client id empty keeps
+# the button off the login page. Grant contact:user.base:readonly and
+# contact:user.email:readonly on the Feishu open-platform app itself; scopes are not sent here.
+# Set OAUTH2_FEISHU_AUTHORIZE_URI/OAUTH2_FEISHU_BASE_URI to the Lark endpoints for
+# international tenants (open.larksuite.com).
+OAUTH2_FEISHU_CLIENT_ID=
+OAUTH2_FEISHU_CLIENT_SECRET=
+OAUTH2_FEISHU_AUTHORIZE_URI=https://accounts.feishu.cn
+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.
diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml
index c5507532..023f3624 100644
--- a/server/skillhub-app/src/main/resources/application.yml
+++ b/server/skillhub-app/src/main/resources/application.yml
@@ -70,6 +70,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:
api-base-url: ${OAUTH2_GITHUB_API_BASE_URL:https://api.github.com}
@@ -79,6 +88,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_AUTHORIZE_URI:https://accounts.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
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java
new file mode 100644
index 00000000..a2ad65e9
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java
@@ -0,0 +1,71 @@
+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 Feishu (Lark) OAuth users. Attributes are already
+ * unwrapped from the Feishu response envelope by {@link FeishuOAuth2UserService}.
+ *
+ *
Like the GitHub and GitLab 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 FeishuClaimsExtractor implements OAuthClaimsExtractor {
+
+ @Override
+ public String getProvider() {
+ return FeishuOAuth2UserService.PROVIDER;
+ }
+
+ @Override
+ public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) {
+ Map attrs = oAuth2User.getAttributes();
+
+ // open_id is the stable primary subject: unique per user within one Feishu app, and it is
+ // what Feishu guarantees to keep across logins. union_id stays in extra rather than acting
+ // as a fallback -- a subject that can silently change identity between logins would bind
+ // the same person to two platform accounts. Promoting union_id later needs an explicit
+ // alias migration, not a fallback here.
+ String subject = requireText(attrs.get("open_id"), "open_id");
+
+ String email = (String) attrs.get("enterprise_email");
+ if (email == null) {
+ email = (String) attrs.get("email");
+ }
+ // Feishu emails are imported by the organization admin and not verified with the user
+ // in real time, so they carry no verification signal; keep emailVerified false.
+ boolean emailVerified = false;
+
+ 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;
+ }
+
+ return new OAuthClaims(
+ FeishuOAuth2UserService.PROVIDER,
+ subject,
+ email,
+ emailVerified,
+ username,
+ attrs
+ );
+ }
+
+ private static String requireText(Object value, String attribute) {
+ String text = value == null ? null : String.valueOf(value).trim();
+ if (text == null || text.isEmpty()) {
+ throw new OAuth2AuthenticationException(
+ new OAuth2Error("missing_subject", "Feishu user info is missing " + attribute, null)
+ );
+ }
+ return text;
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java
new file mode 100644
index 00000000..d866ac41
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java
@@ -0,0 +1,149 @@
+package com.iflytek.skillhub.auth.oauth;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.Duration;
+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.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 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;
+
+ private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
+ private static final Duration READ_TIMEOUT = Duration.ofSeconds(10);
+
+ /**
+ * 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().requestFactory(defaultRequestFactory()));
+ }
+
+ public FeishuOAuth2UserService(RestClient.Builder restClientBuilder) {
+ this.restClient = restClientBuilder
+ .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
+ .build();
+ }
+
+ /**
+ * Bounds the userinfo call so an unresponsive Feishu 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 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() {});
+ } catch (Exception e) {
+ // The cause carries the detail for operators; the OAuth2Error description stays generic
+ // because an upstream message can quote the request URI, which holds the access token.
+ throw new OAuth2AuthenticationException(
+ new OAuth2Error("feishu_userinfo_error", "Failed to load Feishu user info", null),
+ e
+ );
+ }
+
+ if (response == null || response.code() != 0 || response.data() == null) {
+ throw new OAuth2AuthenticationException(
+ new OAuth2Error(
+ "feishu_userinfo_error",
+ "Feishu user info error, code " + (response == null ? "none" : response.code()),
+ null
+ )
+ );
+ }
+
+ String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
+ .getUserInfoEndpoint().getUserNameAttributeName();
+
+ Map attributes = flatten(response.data(), userNameAttributeName);
+ return new DefaultOAuth2User(
+ Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
+ attributes,
+ userNameAttributeName
+ );
+ }
+
+ private Map flatten(FeishuUserData data, String userNameAttributeName) {
+ Map 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 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
+ ) {}
+}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java
new file mode 100644
index 00000000..9b70fd72
--- /dev/null
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java
@@ -0,0 +1,151 @@
+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.DefaultOAuth2User;
+import org.springframework.security.oauth2.core.user.OAuth2User;
+
+class FeishuClaimsExtractorTest {
+
+ private final FeishuClaimsExtractor extractor = new FeishuClaimsExtractor();
+
+ @Test
+ void extract_prefersEnterpriseEmailOverPersonalEmail() {
+ Map 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");
+ // Feishu emails are admin-imported; the extractor must not claim verification.
+ assertThat(claims.emailVerified()).isFalse();
+ assertThat(claims.providerLogin()).isEqualTo("张三");
+ }
+
+ @Test
+ void extract_allowsNullEmailAndFallsBackUsername() {
+ Map 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 attrs = new HashMap<>(Map.of(
+ "open_id", "ou_789",
+ "en_name", "Alice"
+ ));
+
+ OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
+
+ assertThat(claims.providerLogin()).isEqualTo("Alice");
+ }
+
+ @Test
+ void extract_rejectsBlankOpenId() {
+ // Blank must fail rather than become a subject. DefaultOAuth2User already rejects a
+ // wholly absent open_id, so a permissive OAuth2User is used to test this contract
+ // directly instead of relying on that upstream guard.
+ Map attrs = new HashMap<>();
+ attrs.put("open_id", " ");
+ attrs.put("name", "张三");
+
+ assertThatThrownBy(() -> extractor.extract(userRequest(), permissiveUser(attrs)))
+ .isInstanceOf(OAuth2AuthenticationException.class)
+ .hasMessageContaining("open_id");
+ }
+
+ @Test
+ void extract_rejectsMissingOpenIdWithoutFabricatingASubject() {
+ Map attrs = new HashMap<>();
+ attrs.put("name", "张三");
+
+ assertThatThrownBy(() -> extractor.extract(userRequest(), permissiveUser(attrs)))
+ .isInstanceOf(OAuth2AuthenticationException.class)
+ .hasMessageContaining("open_id");
+ }
+
+ /** An {@link OAuth2User} that does not enforce the name attribute, unlike DefaultOAuth2User. */
+ private OAuth2User permissiveUser(Map attrs) {
+ return new OAuth2User() {
+ @Override
+ public Map 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("open_id"));
+ }
+ };
+ }
+
+ @Test
+ void extract_doesNotPromoteUnionIdToSubject() {
+ // union_id stays in extra: a subject that can change between logins would split one
+ // person across two platform accounts.
+ Map attrs = new HashMap<>(Map.of(
+ "open_id", "ou_abc",
+ "union_id", "on_xyz"
+ ));
+
+ OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
+
+ assertThat(claims.subject()).isEqualTo("ou_abc");
+ assertThat(claims.extra()).containsEntry("union_id", "on_xyz");
+ }
+
+ private DefaultOAuth2User user(Map 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://accounts.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);
+ }
+}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java
new file mode 100644
index 00000000..ab9c5ff1
--- /dev/null
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java
@@ -0,0 +1,130 @@
+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();
+ }
+
+ @Test
+ void loadUser_errorDescriptionDoesNotEchoUpstreamTextOrToken() {
+ 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": "token token-123 rejected for cli_test123"}
+ """,
+ MediaType.APPLICATION_JSON
+ ));
+ FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
+
+ assertThatThrownBy(() -> service.loadUser(userRequest()))
+ .isInstanceOf(OAuth2AuthenticationException.class)
+ .satisfies(ex -> {
+ String description = ((OAuth2AuthenticationException) ex).getError().getDescription();
+ // The upstream message can quote the access token; only the code may surface.
+ assertThat(description).doesNotContain("token-123");
+ assertThat(description).doesNotContain("rejected");
+ assertThat(description).contains("99991663");
+ });
+ 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://accounts.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);
+ }
+}
diff --git a/web/public/feishu-logo.svg b/web/public/feishu-logo.svg
new file mode 100644
index 00000000..0cb86de7
--- /dev/null
+++ b/web/public/feishu-logo.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file