diff --git a/.env.release.example b/.env.release.example index cee00087..682e3456 100644 --- a/.env.release.example +++ b/.env.release.example @@ -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). diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index dc741030..6d5b24f0 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -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 diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index 1bede63b..7139f13f 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -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" } diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index fe289836..c1a8e635 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -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 diff --git a/compose.release.yml b/compose.release.yml index 6d772039..ab3318ca 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -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} diff --git a/deploy/k8s/base/backend-deployment.yaml b/deploy/k8s/base/backend-deployment.yaml index 5f11534f..38fdbfa9 100644 --- a/deploy/k8s/base/backend-deployment.yaml +++ b/deploy/k8s/base/backend-deployment.yaml @@ -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 diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index c8735b63..c609f4f1 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -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 diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 3671c94d..23ff0e8a 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -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` 中把 diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 023f3624..8cb57f6a 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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: diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/FeishuOAuthBrowserCallbackIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/FeishuOAuthBrowserCallbackIntegrationTest.java new file mode 100644 index 00000000..52108227 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/FeishuOAuthBrowserCallbackIntegrationTest.java @@ -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 TOKEN_REQUEST_CONTENT_TYPE = new AtomicReference<>(); + private static final AtomicReference TOKEN_REQUEST_BODY = new AtomicReference<>(); + private static final AtomicReference 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 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 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 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 queryParameters(String rawQuery) { + Map 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); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 91942f56..5880f58a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -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)) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClient.java new file mode 100644 index 00000000..c283115e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClient.java @@ -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 { + + 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 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 standardClient) { + this(restClientBuilder, standardClient, V3); + } + + FeishuOAuth2AccessTokenResponseClient( + RestClient.Builder restClientBuilder, + OAuth2AccessTokenResponseClient 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 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); + } +} 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 index 10d4e44a..f8141a8a 100644 --- 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 @@ -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 { 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 index d7715045..6cc2239b 100644 --- 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 @@ -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("飞书") diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClientTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClientTest.java new file mode 100644 index 00000000..1f022424 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClientTest.java @@ -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 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)); + } +} 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 index e5d62e6d..128d173d 100644 --- 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 @@ -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("飞书")