fix(auth): use JSON token exchange for Feishu OAuth

Made-with: Proma
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-18 18:07:21 +08:00
parent dd82c8a62c
commit 2100996963
16 changed files with 759 additions and 14 deletions

View file

@ -120,16 +120,20 @@ 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).
# Full Feishu endpoints are configurable for Lark international, private deployments, and gateways.
# Legacy OAUTH2_FEISHU_AUTHORIZE_URI/OAUTH2_FEISHU_BASE_URI remain supported as base-URI fallbacks.
# The token endpoint must accept Feishu's JSON authorization-code exchange contract. Supported
# token protocols are v2 and v3; v3 is the default. Selection is explicit and never falls back.
# Feishu emails are admin-imported and never confirmed with the user, so emailVerified is always
# false. If you set skillhub.access-policy.mode=EMAIL_DOMAIN in application.yml, that policy
# denies every unverified email and Feishu login will always fail; keep the default OPEN mode,
# or use another policy, when enabling this provider.
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_AUTHORIZATION_URI=https://accounts.feishu.cn/open-apis/authen/v1/authorize
OAUTH2_FEISHU_PROTOCOL_VERSION=v3
OAUTH2_FEISHU_TOKEN_URI=https://accounts.feishu.cn/oauth/v3/token
OAUTH2_FEISHU_USER_INFO_URI=https://open.feishu.cn/open-apis/authen/v1/user_info
OAUTH2_FEISHU_DISPLAY_NAME=飞书
# Optional: OIDC login (e.g. Keycloak, Okta, Azure AD).

View file

@ -368,6 +368,14 @@ spec:
name: {{ include "skillhub.secretName" . }}
key: oauth2-feishu-client-secret
optional: true
- name: OAUTH2_FEISHU_AUTHORIZATION_URI
value: {{ .Values.oauth2.feishu.authorizationUri | default "https://accounts.feishu.cn/open-apis/authen/v1/authorize" | quote }}
- name: OAUTH2_FEISHU_PROTOCOL_VERSION
value: {{ .Values.oauth2.feishu.protocolVersion | default "v3" | quote }}
- name: OAUTH2_FEISHU_TOKEN_URI
value: {{ .Values.oauth2.feishu.tokenUri | default "https://accounts.feishu.cn/oauth/v3/token" | quote }}
- name: OAUTH2_FEISHU_USER_INFO_URI
value: {{ .Values.oauth2.feishu.userInfoUri | default "https://open.feishu.cn/open-apis/authen/v1/user_info" | quote }}
{{- if .Values.server.javaOpts }}
- name: JAVA_OPTS

View file

@ -34,6 +34,24 @@
}
}
},
"oauth2": {
"type": "object",
"additionalProperties": false,
"required": ["feishu"],
"properties": {
"feishu": {
"type": "object",
"additionalProperties": false,
"required": ["protocolVersion", "tokenUri"],
"properties": {
"authorizationUri": { "type": "string", "format": "uri" },
"protocolVersion": { "type": "string", "enum": ["v2", "v3"] },
"tokenUri": { "type": "string", "format": "uri" },
"userInfoUri": { "type": "string", "format": "uri" }
}
}
}
},
"builtinSkills": {
"type": "object",
"additionalProperties": false,
@ -156,6 +174,8 @@
"downloadAnonCookieSecret": { "type": "string" },
"oauth2GithubClientId": { "type": "string" },
"oauth2GithubClientSecret": { "type": "string" },
"oauth2FeishuClientId": { "type": "string" },
"oauth2FeishuClientSecret": { "type": "string" },
"scannerLlmApiKey": { "type": "string" },
"scannerLlmBaseUrl": { "type": "string" },
"scannerLlmModel": { "type": "string" }

View file

@ -22,6 +22,13 @@ auth:
enabled: true
provider: local
oauth2:
feishu:
authorizationUri: https://accounts.feishu.cn/open-apis/authen/v1/authorize
protocolVersion: v3
tokenUri: https://accounts.feishu.cn/oauth/v3/token
userInfoUri: https://open.feishu.cn/open-apis/authen/v1/user_info
builtinSkills:
enabled: true

View file

@ -121,8 +121,10 @@ services:
OAUTH2_GITLAB_DISPLAY_NAME: ${OAUTH2_GITLAB_DISPLAY_NAME:-GitLab}
OAUTH2_FEISHU_CLIENT_ID: ${OAUTH2_FEISHU_CLIENT_ID:-local-placeholder}
OAUTH2_FEISHU_CLIENT_SECRET: ${OAUTH2_FEISHU_CLIENT_SECRET:-local-placeholder}
OAUTH2_FEISHU_AUTHORIZE_URI: ${OAUTH2_FEISHU_AUTHORIZE_URI:-https://accounts.feishu.cn}
OAUTH2_FEISHU_BASE_URI: ${OAUTH2_FEISHU_BASE_URI:-https://open.feishu.cn}
OAUTH2_FEISHU_AUTHORIZATION_URI: ${OAUTH2_FEISHU_AUTHORIZATION_URI:-${OAUTH2_FEISHU_AUTHORIZE_URI:-https://accounts.feishu.cn}/open-apis/authen/v1/authorize}
OAUTH2_FEISHU_PROTOCOL_VERSION: ${OAUTH2_FEISHU_PROTOCOL_VERSION:-v3}
OAUTH2_FEISHU_TOKEN_URI: ${OAUTH2_FEISHU_TOKEN_URI:-https://accounts.feishu.cn/oauth/v3/token}
OAUTH2_FEISHU_USER_INFO_URI: ${OAUTH2_FEISHU_USER_INFO_URI:-${OAUTH2_FEISHU_BASE_URI:-https://open.feishu.cn}/open-apis/authen/v1/user_info}
OAUTH2_FEISHU_DISPLAY_NAME: ${OAUTH2_FEISHU_DISPLAY_NAME:-飞书}
SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-}
SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-25}

View file

@ -240,6 +240,14 @@ spec:
name: skillhub-secret
key: oauth2-feishu-client-secret
optional: true
- name: OAUTH2_FEISHU_AUTHORIZATION_URI
value: "https://accounts.feishu.cn/open-apis/authen/v1/authorize"
- name: OAUTH2_FEISHU_PROTOCOL_VERSION
value: "v3"
- name: OAUTH2_FEISHU_TOKEN_URI
value: "https://accounts.feishu.cn/oauth/v3/token"
- name: OAUTH2_FEISHU_USER_INFO_URI
value: "https://open.feishu.cn/open-apis/authen/v1/user_info"
volumeMounts:
- name: skillhub-storage

View file

@ -276,11 +276,19 @@ spring:
client-secret: ${OAUTH2_GITLAB_CLIENT_SECRET}
authorization-grant-type: authorization_code
feishu:
provider: feishu
client-id: ${OAUTH2_FEISHU_CLIENT_ID}
client-secret: ${OAUTH2_FEISHU_CLIENT_SECRET}
# 飞书的 scope 配在开放平台应用上,不在这里传
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
provider:
feishu:
# Full endpoints are configurable for Lark, private deployments, and gateways.
authorization-uri: ${OAUTH2_FEISHU_AUTHORIZATION_URI:${OAUTH2_FEISHU_AUTHORIZE_URI:https://accounts.feishu.cn}/open-apis/authen/v1/authorize}
# OAUTH2_FEISHU_PROTOCOL_VERSION supports v2 and v3; default is v3.
token-uri: ${OAUTH2_FEISHU_TOKEN_URI:https://accounts.feishu.cn/oauth/v3/token}
user-info-uri: ${OAUTH2_FEISHU_USER_INFO_URI:${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v1/user_info}
```
Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider 只需:
@ -303,6 +311,29 @@ fallback否则同一个人会被拆成两个平台账号、只有在 Provi
所有权时才置 `emailVerified=true`、远程调用要有超时与响应大小上限、
claims 提取过程不记录 subject/email/token。
#### 飞书 token 协议版本
飞书 token client 支持显式选择 `v2``v3`,默认值为 `v3`
```bash
OAUTH2_FEISHU_PROTOCOL_VERSION=v3
OAUTH2_FEISHU_AUTHORIZATION_URI=https://accounts.feishu.cn/open-apis/authen/v1/authorize
OAUTH2_FEISHU_TOKEN_URI=https://accounts.feishu.cn/oauth/v3/token
OAUTH2_FEISHU_USER_INFO_URI=https://open.feishu.cn/open-apis/authen/v1/user_info
# 历史 v2 应用可显式切换:
# OAUTH2_FEISHU_PROTOCOL_VERSION=v2
# OAUTH2_FEISHU_TOKEN_URI=https://open.feishu.cn/open-apis/authen/v2/oauth/token
```
两个版本都使用 JSON authorization-code exchange当前实现会根据协议版本
选择对应的标准 token endpoint如需代理、区域或私有化 endpoint可通过
`OAUTH2_FEISHU_TOKEN_URI` 覆盖。授权和 userinfo endpoint 也分别通过
`OAUTH2_FEISHU_AUTHORIZATION_URI``OAUTH2_FEISHU_USER_INFO_URI` 配置。协议版本不合法
时应用启动失败。不会在 v3 失败后自动使用 v2因为 authorization code 只能使用一次,
自动重试可能造成重复请求并掩盖配置错误。旧的 `OAUTH2_FEISHU_AUTHORIZE_URI`
`OAUTH2_FEISHU_BASE_URI` 仍作为 base-URI 兼容回退,但新部署应使用完整 endpoint 变量。
## 4. 核心接口设计
```java

View file

@ -286,8 +286,18 @@ services:
- 如果要开放真实登录,再补充对应 Provider 的 client id/secret
- GitHub`OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET`
- GitLab`OAUTH2_GITLAB_CLIENT_ID` / `OAUTH2_GITLAB_CLIENT_SECRET`(自建实例再设 `OAUTH2_GITLAB_BASE_URI`
- 飞书:`OAUTH2_FEISHU_CLIENT_ID` / `OAUTH2_FEISHU_CLIENT_SECRET`
(国际版 Lark 再设 `OAUTH2_FEISHU_AUTHORIZE_URI` / `OAUTH2_FEISHU_BASE_URI`
- 飞书:`OAUTH2_FEISHU_CLIENT_ID` / `OAUTH2_FEISHU_CLIENT_SECRET`
Endpoint 默认配置为:
- `OAUTH2_FEISHU_AUTHORIZATION_URI=https://accounts.feishu.cn/open-apis/authen/v1/authorize`
- `OAUTH2_FEISHU_PROTOCOL_VERSION=v3`
- `OAUTH2_FEISHU_TOKEN_URI=https://accounts.feishu.cn/oauth/v3/token`
- `OAUTH2_FEISHU_USER_INFO_URI=https://open.feishu.cn/open-apis/authen/v1/user_info`
Lark 国际版、私有化部署或企业网关可分别覆盖这三个完整 endpoint历史的
`OAUTH2_FEISHU_AUTHORIZE_URI` / `OAUTH2_FEISHU_BASE_URI` 仍可作为 base-URI
兼容回退。`OAUTH2_FEISHU_TOKEN_URI` 必须指向支持 JSON authorization-code
exchange 的 endpoint。`OAUTH2_FEISHU_PROTOCOL_VERSION` 只允许 `v2``v3`
默认 `v3`,不会自动 fallback。
留空即不展示该入口,无需改配置文件。注意:飞书邮箱由企业管理员导入、未经用户
确认,因此 `emailVerified` 恒为 false若在 `application.yml` 中把

View file

@ -71,6 +71,7 @@ spring:
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab}
feishu:
provider: 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
@ -89,9 +90,12 @@ spring:
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
# Full endpoints are configurable for Lark, private deployments, and gateways.
# The legacy base-URI variables remain as compatibility fallbacks.
authorization-uri: ${OAUTH2_FEISHU_AUTHORIZATION_URI:${OAUTH2_FEISHU_AUTHORIZE_URI:https://accounts.feishu.cn}/open-apis/authen/v1/authorize}
# Supported values: v2 and v3. V3 is the default; selection is explicit and never falls back.
token-uri: ${OAUTH2_FEISHU_TOKEN_URI:https://accounts.feishu.cn/oauth/v3/token}
user-info-uri: ${OAUTH2_FEISHU_USER_INFO_URI:${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v1/user_info}
user-name-attribute: open_id
servlet:
multipart:

View file

@ -0,0 +1,196 @@
package com.iflytek.skillhub.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
/**
* Exercises the browser-facing Feishu OAuth flow against a local protocol-compatible provider.
* The mock intentionally implements the authorization redirect, JSON token exchange, and wrapped
* user-info response rather than mocking Spring Security internals.
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class FeishuOAuthBrowserCallbackIntegrationTest {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final HttpServer PROVIDER_SERVER = startProviderServer();
private static final String PROVIDER_BASE_URI = "http://127.0.0.1:" + PROVIDER_SERVER.getAddress().getPort();
private static final AtomicReference<String> TOKEN_REQUEST_CONTENT_TYPE = new AtomicReference<>();
private static final AtomicReference<String> TOKEN_REQUEST_BODY = new AtomicReference<>();
private static final AtomicReference<String> USERINFO_AUTHORIZATION = new AtomicReference<>();
@Autowired
private MockMvc mockMvc;
@MockBean
private GlobalNamespaceMembershipService globalNamespaceMembershipService;
@BeforeAll
static void startProvider() {
PROVIDER_SERVER.start();
}
@AfterAll
static void stopProvider() {
PROVIDER_SERVER.stop(0);
}
@DynamicPropertySource
static void feishuProperties(DynamicPropertyRegistry registry) {
registry.add("spring.security.oauth2.client.registration.feishu.client-id",
() -> "mock-feishu-client");
registry.add("spring.security.oauth2.client.registration.feishu.client-secret",
() -> "mock-feishu-secret");
registry.add("spring.security.oauth2.client.provider.feishu.authorization-uri",
() -> PROVIDER_BASE_URI + "/authorize");
registry.add("spring.security.oauth2.client.provider.feishu.token-uri",
() -> PROVIDER_BASE_URI + "/oauth/v3/token");
registry.add("spring.security.oauth2.client.provider.feishu.user-info-uri",
() -> PROVIDER_BASE_URI + "/open-apis/authen/v1/user_info");
registry.add("spring.security.oauth2.client.provider.feishu.user-name-attribute",
() -> "open_id");
}
@Test
void browserAuthorizationCallbackExchangesJsonTokenLoadsUserAndCreatesSession() throws Exception {
TOKEN_REQUEST_CONTENT_TYPE.set(null);
TOKEN_REQUEST_BODY.set(null);
USERINFO_AUTHORIZATION.set(null);
MvcResult authorization = mockMvc.perform(get("/oauth2/authorization/feishu")
.param("returnTo", "/dashboard"))
.andExpect(status().is3xxRedirection())
.andReturn();
URI providerAuthorization = URI.create(authorization.getResponse().getHeader("Location"));
assertThat(providerAuthorization.getPath()).isEqualTo("/authorize");
Map<String, String> authorizationParameters = queryParameters(providerAuthorization.getRawQuery());
assertThat(authorizationParameters.get("client_id")).isEqualTo("mock-feishu-client");
assertThat(authorizationParameters.get("redirect_uri"))
.isEqualTo("http://localhost/login/oauth2/code/feishu");
assertThat(authorizationParameters.get("state")).isNotBlank();
HttpResponse<Void> providerAuthorizationResponse = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(providerAuthorization).GET().build(),
HttpResponse.BodyHandlers.discarding());
assertThat(providerAuthorizationResponse.statusCode()).isEqualTo(302);
URI callback = URI.create(providerAuthorizationResponse.headers().firstValue("Location").orElseThrow());
assertThat(queryParameters(callback.getRawQuery()))
.containsEntry("code", "mock-authorization-code")
.containsEntry("state", authorizationParameters.get("state"));
MockHttpSession session = (MockHttpSession) authorization.getRequest().getSession(false);
MvcResult callbackResult = mockMvc.perform(get(callback.getPath() + "?" + callback.getRawQuery())
.session(session))
.andExpect(redirectedUrl("/dashboard"))
.andReturn();
assertThat(TOKEN_REQUEST_CONTENT_TYPE).hasValue("application/json;charset=utf-8");
JsonNode tokenRequest = OBJECT_MAPPER.readTree(TOKEN_REQUEST_BODY.get());
assertThat(tokenRequest.path("grant_type").asText()).isEqualTo("authorization_code");
assertThat(tokenRequest.path("client_id").asText()).isEqualTo("mock-feishu-client");
assertThat(tokenRequest.path("client_secret").asText()).isEqualTo("mock-feishu-secret");
assertThat(tokenRequest.path("code").asText()).isEqualTo("mock-authorization-code");
assertThat(USERINFO_AUTHORIZATION).hasValue("Bearer mock-access-token");
assertThat(callbackResult.getRequest().getSession(false)).isSameAs(session);
}
private static HttpServer startProviderServer() {
try {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/authorize", FeishuOAuthBrowserCallbackIntegrationTest::authorize);
server.createContext("/oauth/v3/token", FeishuOAuthBrowserCallbackIntegrationTest::token);
server.createContext("/open-apis/authen/v1/user_info", FeishuOAuthBrowserCallbackIntegrationTest::userInfo);
return server;
} catch (IOException exception) {
throw new ExceptionInInitializerError(exception);
}
}
private static void authorize(HttpExchange exchange) throws IOException {
Map<String, String> parameters = queryParameters(exchange.getRequestURI().getRawQuery());
URI redirect = URI.create(parameters.get("redirect_uri"));
String separator = redirect.getRawQuery() == null ? "?" : "&";
URI callback = URI.create(redirect + separator + "code=mock-authorization-code&state="
+ parameters.get("state"));
redirect(exchange, callback.toString());
}
private static void token(HttpExchange exchange) throws IOException {
TOKEN_REQUEST_CONTENT_TYPE.set(exchange.getRequestHeaders().getFirst("Content-Type"));
TOKEN_REQUEST_BODY.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
respond(exchange, 200, """
{"code":0,"access_token":"mock-access-token","token_type":"Bearer",\n"expires_in":3600,"scope":"contact:user.base:readonly"}
""".replace("\n", ""));
}
private static void userInfo(HttpExchange exchange) throws IOException {
USERINFO_AUTHORIZATION.set(exchange.getRequestHeaders().getFirst("Authorization"));
respond(exchange, 200, """
{"code":0,"msg":"ok","data":{"open_id":"mock-open-id","name":"Mock Feishu User","email":"mock@example.com"}}
""");
}
private static void redirect(HttpExchange exchange, String location) throws IOException {
exchange.getResponseHeaders().set("Location", location);
exchange.sendResponseHeaders(302, -1);
exchange.close();
}
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(status, bytes.length);
try (var output = exchange.getResponseBody()) {
output.write(bytes);
}
}
private static Map<String, String> queryParameters(String rawQuery) {
Map<String, String> parameters = new HashMap<>();
if (rawQuery == null || rawQuery.isBlank()) {
return parameters;
}
for (String pair : rawQuery.split("&")) {
String[] keyValue = pair.split("=", 2);
parameters.put(urlDecode(keyValue[0]), keyValue.length == 2 ? urlDecode(keyValue[1]) : "");
}
return parameters;
}
private static String urlDecode(String value) {
return java.net.URLDecoder.decode(value, StandardCharsets.UTF_8);
}
}

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.auth.config;
import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService;
import com.iflytek.skillhub.auth.oauth.CustomOidcUserService;
import com.iflytek.skillhub.auth.oauth.FeishuOAuth2AccessTokenResponseClient;
import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler;
import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler;
import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver;
@ -61,6 +62,7 @@ public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final CustomOidcUserService customOidcUserService;
private final FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient;
private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver;
private final OAuth2LoginSuccessHandler successHandler;
private final OAuth2LoginFailureHandler failureHandler;
@ -75,6 +77,7 @@ public class SecurityConfig {
public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
CustomOidcUserService customOidcUserService,
FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient,
SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver,
OAuth2LoginSuccessHandler successHandler,
OAuth2LoginFailureHandler failureHandler,
@ -88,6 +91,7 @@ public class SecurityConfig {
@Value("${server.servlet.session.cookie.name:SESSION}") String sessionCookieName) {
this.customOAuth2UserService = customOAuth2UserService;
this.customOidcUserService = customOidcUserService;
this.feishuOAuth2AccessTokenResponseClient = feishuOAuth2AccessTokenResponseClient;
this.authorizationRequestResolver = authorizationRequestResolver;
this.successHandler = successHandler;
this.failureHandler = failureHandler;
@ -132,6 +136,8 @@ public class SecurityConfig {
})
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver))
.tokenEndpoint(tokenEndpoint -> tokenEndpoint
.accessTokenResponseClient(feishuOAuth2AccessTokenResponseClient))
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
.oidcUserService(customOidcUserService))

View file

@ -0,0 +1,218 @@
package com.iflytek.skillhub.auth.oauth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
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.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
/**
* Provider-aware authorization-code token client. Feishu's token endpoint accepts a JSON request
* and returns business errors in a HTTP-200 response, unlike the form-based OAuth client used by
* the other providers.
*/
@Component
public class FeishuOAuth2AccessTokenResponseClient
implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
private static final String FEISHU_PROVIDER = "feishu";
private static final String V2 = "v2";
private static final String V3 = "v3";
private static final String DEFAULT_V2_TOKEN_URI = "https://open.feishu.cn/open-apis/authen/v2/oauth/token";
private static final String DEFAULT_V3_TOKEN_URI = "https://accounts.feishu.cn/oauth/v3/token";
private static final String INVALID_TOKEN_RESPONSE = "feishu_invalid_token_response";
private static final int MAX_RESPONSE_BYTES = 64 * 1024;
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
private static final Duration READ_TIMEOUT = Duration.ofSeconds(10);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final RestClient restClient;
private final OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> standardClient;
private final String protocolVersion;
@Autowired
public FeishuOAuth2AccessTokenResponseClient(
@Value("${OAUTH2_FEISHU_PROTOCOL_VERSION:v3}") String protocolVersion) {
this(RestClient.builder().requestFactory(defaultRequestFactory()),
new DefaultAuthorizationCodeTokenResponseClient(), protocolVersion);
}
FeishuOAuth2AccessTokenResponseClient(
RestClient.Builder restClientBuilder) {
this(restClientBuilder, new DefaultAuthorizationCodeTokenResponseClient(), V3);
}
FeishuOAuth2AccessTokenResponseClient(
RestClient.Builder restClientBuilder,
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> standardClient) {
this(restClientBuilder, standardClient, V3);
}
FeishuOAuth2AccessTokenResponseClient(
RestClient.Builder restClientBuilder,
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> standardClient,
String protocolVersion) {
this.restClient = restClientBuilder.build();
this.standardClient = standardClient;
this.protocolVersion = normalizeProtocolVersion(protocolVersion);
}
@Override
public OAuth2AccessTokenResponse getTokenResponse(
OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) {
if (!FEISHU_PROVIDER.equals(authorizationCodeGrantRequest.getClientRegistration().getRegistrationId())) {
return standardClient.getTokenResponse(authorizationCodeGrantRequest);
}
Map<String, Object> requestBody = new LinkedHashMap<>();
requestBody.put("grant_type", "authorization_code");
requestBody.put("client_id", authorizationCodeGrantRequest.getClientRegistration().getClientId());
requestBody.put("client_secret", authorizationCodeGrantRequest.getClientRegistration().getClientSecret());
requestBody.put("code", authorizationCodeGrantRequest.getAuthorizationExchange()
.getAuthorizationResponse().getCode());
String redirectUri = authorizationCodeGrantRequest.getAuthorizationExchange()
.getAuthorizationRequest().getRedirectUri();
if (redirectUri != null && !redirectUri.isBlank()) {
requestBody.put("redirect_uri", redirectUri);
}
Object codeVerifier = authorizationCodeGrantRequest.getAuthorizationExchange()
.getAuthorizationRequest().getAttribute("code_verifier");
if (codeVerifier instanceof String verifier && !verifier.isBlank()) {
requestBody.put("code_verifier", verifier);
}
try {
return restClient.post()
.uri(tokenUri(authorizationCodeGrantRequest))
.contentType(MediaType.parseMediaType("application/json; charset=utf-8"))
.header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.body(requestBody)
.exchange((request, response) -> {
if (!response.getStatusCode().is2xxSuccessful()) {
throw tokenError("Feishu token endpoint returned HTTP "
+ response.getStatusCode().value());
}
return parseResponse(readBounded(response.getBody()));
});
} catch (OAuth2AuthorizationException exception) {
throw exception;
} catch (Exception exception) {
throw tokenError("Feishu token exchange failed", exception);
}
}
private static OAuth2AccessTokenResponse parseResponse(byte[] responseBytes) {
try {
JsonNode response = OBJECT_MAPPER.readTree(responseBytes);
int code = response.path("code").asInt(-1);
if (code != 0) {
throw tokenError("Feishu token endpoint returned business error code " + code);
}
String accessToken = text(response, "access_token");
if (accessToken == null) {
throw tokenError("Feishu token endpoint returned no access token");
}
String tokenType = text(response, "token_type");
if (tokenType != null && !"Bearer".equalsIgnoreCase(tokenType)) {
throw tokenError("Feishu token endpoint returned unsupported token type");
}
long expiresIn = response.path("expires_in").asLong(-1);
if (expiresIn <= 0) {
throw tokenError("Feishu token endpoint returned invalid expires_in");
}
OAuth2AccessTokenResponse.Builder tokenResponse = OAuth2AccessTokenResponse
.withToken(accessToken)
.tokenType(OAuth2AccessToken.TokenType.BEARER)
.expiresIn(expiresIn);
String refreshToken = text(response, "refresh_token");
if (refreshToken != null) {
tokenResponse.refreshToken(refreshToken);
}
String scope = text(response, "scope");
if (scope != null) {
tokenResponse.scopes(Set.of(scope.trim().split("\\s+")));
}
return tokenResponse.build();
} catch (OAuth2AuthorizationException exception) {
throw exception;
} catch (Exception exception) {
throw tokenError("Feishu token endpoint returned an invalid response", exception);
}
}
private String tokenUri(OAuth2AuthorizationCodeGrantRequest request) {
String configuredUri = request.getClientRegistration().getProviderDetails().getTokenUri();
if (V2.equals(protocolVersion) && DEFAULT_V3_TOKEN_URI.equals(configuredUri)) {
return DEFAULT_V2_TOKEN_URI;
}
if (V3.equals(protocolVersion) && DEFAULT_V2_TOKEN_URI.equals(configuredUri)) {
return DEFAULT_V3_TOKEN_URI;
}
return configuredUri;
}
private static String normalizeProtocolVersion(String value) {
String normalized = value == null ? V3 : value.trim().toLowerCase(java.util.Locale.ROOT);
if (!V2.equals(normalized) && !V3.equals(normalized)) {
throw new IllegalArgumentException(
"OAUTH2_FEISHU_PROTOCOL_VERSION must be either v2 or v3");
}
return normalized;
}
private static String text(JsonNode node, String field) {
JsonNode value = node.get(field);
return value != null && value.isTextual() && !value.textValue().isBlank()
? value.textValue()
: null;
}
private static ClientHttpRequestFactory defaultRequestFactory() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(CONNECT_TIMEOUT);
factory.setReadTimeout(READ_TIMEOUT);
return factory;
}
private static byte[] readBounded(InputStream body) throws IOException {
if (body == null) {
throw new IOException("empty response body");
}
byte[] bytes = body.readNBytes(MAX_RESPONSE_BYTES + 1);
if (bytes.length > MAX_RESPONSE_BYTES) {
throw new IOException("response body exceeds configured limit");
}
return bytes;
}
private static OAuth2AuthorizationException tokenError(String description) {
return tokenError(description, null);
}
private static OAuth2AuthorizationException tokenError(String description, Throwable cause) {
OAuth2Error error = new OAuth2Error(INVALID_TOKEN_RESPONSE, description, null);
return cause == null ? new OAuth2AuthorizationException(error) : new OAuth2AuthorizationException(error, cause);
}
}

View file

@ -74,7 +74,7 @@ public class FeishuOAuth2UserService implements ProviderOAuth2UserService {
/**
* Reads at most {@link #MAX_RESPONSE_BYTES} before parsing, so a misconfigured or hostile
* {@code OAUTH2_FEISHU_BASE_URI} cannot stream an unbounded body into the parser. Reading one
* A misconfigured Feishu user-info endpoint cannot stream an unbounded body into the parser. Reading one
* byte past the cap is what distinguishes an oversized payload from one that exactly fills it.
*/
private static FeishuUserResponse readBounded(InputStream body) throws IOException {

View file

@ -127,7 +127,7 @@ class FeishuClaimsExtractorTest {
.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")
.tokenUri("https://accounts.feishu.cn/oauth/v3/token")
.userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info")
.userNameAttributeName("open_id")
.clientName("飞书")

View file

@ -0,0 +1,231 @@
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.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
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.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
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.OAuth2AuthorizationException;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
class FeishuOAuth2AccessTokenResponseClientTest {
@Test
void getTokenResponse_postsFeishuJsonRequestAndParsesTokenResponse() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token"))
.andExpect(method(HttpMethod.POST))
.andExpect(header(HttpHeaders.CONTENT_TYPE, "application/json;charset=utf-8"))
.andExpect(content().json("""
{
"grant_type": "authorization_code",
"client_id": "cli_test",
"client_secret": "secret_test",
"code": "auth-code",
"redirect_uri": "https://skillhub.example.com/login/oauth2/code/feishu"
}
""", false))
.andRespond(withSuccess("""
{
"code": 0,
"access_token": "access-token",
"token_type": "Bearer",
"expires_in": 7200,
"refresh_token": "refresh-token",
"scope": "contact:user.base:readonly offline_access"
}
""", MediaType.APPLICATION_JSON));
FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder);
var response = client.getTokenResponse(grantRequest(false));
assertThat(response.getAccessToken().getTokenValue()).isEqualTo("access-token");
assertThat(response.getAccessToken().getTokenType()).isEqualTo(OAuth2AccessToken.TokenType.BEARER);
assertThat(response.getAccessToken().getScopes())
.containsExactlyInAnyOrder("contact:user.base:readonly", "offline_access");
assertThat(response.getRefreshToken()).isNotNull();
assertThat(response.getRefreshToken().getTokenValue()).isEqualTo("refresh-token");
assertThat(response.getAccessToken().getExpiresAt()).isAfter(Instant.now());
server.verify();
}
@Test
void getTokenResponse_usesV2EndpointWhenConfigured() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v2/oauth/token"))
.andExpect(method(HttpMethod.POST))
.andExpect(header(HttpHeaders.CONTENT_TYPE, "application/json;charset=utf-8"))
.andRespond(withSuccess("{\"code\":0,\"access_token\":\"v2-access-token\","
+ "\"token_type\":\"Bearer\",\"expires_in\":3600}",
MediaType.APPLICATION_JSON));
FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(
builder, request -> OAuth2AccessTokenResponse.withToken("unused").build(), "v2");
assertThat(client.getTokenResponse(grantRequest(false)).getAccessToken().getTokenValue())
.isEqualTo("v2-access-token");
server.verify();
}
@Test
void constructorRejectsUnsupportedProtocolVersion() {
assertThatThrownBy(() -> new FeishuOAuth2AccessTokenResponseClient(
RestClient.builder(), request -> OAuth2AccessTokenResponse.withToken("unused").build(), "v1"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("v2 or v3");
}
@Test
void getTokenResponse_forwardsCodeVerifierWhenAuthorizationRequestContainsIt() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token"))
.andExpect(content().json("""
{
"grant_type": "authorization_code",
"client_id": "cli_test",
"client_secret": "secret_test",
"code": "auth-code",
"redirect_uri": "https://skillhub.example.com/login/oauth2/code/feishu",
"code_verifier": "verifier-value"
}
""", false))
.andRespond(withSuccess("{\"code\":0,\"access_token\":\"access-token\","
+ "\"token_type\":\"Bearer\",\"expires_in\":3600}",
MediaType.APPLICATION_JSON));
FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder);
client.getTokenResponse(grantRequest(true));
server.verify();
}
@Test
void getTokenResponse_rejectsFeishuBusinessErrorReturnedAsHttp200() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token"))
.andRespond(withSuccess("""
{"code": 20003, "error": "invalid_grant", "error_description": "secret_test rejected auth-code"}
""", MediaType.APPLICATION_JSON));
FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder);
assertThatThrownBy(() -> client.getTokenResponse(grantRequest(false)))
.isInstanceOf(OAuth2AuthorizationException.class)
.satisfies(error -> {
var oauthError = ((OAuth2AuthorizationException) error).getError();
assertThat(oauthError.getErrorCode()).isEqualTo("feishu_invalid_token_response");
assertThat(oauthError.getDescription()).doesNotContain("secret_test", "auth-code", "rejected");
});
server.verify();
}
@Test
void getTokenResponse_rejectsInvalidSuccessfulResponse() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token"))
.andRespond(withSuccess("{\"code\":0,\"access_token\":\"access-token\","
+ "\"token_type\":\"mac\",\"expires_in\":3600}", MediaType.APPLICATION_JSON));
FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder);
assertThatThrownBy(() -> client.getTokenResponse(grantRequest(false)))
.isInstanceOf(OAuth2AuthorizationException.class)
.satisfies(error -> assertThat(((OAuth2AuthorizationException) error).getError().getDescription())
.contains("unsupported token type"));
server.verify();
}
@Test
void getTokenResponse_rejectsHttpErrorWithoutExposingResponseDetails() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token"))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withStatus(org.springframework.http.HttpStatus.BAD_REQUEST)
.body("client_secret=secret_test"));
FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder);
assertThatThrownBy(() -> client.getTokenResponse(grantRequest(false)))
.isInstanceOf(OAuth2AuthorizationException.class)
.satisfies(error -> assertThat(((OAuth2AuthorizationException) error).getError().getDescription())
.doesNotContain("secret_test", "auth-code"));
server.verify();
}
@Test
void getTokenResponse_delegatesNonFeishuRegistrationToStandardClient() {
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> delegate = request ->
org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse.withToken("github-token")
.tokenType(OAuth2AccessToken.TokenType.BEARER)
.build();
FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(
RestClient.builder(), delegate);
var response = client.getTokenResponse(grantRequest("github", false));
assertThat(response.getAccessToken().getTokenValue()).isEqualTo("github-token");
}
private OAuth2AuthorizationCodeGrantRequest grantRequest(boolean withCodeVerifier) {
return grantRequest("feishu", withCodeVerifier);
}
private OAuth2AuthorizationCodeGrantRequest grantRequest(String registrationId, boolean withCodeVerifier) {
ClientRegistration registration = ClientRegistration.withRegistrationId(registrationId)
.clientId("cli_test")
.clientSecret("secret_test")
.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://accounts.feishu.cn/oauth/v3/token")
.userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info")
.userNameAttributeName("open_id")
.clientName("飞书")
.build();
OAuth2AuthorizationRequest.Builder request = OAuth2AuthorizationRequest.authorizationCode()
.authorizationUri(registration.getProviderDetails().getAuthorizationUri())
.clientId(registration.getClientId())
.redirectUri("https://skillhub.example.com/login/oauth2/code/feishu")
.state("state")
.attributes(attributes -> {
if (withCodeVerifier) {
attributes.put("code_verifier", "verifier-value");
}
});
OAuth2AuthorizationResponse response = OAuth2AuthorizationResponse.success("auth-code")
.redirectUri("https://skillhub.example.com/login/oauth2/code/feishu")
.state("state")
.build();
return new OAuth2AuthorizationCodeGrantRequest(
registration,
new OAuth2AuthorizationExchange(request.build(), response));
}
}

View file

@ -174,7 +174,7 @@ class FeishuOAuth2UserServiceTest {
.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")
.tokenUri("https://accounts.feishu.cn/oauth/v3/token")
.userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info")
.userNameAttributeName("open_id")
.clientName("飞书")