mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-24 00:55:35 +00:00
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:
parent
dd82c8a62c
commit
2100996963
16 changed files with 759 additions and 14 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` 中把
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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("飞书")
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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("飞书")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue