fix(auth): align Feishu OAuth with current official protocol docs

Addresses review on PR #696:

1. The authorize step now uses the currently documented endpoint
   https://accounts.feishu.cn/open-apis/authen/v1/authorize with standard
   OAuth2 parameters (client_id, optional scope), so the legacy
   app_id/no-scope URI rewrite in SkillHubOAuth2AuthorizationRequestResolver
   is removed. The authorize host is overridable via
   OAUTH2_FEISHU_AUTHORIZE_URI; token and userinfo endpoints stay on
   OAUTH2_FEISHU_BASE_URI.

2. FeishuClaimsExtractor no longer claims emailVerified: Feishu emails are
   admin-imported and carry no real-time verification signal, so
   emailVerified is always false. EMAIL_DOMAIN policy matches the email
   domain only and is unaffected.

Signed-off-by: yhd <yhd4711499@live.com>
This commit is contained in:
yhd 2026-08-11 20:26:51 +08:00
parent 7164822485
commit e192b94d19
7 changed files with 24 additions and 55 deletions

View file

@ -121,6 +121,8 @@ OAUTH2_GITLAB_DISPLAY_NAME=GitLab
OAUTH2_FEISHU_CLIENT_ID=
OAUTH2_FEISHU_CLIENT_SECRET=
OAUTH2_FEISHU_BASE_URI=https://open.feishu.cn
# Host of the OAuth authorize (consent) page; override for Lark/international deployments.
OAUTH2_FEISHU_AUTHORIZE_URI=https://accounts.feishu.cn
OAUTH2_FEISHU_DISPLAY_NAME=飞书
# Optional: OIDC login (e.g. Keycloak, Okta, Azure AD).

View file

@ -287,8 +287,11 @@ Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider
飞书 OAuth 与标准 OAuth2 存在偏差,接入时做了以下定制,可作为后续非标准 Provider 的参考:
1. **授权端点参数**:飞书要求 `app_id` 而非 `client_id`,且不接受 `scope` 参数(权限在开放平台应用内配置)。
`SkillHubOAuth2AuthorizationRequestResolver``feishu` registration 重建授权 URI。
1. **授权端点**:使用官方当前文档的标准 OAuth2 授权端点
`https://accounts.feishu.cn/open-apis/authen/v1/authorize``client_id` + 可选 `scope`
权限在开放平台应用内配置),授权请求由 Spring Security 默认 resolver 构建,
host 可用 `OAUTH2_FEISHU_AUTHORIZE_URI` 覆盖token / userinfo 端点仍在 `open.feishu.cn`
`OAUTH2_FEISHU_BASE_URI` 覆盖)。
2. **userinfo 响应包裹**:响应为 `{code, msg, data}` 结构且错误以 HTTP 200 返回。
通过 `ProviderOAuth2UserService` 扩展点实现 `FeishuOAuth2UserService`,覆盖默认的 user info 加载并解包 `data`
`OAuthLoginFlowService` 按 registrationId 选择 loader其余 Provider 仍走 `DefaultOAuth2UserService`
@ -296,6 +299,8 @@ Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider
4. **subject 选择**:绑定主体使用 `open_id`(应用内唯一);`union_id` 保留在 extra 中,
未来若同一部署接入多个飞书应用可基于它做身份归并。
5. **准入策略注意**邮箱域名策略EMAIL_DOMAIN模式下未绑定邮箱的飞书用户会被拒绝。
6. **email_verified 语义**:飞书 user-info 返回的邮箱由组织管理员导入,无实时验证信号,
`FeishuClaimsExtractor` 恒置 `emailVerified=false`EMAIL_DOMAIN 策略仅匹配邮箱域名,不依赖该标志。
## 4. 核心接口设计

View file

@ -87,7 +87,7 @@ spring:
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
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

View file

@ -33,7 +33,9 @@ public class FeishuClaimsExtractor implements OAuthClaimsExtractor {
if (email == null) {
email = (String) attrs.get("email");
}
boolean emailVerified = email != null;
// 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()) {

View file

@ -5,19 +5,16 @@ 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
* the HTTP session.
* the HTTP session. Authorization URIs are taken verbatim from the client registration; Feishu's
* current authorize endpoint accepts standard OAuth2 parameters (client_id, optional scope).
*/
@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;
@ -34,51 +31,13 @@ public class SkillHubOAuth2AuthorizationRequestResolver
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request);
oauthLoginFlowService.rememberReturnTo(request);
return customizeFeishu(authorizationRequest, registrationIdFrom(request));
return authorizationRequest;
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request, clientRegistrationId);
oauthLoginFlowService.rememberReturnTo(request);
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();
return authorizationRequest;
}
}

View file

@ -31,7 +31,8 @@ class FeishuClaimsExtractorTest {
assertThat(claims.provider()).isEqualTo("feishu");
assertThat(claims.subject()).isEqualTo("ou_123");
assertThat(claims.email()).isEqualTo("zhangsan@corp.example");
assertThat(claims.emailVerified()).isTrue();
// Feishu emails are admin-imported; the extractor must not claim verification.
assertThat(claims.emailVerified()).isFalse();
assertThat(claims.providerLogin()).isEqualTo("张三");
}

View file

@ -33,7 +33,7 @@ class OAuth2AuthorizationRequestResolverTest {
ClientRegistration feishu = ClientRegistration.withRegistrationId("feishu")
.clientId("cli_test123")
.clientSecret("secret")
.authorizationUri("https://open.feishu.cn/open-apis/authen/v1/authorize")
.authorizationUri("https://accounts.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")
@ -80,18 +80,18 @@ class OAuth2AuthorizationRequestResolverTest {
}
@Test
void resolve_feishu_usesAppIdInsteadOfClientId() {
void resolve_feishu_usesStandardOAuth2Parameters() {
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).startsWith("https://accounts.feishu.cn/open-apis/authen/v1/authorize");
assertThat(uri).contains("client_id=cli_test123");
assertThat(uri).contains("response_type=code");
assertThat(uri).contains("state=");
assertThat(uri).doesNotContain("client_id=");
assertThat(uri).doesNotContain("scope=");
assertThat(uri).doesNotContain("app_id=");
}
@Test