From f29ac241fca305c01ad632b9b5d5688ff652205a Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:12:48 +0800 Subject: [PATCH] feat(auth): add Feishu as a public login provider Adds Feishu (Lark) as a public sign-in option: it authenticates a SkillHub platform account and nothing more. No Organization membership, no directory sync, no Namespace grants. Feishu deviates from standard OAuth in two ways this handles: its userinfo response is wrapped in a {code, msg, data} envelope, and it reports errors with HTTP 200. FeishuOAuth2UserService unwraps that envelope into flat attributes; FeishuClaimsExtractor maps them to the shared OAuthClaims, so account decisions still run through the unified identity core added in R1-A. Subject and email semantics, which decide whether a login can reach an existing account: - open_id is the only subject. union_id stays in extra rather than acting as a fallback: a subject that can change between logins would split one person across two platform accounts. Promoting union_id later needs an explicit alias migration. - A blank or missing open_id fails the login instead of binding the literal string "null". - emailVerified is always false. Feishu emails are imported by an organization admin and never confirmed with the user, so they carry no verification signal and cannot be used to join an existing account. Operational bounds: the userinfo call has connect and read timeouts so an unresponsive Feishu endpoint cannot hold a login thread, and the OAuth2Error description carries only the provider error code, because an upstream message can quote the request URI and with it the access token. Like the GitHub and GitLab extractors, the claims extractor logs nothing. The login button follows the existing config-driven catalog: with no client id configured, /api/v1/auth/methods does not list Feishu and no button renders. No frontend code change is needed; the icon resolves by provider name. Adapted from the implementation in #696 by @yhd4711499, re-extracted onto current main with the subject, logging and timeout changes above. Part of R1-A2 (public Provider adapters) per openspec/changes/enterprise-identity-platform/rollout-plan.md. Co-authored-by: yhd4711499 Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .env.release.example | 11 ++ .../src/main/resources/application.yml | 14 ++ .../auth/oauth/FeishuClaimsExtractor.java | 71 ++++++++ .../auth/oauth/FeishuOAuth2UserService.java | 149 +++++++++++++++++ .../auth/oauth/FeishuClaimsExtractorTest.java | 151 ++++++++++++++++++ .../oauth/FeishuOAuth2UserServiceTest.java | 130 +++++++++++++++ web/public/feishu-logo.svg | 2 + 7 files changed, 528 insertions(+) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java create mode 100644 web/public/feishu-logo.svg 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 + 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