mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-05 02:41:49 +00:00
Merge branch 'iflytek:main' into main
This commit is contained in:
commit
9991938983
72 changed files with 5388 additions and 55 deletions
|
|
@ -117,6 +117,41 @@ OAUTH2_GITLAB_CLIENT_SECRET=
|
|||
OAUTH2_GITLAB_BASE_URI=https://gitlab.com
|
||||
OAUTH2_GITLAB_DISPLAY_NAME=GitLab
|
||||
|
||||
# Optional: Feishu (Lark) login as a public sign-in provider. Leaving the client id empty keeps
|
||||
# the button off the login page. Grant contact:user.base:readonly and
|
||||
# contact:user.email:readonly on the Feishu open-platform app itself; scopes are not sent here.
|
||||
# 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_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
|
||||
# Optional; defaults to {baseUrl}/login/oauth2/code/feishu. Set explicitly for local previews or reverse proxies.
|
||||
OAUTH2_FEISHU_REDIRECT_URI=
|
||||
OAUTH2_FEISHU_DISPLAY_NAME=飞书
|
||||
|
||||
# Optional: DingTalk login as a public sign-in provider. Leaving the client id empty keeps the
|
||||
# button off the login page. Use the app's AppKey as the client id and AppSecret as the secret.
|
||||
# Like Feishu, DingTalk returns an organization-recorded email without attesting ownership, so
|
||||
# emailVerified is always false and the EMAIL_DOMAIN access policy would reject every login.
|
||||
# The DingTalk console's server egress IP must be the real public IP of the backend calling
|
||||
# api.dingtalk.com. A reverse tunnel only changes callback ingress and does not change egress.
|
||||
OAUTH2_DINGTALK_CLIENT_ID=
|
||||
OAUTH2_DINGTALK_CLIENT_SECRET=
|
||||
OAUTH2_DINGTALK_AUTHORIZE_URI=https://login.dingtalk.com
|
||||
OAUTH2_DINGTALK_BASE_URI=https://api.dingtalk.com
|
||||
# Optional; defaults to {baseUrl}/login/oauth2/code/dingtalk.
|
||||
OAUTH2_DINGTALK_REDIRECT_URI=
|
||||
OAUTH2_DINGTALK_DISPLAY_NAME=钉钉
|
||||
|
||||
# Optional: OIDC login (e.g. Keycloak, Okta, Azure AD).
|
||||
# Replace "OIDC" in variable names with your registration id (uppercase).
|
||||
# The registration id becomes identity_binding.provider_code — keep it stable.
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -569,6 +569,18 @@ protocol is not compatible with SkillHub; use the first-party CLI shown above.
|
|||
|
||||
📖 **[Complete Hermes Agent Integration Guide →](./docs/hermes-integration-en.md)**
|
||||
|
||||
### [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
|
||||
|
||||
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`) discovers standard `SKILL.md` packages from `.dsh/skills` and the shared `.agents/skills` roots. Install directly into its native user directory with the first-party SkillHub CLI:
|
||||
|
||||
```bash
|
||||
skillhub install my-skill --agent dsh --scope user
|
||||
```
|
||||
|
||||
Project-scoped installs use `<repository>/.dsh/skills`; run them from the repository root. dsh watches its skill roots, so newly installed skills are discovered without restarting the process.
|
||||
|
||||
📖 **[Complete DeepSeek Harness Integration Guide →](./docs/dsh-integration-en.md)**
|
||||
|
||||
### [HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine)
|
||||
|
||||
[HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine) is a Go LLM programming assistant engine that exposes its capabilities over WebSocket. It loads skills from `SKILL.md` files with YAML frontmatter and parameter substitution, scanning each configured directory for `skill-name/SKILL.md` (default `~/.harnessclaw/workspace/skills/`, with earlier directories taking priority on name conflicts). Install a SkillHub package straight into that directory with the CLI's `--dir` option, no registry adapter required:
|
||||
|
|
|
|||
12
README_zh.md
12
README_zh.md
|
|
@ -454,6 +454,18 @@ ClawHub 兼容范围包含搜索、查看和安装;其发布协议与 SkillHub
|
|||
|
||||
📖 **[完整 Hermes Agent 集成指南 →](./docs/hermes-integration.md)**
|
||||
|
||||
### [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
|
||||
|
||||
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)会从 `.dsh/skills` 和共享的 `.agents/skills` 根目录发现标准 `SKILL.md` 技能包。使用第一方 SkillHub CLI 可直接安装到它的原生用户目录:
|
||||
|
||||
```bash
|
||||
skillhub install my-skill --agent dsh --scope user
|
||||
```
|
||||
|
||||
项目级安装会写入 `<仓库>/.dsh/skills`,请在仓库根目录执行。dsh 会监听技能根目录,因此安装后无需重启进程即可发现新技能。
|
||||
|
||||
📖 **[完整 DeepSeek Harness 集成指南 →](./docs/dsh-integration.md)**
|
||||
|
||||
### [HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine)
|
||||
|
||||
[HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine) 是基于 Go 的 LLM 编程助手引擎,通过 WebSocket 协议对外提供能力。它从 `SKILL.md` 文件加载技能,支持 YAML frontmatter 与参数替换,并按配置顺序扫描各目录下的 `skill-name/SKILL.md`(默认 `~/.harnessclaw/workspace/skills/`,靠前的目录在重名时优先)。通过 SkillHub CLI 的 `--dir` 参数即可把技能包直接安装到该目录,无需新增 registry 适配器:
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \
|
|||
| `skillhub-download-anon-cookie-secret` | 是 | 至少 32 字符的匿名下载 Cookie 签名密钥 |
|
||||
| `oauth2-github-client-id` | 否 | GitHub OAuth2 Client ID |
|
||||
| `oauth2-github-client-secret` | 否 | GitHub OAuth2 Client Secret |
|
||||
| `oauth2-dingtalk-client-id` | 否 | DingTalk AppKey |
|
||||
| `oauth2-dingtalk-client-secret` | 否 | DingTalk AppSecret |
|
||||
| `skill-scanner-llm-api-key` | 否 | Scanner LLM API Key |
|
||||
| `skill-scanner-llm-base-url` | 否 | Scanner 自定义 LLM API 地址 |
|
||||
| `skill-scanner-llm-model` | 否 | Scanner LLM 模型名称 |
|
||||
|
|
|
|||
|
|
@ -59,6 +59,22 @@ stringData:
|
|||
oauth2-github-client-secret: {{ .Values.secrets.oauth2GithubClientSecret | quote }}
|
||||
{{- end }}
|
||||
|
||||
# OAuth2 Feishu (optional)
|
||||
{{- if .Values.secrets.oauth2FeishuClientId }}
|
||||
oauth2-feishu-client-id: {{ .Values.secrets.oauth2FeishuClientId | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secrets.oauth2FeishuClientSecret }}
|
||||
oauth2-feishu-client-secret: {{ .Values.secrets.oauth2FeishuClientSecret | quote }}
|
||||
{{- end }}
|
||||
|
||||
# OAuth2 DingTalk (optional)
|
||||
{{- if .Values.secrets.oauth2DingtalkClientId }}
|
||||
oauth2-dingtalk-client-id: {{ .Values.secrets.oauth2DingtalkClientId | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secrets.oauth2DingtalkClientSecret }}
|
||||
oauth2-dingtalk-client-secret: {{ .Values.secrets.oauth2DingtalkClientSecret | quote }}
|
||||
{{- end }}
|
||||
|
||||
# Scanner LLM 配置 (optional)
|
||||
{{- if .Values.secrets.scannerLlmApiKey }}
|
||||
skill-scanner-llm-api-key: {{ .Values.secrets.scannerLlmApiKey | quote }}
|
||||
|
|
|
|||
|
|
@ -355,6 +355,56 @@ spec:
|
|||
key: oauth2-github-client-secret
|
||||
optional: true
|
||||
|
||||
# OAuth2 Feishu (optional)
|
||||
- name: OAUTH2_FEISHU_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "skillhub.secretName" . }}
|
||||
key: oauth2-feishu-client-id
|
||||
optional: true
|
||||
- name: OAUTH2_FEISHU_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
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 }}
|
||||
{{- with .Values.oauth2.feishu.redirectUri }}
|
||||
- name: OAUTH2_FEISHU_REDIRECT_URI
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
|
||||
# OAuth2 DingTalk (optional)
|
||||
- name: OAUTH2_DINGTALK_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "skillhub.secretName" . }}
|
||||
key: oauth2-dingtalk-client-id
|
||||
optional: true
|
||||
- name: OAUTH2_DINGTALK_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "skillhub.secretName" . }}
|
||||
key: oauth2-dingtalk-client-secret
|
||||
optional: true
|
||||
- name: OAUTH2_DINGTALK_AUTHORIZE_URI
|
||||
value: {{ .Values.oauth2.dingtalk.authorizeBaseUri | quote }}
|
||||
- name: OAUTH2_DINGTALK_BASE_URI
|
||||
value: {{ .Values.oauth2.dingtalk.apiBaseUri | quote }}
|
||||
{{- with .Values.oauth2.dingtalk.redirectUri }}
|
||||
- name: OAUTH2_DINGTALK_REDIRECT_URI
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
- name: OAUTH2_DINGTALK_DISPLAY_NAME
|
||||
value: {{ .Values.oauth2.dingtalk.displayName | quote }}
|
||||
|
||||
{{- if .Values.server.javaOpts }}
|
||||
- name: JAVA_OPTS
|
||||
value: {{ .Values.server.javaOpts }}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,26 @@ grep -Fq 'fsGroupChangePolicy: OnRootMismatch' "$TMP_DIR/default.yaml"
|
|||
grep -Fq 'type: Recreate' "$TMP_DIR/default.yaml"
|
||||
grep -A1 -F 'name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED' "$TMP_DIR/default.yaml" \
|
||||
| grep -Fq 'value: "false"'
|
||||
if grep -Fq 'name: OAUTH2_FEISHU_REDIRECT_URI' "$TMP_DIR/default.yaml"; then
|
||||
fail "default Helm rendering must omit an empty Feishu redirect URI so Spring can derive baseUrl"
|
||||
fi
|
||||
if grep -Fq 'name: OAUTH2_DINGTALK_REDIRECT_URI' "$TMP_DIR/default.yaml"; then
|
||||
fail "default Helm rendering must omit an empty DingTalk redirect URI so Spring can derive baseUrl"
|
||||
fi
|
||||
|
||||
render feishu-redirect "$CHART_DIR" \
|
||||
--set-string oauth2.feishu.redirectUri=https://skills.example.com/login/oauth2/code/feishu \
|
||||
--show-only templates/server-deployment.yaml >"$TMP_DIR/feishu-redirect.yaml"
|
||||
grep -A1 -F 'name: OAUTH2_FEISHU_REDIRECT_URI' "$TMP_DIR/feishu-redirect.yaml" \
|
||||
| grep -Fq 'value: "https://skills.example.com/login/oauth2/code/feishu"' \
|
||||
|| fail "Helm must inject an explicitly configured Feishu redirect URI"
|
||||
|
||||
render dingtalk-redirect "$CHART_DIR" \
|
||||
--set-string oauth2.dingtalk.redirectUri=https://skills.example.com/login/oauth2/code/dingtalk \
|
||||
--show-only templates/server-deployment.yaml >"$TMP_DIR/dingtalk-redirect.yaml"
|
||||
grep -A1 -F 'name: OAUTH2_DINGTALK_REDIRECT_URI' "$TMP_DIR/dingtalk-redirect.yaml" \
|
||||
| grep -Fq 'value: "https://skills.example.com/login/oauth2/code/dingtalk"' \
|
||||
|| fail "Helm must inject an explicitly configured DingTalk redirect URI"
|
||||
|
||||
render suite-review-enabled "$CHART_DIR" \
|
||||
--set server.suiteReviewWritesEnabled=true \
|
||||
|
|
@ -64,6 +84,17 @@ render stable "$CHART_DIR" "${stable_args[@]}" >"$TMP_DIR/stable-a.yaml"
|
|||
render stable "$CHART_DIR" "${stable_args[@]}" >"$TMP_DIR/stable-b.yaml"
|
||||
cmp "$TMP_DIR/stable-a.yaml" "$TMP_DIR/stable-b.yaml"
|
||||
|
||||
render dingtalk "$CHART_DIR" "${stable_args[@]}" \
|
||||
--set-string secrets.oauth2DingtalkClientId=ding-test \
|
||||
--set-string secrets.oauth2DingtalkClientSecret=dingtalk-test-secret \
|
||||
>"$TMP_DIR/dingtalk.yaml"
|
||||
grep -Fq 'oauth2-dingtalk-client-id: "ding-test"' "$TMP_DIR/dingtalk.yaml" \
|
||||
|| fail "Helm must render the configured DingTalk client id"
|
||||
grep -Fq 'oauth2-dingtalk-client-secret: "dingtalk-test-secret"' "$TMP_DIR/dingtalk.yaml" \
|
||||
|| fail "Helm must render the configured DingTalk client secret"
|
||||
grep -Fq 'name: OAUTH2_DINGTALK_CLIENT_ID' "$TMP_DIR/dingtalk.yaml" \
|
||||
|| fail "server deployment must inject the DingTalk client id"
|
||||
|
||||
render private-registry "$CHART_DIR" \
|
||||
--set server.dependencyWait.image.registry=registry.example.com \
|
||||
--set server.dependencyWait.image.repository=library/busybox \
|
||||
|
|
|
|||
|
|
@ -34,6 +34,36 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"oauth2": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["feishu", "dingtalk"],
|
||||
"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" },
|
||||
"redirectUri": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"dingtalk": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["authorizeBaseUri", "apiBaseUri", "redirectUri", "displayName"],
|
||||
"properties": {
|
||||
"authorizeBaseUri": { "type": "string", "format": "uri" },
|
||||
"apiBaseUri": { "type": "string", "format": "uri" },
|
||||
"redirectUri": { "type": "string" },
|
||||
"displayName": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"builtinSkills": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
|
@ -156,6 +186,10 @@
|
|||
"downloadAnonCookieSecret": { "type": "string" },
|
||||
"oauth2GithubClientId": { "type": "string" },
|
||||
"oauth2GithubClientSecret": { "type": "string" },
|
||||
"oauth2FeishuClientId": { "type": "string" },
|
||||
"oauth2FeishuClientSecret": { "type": "string" },
|
||||
"oauth2DingtalkClientId": { "type": "string" },
|
||||
"oauth2DingtalkClientSecret": { "type": "string" },
|
||||
"scannerLlmApiKey": { "type": "string" },
|
||||
"scannerLlmBaseUrl": { "type": "string" },
|
||||
"scannerLlmModel": { "type": "string" }
|
||||
|
|
|
|||
|
|
@ -22,6 +22,19 @@ 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
|
||||
redirectUri: ""
|
||||
dingtalk:
|
||||
authorizeBaseUri: https://login.dingtalk.com
|
||||
apiBaseUri: https://api.dingtalk.com
|
||||
redirectUri: ""
|
||||
displayName: 钉钉
|
||||
|
||||
builtinSkills:
|
||||
enabled: true
|
||||
|
||||
|
|
@ -93,6 +106,10 @@ secrets:
|
|||
downloadAnonCookieSecret: ""
|
||||
oauth2GithubClientId: ""
|
||||
oauth2GithubClientSecret: ""
|
||||
oauth2FeishuClientId: ""
|
||||
oauth2FeishuClientSecret: ""
|
||||
oauth2DingtalkClientId: ""
|
||||
oauth2DingtalkClientSecret: ""
|
||||
scannerLlmApiKey: ""
|
||||
scannerLlmBaseUrl: ""
|
||||
scannerLlmModel: ""
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ All notable CLI behavior changes are documented in this file.
|
|||
|
||||
### Added
|
||||
|
||||
- Add the `dsh` agent profile, displayed as DeepSeek Harness, with automatic detection of
|
||||
project-level and user-level `.dsh/skills` directories.
|
||||
- Add OAuth Device Flow to `skillhub login` when no API token is supplied, including best-effort
|
||||
browser launch, a `--no-open` headless mode, bounded polling, and non-secret JSON progress output.
|
||||
- Add the `pi` agent profile, displayed as Pi, with `--agent pi`, project-level
|
||||
|
|
|
|||
|
|
@ -185,6 +185,9 @@ skillhub install pdf-parser --agent astudio
|
|||
# Install to Pi's user-level directory (use --scope project for the project directory)
|
||||
skillhub install pdf-parser --agent pi
|
||||
|
||||
# Install to DeepSeek Harness (use --scope project from the repository root for project skills)
|
||||
skillhub install pdf-parser --agent dsh
|
||||
|
||||
# Install to multiple Agents
|
||||
skillhub install pdf-parser --agent codex --agent claude-code
|
||||
|
||||
|
|
@ -221,6 +224,7 @@ Most Agents have both project-level and user-level skills directories. Use `--sc
|
|||
| `claude-code` | `<project>/.claude/skills/` | `~/.claude/skills/` |
|
||||
| `codex` | `<project>/.codex/skills/` | `~/.codex/skills/` |
|
||||
| `cursor` | `<project>/.cursor/skills/` | `~/.cursor/skills/` |
|
||||
| `dsh` (DeepSeek Harness) | `<project>/.dsh/skills/` | `~/.dsh/skills/` |
|
||||
| `github-copilot` | `<project>/.github-copilot/skills/` | `~/.github-copilot/skills/` |
|
||||
| `gemini-cli` | `<project>/.gemini/skills/` | `~/.gemini/skills/` |
|
||||
| `windsurf` | `<project>/.windsurf/skills/` | `~/.windsurf/skills/` |
|
||||
|
|
@ -237,6 +241,8 @@ Most Agents have both project-level and user-level skills directories. Use `--sc
|
|||
|
||||
For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. AStudio appears in that selector when `~/.acode/skills/` exists. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above.
|
||||
|
||||
DeepSeek Harness resolves project skills from the nearest Git repository root, while SkillHub CLI uses the current directory for project-scoped profiles. Run `--scope project --agent dsh` from the repository root. If `DSH_HOME` overrides the default `~/.dsh`, install with `--dir "$DSH_HOME/skills"`.
|
||||
|
||||
### File Structure After Installation
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { aStudioProfile } from './profiles/astudio'
|
|||
import { claudeCodeProfile } from './profiles/claude-code'
|
||||
import { codexProfile } from './profiles/codex'
|
||||
import { cursorProfile } from './profiles/cursor'
|
||||
import { dshProfile } from './profiles/dsh'
|
||||
import { githubCopilotProfile } from './profiles/github-copilot'
|
||||
import { geminiCliProfile } from './profiles/gemini-cli'
|
||||
import { openhandsProfile } from './profiles/openhands'
|
||||
|
|
@ -17,14 +18,14 @@ import { kiloProfile } from './profiles/kilo'
|
|||
import { piProfile } from './profiles/pi'
|
||||
|
||||
export {
|
||||
aStudioProfile, claudeCodeProfile, codexProfile, cursorProfile, githubCopilotProfile,
|
||||
aStudioProfile, claudeCodeProfile, codexProfile, cursorProfile, dshProfile, githubCopilotProfile,
|
||||
geminiCliProfile, openhandsProfile, windsurfProfile, openclawProfile,
|
||||
kiroCliProfile, rooProfile, traeProfile, traeCnProfile,
|
||||
opencodeProfile, kiloProfile, piProfile
|
||||
}
|
||||
|
||||
export const allProfiles: AgentProfile[] = [
|
||||
aStudioProfile, claudeCodeProfile, codexProfile, cursorProfile, githubCopilotProfile,
|
||||
aStudioProfile, claudeCodeProfile, codexProfile, cursorProfile, dshProfile, githubCopilotProfile,
|
||||
geminiCliProfile, openhandsProfile, windsurfProfile, openclawProfile,
|
||||
kiroCliProfile, rooProfile, traeProfile, traeCnProfile,
|
||||
opencodeProfile, kiloProfile, piProfile
|
||||
|
|
|
|||
2
cli/src/agents/profiles/dsh.ts
Normal file
2
cli/src/agents/profiles/dsh.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const dshProfile = makeProfile('dsh', 'DeepSeek Harness', '.dsh/skills', '.dsh/skills')
|
||||
|
|
@ -667,6 +667,68 @@ describe('install command — server errors', () => {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('install command — multi-agent & auto-detect', () => {
|
||||
test('--agent dsh defaults to the user root and persists the DeepSeek Harness agent id', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const result = await runCli(
|
||||
[
|
||||
'install', 'pdf-parser',
|
||||
'--agent', 'dsh',
|
||||
'--registry', registry.url,
|
||||
'--token', 'sk_ok',
|
||||
'--json'
|
||||
],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const installDir = join(env.home, '.dsh', 'skills', 'pdf-parser')
|
||||
const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string; dir: string }> }
|
||||
expect(parsed.installed).toEqual([{ agent: 'dsh', dir: installDir }])
|
||||
expect(JSON.parse(await readFile(
|
||||
join(installDir, '.skillhub', 'metadata.json'),
|
||||
'utf-8'
|
||||
)).agent).toBe('dsh')
|
||||
})
|
||||
|
||||
test('auto-detects an existing DeepSeek Harness project skills directory end to end', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await mkdir(join(env.cwd, '.dsh', 'skills'), { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string; dir: string }> }
|
||||
expect(parsed.installed).toHaveLength(1)
|
||||
expect(parsed.installed[0]?.agent).toBe('dsh')
|
||||
expect(parsed.installed[0]?.dir).toMatch(/[/\\]\.dsh[/\\]skills[/\\]pdf-parser/)
|
||||
expect(await Bun.file(join(
|
||||
env.cwd,
|
||||
'.dsh',
|
||||
'skills',
|
||||
'pdf-parser',
|
||||
'.skillhub',
|
||||
'metadata.json'
|
||||
)).exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('--agent pi defaults to the user root and persists the Pi agent id', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { describe, expect, test } from 'bun:test'
|
|||
import { allProfiles, profileMap } from '../../../src/agents/detector'
|
||||
|
||||
describe('agent profiles', () => {
|
||||
test('has 16 tier 1 profiles', () => {
|
||||
expect(allProfiles).toHaveLength(16)
|
||||
test('has 17 tier 1 profiles', () => {
|
||||
expect(allProfiles).toHaveLength(17)
|
||||
})
|
||||
|
||||
test('all profiles have unique ids', () => {
|
||||
|
|
@ -15,11 +15,12 @@ describe('agent profiles', () => {
|
|||
})
|
||||
|
||||
test('profileMap contains all profiles', () => {
|
||||
expect(profileMap.size).toBe(16)
|
||||
expect(profileMap.size).toBe(17)
|
||||
expect(profileMap.has('astudio')).toBe(true)
|
||||
expect(profileMap.has('claude-code')).toBe(true)
|
||||
expect(profileMap.has('codex')).toBe(true)
|
||||
expect(profileMap.has('cursor')).toBe(true)
|
||||
expect(profileMap.has('dsh')).toBe(true)
|
||||
expect(profileMap.has('kilo')).toBe(true)
|
||||
expect(profileMap.has('pi')).toBe(true)
|
||||
})
|
||||
|
|
@ -41,6 +42,45 @@ describe('agent profiles', () => {
|
|||
expect(profile.projectRoots('/repo')).toEqual(['/repo/.cursor/skills'])
|
||||
})
|
||||
|
||||
test('DeepSeek Harness exposes and detects its project and user skills directories', async () => {
|
||||
const base = await mkdtemp(join(tmpdir(), 'skillhub-dsh-profile-'))
|
||||
const cwd = join(base, 'repo')
|
||||
const home = join(base, 'home')
|
||||
const projectRoot = `${cwd}/.dsh/skills`
|
||||
const userRoot = `${home}/.dsh/skills`
|
||||
const profile = profileMap.get('dsh')!
|
||||
|
||||
try {
|
||||
await mkdir(cwd, { recursive: true })
|
||||
await mkdir(home, { recursive: true })
|
||||
|
||||
expect(profile.displayName).toBe('DeepSeek Harness')
|
||||
expect(profile.projectRoots(cwd)).toEqual([projectRoot])
|
||||
expect(profile.userRoots(home)).toEqual([userRoot])
|
||||
expect(await profile.detectInstalled(cwd, home)).toEqual([])
|
||||
|
||||
await mkdir(projectRoot, { recursive: true })
|
||||
await mkdir(userRoot, { recursive: true })
|
||||
|
||||
expect(await profile.detectInstalled(cwd, home)).toEqual([
|
||||
{
|
||||
agent: 'dsh',
|
||||
rootDir: projectRoot,
|
||||
scope: 'project',
|
||||
source: 'detected'
|
||||
},
|
||||
{
|
||||
agent: 'dsh',
|
||||
rootDir: userRoot,
|
||||
scope: 'user',
|
||||
source: 'detected'
|
||||
}
|
||||
])
|
||||
} finally {
|
||||
await rm(base, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('Pi exposes and detects its project and user skills directories', async () => {
|
||||
const base = await mkdtemp(join(tmpdir(), 'skillhub-pi-profile-'))
|
||||
const cwd = join(base, 'repo')
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ services:
|
|||
SKILLHUB_STORAGE_S3_SECRET_KEY: ${SKILLHUB_STORAGE_S3_SECRET_KEY:-}
|
||||
SKILLHUB_STORAGE_S3_REGION: ${SKILLHUB_STORAGE_S3_REGION:-us-east-1}
|
||||
SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE: ${SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE:-false}
|
||||
SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING: ${SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING:-false}
|
||||
SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET: ${SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET:-false}
|
||||
SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY: ${SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY:-PT10M}
|
||||
SKILLHUB_SECURITY_SCANNER_ENABLED: ${SKILLHUB_SECURITY_SCANNER_ENABLED:-true}
|
||||
|
|
@ -115,6 +116,24 @@ services:
|
|||
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-admin@skillhub.local}
|
||||
OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID:-local-placeholder}
|
||||
OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET:-local-placeholder}
|
||||
OAUTH2_GITLAB_CLIENT_ID: ${OAUTH2_GITLAB_CLIENT_ID:-local-placeholder}
|
||||
OAUTH2_GITLAB_CLIENT_SECRET: ${OAUTH2_GITLAB_CLIENT_SECRET:-local-placeholder}
|
||||
OAUTH2_GITLAB_BASE_URI: ${OAUTH2_GITLAB_BASE_URI:-https://gitlab.com}
|
||||
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_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_REDIRECT_URI: ${OAUTH2_FEISHU_REDIRECT_URI:-${SKILLHUB_PUBLIC_BASE_URL:-http://localhost}/login/oauth2/code/feishu}
|
||||
OAUTH2_FEISHU_DISPLAY_NAME: ${OAUTH2_FEISHU_DISPLAY_NAME:-飞书}
|
||||
OAUTH2_DINGTALK_CLIENT_ID: ${OAUTH2_DINGTALK_CLIENT_ID:-local-placeholder}
|
||||
OAUTH2_DINGTALK_CLIENT_SECRET: ${OAUTH2_DINGTALK_CLIENT_SECRET:-local-placeholder}
|
||||
OAUTH2_DINGTALK_AUTHORIZE_URI: ${OAUTH2_DINGTALK_AUTHORIZE_URI:-https://login.dingtalk.com}
|
||||
OAUTH2_DINGTALK_BASE_URI: ${OAUTH2_DINGTALK_BASE_URI:-https://api.dingtalk.com}
|
||||
OAUTH2_DINGTALK_REDIRECT_URI: ${OAUTH2_DINGTALK_REDIRECT_URI:-${SKILLHUB_PUBLIC_BASE_URL:-http://localhost}/login/oauth2/code/dingtalk}
|
||||
OAUTH2_DINGTALK_DISPLAY_NAME: ${OAUTH2_DINGTALK_DISPLAY_NAME:-钉钉}
|
||||
SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-}
|
||||
SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-25}
|
||||
SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME:-}
|
||||
|
|
|
|||
|
|
@ -227,6 +227,47 @@ spec:
|
|||
key: oauth2-github-client-secret
|
||||
optional: true
|
||||
|
||||
# OAuth2 Feishu (optional)
|
||||
- name: OAUTH2_FEISHU_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: skillhub-secret
|
||||
key: oauth2-feishu-client-id
|
||||
optional: true
|
||||
- name: OAUTH2_FEISHU_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
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"
|
||||
|
||||
# OAuth2 DingTalk (optional)
|
||||
- name: OAUTH2_DINGTALK_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: skillhub-secret
|
||||
key: oauth2-dingtalk-client-id
|
||||
optional: true
|
||||
- name: OAUTH2_DINGTALK_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: skillhub-secret
|
||||
key: oauth2-dingtalk-client-secret
|
||||
optional: true
|
||||
- name: OAUTH2_DINGTALK_AUTHORIZE_URI
|
||||
value: "https://login.dingtalk.com"
|
||||
- name: OAUTH2_DINGTALK_BASE_URI
|
||||
value: "https://api.dingtalk.com"
|
||||
- name: OAUTH2_DINGTALK_DISPLAY_NAME
|
||||
value: "钉钉"
|
||||
volumeMounts:
|
||||
- name: skillhub-storage
|
||||
mountPath: /var/lib/skillhub/storage
|
||||
|
|
|
|||
|
|
@ -27,6 +27,14 @@ stringData:
|
|||
oauth2-github-client-id: ""
|
||||
oauth2-github-client-secret: ""
|
||||
|
||||
# 飞书 OAuth(可选,用于飞书登录;留空则登录页不展示该入口)
|
||||
oauth2-feishu-client-id: ""
|
||||
oauth2-feishu-client-secret: ""
|
||||
|
||||
# 钉钉 OAuth(可选,用于钉钉登录;留空则登录页不展示该入口)
|
||||
oauth2-dingtalk-client-id: ""
|
||||
oauth2-dingtalk-client-secret: ""
|
||||
|
||||
# LLM 配置(可选,用于技能扫描)
|
||||
skill-scanner-llm-api-key: ""
|
||||
skill-scanner-llm-base-url: ""
|
||||
|
|
|
|||
|
|
@ -271,18 +271,92 @@ spring:
|
|||
client-id: ${OAUTH2_GITHUB_CLIENT_ID}
|
||||
client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET}
|
||||
scope: read:user,user:email
|
||||
# 二期扩展示例:
|
||||
# gitlab:
|
||||
# client-id: ...
|
||||
# authorization-grant-type: authorization_code
|
||||
# google:
|
||||
# client-id: ...
|
||||
gitlab:
|
||||
client-id: ${OAUTH2_GITLAB_CLIENT_ID}
|
||||
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
|
||||
dingtalk:
|
||||
client-id: ${OAUTH2_DINGTALK_CLIENT_ID}
|
||||
client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET}
|
||||
# 故意不声明 scope:钉钉的授权端点要 scope=openid,但在这里声明会让
|
||||
# Spring 把该注册当成 OIDC 客户端并附加 nonce,而钉钉不接受 nonce。
|
||||
# scope=openid 与 prompt=consent 由 DingTalkAuthorizationRequestCustomizer
|
||||
# 在请求阶段补上。
|
||||
# 钉钉是 confidential client,只是由自定义 token client 把 secret 放进 JSON body。
|
||||
# 不使用 none,避免 Spring 自动添加本实现无法应答的 PKCE challenge。
|
||||
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 只需:
|
||||
1. `application.yml` 添加 registration 配置
|
||||
2. `CustomOAuth2UserService` 中按 `registrationId` 分支处理用户属性映射
|
||||
3. 前端登录页增加对应按钮(通过 `/api/v1/auth/providers` 自动发现)
|
||||
1. `application.yml` 添加 registration 与 provider 配置
|
||||
2. 实现一个 `OAuthClaimsExtractor`,把该 Provider 的属性映射成统一的 `OAuthClaims`
|
||||
3. 登录页无需改代码:`/api/v1/auth/methods` 只返回配置了真实 client id 的注册,
|
||||
图标按 provider 名解析为 `/{provider}-logo.svg`
|
||||
|
||||
第 2 步是按 Provider 注册一个 Bean,而不是在某个类里按 `registrationId` 分支。
|
||||
账号匹配、建号、资料权威和账号守卫都在 `OAuthClaims` 之后共享,Provider 自己不做这些决策。
|
||||
|
||||
如果该 Provider 的协议有偏离标准之处,按偏离的环节实现对应的策略接口,
|
||||
每个接口都声明自己负责哪个 `registrationId`,由框架分发,不需要在共享类里写分支:
|
||||
|
||||
| 偏离环节 | 策略接口 | 现有实现 |
|
||||
|---|---|---|
|
||||
| 授权请求参数 | `ProviderAuthorizationRequestCustomizer` | 钉钉补 `scope=openid` 与 `prompt=consent` |
|
||||
| token 交换 | `ProviderTokenResponseClient` | 钉钉用 JSON body 而非表单 |
|
||||
| userinfo 加载 | `ProviderOAuth2UserService` | 飞书拆信封;钉钉用自定义 token header |
|
||||
|
||||
以 userinfo 为例:飞书用 `{code, msg, data}` 信封且以 HTTP 200 返回错误,
|
||||
钉钉则把 token 放在 `x-acs-dingtalk-access-token` 而不是 `Authorization: Bearer`。
|
||||
两者都只接管加载步骤,其余流程不变。该覆盖运行在
|
||||
`RemoteIdentityIoExecutor` 边界内,因此 Provider 的 HTTP 调用不会持有数据库事务。
|
||||
|
||||
Provider 的实现**不得**自己做账号决策 —— 不建号、不绑定、不建 session。
|
||||
这些一律交给统一身份核心,否则每个 Provider 都会长出一套账号逻辑,
|
||||
正是统一身份认证要消除的问题。
|
||||
|
||||
Provider 侧还需遵守:subject 必须稳定(不要用可能在两次登录间变化的字段做
|
||||
fallback,否则同一个人会被拆成两个平台账号)、只有在 Provider 真正证明了邮箱
|
||||
所有权时才置 `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
|
||||
OAUTH2_FEISHU_REDIRECT_URI=
|
||||
|
||||
# 历史 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. 核心接口设计
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,11 @@ skillhub CLI 遵循以下目录优先级,与 OpenSkills/Claude 保持互操作
|
|||
|
||||
安装后目录名等于 `skill.slug`(SKILL.md 的 `name` 字段),确保其他兼容客户端可通过目录名发现。
|
||||
|
||||
DeepSeek Harness 的 `dsh` profile 使用项目级 `./.dsh/skills/` 和用户级
|
||||
`~/.dsh/skills/`;dsh 同时原生扫描上表的 `.agents/skills/` 通用目录。dsh 把最近的
|
||||
`.git` 祖先作为项目根目录,因此项目级安装应从仓库根目录执行。若 `DSH_HOME` 指向
|
||||
自定义目录,使用 `--dir "$DSH_HOME/skills"` 显式安装。
|
||||
|
||||
## 8.5 与 AGENTS.md 的关系
|
||||
|
||||
- skillhub CLI 安装技能后,通过 `sync` 命令在 AGENTS.md 中生成 `<skill>` 描述块
|
||||
|
|
|
|||
|
|
@ -163,7 +163,8 @@ Sentinel 配置优先于 Cluster 和单机 `host`/`port`。在 Kubernetes 等 Se
|
|||
- 使用发布镜像,不在用户机器上执行本地构建
|
||||
- 负责拉起 PostgreSQL、Redis、server、web
|
||||
- PostgreSQL、Redis 默认只绑定到 `127.0.0.1`
|
||||
- Web 和后端都支持运行时环境变量注入,不需要为每个环境重建镜像
|
||||
- Web 和后端都支持运行时环境变量注入,不需要为每个环境重建镜像;S3/OSS 的
|
||||
`SKILLHUB_STORAGE_S3_*` 变量会透传到 server
|
||||
- `.env.release.example`
|
||||
- 运行时变量模板
|
||||
- 包含镜像名、镜像版本、端口、数据库凭证、外部 OSS、站点公网地址和首登管理员参数
|
||||
|
|
@ -171,6 +172,18 @@ Sentinel 配置优先于 Cluster 和单机 `host`/`port`。在 Kubernetes 等 Se
|
|||
- 在启动前校验 `.env.release`
|
||||
- 可提前拦截占位值、URL 格式错误、缺失的 OSS 凭据、危险的明文默认值
|
||||
|
||||
阿里云 OSS 等不支持 AWS chunked encoding 的对象存储,需要在 `.env.release` 中设置:
|
||||
|
||||
```dotenv
|
||||
SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING=true
|
||||
```
|
||||
|
||||
该变量由 `compose.release.yml` 透传到 server;修改后需要重新创建 server 容器:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.release -f compose.release.yml up -d --force-recreate server
|
||||
```
|
||||
|
||||
### 5.5 镜像标签约定
|
||||
|
||||
- `edge`
|
||||
|
|
@ -283,7 +296,215 @@ services:
|
|||
- `SKILLHUB_WEB_API_BASE_URL=/skillhub`
|
||||
- `SKILLHUB_PUBLIC_BASE_URL=https://example.com/skillhub`
|
||||
网关可以在转发到 Web 容器前将该前缀重写掉,但公网 URL 仍必须保留前缀,确保 OAuth、CLI 和 registry 链接正确。
|
||||
- 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET`
|
||||
- 如果要开放真实登录,再补充对应 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`。
|
||||
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`
|
||||
- `OAUTH2_FEISHU_REDIRECT_URI=`(可选;Compose 默认根据
|
||||
`SKILLHUB_PUBLIC_BASE_URL` 生成 `/login/oauth2/code/feishu`,Helm/K8s 未设置时由
|
||||
Spring 使用 `{baseUrl}`;经过特殊反向代理或本地动态端口时应显式设置完整回调 URL)
|
||||
|
||||
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。
|
||||
- 钉钉:`OAUTH2_DINGTALK_CLIENT_ID` / `OAUTH2_DINGTALK_CLIENT_SECRET`
|
||||
(分别填应用的 AppKey 与 AppSecret)。在钉钉开发者后台登记
|
||||
`https://<公网域名>/login/oauth2/code/dingtalk`,并为用户信息接口开通所需权限。
|
||||
同时将钉钉开发者后台的“服务器出口 IP”配置为实际运行 SkillHub 后端并调用
|
||||
DingTalk API 的机器公网 IP;仅将回调域名或反向隧道服务器 IP 加入白名单并不能
|
||||
改变本地后端的出站 IP。使用 SSH 反向隧道做本地预览时,应临时加入本机出站 IP,
|
||||
或让后端出站流量经过已加入白名单的服务器;生产环境应只配置生产后端的固定出口 IP。
|
||||
`OAUTH2_DINGTALK_REDIRECT_URI` 可在动态端口或特殊反向代理场景显式覆盖;Compose
|
||||
默认根据 `SKILLHUB_PUBLIC_BASE_URL` 生成回调,Helm/K8s 未设置时由 Spring 使用
|
||||
`{baseUrl}`。国际版或网关场景可覆盖 `OAUTH2_DINGTALK_AUTHORIZE_URI` 与
|
||||
`OAUTH2_DINGTALK_BASE_URI`。
|
||||
|
||||
留空即不展示该入口,无需改配置文件。注意:飞书和钉钉的邮箱都由企业管理员导入、
|
||||
未经用户确认,因此 `emailVerified` 恒为 false;若在 `application.yml` 中把
|
||||
`skillhub.access-policy.mode` 设为 `EMAIL_DOMAIN`,该策略会拒绝所有未验证邮箱,
|
||||
这两个入口的登录将一律失败。启用它们时请保留默认的 `OPEN` 或改用其他准入模式。
|
||||
|
||||
启用飞书前,使用一个测试租户完成一次真实回调验收。不要把真实 client secret
|
||||
写入仓库、报告或聊天记录;只在受控的 `.env.release`、CI Secret 或 Kubernetes
|
||||
Secret 中注入:
|
||||
|
||||
1. 在飞书自建应用中登记
|
||||
`https://<公网域名>/login/oauth2/code/feishu`,并开启用户信息所需权限;如果使用
|
||||
本地预览,则把 `OAUTH2_FEISHU_REDIRECT_URI` 设置为预览 Web 地址对应的完整回调 URL。
|
||||
2. 在受控环境设置 `OAUTH2_FEISHU_CLIENT_ID`、`OAUTH2_FEISHU_CLIENT_SECRET`,确认
|
||||
`OAUTH2_FEISHU_PROTOCOL_VERSION` 与 token endpoint 匹配,然后运行:
|
||||
|
||||
```bash
|
||||
make validate-release-config
|
||||
docker compose --env-file .env.release -f compose.release.yml up -d
|
||||
curl -fsS http://127.0.0.1:8080/actuator/health
|
||||
curl -fsS http://127.0.0.1:8080/api/v1/auth/methods
|
||||
```
|
||||
|
||||
3. 在登录页选择“飞书”,确认浏览器跳转到配置的授权域名;完成授权后应回到
|
||||
`/login/oauth2/code/feishu`,最终进入 `/` 或原始的 root-relative `returnTo`。
|
||||
4. 用同一个飞书账号再次登录,确认仍绑定同一个 SkillHub 账号;再用已禁用的
|
||||
SkillHub 账号登录,预期跳转 `/access-denied`,且不创建新 Session。
|
||||
5. 检查日志中只有 provider、HTTP 状态、错误码和阶段信息,不应出现 client secret、
|
||||
authorization code、access token、`open_id` 或上游错误文本:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.release.yml logs --tail=200 server \
|
||||
| rg -i 'client_secret|authorization code|access[_-]?token|open_id|secret|token'
|
||||
```
|
||||
|
||||
本地 mock 回调只能证明 SkillHub 与协议形状的集成,不能替代上述真实租户验收。
|
||||
没有可用飞书租户时,应将该项记录为“未验证”,不要宣称 Feishu 登录已通过。
|
||||
|
||||
钉钉登录使用同样的验收边界,但协议配置不同:在钉钉开发者后台创建企业内部
|
||||
H5 微应用,使用应用的 AppKey/AppSecret,进入“钉钉登录与分享”登记
|
||||
`https://<公网域名>/login/oauth2/code/dingtalk`,并开通个人信息读取权限。
|
||||
验收前设置:
|
||||
|
||||
```dotenv
|
||||
OAUTH2_DINGTALK_CLIENT_ID=<AppKey>
|
||||
OAUTH2_DINGTALK_CLIENT_SECRET=<AppSecret>
|
||||
OAUTH2_DINGTALK_REDIRECT_URI=https://<公网域名>/login/oauth2/code/dingtalk
|
||||
```
|
||||
|
||||
登录请求必须包含 `scope=openid` 和 `prompt=consent`,但配置文件不能声明 `openid`
|
||||
scope;实现会把它们仅写入外发授权 URL,避免 Spring 将回调路由到 OIDC。验收时应
|
||||
确认 token 请求为 JSON body,userinfo 请求使用 `x-acs-dingtalk-access-token`,重复
|
||||
登录仍绑定同一 `unionId`。上游失败时日志只记录 HTTP 状态、错误码、requiredScopes
|
||||
和 requestId,不记录 AppSecret、authorization code、access token、unionId 或完整错误正文。
|
||||
没有钉钉测试应用凭据时,这些只能标记为“协议测试通过、真实厂商往返未验证”。
|
||||
|
||||
### 7.1 钉钉配置示例
|
||||
|
||||
以下示例中的 `AppKey`、`AppSecret`、公网地址和出口 IP 都必须替换为部署环境的真实值。
|
||||
不要把 `AppSecret` 提交到 Git、镜像或 HTML 报告。
|
||||
|
||||
#### Docker Compose release
|
||||
|
||||
在受保护的 `.env.release` 中设置:
|
||||
|
||||
```dotenv
|
||||
# 浏览器访问地址,不带末尾斜杠
|
||||
SKILLHUB_PUBLIC_BASE_URL=https://skills.example.com
|
||||
SESSION_COOKIE_SECURE=true
|
||||
|
||||
# 钉钉企业内部 H5 微应用
|
||||
OAUTH2_DINGTALK_CLIENT_ID=dingxxxxxxxx
|
||||
OAUTH2_DINGTALK_CLIENT_SECRET=<从密钥管理系统注入>
|
||||
OAUTH2_DINGTALK_REDIRECT_URI=https://skills.example.com/login/oauth2/code/dingtalk
|
||||
OAUTH2_DINGTALK_AUTHORIZE_URI=https://login.dingtalk.com
|
||||
OAUTH2_DINGTALK_BASE_URI=https://api.dingtalk.com
|
||||
OAUTH2_DINGTALK_DISPLAY_NAME=钉钉
|
||||
```
|
||||
|
||||
启动和检查:
|
||||
|
||||
```bash
|
||||
make validate-release-config
|
||||
docker compose --env-file .env.release -f compose.release.yml up -d
|
||||
curl -fsS http://127.0.0.1:8080/actuator/health
|
||||
curl -fsS http://127.0.0.1:8080/api/v1/auth/methods
|
||||
```
|
||||
|
||||
钉钉后台必须同时配置:
|
||||
|
||||
1. “钉钉登录与分享”回调 URL:与 `OAUTH2_DINGTALK_REDIRECT_URI` 完全一致。
|
||||
2. `Contact.User.Read` 个人信息读取权限,并将应用发布到当前版本。
|
||||
3. 服务器出口 IP:填写运行 SkillHub 后端并访问 `api.dingtalk.com` 的真实公网出口。
|
||||
4. 测试账号必须属于应用所属组织,并在应用可用范围内。
|
||||
|
||||
#### Helm 私有化部署
|
||||
|
||||
推荐使用 Kubernetes Secret,不把密钥写入 `values-production.yaml`:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: skillhub-production-secret
|
||||
namespace: skillhub
|
||||
type: Opaque
|
||||
stringData:
|
||||
bootstrap-admin-password: "<固定随机密码>"
|
||||
skillhub-download-anon-cookie-secret: "<至少32字符随机值>"
|
||||
oauth2-dingtalk-client-id: "dingxxxxxxxx"
|
||||
oauth2-dingtalk-client-secret: "<从密钥管理系统注入>"
|
||||
```
|
||||
|
||||
`values-production.yaml` 只放非敏感配置:
|
||||
|
||||
```yaml
|
||||
images:
|
||||
registry: ghcr.io/iflytek
|
||||
tag: <固定发布版本>
|
||||
pullPolicy: IfNotPresent
|
||||
publicBaseUrl: https://skills.example.com
|
||||
session:
|
||||
cookieSecure: true
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: skills.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- hosts:
|
||||
- skills.example.com
|
||||
secretName: skillhub-tls
|
||||
oauth2:
|
||||
dingtalk:
|
||||
authorizeBaseUri: https://login.dingtalk.com
|
||||
apiBaseUri: https://api.dingtalk.com
|
||||
redirectUri: https://skills.example.com/login/oauth2/code/dingtalk
|
||||
displayName: 钉钉
|
||||
```
|
||||
|
||||
安装或升级:
|
||||
|
||||
```bash
|
||||
kubectl create namespace skillhub --dry-run=client -o yaml | kubectl apply -f -
|
||||
kubectl apply -f skillhub-production-secret.yaml
|
||||
helm upgrade --install skillhub ./charts/skillhub \
|
||||
--namespace skillhub \
|
||||
-f values-production.yaml \
|
||||
--set existingSecret=skillhub-production-secret
|
||||
```
|
||||
|
||||
如果使用 Chart 自己创建 Secret,也可以在受保护的 values 文件中设置
|
||||
`secrets.oauth2DingtalkClientId` 和 `secrets.oauth2DingtalkClientSecret`;生产环境优先使用
|
||||
External Secrets、Sealed Secrets 或其他密钥注入方案。
|
||||
|
||||
#### 原生 Kubernetes/Kustomize
|
||||
|
||||
在 `deploy/k8s/base/secret.yaml.example` 对应的 Secret 中提供:
|
||||
|
||||
```yaml
|
||||
stringData:
|
||||
oauth2-dingtalk-client-id: dingxxxxxxxx
|
||||
oauth2-dingtalk-client-secret: "<从密钥管理系统注入>"
|
||||
```
|
||||
|
||||
再通过环境变量或 overlay 设置公开地址和回调:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: SKILLHUB_PUBLIC_BASE_URL
|
||||
value: https://skills.example.com
|
||||
- name: OAUTH2_DINGTALK_REDIRECT_URI
|
||||
value: https://skills.example.com/login/oauth2/code/dingtalk
|
||||
```
|
||||
|
||||
Kubernetes 集群节点或出口网关的公网 IP 必须加入钉钉服务器出口 IP 白名单。Ingress 只负责浏览器
|
||||
回调可达性,不会替代后端出站 IP 白名单。
|
||||
- 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md`
|
||||
|
||||
## 8 OIDC 登录配置
|
||||
|
|
|
|||
83
docs/dsh-integration-en.md
Normal file
83
docs/dsh-integration-en.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# DeepSeek Harness Integration Guide
|
||||
|
||||
This guide explains how to install SkillHub packages into
|
||||
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), whose CLI is `dsh`.
|
||||
|
||||
## Verified scope
|
||||
|
||||
The directory behavior described here was verified on 2026-09-20 against
|
||||
`@deepseek-ai/dsh-skill-filesystem` at DeepSeek Harness commit
|
||||
[`ddefc45`](https://github.com/deepseek-ai/deepseek-harness/tree/ddefc45fbc7f8e46dd73185e68295696d1297887/packages/skill/skill-filesystem).
|
||||
That release is still a `0.1.x` developer preview; recheck the roots after upgrading dsh.
|
||||
|
||||
## Install into native dsh roots
|
||||
|
||||
dsh uses `<repository>/.dsh/skills` for project skills and `$DSH_HOME/skills`
|
||||
(default `~/.dsh/skills`) for user skills. With the default home:
|
||||
|
||||
```bash
|
||||
# User scope: ~/.dsh/skills/<skill-slug>/
|
||||
skillhub install my-skill --agent dsh --scope user
|
||||
|
||||
# Project scope: run from the repository root
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
skillhub install my-skill --agent dsh --scope project
|
||||
```
|
||||
|
||||
Explicit `--agent dsh` without `--scope` defaults to the user root. Verify the local
|
||||
SkillHub record with:
|
||||
|
||||
```bash
|
||||
skillhub list --agent dsh
|
||||
```
|
||||
|
||||
dsh watches configured skill roots, so added, renamed, or removed skills appear after
|
||||
the next catalog refresh without restarting the process.
|
||||
|
||||
## Use the shared `.agents/skills` roots
|
||||
|
||||
dsh also scans project `.agents/skills` and user `~/.agents/skills`. This works with an
|
||||
older SkillHub CLI that lacks the profile and lets multiple agents share one installation:
|
||||
|
||||
```bash
|
||||
skillhub install my-skill --dir "$HOME/.agents/skills"
|
||||
skillhub install my-skill --dir "$(git rev-parse --show-toplevel)/.agents/skills"
|
||||
```
|
||||
|
||||
## Custom DSH_HOME
|
||||
|
||||
The SkillHub profile maps the default `~/.dsh/skills` root. If dsh uses a custom
|
||||
`DSH_HOME`, pass its actual path explicitly:
|
||||
|
||||
```bash
|
||||
skillhub install my-skill --dir "${DSH_HOME:-$HOME/.dsh}/skills"
|
||||
```
|
||||
|
||||
## Project-root difference
|
||||
|
||||
dsh finds the nearest `.git` ancestor and treats it as the project root. SkillHub CLI
|
||||
profiles use the current working directory. Running `--scope project --agent dsh` from a
|
||||
repository subdirectory would therefore write a `.dsh/skills` directory that dsh does not
|
||||
treat as the project root. Change to the path returned by `git rev-parse --show-toplevel`
|
||||
before project-scoped installation.
|
||||
|
||||
## Compatibility boundary
|
||||
|
||||
- SkillHub writes `<skill-slug>/SKILL.md`, matching dsh's one-level bundle discovery.
|
||||
- dsh also accepts a flat `<name>.md`; SkillHub packages still require root-level `SKILL.md`.
|
||||
- `SKILL.md` needs a valid kebab-case `name` and a non-empty `description` frontmatter field.
|
||||
- Format compatibility does not guarantee runtime compatibility. Agent-specific tools,
|
||||
commands, MCP servers, environment variables, and operating-system requirements still
|
||||
need separate validation.
|
||||
- Review package contents and the SkillHub security report before installation. Never put a
|
||||
registry token in a skill package.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If dsh does not discover an installed skill:
|
||||
|
||||
1. Run `skillhub list` and verify the recorded directory and status.
|
||||
2. Confirm the layout is `<skill-root>/<skill-slug>/SKILL.md` without another nesting level.
|
||||
3. Check the `name` and `description` frontmatter in `SKILL.md`.
|
||||
4. For project scope, confirm the directory is under the nearest `.git` ancestor.
|
||||
5. When using `DSH_HOME` or `DSH_AGENTS_HOME`, confirm installation used the actual configured root.
|
||||
83
docs/dsh-integration.md
Normal file
83
docs/dsh-integration.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# DeepSeek Harness 集成指南
|
||||
|
||||
本文说明如何把 SkillHub 中的技能安装到
|
||||
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(CLI 名为 `dsh`)。
|
||||
|
||||
## 已验证范围
|
||||
|
||||
本文依据 DeepSeek Harness 提交
|
||||
[`ddefc45`](https://github.com/deepseek-ai/deepseek-harness/tree/ddefc45fbc7f8e46dd73185e68295696d1297887/packages/skill/skill-filesystem)
|
||||
中的 `@deepseek-ai/dsh-skill-filesystem` 行为编写,验证日期为 2026-09-20。
|
||||
该版本仍处于 `0.1.x` developer preview;升级 dsh 后请重新核对技能根目录约定。
|
||||
|
||||
## 安装到 dsh 原生目录
|
||||
|
||||
dsh 的原生项目级和用户级技能根分别是 `<仓库>/.dsh/skills` 与
|
||||
`$DSH_HOME/skills`(默认 `~/.dsh/skills`)。使用默认目录时:
|
||||
|
||||
```bash
|
||||
# 用户级:安装到 ~/.dsh/skills/<skill-slug>/
|
||||
skillhub install my-skill --agent dsh --scope user
|
||||
|
||||
# 项目级:从仓库根目录执行,安装到 .dsh/skills/<skill-slug>/
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
skillhub install my-skill --agent dsh --scope project
|
||||
```
|
||||
|
||||
省略 `--scope` 时,显式的 `--agent dsh` 默认选择用户级目录。安装后可用
|
||||
SkillHub CLI 核对记录:
|
||||
|
||||
```bash
|
||||
skillhub list --agent dsh
|
||||
```
|
||||
|
||||
dsh 会监听已配置的技能根;新增、重命名或删除技能后,无需重启进程即可在后续技能目录
|
||||
刷新中看到变化。
|
||||
|
||||
## 使用共享 `.agents/skills`
|
||||
|
||||
dsh 也原生扫描项目级 `.agents/skills` 和用户级 `~/.agents/skills`。因此在尚未升级到
|
||||
含 `dsh` profile 的 SkillHub CLI 时,或需要与其他 Agent 共享同一份技能时,可以使用:
|
||||
|
||||
```bash
|
||||
# 用户级共享目录
|
||||
skillhub install my-skill --dir "$HOME/.agents/skills"
|
||||
|
||||
# 项目级共享目录;仍建议从仓库根目录执行
|
||||
skillhub install my-skill --dir "$(git rev-parse --show-toplevel)/.agents/skills"
|
||||
```
|
||||
|
||||
## 自定义 DSH_HOME
|
||||
|
||||
SkillHub CLI 的 `dsh` profile 对应默认的 `~/.dsh/skills`。如果 dsh 使用了自定义
|
||||
`DSH_HOME`,请显式指定实际目录:
|
||||
|
||||
```bash
|
||||
skillhub install my-skill --dir "${DSH_HOME:-$HOME/.dsh}/skills"
|
||||
```
|
||||
|
||||
## 项目根目录差异
|
||||
|
||||
dsh 会向上查找最近的 `.git` 祖先作为项目根目录;SkillHub CLI 的项目级 profile
|
||||
则以当前工作目录为根。如果在仓库子目录执行 `--scope project --agent dsh`,SkillHub
|
||||
CLI 会写入子目录下的 `.dsh/skills`,而 dsh 不会把它当作项目根。项目级安装前应先
|
||||
切换到 `git rev-parse --show-toplevel` 返回的目录。
|
||||
|
||||
## 兼容性边界
|
||||
|
||||
- SkillHub 安装目录采用 `<skill-slug>/SKILL.md`,符合 dsh 对根目录一级技能包的发现规则。
|
||||
- dsh 还支持根目录中的单文件 `<name>.md`;SkillHub 包仍以根级 `SKILL.md` 为规范入口。
|
||||
- `SKILL.md` 至少需要合法的 kebab-case `name` 和非空 `description` frontmatter。
|
||||
- 格式兼容不代表运行时能力完全相同。技能依赖的 Agent 专用工具、命令、MCP server、
|
||||
环境变量和操作系统能力仍需单独验证。
|
||||
- 安装前应审查技能内容和 SkillHub 安全报告;Registry Token 不应写入技能包。
|
||||
|
||||
## 故障排查
|
||||
|
||||
如果 dsh 未发现已安装技能:
|
||||
|
||||
1. 运行 `skillhub list`,确认安装目录和状态。
|
||||
2. 确认目录结构为 `<技能根>/<skill-slug>/SKILL.md`,没有额外嵌套层级。
|
||||
3. 检查 `SKILL.md` 的 `name` 与 `description` frontmatter。
|
||||
4. 项目级安装确认位于最近的 `.git` 祖先下,而不是仓库子目录。
|
||||
5. 自定义 `DSH_HOME` 或 `DSH_AGENTS_HOME` 时,确认安装命令使用了对应实际路径。
|
||||
|
|
@ -190,9 +190,14 @@ A: Skill names are generally in English; Chinese names are not currently support
|
|||
|
||||
A: As long as you have permission to view it, it can generally be downloaded.
|
||||
|
||||
## Q: How do I hide or remove the GitHub / GitLab SSO login options on the login page?
|
||||
## Q: How do I hide or remove third-party SSO login options on the login page?
|
||||
|
||||
A: Edit `application.yml` and comment out or delete the `github` and `gitlab` blocks under `spring.security.oauth2.client.registration`, along with their corresponding `provider` sections. Spring Boot then won't create these registrations at startup, and the login page won't show those entries.
|
||||
A: Login entries are config-driven: `/api/v1/auth/methods` only returns registrations that have a real client id. When a client id is empty or contains `placeholder`, that entry never reaches the login page.
|
||||
|
||||
So there are two ways to hide one:
|
||||
|
||||
- Leave the matching environment variable unset (for example, omit `OAUTH2_FEISHU_CLIENT_ID`). No config file change needed.
|
||||
- Or edit `application.yml` and comment out or delete the relevant registration block (`github`, `gitlab`, `feishu`, `dingtalk`) under `spring.security.oauth2.client.registration`, along with its `provider` section. Spring Boot then won't create that registration at startup.
|
||||
|
||||
## Q: Is SkillHub's security scanning (Skill Scanner) developed in-house by iFLYTEK? What license does it use?
|
||||
|
||||
|
|
|
|||
|
|
@ -160,6 +160,9 @@ skillhub install pdf-parser --agent astudio
|
|||
# Install to Pi's user-level directory (use --scope project for the project directory)
|
||||
skillhub install pdf-parser --agent pi
|
||||
|
||||
# Install to DeepSeek Harness (use --scope project from the repository root for project skills)
|
||||
skillhub install pdf-parser --agent dsh
|
||||
|
||||
# Install to multiple Agents
|
||||
skillhub install pdf-parser --agent codex --agent claude-code
|
||||
|
||||
|
|
@ -196,6 +199,7 @@ Most Agents have both project-level and user-level skills directories. Use `--sc
|
|||
| `claude-code` | `<project>/.claude/skills/` | `~/.claude/skills/` |
|
||||
| `codex` | `<project>/.codex/skills/` | `~/.codex/skills/` |
|
||||
| `cursor` | `<project>/.cursor/skills/` | `~/.cursor/skills/` |
|
||||
| `dsh` (DeepSeek Harness) | `<project>/.dsh/skills/` | `~/.dsh/skills/` |
|
||||
| `github-copilot` | `<project>/.github-copilot/skills/` | `~/.github-copilot/skills/` |
|
||||
| `gemini-cli` | `<project>/.gemini/skills/` | `~/.gemini/skills/` |
|
||||
| `windsurf` | `<project>/.windsurf/skills/` | `~/.windsurf/skills/` |
|
||||
|
|
@ -212,6 +216,8 @@ Most Agents have both project-level and user-level skills directories. Use `--sc
|
|||
|
||||
For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. AStudio appears in that selector when `~/.acode/skills/` exists. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above.
|
||||
|
||||
DeepSeek Harness resolves project skills from the nearest Git repository root, while SkillHub CLI uses the current directory for project-scoped profiles. Run `--scope project --agent dsh` from the repository root. If `DSH_HOME` overrides the default `~/.dsh`, install with `--dir "$DSH_HOME/skills"`.
|
||||
|
||||
### File Structure After Installation
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -190,9 +190,17 @@ A: skill name 一般使用英文,目前不支持中文名(在 OpenClaw 中
|
|||
|
||||
A: 只要拥有可查看的权限,一般都可以下载。
|
||||
|
||||
## Q: 如何隐藏或删除登录页的 GitHub / GitLab SSO 登录方式?
|
||||
## Q: 如何隐藏或删除登录页的第三方 SSO 登录方式?
|
||||
|
||||
A: 修改 `application.yml`,注释或删除 `spring.security.oauth2.client.registration` 下的 `github` 和 `gitlab` 两块,并删除对应的 `provider` 段。Spring Boot 启动时便不会创建这两个注册,登录页也不会再显示对应入口。
|
||||
A: 登录入口是配置驱动的:`/api/v1/auth/methods` 只返回配置了真实 client id 的
|
||||
注册,client id 为空或包含 `placeholder` 时该入口不会出现在登录页。
|
||||
|
||||
所以隐藏某个入口有两种方式:
|
||||
|
||||
- 留空对应的环境变量即可(例如不设置 `OAUTH2_FEISHU_CLIENT_ID`),无需改动配置文件。
|
||||
- 或修改 `application.yml`,注释/删除 `spring.security.oauth2.client.registration`
|
||||
下对应的注册块(`github`、`gitlab`、`feishu`、`dingtalk`)以及对应的 `provider` 段,
|
||||
Spring Boot 启动时便不会创建该注册。
|
||||
|
||||
## Q: SkillHub 的安全扫描(Skill Scanner)是讯飞自研的吗?使用什么协议?
|
||||
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@ skillhub install pdf-parser --agent astudio
|
|||
# 安装到 Pi 的用户级目录(添加 --scope project 可安装到项目级目录)
|
||||
skillhub install pdf-parser --agent pi
|
||||
|
||||
# 安装到 DeepSeek Harness(项目级安装请在仓库根目录执行)
|
||||
skillhub install pdf-parser --agent dsh
|
||||
|
||||
# 安装到多个 Agent
|
||||
skillhub install pdf-parser --agent codex --agent claude-code
|
||||
|
||||
|
|
@ -192,6 +195,7 @@ CLI 按以下逻辑确定安装位置:
|
|||
| `claude-code` | `<project>/.claude/skills/` | `~/.claude/skills/` |
|
||||
| `codex` | `<project>/.codex/skills/` | `~/.codex/skills/` |
|
||||
| `cursor` | `<project>/.cursor/skills/` | `~/.cursor/skills/` |
|
||||
| `dsh`(DeepSeek Harness) | `<project>/.dsh/skills/` | `~/.dsh/skills/` |
|
||||
| `github-copilot` | `<project>/.github-copilot/skills/` | `~/.github-copilot/skills/` |
|
||||
| `gemini-cli` | `<project>/.gemini/skills/` | `~/.gemini/skills/` |
|
||||
| `windsurf` | `<project>/.windsurf/skills/` | `~/.windsurf/skills/` |
|
||||
|
|
@ -208,6 +212,8 @@ CLI 按以下逻辑确定安装位置:
|
|||
|
||||
对于自定义路径或不在列表中的 Agent 目录,使用 `--dir` 显式指定安装路径。交互式 user scope 下会与已探测 Agent 目标一同提供 `generic` 目标;当 `~/.acode/skills/` 存在时,选择器会显示 AStudio。当 `--scope user|project` 找不到匹配的 agent 目录时,CLI 会回退到上表的 `_fallback_` 行。
|
||||
|
||||
DeepSeek Harness 从最近的 Git 仓库根目录解析项目技能,而 SkillHub CLI 的项目级 profile 使用当前目录。请在仓库根目录运行 `--scope project --agent dsh`。如果通过 `DSH_HOME` 覆盖了默认的 `~/.dsh`,请改用 `--dir "$DSH_HOME/skills"` 安装。
|
||||
|
||||
### 安装后的文件结构
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -68,8 +68,66 @@ tmp="$(new_tmp)"
|
|||
|
||||
valid_env="$tmp/valid.env"
|
||||
write_env "$valid_env" "release-download-secret-32-bytes-minimum"
|
||||
printf '%s\n' "SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING=true" >>"$valid_env"
|
||||
"$SCRIPT" "$valid_env" >/dev/null
|
||||
|
||||
compose_default_redirect="$tmp/compose-default-redirect.txt"
|
||||
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=release-download-secret-32-bytes-minimum \
|
||||
SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com \
|
||||
docker compose -f "$REPO_ROOT/compose.release.yml" config \
|
||||
| grep -A1 'OAUTH2_FEISHU_REDIRECT_URI:' >"$compose_default_redirect"
|
||||
grep -Fq 'https://skillhub.example.com/login/oauth2/code/feishu' "$compose_default_redirect" \
|
||||
|| fail "compose must derive the default Feishu redirect URI from SKILLHUB_PUBLIC_BASE_URL"
|
||||
|
||||
valid_feishu_env="$tmp/valid-feishu.env"
|
||||
write_env "$valid_feishu_env" "release-download-secret-32-bytes-minimum"
|
||||
cat >>"$valid_feishu_env" <<'EOF'
|
||||
OAUTH2_FEISHU_CLIENT_ID=cli_test
|
||||
OAUTH2_FEISHU_CLIENT_SECRET=secret_test
|
||||
OAUTH2_FEISHU_PROTOCOL_VERSION=v2
|
||||
OAUTH2_FEISHU_AUTHORIZATION_URI=https://accounts.feishu.cn/open-apis/authen/v1/authorize
|
||||
OAUTH2_FEISHU_TOKEN_URI=https://open.feishu.cn/open-apis/authen/v2/oauth/token
|
||||
OAUTH2_FEISHU_USER_INFO_URI=https://open.feishu.cn/open-apis/authen/v1/user_info
|
||||
OAUTH2_FEISHU_REDIRECT_URI=http://127.0.0.1:55041/login/oauth2/code/feishu
|
||||
EOF
|
||||
"$SCRIPT" "$valid_feishu_env" >/dev/null
|
||||
|
||||
invalid_feishu_protocol_env="$tmp/invalid-feishu-protocol.env"
|
||||
write_env "$invalid_feishu_protocol_env" "release-download-secret-32-bytes-minimum"
|
||||
printf '%s\n' "OAUTH2_FEISHU_PROTOCOL_VERSION=v1" >>"$invalid_feishu_protocol_env"
|
||||
expect_fail "$invalid_feishu_protocol_env" "OAUTH2_FEISHU_PROTOCOL_VERSION must be either v2 or v3"
|
||||
|
||||
invalid_feishu_endpoint_env="$tmp/invalid-feishu-endpoint.env"
|
||||
write_env "$invalid_feishu_endpoint_env" "release-download-secret-32-bytes-minimum"
|
||||
printf '%s\n' "OAUTH2_FEISHU_TOKEN_URI=https://open.feishu.cn/oauth/token?tenant=prod" >>"$invalid_feishu_endpoint_env"
|
||||
expect_fail "$invalid_feishu_endpoint_env" "OAUTH2_FEISHU_TOKEN_URI must not contain a query"
|
||||
|
||||
invalid_feishu_redirect_env="$tmp/invalid-feishu-redirect.env"
|
||||
write_env "$invalid_feishu_redirect_env" "release-download-secret-32-bytes-minimum"
|
||||
printf '%s\n' "OAUTH2_FEISHU_REDIRECT_URI=https://skillhub.example.com/login/oauth2/code/feishu?bad=1" >>"$invalid_feishu_redirect_env"
|
||||
expect_fail "$invalid_feishu_redirect_env" "OAUTH2_FEISHU_REDIRECT_URI must not contain a query"
|
||||
|
||||
valid_dingtalk_env="$tmp/valid-dingtalk.env"
|
||||
write_env "$valid_dingtalk_env" "release-download-secret-32-bytes-minimum"
|
||||
cat >>"$valid_dingtalk_env" <<'EOF'
|
||||
OAUTH2_DINGTALK_CLIENT_ID=ding-test
|
||||
OAUTH2_DINGTALK_CLIENT_SECRET=dingtalk-test-secret
|
||||
OAUTH2_DINGTALK_AUTHORIZE_URI=https://login.dingtalk.com
|
||||
OAUTH2_DINGTALK_BASE_URI=https://api.dingtalk.com
|
||||
OAUTH2_DINGTALK_REDIRECT_URI=https://skillhub.example.com/login/oauth2/code/dingtalk
|
||||
EOF
|
||||
"$SCRIPT" "$valid_dingtalk_env" >/dev/null
|
||||
|
||||
invalid_dingtalk_base_env="$tmp/invalid-dingtalk-base.env"
|
||||
write_env "$invalid_dingtalk_base_env" "release-download-secret-32-bytes-minimum"
|
||||
printf '%s\n' "OAUTH2_DINGTALK_BASE_URI=https://api.dingtalk.com/" >>"$invalid_dingtalk_base_env"
|
||||
expect_fail "$invalid_dingtalk_base_env" "OAUTH2_DINGTALK_BASE_URI must not have a trailing slash"
|
||||
|
||||
invalid_dingtalk_redirect_env="$tmp/invalid-dingtalk-redirect.env"
|
||||
write_env "$invalid_dingtalk_redirect_env" "release-download-secret-32-bytes-minimum"
|
||||
printf '%s\n' "OAUTH2_DINGTALK_REDIRECT_URI=https://skillhub.example.com/callback?bad=1" >>"$invalid_dingtalk_redirect_env"
|
||||
expect_fail "$invalid_dingtalk_redirect_env" "OAUTH2_DINGTALK_REDIRECT_URI must not contain a query"
|
||||
|
||||
disabled_builtin_skills_env="$tmp/disabled-builtin-skills.env"
|
||||
write_env "$disabled_builtin_skills_env" "release-download-secret-32-bytes-minimum"
|
||||
printf '%s\n' "SKILLHUB_BUILTIN_SKILLS_ENABLED=false" >>"$disabled_builtin_skills_env"
|
||||
|
|
@ -253,6 +311,29 @@ write_env "$invalid_redis_sentinel_check_env" "release-download-secret-32-bytes-
|
|||
printf '%s\n' "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=yes" >>"$invalid_redis_sentinel_check_env"
|
||||
expect_fail "$invalid_redis_sentinel_check_env" "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST must be true or false"
|
||||
|
||||
# An OAuth client id without its secret (or vice versa) leaves the provider half-configured:
|
||||
# the login button renders but the exchange fails. Checked for every supported provider.
|
||||
for provider in GITHUB GITLAB FEISHU DINGTALK; do
|
||||
missing_oauth_secret_env="$tmp/missing-oauth-secret.env"
|
||||
write_env "$missing_oauth_secret_env" "release-download-secret-32-bytes-minimum"
|
||||
printf 'OAUTH2_%s_CLIENT_ID=real-client-id\n' "$provider" >>"$missing_oauth_secret_env"
|
||||
expect_fail "$missing_oauth_secret_env" "OAUTH2_${provider}_CLIENT_SECRET is required"
|
||||
|
||||
missing_oauth_id_env="$tmp/missing-oauth-id.env"
|
||||
write_env "$missing_oauth_id_env" "release-download-secret-32-bytes-minimum"
|
||||
printf 'OAUTH2_%s_CLIENT_SECRET=real-client-secret\n' "$provider" >>"$missing_oauth_id_env"
|
||||
expect_fail "$missing_oauth_id_env" "OAUTH2_${provider}_CLIENT_ID is required"
|
||||
done
|
||||
|
||||
# A fully configured provider pair must pass.
|
||||
valid_oauth_env="$tmp/valid-oauth.env"
|
||||
write_env "$valid_oauth_env" "release-download-secret-32-bytes-minimum"
|
||||
cat >>"$valid_oauth_env" <<'EOF'
|
||||
OAUTH2_FEISHU_CLIENT_ID=cli_release_example
|
||||
OAUTH2_FEISHU_CLIENT_SECRET=release-feishu-secret
|
||||
EOF
|
||||
"$SCRIPT" "$valid_oauth_env" >/dev/null
|
||||
|
||||
draft_env="$tmp/draft.env"
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
case "$line" in
|
||||
|
|
|
|||
|
|
@ -380,14 +380,40 @@ if [ "${REDIS_BIND_ADDRESS:-127.0.0.1}" != "127.0.0.1" ]; then
|
|||
warn "REDIS_BIND_ADDRESS is not 127.0.0.1; confirm Redis exposure is intended"
|
||||
fi
|
||||
|
||||
oauth_id="${OAUTH2_GITHUB_CLIENT_ID:-}"
|
||||
oauth_secret="${OAUTH2_GITHUB_CLIENT_SECRET:-}"
|
||||
if [ -n "$oauth_id" ] && [ -z "$oauth_secret" ]; then
|
||||
error "OAUTH2_GITHUB_CLIENT_SECRET is required when OAUTH2_GITHUB_CLIENT_ID is set"
|
||||
fi
|
||||
if [ -n "$oauth_secret" ] && [ -z "$oauth_id" ]; then
|
||||
error "OAUTH2_GITHUB_CLIENT_ID is required when OAUTH2_GITHUB_CLIENT_SECRET is set"
|
||||
fi
|
||||
for provider in GITHUB GITLAB FEISHU DINGTALK; do
|
||||
eval "oauth_id=\"\${OAUTH2_${provider}_CLIENT_ID:-}\""
|
||||
eval "oauth_secret=\"\${OAUTH2_${provider}_CLIENT_SECRET:-}\""
|
||||
if [ -n "$oauth_id" ] && [ -z "$oauth_secret" ]; then
|
||||
error "OAUTH2_${provider}_CLIENT_SECRET is required when OAUTH2_${provider}_CLIENT_ID is set"
|
||||
fi
|
||||
if [ -n "$oauth_secret" ] && [ -z "$oauth_id" ]; then
|
||||
error "OAUTH2_${provider}_CLIENT_ID is required when OAUTH2_${provider}_CLIENT_SECRET is set"
|
||||
fi
|
||||
done
|
||||
|
||||
feishu_protocol="${OAUTH2_FEISHU_PROTOCOL_VERSION:-v3}"
|
||||
case "$feishu_protocol" in
|
||||
v2|v3) ;;
|
||||
*) error "OAUTH2_FEISHU_PROTOCOL_VERSION must be either v2 or v3" ;;
|
||||
esac
|
||||
|
||||
# OAuth endpoints are sent directly to the provider. Validate them here so a
|
||||
# typo fails before the release container starts.
|
||||
for feishu_endpoint in OAUTH2_FEISHU_AUTHORIZATION_URI OAUTH2_FEISHU_TOKEN_URI OAUTH2_FEISHU_USER_INFO_URI OAUTH2_FEISHU_REDIRECT_URI; do
|
||||
eval "feishu_endpoint_value=\${$feishu_endpoint:-}"
|
||||
if [ -n "$feishu_endpoint_value" ]; then
|
||||
validate_url "$feishu_endpoint"
|
||||
fi
|
||||
done
|
||||
|
||||
for dingtalk_endpoint in OAUTH2_DINGTALK_AUTHORIZE_URI OAUTH2_DINGTALK_BASE_URI OAUTH2_DINGTALK_REDIRECT_URI; do
|
||||
eval "dingtalk_endpoint_value=\${$dingtalk_endpoint:-}"
|
||||
if [ -n "$dingtalk_endpoint_value" ]; then
|
||||
validate_url "$dingtalk_endpoint"
|
||||
fi
|
||||
done
|
||||
validate_no_trailing_slash OAUTH2_DINGTALK_AUTHORIZE_URI
|
||||
validate_no_trailing_slash OAUTH2_DINGTALK_BASE_URI
|
||||
|
||||
if [ "$errors" -gt 0 ]; then
|
||||
echo "Release config validation failed: $errors error(s), $warnings warning(s)." >&2
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import org.springframework.web.util.ContentCachingRequestWrapper;
|
|||
import org.springframework.web.util.ContentCachingResponseWrapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
|
|
@ -54,7 +55,7 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
|
|||
private void logRequest(ContentCachingRequestWrapper request, ContentCachingResponseWrapper response, long duration) {
|
||||
String requestUri = request.getRequestURI();
|
||||
String queryString = request.getQueryString();
|
||||
String fullUrl = queryString != null ? requestUri + "?" + queryString : requestUri;
|
||||
String fullUrl = queryString != null ? requestUri + "?" + sanitizeQueryString(queryString) : requestUri;
|
||||
|
||||
String contentType = request.getContentType();
|
||||
String userAgent = request.getHeader("User-Agent");
|
||||
|
|
@ -74,6 +75,26 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
|
|||
log.info(sb.toString());
|
||||
}
|
||||
|
||||
private String sanitizeQueryString(String queryString) {
|
||||
return java.util.Arrays.stream(queryString.split("&", -1))
|
||||
.map(parameter -> {
|
||||
int separator = parameter.indexOf('=');
|
||||
if (separator < 0) {
|
||||
return parameter;
|
||||
}
|
||||
String name = parameter.substring(0, separator).toLowerCase(Locale.ROOT);
|
||||
return isSensitiveQueryParameter(name)
|
||||
? parameter.substring(0, separator) + "=[REDACTED]"
|
||||
: parameter;
|
||||
})
|
||||
.collect(java.util.stream.Collectors.joining("&"));
|
||||
}
|
||||
|
||||
private boolean isSensitiveQueryParameter(String name) {
|
||||
return Set.of("code", "state", "error", "error_description", "error_uri", "access_token",
|
||||
"refresh_token", "id_token", "client_secret").contains(name);
|
||||
}
|
||||
|
||||
private boolean shouldSkip(String uri) {
|
||||
for (String prefix : SKIP_PREFIXES) {
|
||||
if (uri.startsWith(prefix)) {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,31 @@ spring:
|
|||
authorization-grant-type: authorization_code
|
||||
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
|
||||
# (contact:user.base:readonly, contact:user.email:readonly).
|
||||
authorization-grant-type: authorization_code
|
||||
client-authentication-method: client_secret_post
|
||||
redirect-uri: "${OAUTH2_FEISHU_REDIRECT_URI:{baseUrl}/login/oauth2/code/{registrationId}}"
|
||||
client-name: ${OAUTH2_FEISHU_DISPLAY_NAME:飞书}
|
||||
dingtalk:
|
||||
client-id: ${OAUTH2_DINGTALK_CLIENT_ID:placeholder}
|
||||
client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:placeholder}
|
||||
# No scope is declared on purpose. DingTalk's authorize endpoint wants scope=openid,
|
||||
# but declaring it here makes Spring treat the registration as OIDC and attach a
|
||||
# nonce, which DingTalk rejects. DingTalkAuthorizationRequestCustomizer adds the
|
||||
# scope back to the outgoing URI without turning this into an OIDC flow.
|
||||
authorization-grant-type: authorization_code
|
||||
# DingTalk is a confidential client that happens to carry its secret in a JSON body,
|
||||
# which DingTalkTokenResponseClient builds. client-secret-post is the honest
|
||||
# description; "none" would additionally make Spring apply PKCE, and the DingTalk token
|
||||
# request sends no code_verifier to match the challenge.
|
||||
client-authentication-method: client_secret_post
|
||||
redirect-uri: "${OAUTH2_DINGTALK_REDIRECT_URI:{baseUrl}/login/oauth2/code/{registrationId}}"
|
||||
client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:钉钉}
|
||||
provider:
|
||||
github:
|
||||
api-base-url: ${OAUTH2_GITHUB_API_BASE_URL:https://api.github.com}
|
||||
|
|
@ -79,6 +104,19 @@ spring:
|
|||
token-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/token
|
||||
user-info-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/api/v4/user
|
||||
user-name-attribute: username
|
||||
feishu:
|
||||
# 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
|
||||
dingtalk:
|
||||
authorization-uri: ${OAUTH2_DINGTALK_AUTHORIZE_URI:https://login.dingtalk.com}/oauth2/auth
|
||||
token-uri: ${OAUTH2_DINGTALK_BASE_URI:https://api.dingtalk.com}/v1.0/oauth2/userAccessToken
|
||||
user-info-uri: ${OAUTH2_DINGTALK_BASE_URI:https://api.dingtalk.com}/v1.0/contact/users/me
|
||||
user-name-attribute: unionId
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 100MB
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.iflytek.skillhub.TestRedisConfig;
|
||||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.auth.oauth.DingTalkOAuth2Constants;
|
||||
import com.iflytek.skillhub.auth.oauth.DispatchingTokenResponseClient;
|
||||
import com.iflytek.skillhub.auth.oauth.OAuthClaimsExtractor;
|
||||
import com.iflytek.skillhub.auth.oauth.ProviderAuthorizationRequestCustomizer;
|
||||
import com.iflytek.skillhub.auth.oauth.ProviderOAuth2UserService;
|
||||
import com.iflytek.skillhub.auth.oauth.ProviderTokenResponseClient;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
/**
|
||||
* Loads the real application context to prove the provider strategy beans are constructible.
|
||||
*
|
||||
* <p>The unit tests for these classes call their package-visible constructors directly, so they
|
||||
* cannot catch Spring wiring faults: a component with two constructors and no {@code @Autowired}
|
||||
* marker compiles and unit-tests green, then fails at startup with "No default constructor found".
|
||||
* This test is the guard for that class of failure.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Import(TestRedisConfig.class)
|
||||
class ProviderStrategyWiringTest {
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@MockBean
|
||||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@Autowired
|
||||
private DispatchingTokenResponseClient dispatchingTokenResponseClient;
|
||||
|
||||
@Autowired
|
||||
private List<ProviderTokenResponseClient> tokenResponseClients;
|
||||
|
||||
@Autowired
|
||||
private List<ProviderOAuth2UserService> userServices;
|
||||
|
||||
@Autowired
|
||||
private List<ProviderAuthorizationRequestCustomizer> authorizationCustomizers;
|
||||
|
||||
@Autowired
|
||||
private List<OAuthClaimsExtractor> claimsExtractors;
|
||||
|
||||
@Test
|
||||
void dispatcherAndEveryProviderStrategyAreConstructible() {
|
||||
assertThat(dispatchingTokenResponseClient).isNotNull();
|
||||
|
||||
// DingTalk needs all three strategy hooks; a missing bean would silently fall back to the
|
||||
// standard OAuth2 behaviour its endpoints reject.
|
||||
assertThat(tokenResponseClients)
|
||||
.extracting(ProviderTokenResponseClient::getProvider)
|
||||
.contains(DingTalkOAuth2Constants.REGISTRATION_ID, "feishu");
|
||||
assertThat(authorizationCustomizers)
|
||||
.extracting(ProviderAuthorizationRequestCustomizer::getProvider)
|
||||
.contains(DingTalkOAuth2Constants.REGISTRATION_ID);
|
||||
assertThat(userServices)
|
||||
.extracting(ProviderOAuth2UserService::getProvider)
|
||||
.contains(DingTalkOAuth2Constants.REGISTRATION_ID, "feishu");
|
||||
assertThat(claimsExtractors)
|
||||
.extracting(OAuthClaimsExtractor::getProvider)
|
||||
.contains(DingTalkOAuth2Constants.REGISTRATION_ID, "feishu", "github");
|
||||
}
|
||||
|
||||
@Test
|
||||
void providerKeysAreUniqueSoDispatchMapsCannotCollide() {
|
||||
// Collectors.toMap in the dispatchers throws on duplicate keys, which would break startup.
|
||||
assertThat(tokenResponseClients).extracting(ProviderTokenResponseClient::getProvider)
|
||||
.doesNotHaveDuplicates();
|
||||
assertThat(userServices).extracting(ProviderOAuth2UserService::getProvider)
|
||||
.doesNotHaveDuplicates();
|
||||
assertThat(authorizationCustomizers).extracting(ProviderAuthorizationRequestCustomizer::getProvider)
|
||||
.doesNotHaveDuplicates();
|
||||
assertThat(claimsExtractors).extracting(OAuthClaimsExtractor::getProvider)
|
||||
.doesNotHaveDuplicates();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -104,6 +104,28 @@ class RequestLoggingFilterTest {
|
|||
assertThat(loggedMessages()).noneMatch(message -> message.contains("Headers: {"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doFilterInternal_redactsOAuthCallbackQueryParameters() throws Exception {
|
||||
RequestLoggingFilter filter = new RequestLoggingFilter();
|
||||
attachAppender();
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/login/oauth2/code/feishu");
|
||||
request.setQueryString("code=authorization-code&state=csrf-state&scope=contact:user.base:readonly");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(request, response, (req, res) -> {});
|
||||
|
||||
String message = loggedMessages().stream()
|
||||
.filter(entry -> entry.contains("GET /login/oauth2/code/feishu"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertThat(message).contains("code=[REDACTED]");
|
||||
assertThat(message).contains("state=[REDACTED]");
|
||||
assertThat(message).contains("scope=contact:user.base:readonly");
|
||||
assertThat(message).doesNotContain("authorization-code");
|
||||
assertThat(message).doesNotContain("csrf-state");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doFilterInternal_shouldKeepCachingWrapperForRegularApiResponses() throws Exception {
|
||||
RequestLoggingFilter filter = new RequestLoggingFilter();
|
||||
|
|
|
|||
|
|
@ -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.DispatchingTokenResponseClient;
|
||||
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 DispatchingTokenResponseClient tokenResponseClient;
|
||||
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,
|
||||
DispatchingTokenResponseClient tokenResponseClient,
|
||||
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.tokenResponseClient = tokenResponseClient;
|
||||
this.authorizationRequestResolver = authorizationRequestResolver;
|
||||
this.successHandler = successHandler;
|
||||
this.failureHandler = failureHandler;
|
||||
|
|
@ -132,6 +136,7 @@ public class SecurityConfig {
|
|||
})
|
||||
.oauth2Login(oauth2 -> oauth2
|
||||
.authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver))
|
||||
.tokenEndpoint(token -> token.accessTokenResponseClient(tokenResponseClient))
|
||||
.userInfoEndpoint(userInfo -> userInfo
|
||||
.userService(customOAuth2UserService)
|
||||
.oidcUserService(customOidcUserService))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* Sends the {@code scope=openid} parameter DingTalk's authorize endpoint requires, without letting
|
||||
* Spring Security classify the login as OIDC.
|
||||
*
|
||||
* <p>Two separate mechanisms keyed off {@code openid} have to be avoided, which is why the scope is
|
||||
* written onto the URI rather than into the request's scope set:
|
||||
*
|
||||
* <ul>
|
||||
* <li>A registration declaring {@code openid} in configuration becomes an OIDC client, and
|
||||
* {@code DefaultOAuth2AuthorizationRequestResolver} attaches a {@code nonce} that DingTalk
|
||||
* rejects. Hence no scope in {@code application.yml}.
|
||||
* <li>{@code OAuth2LoginAuthenticationProvider.authenticate} returns null when the authorization
|
||||
* request's {@code getScopes()} contains {@code openid}, handing the callback to
|
||||
* {@code OidcAuthorizationCodeAuthenticationProvider}, which then fails with
|
||||
* {@code invalid_id_token} because DingTalk returns no {@code id_token}. Hence the scope set
|
||||
* stays empty and only the outgoing URI carries the parameter.
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
public class DingTalkAuthorizationRequestCustomizer implements ProviderAuthorizationRequestCustomizer {
|
||||
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return DingTalkOAuth2Constants.REGISTRATION_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void customize(OAuth2AuthorizationRequest.Builder builder) {
|
||||
String authorizationRequestUri = UriComponentsBuilder
|
||||
.fromUriString(builder.build().getAuthorizationRequestUri())
|
||||
.replaceQueryParam("scope", DingTalkOAuth2Constants.AUTHORIZATION_SCOPE)
|
||||
.replaceQueryParam("prompt", "consent")
|
||||
.build(true)
|
||||
.toUriString();
|
||||
builder.authorizationRequestUri(authorizationRequestUri);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Provider-specific claims extractor for DingTalk (钉钉). Attributes are already fetched by
|
||||
* {@link DingTalkOAuth2UserService}, which reads them from DingTalk's non-standard user info
|
||||
* endpoint.
|
||||
*
|
||||
* <p>Like the GitHub and Feishu extractors, this class logs nothing: the subject, display name and
|
||||
* email it handles are exactly the values that must stay out of the logs.
|
||||
*/
|
||||
@Component
|
||||
public class DingTalkClaimsExtractor implements OAuthClaimsExtractor {
|
||||
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return DingTalkOAuth2Constants.REGISTRATION_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) {
|
||||
Map<String, Object> attrs = oAuth2User.getAttributes();
|
||||
|
||||
String subject = requireText(
|
||||
attrs.get(DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME),
|
||||
DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME
|
||||
);
|
||||
|
||||
String email = text(attrs.get("email"));
|
||||
// DingTalk's user info endpoint returns the email recorded by the organization admin and
|
||||
// does not attest that the user controls it, so it carries no verification signal and
|
||||
// cannot be used to join an existing account.
|
||||
boolean emailVerified = false;
|
||||
|
||||
// nick -> name and stop. Falling back to the subject would write it into
|
||||
// UserAccount.displayName and into UserActivatedEvent, pushing the external subject
|
||||
// somewhere event consumers may log it.
|
||||
String providerLogin = text(attrs.get("nick"));
|
||||
if (providerLogin == null) {
|
||||
providerLogin = text(attrs.get("name"));
|
||||
}
|
||||
|
||||
return new OAuthClaims(
|
||||
DingTalkOAuth2Constants.REGISTRATION_ID,
|
||||
subject,
|
||||
email,
|
||||
emailVerified,
|
||||
providerLogin,
|
||||
attrs
|
||||
);
|
||||
}
|
||||
|
||||
private static String requireText(Object value, String attribute) {
|
||||
String text = text(value);
|
||||
if (text == null) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("missing_subject", "DingTalk user info is missing " + attribute, null)
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private static String text(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
/** Shared protocol constants for the DingTalk OAuth2 adapter. */
|
||||
public final class DingTalkOAuth2Constants {
|
||||
|
||||
public static final String REGISTRATION_ID = "dingtalk";
|
||||
public static final String AUTHORIZATION_SCOPE = "openid";
|
||||
public static final String ACCESS_TOKEN_HEADER = "x-acs-dingtalk-access-token";
|
||||
|
||||
/**
|
||||
* The only accepted subject claim. DingTalk also returns {@code openId} and {@code userId}, but
|
||||
* they must not act as fallbacks: {@code openId} is scoped per app and {@code userId} per
|
||||
* organization, so a login that fell back to either would bind a different identity than a
|
||||
* later login carrying {@code unionId}, splitting one person across two platform accounts.
|
||||
* Promoting another claim later needs an explicit alias migration.
|
||||
*/
|
||||
static final String SUBJECT_CLAIM_NAME = "unionId";
|
||||
|
||||
private DingTalkOAuth2Constants() {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* Loads DingTalk (钉钉) user info, which deviates from standard OAuth: the access token travels in
|
||||
* a custom {@code x-acs-dingtalk-access-token} header rather than {@code Authorization: Bearer}.
|
||||
*
|
||||
* <p>This service only fetches attributes. Account matching, provisioning and session creation
|
||||
* stay with the unified identity core reached through {@link OAuthLoginFlowService}, so DingTalk
|
||||
* cannot decide who a login resolves to.
|
||||
*/
|
||||
@Component
|
||||
public class DingTalkOAuth2UserService implements ProviderOAuth2UserService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DingTalkOAuth2UserService.class);
|
||||
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final Duration READ_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
/** A DingTalk contact payload is well under 1 KB; this only needs to stop an unbounded body. */
|
||||
private static final int MAX_RESPONSE_BYTES = 64 * 1024;
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
/**
|
||||
* Uses an external-service client that is intentionally not customized with application
|
||||
* tracing. Trace context must not be propagated to the external DingTalk service.
|
||||
*/
|
||||
@Autowired
|
||||
public DingTalkOAuth2UserService() {
|
||||
this(RestClient.builder().requestFactory(defaultRequestFactory()));
|
||||
}
|
||||
|
||||
public DingTalkOAuth2UserService(RestClient.Builder restClientBuilder) {
|
||||
this.restClient = restClientBuilder
|
||||
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds the userinfo call so an unresponsive DingTalk endpoint cannot hold a login thread. The
|
||||
* timeouts apply to this provider client only and do not change the shared HTTP defaults.
|
||||
*/
|
||||
private static ClientHttpRequestFactory defaultRequestFactory() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(CONNECT_TIMEOUT);
|
||||
factory.setReadTimeout(READ_TIMEOUT);
|
||||
return factory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return DingTalkOAuth2Constants.REGISTRATION_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
|
||||
String userInfoUri = userRequest.getClientRegistration().getProviderDetails()
|
||||
.getUserInfoEndpoint().getUri();
|
||||
|
||||
Map<String, Object> payload;
|
||||
try {
|
||||
payload = restClient.get()
|
||||
.uri(userInfoUri)
|
||||
.header(
|
||||
DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER,
|
||||
userRequest.getAccessToken().getTokenValue()
|
||||
)
|
||||
.exchange((request, clientResponse) -> {
|
||||
if (!clientResponse.getStatusCode().is2xxSuccessful()) {
|
||||
SafeErrorSummary summary = readSafeErrorSummary(clientResponse.getBody());
|
||||
log.warn(
|
||||
"DingTalk user info returned HTTP {}; code={}, requiredScopes={}, requestId={}",
|
||||
clientResponse.getStatusCode().value(),
|
||||
summary.code(),
|
||||
summary.requiredScopes(),
|
||||
summary.requestId());
|
||||
throw new IOException(
|
||||
"DingTalk user info returned HTTP " + clientResponse.getStatusCode().value());
|
||||
}
|
||||
return readBounded(clientResponse.getBody());
|
||||
});
|
||||
} catch (Exception e) {
|
||||
// Exception class only: the message can quote the request URI, which holds the token.
|
||||
log.warn("DingTalk user info request failed with {}", e.getClass().getSimpleName());
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("dingtalk_userinfo_error", "Failed to load DingTalk user info", null),
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
return new DefaultOAuth2User(
|
||||
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
|
||||
normalize(payload),
|
||||
DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads at most {@link #MAX_RESPONSE_BYTES} before parsing, so a misconfigured or hostile
|
||||
* {@code OAUTH2_DINGTALK_BASE_URI} 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 Map<String, Object> readBounded(InputStream body) throws IOException {
|
||||
byte[] bytes = body.readNBytes(MAX_RESPONSE_BYTES + 1);
|
||||
if (bytes.length > MAX_RESPONSE_BYTES) {
|
||||
throw new IOException("DingTalk user info response exceeds " + MAX_RESPONSE_BYTES + " bytes");
|
||||
}
|
||||
return OBJECT_MAPPER.readValue(bytes, new com.fasterxml.jackson.core.type.TypeReference<>() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Extracts provider diagnostics without logging tokens, messages, or the upstream body. */
|
||||
private static SafeErrorSummary readSafeErrorSummary(InputStream body) {
|
||||
try {
|
||||
byte[] bytes = body.readNBytes(MAX_RESPONSE_BYTES + 1);
|
||||
if (bytes.length > MAX_RESPONSE_BYTES) {
|
||||
return SafeErrorSummary.UNKNOWN;
|
||||
}
|
||||
JsonNode root = OBJECT_MAPPER.readTree(bytes);
|
||||
if (root == null) {
|
||||
return SafeErrorSummary.UNKNOWN;
|
||||
}
|
||||
String code = text(findNode(root, Set.of("code")));
|
||||
String requestId = text(findNode(root, Set.of("requestid")));
|
||||
JsonNode data = findNode(root, Set.of("data"));
|
||||
if (data != null && data.isTextual()) {
|
||||
try {
|
||||
JsonNode nested = OBJECT_MAPPER.readTree(data.asText());
|
||||
if (nested != null) {
|
||||
root = nested;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Keep the outer diagnostic fields when Data is not JSON.
|
||||
}
|
||||
}
|
||||
code = valueOrUnknown(code);
|
||||
requestId = valueOrUnknown(requestId != null ? requestId : text(findNode(root, Set.of("requestid"))));
|
||||
JsonNode scopes = findNode(root, Set.of("requiredscopes"));
|
||||
String requiredScopes = scopes != null && scopes.isArray()
|
||||
? String.join(",", textValues(scopes))
|
||||
: "-";
|
||||
return new SafeErrorSummary(code, requiredScopes, requestId);
|
||||
} catch (Exception ignored) {
|
||||
return SafeErrorSummary.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode findNode(JsonNode node, Set<String> names) {
|
||||
if (node.isObject()) {
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> field = fields.next();
|
||||
if (names.contains(field.getKey().toLowerCase())) {
|
||||
return field.getValue();
|
||||
}
|
||||
JsonNode nested = findNode(field.getValue(), names);
|
||||
if (nested != null) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
} else if (node.isArray()) {
|
||||
for (JsonNode child : node) {
|
||||
JsonNode nested = findNode(child, names);
|
||||
if (nested != null) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<String> textValues(JsonNode array) {
|
||||
List<String> values = new ArrayList<>();
|
||||
array.forEach(value -> {
|
||||
if (value.isTextual() && !value.asText().isBlank()) {
|
||||
values.add(value.asText());
|
||||
}
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
private static String text(JsonNode node) {
|
||||
return node != null && node.isValueNode() ? node.asText() : null;
|
||||
}
|
||||
|
||||
private static String valueOrUnknown(String value) {
|
||||
return value == null || value.isBlank() ? "-" : value;
|
||||
}
|
||||
|
||||
private record SafeErrorSummary(String code, String requiredScopes, String requestId) {
|
||||
private static final SafeErrorSummary UNKNOWN = new SafeErrorSummary("-", "-", "-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies through only the attributes the platform consumes, and aliases DingTalk's
|
||||
* {@code avatarUrl} to the {@code avatar_url} key the identity core reads. Attributes the
|
||||
* platform does not use -- notably {@code mobile} and {@code stateCode} -- are dropped rather
|
||||
* than carried into the principal, keeping unused PII out of claims and logs.
|
||||
*/
|
||||
private static Map<String, Object> normalize(Map<String, Object> payload) {
|
||||
Map<String, Object> attributes = new LinkedHashMap<>();
|
||||
copyIfPresent(attributes, payload, DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME);
|
||||
copyIfPresent(attributes, payload, "nick");
|
||||
copyIfPresent(attributes, payload, "name");
|
||||
copyIfPresent(attributes, payload, "email");
|
||||
Object avatar = payload.get("avatarUrl");
|
||||
if (avatar != null && !String.valueOf(avatar).isBlank()) {
|
||||
attributes.put("avatar_url", avatar);
|
||||
}
|
||||
if (!attributes.containsKey(DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME)) {
|
||||
// A reachable failure: DingTalk omits unionId for some app configurations, and the
|
||||
// operator needs to see why every login is being rejected. The claim name is a
|
||||
// constant, so this records nothing about the user.
|
||||
log.warn(
|
||||
"DingTalk user info response omitted {}; login rejected",
|
||||
DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME
|
||||
);
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error(
|
||||
"dingtalk_userinfo_error",
|
||||
"DingTalk user info missing " + DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME,
|
||||
null
|
||||
)
|
||||
);
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private static void copyIfPresent(
|
||||
Map<String, Object> target,
|
||||
Map<String, Object> source,
|
||||
String key
|
||||
) {
|
||||
Object value = source.get(key);
|
||||
if (value != null && !String.valueOf(value).isBlank()) {
|
||||
target.put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
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.OAuth2AuthenticationException;
|
||||
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.RestClientException;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* Custom token response client for DingTalk (钉钉).
|
||||
*
|
||||
* <p>DingTalk requires a JSON body for token exchange instead of the standard
|
||||
* form-urlencoded format. This client adapts the request accordingly.
|
||||
*
|
||||
* <p>Request body format:
|
||||
* <pre>{ "clientId": "...", "clientSecret": "...", "code": "...", "grantType": "authorization_code" }</pre>
|
||||
*/
|
||||
@Component
|
||||
public class DingTalkTokenResponseClient implements ProviderTokenResponseClient {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
public DingTalkTokenResponseClient() {
|
||||
this.restTemplate = buildRestTemplate();
|
||||
}
|
||||
|
||||
/** Package-visible constructor for unit testing with a mock RestTemplate. */
|
||||
DingTalkTokenResponseClient(RestTemplate restTemplate) {
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return DingTalkOAuth2Constants.REGISTRATION_ID;
|
||||
}
|
||||
|
||||
/** A DingTalk token payload is a few hundred bytes; this only stops an unbounded body. */
|
||||
private static final int MAX_RESPONSE_BYTES = 64 * 1024;
|
||||
|
||||
/** Package-visible so a test can exercise the production template, size cap included. */
|
||||
static RestTemplate buildRestTemplate() {
|
||||
var factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(Duration.ofSeconds(5));
|
||||
factory.setReadTimeout(Duration.ofSeconds(10));
|
||||
RestTemplate template = new RestTemplate(factory);
|
||||
// The timeouts bound how long the exchange may take; this bounds how much it may return, so
|
||||
// a misconfigured or hostile token endpoint cannot stream an unbounded body into the parser.
|
||||
// The userinfo client applies the same cap.
|
||||
template.getInterceptors().add((request, body, execution) -> {
|
||||
ClientHttpResponse response = execution.execute(request, body);
|
||||
byte[] bytes = response.getBody().readNBytes(MAX_RESPONSE_BYTES + 1);
|
||||
if (bytes.length > MAX_RESPONSE_BYTES) {
|
||||
response.close();
|
||||
throw new IOException("DingTalk token response exceeds " + MAX_RESPONSE_BYTES + " bytes");
|
||||
}
|
||||
return new BoundedClientHttpResponse(response, bytes);
|
||||
});
|
||||
return template;
|
||||
}
|
||||
|
||||
/** Replays the already-read, size-checked body so the converters can still parse it. */
|
||||
private record BoundedClientHttpResponse(ClientHttpResponse delegate, byte[] body)
|
||||
implements ClientHttpResponse {
|
||||
|
||||
@Override
|
||||
public HttpStatusCode getStatusCode() throws IOException {
|
||||
return delegate.getStatusCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStatusText() throws IOException {
|
||||
return delegate.getStatusText();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
delegate.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getBody() {
|
||||
return new ByteArrayInputStream(body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders getHeaders() {
|
||||
return delegate.getHeaders();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest)
|
||||
throws OAuth2AuthenticationException {
|
||||
String tokenUri = authorizationCodeGrantRequest.getClientRegistration().getProviderDetails().getTokenUri();
|
||||
String clientId = authorizationCodeGrantRequest.getClientRegistration().getClientId();
|
||||
String clientSecret = authorizationCodeGrantRequest.getClientRegistration().getClientSecret();
|
||||
String code = authorizationCodeGrantRequest.getAuthorizationExchange()
|
||||
.getAuthorizationResponse()
|
||||
.getCode();
|
||||
|
||||
Map<String, Object> tokenRequest = Map.of(
|
||||
"clientId", clientId,
|
||||
"clientSecret", clientSecret,
|
||||
"code", code,
|
||||
"grantType", "authorization_code"
|
||||
);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
ResponseEntity<String> response;
|
||||
try {
|
||||
response = restTemplate.postForEntity(tokenUri, new HttpEntity<>(tokenRequest, headers), String.class);
|
||||
} catch (RestClientResponseException e) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("token_exchange_io_error",
|
||||
"DingTalk token exchange failed with HTTP " + e.getStatusCode().value(), null));
|
||||
} catch (RestClientException e) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("token_exchange_io_error",
|
||||
"DingTalk token exchange request failed", null));
|
||||
}
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
|
||||
try {
|
||||
JsonNode json = MAPPER.readTree(response.getBody());
|
||||
|
||||
JsonNode accessTokenNode = json.get("accessToken");
|
||||
if (accessTokenNode == null || accessTokenNode.isNull()) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("token_response_missing_field",
|
||||
"DingTalk token response missing accessToken field", null));
|
||||
}
|
||||
String accessToken = accessTokenNode.asText();
|
||||
if (accessToken.isBlank()) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("token_response_missing_field",
|
||||
"DingTalk token response has empty accessToken", null));
|
||||
}
|
||||
|
||||
JsonNode expireInNode = json.get("expireIn");
|
||||
if (expireInNode == null || !expireInNode.isIntegralNumber() || !expireInNode.canConvertToLong()) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("token_response_invalid_expiry",
|
||||
"DingTalk token response has invalid expireIn field", null));
|
||||
}
|
||||
long expireInSeconds = expireInNode.longValue();
|
||||
if (expireInSeconds <= 0) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("token_response_invalid_expiry",
|
||||
"DingTalk token response has non-positive expireIn field", null));
|
||||
}
|
||||
|
||||
// Only include non-sensitive fields in additional parameters.
|
||||
Map<String, Object> safeParams = Map.of("expireIn", expireInSeconds);
|
||||
|
||||
return OAuth2AccessTokenResponse.withToken(accessToken)
|
||||
.tokenType(OAuth2AccessToken.TokenType.BEARER)
|
||||
.expiresIn(expireInSeconds)
|
||||
.additionalParameters(safeParams)
|
||||
.build();
|
||||
} catch (OAuth2AuthenticationException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("token_parse_error",
|
||||
"Failed to parse DingTalk token response", null));
|
||||
}
|
||||
}
|
||||
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("token_exchange_failed",
|
||||
"DingTalk token exchange failed: HTTP " + response.getStatusCode(), null));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Routes the authorization-code token exchange to a {@link ProviderTokenResponseClient} when one
|
||||
* claims the registration, and to the standard Spring client otherwise.
|
||||
*
|
||||
* <p>Spring's {@code tokenEndpoint} accepts a single client, so per-provider exchange needs one
|
||||
* dispatcher rather than a branch inside the security configuration.
|
||||
*/
|
||||
@Component
|
||||
public class DispatchingTokenResponseClient
|
||||
implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
|
||||
|
||||
private final Map<String, ProviderTokenResponseClient> overrides;
|
||||
private final OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> delegate;
|
||||
|
||||
@Autowired
|
||||
public DispatchingTokenResponseClient(List<ProviderTokenResponseClient> providerClients) {
|
||||
this(providerClients, new DefaultAuthorizationCodeTokenResponseClient());
|
||||
}
|
||||
|
||||
DispatchingTokenResponseClient(
|
||||
List<ProviderTokenResponseClient> providerClients,
|
||||
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> delegate
|
||||
) {
|
||||
this.overrides = providerClients.stream()
|
||||
.collect(Collectors.toMap(ProviderTokenResponseClient::getProvider, Function.identity()));
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest request) {
|
||||
String registrationId = request.getClientRegistration().getRegistrationId();
|
||||
ProviderTokenResponseClient override = overrides.get(registrationId);
|
||||
return (override != null ? override : delegate).getTokenResponse(request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Provider-specific claims extractor for Feishu (Lark) OAuth users. Attributes are already
|
||||
* unwrapped from the Feishu response envelope by {@link FeishuOAuth2UserService}.
|
||||
*
|
||||
* <p>Like the GitHub and GitLab extractors, this class logs nothing: the subject, display name
|
||||
* and email it handles are exactly the values that must stay out of the logs.
|
||||
*/
|
||||
@Component
|
||||
public class FeishuClaimsExtractor implements OAuthClaimsExtractor {
|
||||
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return FeishuOAuth2UserService.PROVIDER;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) {
|
||||
Map<String, Object> attrs = oAuth2User.getAttributes();
|
||||
|
||||
// open_id is the stable primary subject: unique per user within one Feishu app, and it is
|
||||
// what Feishu guarantees to keep across logins. union_id stays in extra rather than acting
|
||||
// as a fallback -- a subject that can silently change identity between logins would bind
|
||||
// the same person to two platform accounts. Promoting union_id later needs an explicit
|
||||
// alias migration, not a fallback here.
|
||||
String subject = requireText(attrs.get("open_id"), "open_id");
|
||||
|
||||
String email = (String) attrs.get("enterprise_email");
|
||||
if (email == null) {
|
||||
email = (String) attrs.get("email");
|
||||
}
|
||||
// Feishu emails are imported by the organization admin and not verified with the user
|
||||
// in real time, so they carry no verification signal; keep emailVerified false.
|
||||
boolean emailVerified = false;
|
||||
|
||||
// name -> en_name and stop, matching the GitHub and GitLab extractors. Falling back to the
|
||||
// subject would write it into UserAccount.displayName and into UserActivatedEvent, pushing
|
||||
// the external subject somewhere event consumers may log it.
|
||||
String username = (String) attrs.get("name");
|
||||
if (username == null || username.isBlank()) {
|
||||
username = (String) attrs.get("en_name");
|
||||
}
|
||||
|
||||
return new OAuthClaims(
|
||||
FeishuOAuth2UserService.PROVIDER,
|
||||
subject,
|
||||
email,
|
||||
emailVerified,
|
||||
username,
|
||||
attrs
|
||||
);
|
||||
}
|
||||
|
||||
private static String requireText(Object value, String attribute) {
|
||||
String text = value == null ? null : String.valueOf(value).trim();
|
||||
if (text == null || text.isEmpty()) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("missing_subject", "Feishu user info is missing " + attribute, null)
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
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.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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 ProviderTokenResponseClient {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FeishuOAuth2AccessTokenResponseClient.class);
|
||||
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 String getProvider() {
|
||||
return FEISHU_PROVIDER;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
String tokenEndpoint = tokenUri(authorizationCodeGrantRequest);
|
||||
log.info("Feishu token exchange started: protocolVersion={}, endpointHost={}, redirectUriPresent={}, pkcePresent={}",
|
||||
protocolVersion,
|
||||
endpointHost(tokenEndpoint),
|
||||
redirectUri != null && !redirectUri.isBlank(),
|
||||
codeVerifier instanceof String verifier && !verifier.isBlank());
|
||||
try {
|
||||
return restClient.post()
|
||||
.uri(tokenEndpoint)
|
||||
.contentType(MediaType.parseMediaType("application/json; charset=utf-8"))
|
||||
.header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
|
||||
.body(requestBody)
|
||||
.exchange((request, response) -> {
|
||||
int status = response.getStatusCode().value();
|
||||
log.info("Feishu token exchange response: httpStatus={}", status);
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
throw tokenError("Feishu token endpoint returned HTTP " + status);
|
||||
}
|
||||
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+")));
|
||||
}
|
||||
log.info("Feishu token exchange parsed: businessCode=0, accessTokenPresent={}, refreshTokenPresent={}, expiresInSeconds={}, scopePresent={}",
|
||||
accessToken != null,
|
||||
refreshToken != null,
|
||||
expiresIn,
|
||||
scope != null);
|
||||
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 endpointHost(String endpoint) {
|
||||
try {
|
||||
return java.net.URI.create(endpoint).getHost();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return "invalid";
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* Loads Feishu (Lark) user info, which deviates from the standard OAuth format: the response is
|
||||
* wrapped in a {@code {code, msg, data}} envelope and errors are reported with HTTP 200.
|
||||
*/
|
||||
@Component
|
||||
public class FeishuOAuth2UserService implements ProviderOAuth2UserService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FeishuOAuth2UserService.class);
|
||||
|
||||
static final String PROVIDER = "feishu";
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final Duration READ_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
/** A Feishu user_info payload is well under 1 KB; this only needs to stop an unbounded body. */
|
||||
private static final int MAX_RESPONSE_BYTES = 64 * 1024;
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* Uses an external-service client that is intentionally not customized with application
|
||||
* tracing. Trace context must not be propagated to the external Feishu service.
|
||||
*/
|
||||
@Autowired
|
||||
public FeishuOAuth2UserService() {
|
||||
this(RestClient.builder().requestFactory(defaultRequestFactory()));
|
||||
}
|
||||
|
||||
public FeishuOAuth2UserService(RestClient.Builder restClientBuilder) {
|
||||
this.restClient = restClientBuilder
|
||||
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds the userinfo call so an unresponsive Feishu endpoint cannot hold a login thread. The
|
||||
* timeouts apply to this provider client only and do not change the shared HTTP defaults.
|
||||
*/
|
||||
private static ClientHttpRequestFactory defaultRequestFactory() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(CONNECT_TIMEOUT);
|
||||
factory.setReadTimeout(READ_TIMEOUT);
|
||||
return factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads at most {@link #MAX_RESPONSE_BYTES} before parsing, so a misconfigured or hostile
|
||||
* 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 {
|
||||
byte[] bytes = body.readNBytes(MAX_RESPONSE_BYTES + 1);
|
||||
if (bytes.length > MAX_RESPONSE_BYTES) {
|
||||
throw new IOException("Feishu user info response exceeds " + MAX_RESPONSE_BYTES + " bytes");
|
||||
}
|
||||
return OBJECT_MAPPER.readValue(bytes, FeishuUserResponse.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return PROVIDER;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
|
||||
String userInfoUri = userRequest.getClientRegistration().getProviderDetails()
|
||||
.getUserInfoEndpoint().getUri();
|
||||
|
||||
log.info("Feishu userinfo started: endpointHost={}, accessTokenPresent={}",
|
||||
endpointHost(userInfoUri),
|
||||
userRequest.getAccessToken().getTokenValue() != null
|
||||
&& !userRequest.getAccessToken().getTokenValue().isBlank());
|
||||
FeishuUserResponse response;
|
||||
try {
|
||||
response = restClient.get()
|
||||
.uri(userInfoUri)
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userRequest.getAccessToken().getTokenValue())
|
||||
.exchange((request, clientResponse) -> {
|
||||
log.info("Feishu userinfo response: httpStatus={}", clientResponse.getStatusCode().value());
|
||||
return readBounded(clientResponse.getBody());
|
||||
});
|
||||
} catch (Exception e) {
|
||||
// Exception class only: the message can quote the request URI, which holds the token.
|
||||
// Nothing downstream logs this failure, so without this line it would be silent.
|
||||
log.warn("Feishu user info request failed with {}", e.getClass().getSimpleName());
|
||||
// The cause carries the detail for operators; the OAuth2Error description stays generic
|
||||
// for the same reason the log line is.
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("feishu_userinfo_error", "Failed to load Feishu user info", null),
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
if (response == null || response.code() != 0 || response.data() == null) {
|
||||
// Feishu's own error code is safe to record; its msg text is not.
|
||||
log.warn(
|
||||
"Feishu user info returned error code {}",
|
||||
response == null ? "none" : response.code()
|
||||
);
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error(
|
||||
"feishu_userinfo_error",
|
||||
"Feishu user info error, code " + (response == null ? "none" : response.code()),
|
||||
null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
log.info("Feishu userinfo parsed: businessCode=0, openIdPresent={}, unionIdPresent={}, emailPresent={}, displayNamePresent={}",
|
||||
response.data().openId() != null && !response.data().openId().isBlank(),
|
||||
response.data().unionId() != null && !response.data().unionId().isBlank(),
|
||||
(response.data().enterpriseEmail() != null && !response.data().enterpriseEmail().isBlank())
|
||||
|| (response.data().email() != null && !response.data().email().isBlank()),
|
||||
(response.data().name() != null && !response.data().name().isBlank())
|
||||
|| (response.data().enName() != null && !response.data().enName().isBlank()));
|
||||
|
||||
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
|
||||
.getUserInfoEndpoint().getUserNameAttributeName();
|
||||
|
||||
Map<String, Object> attributes = flatten(response.data(), userNameAttributeName);
|
||||
return new DefaultOAuth2User(
|
||||
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
|
||||
attributes,
|
||||
userNameAttributeName
|
||||
);
|
||||
}
|
||||
|
||||
private Map<String, Object> flatten(FeishuUserData data, String userNameAttributeName) {
|
||||
Map<String, Object> attributes = new LinkedHashMap<>();
|
||||
putIfPresent(attributes, "open_id", data.openId());
|
||||
putIfPresent(attributes, "union_id", data.unionId());
|
||||
putIfPresent(attributes, "name", data.name());
|
||||
putIfPresent(attributes, "en_name", data.enName());
|
||||
putIfPresent(attributes, "avatar_url", data.avatarUrl());
|
||||
putIfPresent(attributes, "email", data.email());
|
||||
putIfPresent(attributes, "enterprise_email", data.enterpriseEmail());
|
||||
if (!attributes.containsKey(userNameAttributeName)) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("feishu_userinfo_error", "Feishu user info missing " + userNameAttributeName, null)
|
||||
);
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private static String endpointHost(String endpoint) {
|
||||
try {
|
||||
return java.net.URI.create(endpoint).getHost();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return "invalid";
|
||||
}
|
||||
}
|
||||
|
||||
private void putIfPresent(Map<String, Object> attributes, String key, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
attributes.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record FeishuUserResponse(int code, String msg, @JsonProperty("data") FeishuUserData data) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record FeishuUserData(
|
||||
@JsonProperty("open_id") String openId,
|
||||
@JsonProperty("union_id") String unionId,
|
||||
@JsonProperty("name") String name,
|
||||
@JsonProperty("en_name") String enName,
|
||||
@JsonProperty("avatar_url") String avatarUrl,
|
||||
@JsonProperty("email") String email,
|
||||
@JsonProperty("enterprise_email") String enterpriseEmail
|
||||
) {}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import jakarta.servlet.ServletException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
|
|
@ -16,6 +18,8 @@ import java.io.IOException;
|
|||
@Component
|
||||
public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OAuth2LoginFailureHandler.class);
|
||||
|
||||
private final OAuthLoginFlowService oauthLoginFlowService;
|
||||
|
||||
public OAuth2LoginFailureHandler(OAuthLoginFlowService oauthLoginFlowService) {
|
||||
|
|
@ -28,6 +32,8 @@ public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHan
|
|||
throws IOException, ServletException {
|
||||
String returnTo = oauthLoginFlowService.consumeReturnTo(request.getSession(false));
|
||||
String redirectTarget = oauthLoginFlowService.resolveFailureRedirect(exception, returnTo);
|
||||
log.warn("OAuth login failed: exceptionType={}, returnToPresent={}, redirectPath={}",
|
||||
exception.getClass().getSimpleName(), returnTo != null, redirectTarget);
|
||||
if (redirectTarget != null) {
|
||||
getRedirectStrategy().sendRedirect(request, response, redirectTarget);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import jakarta.servlet.ServletException;
|
|||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
|
||||
|
|
@ -22,6 +24,8 @@ import org.springframework.stereotype.Component;
|
|||
@Component
|
||||
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OAuth2LoginSuccessHandler.class);
|
||||
|
||||
private final PlatformSessionService platformSessionService;
|
||||
private final OAuthLoginFlowService oauthLoginFlowService;
|
||||
|
||||
|
|
@ -43,6 +47,8 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan
|
|||
}
|
||||
String returnTo = oauthLoginFlowService.consumeReturnTo(request.getSession(false));
|
||||
if (returnTo != null) {
|
||||
log.info("OAuth login succeeded: redirectPath={}, returnToPresent=true, sessionAttached=true",
|
||||
returnTo);
|
||||
// returnTo is a root-relative path (web client strips the base path). The redirect
|
||||
// strategy (DefaultRedirectStrategy) already prepends the request context path, which
|
||||
// reflects X-Forwarded-Prefix under forward-headers-strategy=framework — so the browser
|
||||
|
|
@ -52,6 +58,7 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan
|
|||
clearAuthenticationAttributes(request);
|
||||
return;
|
||||
}
|
||||
log.info("OAuth login succeeded: redirectPath={}, returnToPresent=false, sessionAttached=true", "/");
|
||||
super.onAuthenticationSuccess(request, response, authentication);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import java.util.Map;
|
|||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
|
||||
|
|
@ -35,7 +37,10 @@ import org.springframework.stereotype.Service;
|
|||
@Service
|
||||
public class OAuthLoginFlowService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OAuthLoginFlowService.class);
|
||||
|
||||
private final Map<String, OAuthClaimsExtractor> extractors;
|
||||
private final Map<String, ProviderOAuth2UserService> userServiceOverrides;
|
||||
private final AccessPolicy accessPolicy;
|
||||
private final IdentityBindingService identityBindingService;
|
||||
private final LegacyPlatformIdentityCore identityCore;
|
||||
|
|
@ -44,12 +49,14 @@ public class OAuthLoginFlowService {
|
|||
|
||||
@Autowired
|
||||
public OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
|
||||
List<ProviderOAuth2UserService> userServiceList,
|
||||
AccessPolicy accessPolicy,
|
||||
IdentityBindingService identityBindingService,
|
||||
LegacyPlatformIdentityCore identityCore,
|
||||
RemoteIdentityIoExecutor remoteIdentityIo) {
|
||||
this(
|
||||
extractorList,
|
||||
userServiceList,
|
||||
accessPolicy,
|
||||
identityBindingService,
|
||||
identityCore,
|
||||
|
|
@ -59,6 +66,7 @@ public class OAuthLoginFlowService {
|
|||
}
|
||||
|
||||
OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
|
||||
List<ProviderOAuth2UserService> userServiceList,
|
||||
AccessPolicy accessPolicy,
|
||||
IdentityBindingService identityBindingService,
|
||||
LegacyPlatformIdentityCore identityCore,
|
||||
|
|
@ -66,6 +74,8 @@ public class OAuthLoginFlowService {
|
|||
RemoteIdentityIoExecutor remoteIdentityIo) {
|
||||
this.extractors = extractorList.stream()
|
||||
.collect(Collectors.toMap(OAuthClaimsExtractor::getProvider, Function.identity()));
|
||||
this.userServiceOverrides = userServiceList.stream()
|
||||
.collect(Collectors.toMap(ProviderOAuth2UserService::getProvider, Function.identity()));
|
||||
this.accessPolicy = accessPolicy;
|
||||
this.identityBindingService = identityBindingService;
|
||||
this.identityCore = identityCore;
|
||||
|
|
@ -79,6 +89,7 @@ public class OAuthLoginFlowService {
|
|||
LegacyPlatformIdentityCore identityCore) {
|
||||
this(
|
||||
extractorList,
|
||||
List.of(),
|
||||
accessPolicy,
|
||||
identityBindingService,
|
||||
identityCore,
|
||||
|
|
@ -95,27 +106,34 @@ public class OAuthLoginFlowService {
|
|||
|
||||
public AuthenticatedLoginContext loadLoginContext(OAuth2UserRequest request) {
|
||||
LoadedProviderIdentity loadedIdentity = remoteIdentityIo.execute(() -> {
|
||||
OAuth2User upstreamUser = delegate.loadUser(request);
|
||||
String registrationId = request.getClientRegistration().getRegistrationId();
|
||||
ProviderOAuth2UserService override = userServiceOverrides.get(registrationId);
|
||||
OAuth2User upstreamUser = (override != null ? override : delegate).loadUser(request);
|
||||
OAuthClaimsExtractor extractor = extractors.get(registrationId);
|
||||
if (extractor == null) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("unsupported_provider", "Unsupported: " + registrationId, null)
|
||||
);
|
||||
}
|
||||
return new LoadedProviderIdentity(
|
||||
upstreamUser,
|
||||
extractor.extract(request, upstreamUser)
|
||||
);
|
||||
OAuthClaims claims = extractor.extract(request, upstreamUser);
|
||||
log.info("OAuth provider identity loaded: provider={}, subjectPresent={}, emailPresent={}, displayNamePresent={}",
|
||||
registrationId,
|
||||
claims.subject() != null && !claims.subject().isBlank(),
|
||||
claims.email() != null && !claims.email().isBlank(),
|
||||
claims.providerLogin() != null && !claims.providerLogin().isBlank());
|
||||
return new LoadedProviderIdentity(upstreamUser, claims);
|
||||
});
|
||||
|
||||
PlatformPrincipal principal = authenticate(loadedIdentity.claims());
|
||||
log.info("OAuth identity authenticated: provider={}, principalCreated=true, rolesCount={}",
|
||||
loadedIdentity.claims().provider(), principal.platformRoles().size());
|
||||
return new AuthenticatedLoginContext(loadedIdentity.upstreamUser(), principal);
|
||||
}
|
||||
|
||||
public PlatformPrincipal authenticate(OAuthClaims claims) {
|
||||
AccessDecision decision = accessPolicy.evaluate(claims);
|
||||
|
||||
log.info("OAuth access policy evaluated: provider={}, decision={}", claims.provider(), decision);
|
||||
if (decision == AccessDecision.PENDING_APPROVAL) {
|
||||
LegacyPlatformIdentityDecision identityDecision = identityCore.evaluate(claims);
|
||||
ensureActiveCoreAllowsPlatformLogin(identityDecision);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||
|
||||
/**
|
||||
* Strategy interface for provider-specific authorization request tweaks, for providers whose
|
||||
* authorize endpoint deviates from the standard parameter contract.
|
||||
*
|
||||
* <p>The token and userinfo counterparts are {@link ProviderTokenResponseClient} and
|
||||
* {@link ProviderOAuth2UserService}.
|
||||
*/
|
||||
public interface ProviderAuthorizationRequestCustomizer {
|
||||
|
||||
String getProvider();
|
||||
|
||||
void customize(OAuth2AuthorizationRequest.Builder builder);
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
|
||||
/**
|
||||
* Strategy interface for provider-specific OAuth user loading. Implementations override the
|
||||
* default user info loading for providers whose endpoints deviate from the standard
|
||||
* flat-attribute response format.
|
||||
*/
|
||||
public interface ProviderOAuth2UserService extends OAuth2UserService<OAuth2UserRequest, OAuth2User> {
|
||||
String getProvider();
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
|
||||
|
||||
/**
|
||||
* Strategy interface for provider-specific token exchange. Implementations override the default
|
||||
* exchange for providers whose token endpoints deviate from the standard form-urlencoded contract.
|
||||
*
|
||||
* <p>The userinfo counterpart is {@link ProviderOAuth2UserService}.
|
||||
*/
|
||||
public interface ProviderTokenResponseClient
|
||||
extends OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
|
||||
|
||||
String getProvider();
|
||||
}
|
||||
|
|
@ -1,9 +1,17 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
|
|
@ -14,16 +22,41 @@ import org.springframework.stereotype.Component;
|
|||
public class SkillHubOAuth2AuthorizationRequestResolver
|
||||
implements org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SkillHubOAuth2AuthorizationRequestResolver.class);
|
||||
|
||||
private final DefaultOAuth2AuthorizationRequestResolver delegate;
|
||||
private final OAuthLoginFlowService oauthLoginFlowService;
|
||||
|
||||
public SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository,
|
||||
OAuthLoginFlowService oauthLoginFlowService) {
|
||||
SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository,
|
||||
OAuthLoginFlowService oauthLoginFlowService) {
|
||||
this(clientRegistrationRepository, oauthLoginFlowService, List.of());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public SkillHubOAuth2AuthorizationRequestResolver(
|
||||
ClientRegistrationRepository clientRegistrationRepository,
|
||||
OAuthLoginFlowService oauthLoginFlowService,
|
||||
List<ProviderAuthorizationRequestCustomizer> customizers) {
|
||||
this.delegate = new DefaultOAuth2AuthorizationRequestResolver(
|
||||
clientRegistrationRepository,
|
||||
"/oauth2/authorization"
|
||||
);
|
||||
this.oauthLoginFlowService = oauthLoginFlowService;
|
||||
Map<String, ProviderAuthorizationRequestCustomizer> byProvider = customizers.stream()
|
||||
.collect(Collectors.toMap(
|
||||
ProviderAuthorizationRequestCustomizer::getProvider,
|
||||
Function.identity()
|
||||
));
|
||||
// Spring resolves the registration id into the builder attributes, so one customizer hook
|
||||
// can dispatch per provider instead of this class knowing about any of them.
|
||||
this.delegate.setAuthorizationRequestCustomizer(builder -> {
|
||||
OAuth2AuthorizationRequest probe = builder.build();
|
||||
String registrationId = probe.getAttribute(OAuth2ParameterNames.REGISTRATION_ID);
|
||||
ProviderAuthorizationRequestCustomizer customizer = byProvider.get(registrationId);
|
||||
if (customizer != null) {
|
||||
customizer.customize(builder);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -47,6 +80,10 @@ public class SkillHubOAuth2AuthorizationRequestResolver
|
|||
HttpServletRequest request, OAuth2AuthorizationRequest authorizationRequest) {
|
||||
if (authorizationRequest != null) {
|
||||
oauthLoginFlowService.rememberReturnTo(request);
|
||||
log.info("OAuth authorization started: provider={}, redirectUri={}, returnToPresent={}",
|
||||
authorizationRequest.getAttribute("registration_id"),
|
||||
authorizationRequest.getRedirectUri(),
|
||||
request.getParameter("returnTo") != null);
|
||||
}
|
||||
return authorizationRequest;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
|
||||
class DingTalkClaimsExtractorTest {
|
||||
|
||||
private final DingTalkClaimsExtractor extractor = new DingTalkClaimsExtractor();
|
||||
|
||||
@Test
|
||||
void extract_mapsUnionIdAndNick() {
|
||||
Map<String, Object> attrs = new HashMap<>(Map.of(
|
||||
"unionId", "un_123",
|
||||
"nick", "张三",
|
||||
"email", "zhangsan@corp.example"
|
||||
));
|
||||
|
||||
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
|
||||
|
||||
assertThat(claims.provider()).isEqualTo("dingtalk");
|
||||
assertThat(claims.subject()).isEqualTo("un_123");
|
||||
assertThat(claims.providerLogin()).isEqualTo("张三");
|
||||
assertThat(claims.email()).isEqualTo("zhangsan@corp.example");
|
||||
// DingTalk's contact endpoint does not attest email ownership.
|
||||
assertThat(claims.emailVerified()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_neverAcceptsOpenIdOrUserIdAsSubject() {
|
||||
// openId is per-app and userId per-organization. Accepting either as a fallback would bind
|
||||
// a different identity than a later login carrying unionId, splitting one person across
|
||||
// two platform accounts.
|
||||
Map<String, Object> attrs = new HashMap<>(Map.of(
|
||||
"openId", "op_456",
|
||||
"userId", "usr_789",
|
||||
"nick", "张三"
|
||||
));
|
||||
|
||||
assertThatThrownBy(() -> extractor.extract(userRequest(), user(attrs)))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.hasMessageContaining("unionId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_rejectsBlankUnionId() {
|
||||
Map<String, Object> attrs = new HashMap<>();
|
||||
attrs.put("unionId", " ");
|
||||
attrs.put("nick", "张三");
|
||||
|
||||
assertThatThrownBy(() -> extractor.extract(userRequest(), user(attrs)))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.hasMessageContaining("unionId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_fallsBackToNameThenLeavesDisplayNameUnset() {
|
||||
Map<String, Object> withName = new HashMap<>(Map.of("unionId", "un_1", "name", "Alice"));
|
||||
assertThat(extractor.extract(userRequest(), user(withName)).providerLogin()).isEqualTo("Alice");
|
||||
|
||||
// Must not synthesize from the subject: providerLogin is written to displayName and into
|
||||
// UserActivatedEvent, so a synthesized value would carry the subject to event consumers.
|
||||
Map<String, Object> bare = new HashMap<>(Map.of("unionId", "un_2"));
|
||||
OAuthClaims claims = extractor.extract(userRequest(), user(bare));
|
||||
assertThat(claims.providerLogin()).isNull();
|
||||
assertThat(claims.subject()).isEqualTo("un_2");
|
||||
}
|
||||
|
||||
/** Does not enforce the name attribute, unlike DefaultOAuth2User. */
|
||||
private OAuth2User user(Map<String, Object> attrs) {
|
||||
return new OAuth2User() {
|
||||
@Override
|
||||
public Map<String, Object> getAttributes() {
|
||||
return attrs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Collection<? extends org.springframework.security.core.GrantedAuthority>
|
||||
getAuthorities() {
|
||||
return java.util.List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return String.valueOf(attrs.get("unionId"));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private OAuth2UserRequest userRequest() {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk")
|
||||
.clientId("dingoauth_test")
|
||||
.clientSecret("client-secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.authorizationUri("https://login.dingtalk.com/oauth2/auth")
|
||||
.tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")
|
||||
.userInfoUri("https://api.dingtalk.com/v1.0/contact/users/me")
|
||||
.userNameAttributeName("unionId")
|
||||
.clientName("钉钉")
|
||||
.build();
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"token-123",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(3600)
|
||||
);
|
||||
return new OAuth2UserRequest(registration, accessToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
class DingTalkOAuth2UserServiceTest {
|
||||
|
||||
private final Logger logger = (Logger) LoggerFactory.getLogger(DingTalkOAuth2UserService.class);
|
||||
private ListAppender<ILoggingEvent> appender;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (appender != null) {
|
||||
logger.detachAppender(appender);
|
||||
appender.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_sendsCustomTokenHeaderAndNormalizesAttributes() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||
server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me"))
|
||||
// DingTalk reads the token from its own header, not Authorization: Bearer.
|
||||
.andExpect(header(DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER, "token-123"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"unionId": "un_123",
|
||||
"openId": "op_456",
|
||||
"nick": "张三",
|
||||
"avatarUrl": "https://avatar.example/z.png",
|
||||
"email": "zhangsan@corp.example",
|
||||
"mobile": "13800000000",
|
||||
"stateCode": "86"
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder);
|
||||
|
||||
OAuth2User user = service.loadUser(userRequest());
|
||||
|
||||
assertThat(user.getName()).isEqualTo("un_123");
|
||||
assertThat(user.getAttributes())
|
||||
.containsEntry("unionId", "un_123")
|
||||
.containsEntry("nick", "张三")
|
||||
.containsEntry("email", "zhangsan@corp.example")
|
||||
// avatarUrl is aliased to the key the identity core reads.
|
||||
.containsEntry("avatar_url", "https://avatar.example/z.png");
|
||||
// Unused PII must not travel into the principal or claims.
|
||||
assertThat(user.getAttributes()).doesNotContainKeys("mobile", "stateCode", "avatarUrl");
|
||||
// openId must not survive as a usable subject candidate.
|
||||
assertThat(user.getAttributes()).doesNotContainKey("openId");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_rejectsResponseWithoutUnionId() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||
server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{"openId": "op_456", "nick": "张三"}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder);
|
||||
|
||||
assertThatThrownBy(() -> service.loadUser(userRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.hasMessageContaining("unionId");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_rejectsOversizedResponseBody() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||
// 64 KB cap; pad a structurally valid payload past it so the size check fires, not the parser.
|
||||
String padding = "x".repeat(70 * 1024);
|
||||
server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me"))
|
||||
.andRespond(withSuccess(
|
||||
"{\"unionId\":\"un_123\",\"nick\":\"" + padding + "\"}",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder);
|
||||
|
||||
assertThatThrownBy(() -> service.loadUser(userRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode())
|
||||
.isEqualTo("dingtalk_userinfo_error"));
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_rejectsNonSuccessfulHttpStatusWithoutExposingBody() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||
server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me"))
|
||||
.andRespond(withStatus(HttpStatus.FORBIDDEN)
|
||||
.body("access denied for token-123")
|
||||
.contentType(MediaType.APPLICATION_JSON));
|
||||
DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder);
|
||||
|
||||
assertThatThrownBy(() -> service.loadUser(userRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> {
|
||||
var error = ((OAuth2AuthenticationException) ex).getError();
|
||||
assertThat(error.getErrorCode()).isEqualTo("dingtalk_userinfo_error");
|
||||
assertThat(error.getDescription()).doesNotContain("token-123", "access denied");
|
||||
});
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_logsSafeProviderDiagnosticsWithoutUpstreamMessageOrToken() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||
server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me"))
|
||||
.andRespond(withStatus(HttpStatus.FORBIDDEN)
|
||||
.body("{\"Code\":\"Forbidden.AccessDenied.AccessTokenPermissionDenied\","
|
||||
+ "\"Data\":\"{\\\"AccessDeniedDetail\\\":{\\\"requiredScopes\\\":[\\\"Contact.User.Read\\\"]},"
|
||||
+ "\\\"RequestId\\\":\\\"req-123\\\"}\","
|
||||
+ "\"Message\":\"secret upstream message token-123\"}")
|
||||
.contentType(MediaType.APPLICATION_JSON));
|
||||
attachAppender();
|
||||
DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder);
|
||||
|
||||
assertThatThrownBy(() -> service.loadUser(userRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class);
|
||||
|
||||
assertThat(appender.list).extracting(ILoggingEvent::getFormattedMessage)
|
||||
.anySatisfy(message -> assertThat(message)
|
||||
.contains("code=Forbidden.AccessDenied.AccessTokenPermissionDenied")
|
||||
.contains("requiredScopes=Contact.User.Read")
|
||||
.contains("requestId=req-123")
|
||||
.doesNotContain("secret upstream message", "token-123"));
|
||||
server.verify();
|
||||
}
|
||||
|
||||
private void attachAppender() {
|
||||
logger.setLevel(Level.INFO);
|
||||
appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logger.addAppender(appender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_errorDescriptionDoesNotEchoUpstreamTextOrToken() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||
server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me"))
|
||||
.andRespond(withSuccess("not json at all: token-123", MediaType.APPLICATION_JSON));
|
||||
DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder);
|
||||
|
||||
assertThatThrownBy(() -> service.loadUser(userRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> {
|
||||
String description = ((OAuth2AuthenticationException) ex).getError().getDescription();
|
||||
assertThat(description).doesNotContain("token-123");
|
||||
});
|
||||
server.verify();
|
||||
}
|
||||
|
||||
private OAuth2UserRequest userRequest() {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk")
|
||||
.clientId("dingoauth_test")
|
||||
.clientSecret("client-secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.authorizationUri("https://login.dingtalk.com/oauth2/auth")
|
||||
.tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")
|
||||
.userInfoUri("https://api.dingtalk.com/v1.0/contact/users/me")
|
||||
.userNameAttributeName("unionId")
|
||||
.clientName("钉钉")
|
||||
.build();
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"token-123",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(3600)
|
||||
);
|
||||
return new OAuth2UserRequest(registration, accessToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
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.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
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.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
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.RestTemplate;
|
||||
|
||||
class DingTalkTokenResponseClientTest {
|
||||
|
||||
private DingTalkTokenResponseClient client;
|
||||
private MockRestServiceServer mockServer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
mockServer = MockRestServiceServer.createServer(restTemplate);
|
||||
client = new DingTalkTokenResponseClient(restTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_returnsAccessTokenOnSuccess() {
|
||||
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"accessToken": "dt_access_token_123",
|
||||
"expireIn": 7200
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
|
||||
OAuth2AccessTokenResponse response = client.getTokenResponse(authorizationCodeGrantRequest());
|
||||
|
||||
assertThat(response.getAccessToken().getTokenValue()).isEqualTo("dt_access_token_123");
|
||||
assertThat(response.getAccessToken().getTokenType()).isEqualTo(OAuth2AccessToken.TokenType.BEARER);
|
||||
assertThat(response.getAccessToken().getIssuedAt()).isNotNull();
|
||||
assertThat(response.getAccessToken().getExpiresAt()).isNotNull();
|
||||
assertThat(Duration.between(
|
||||
response.getAccessToken().getIssuedAt(),
|
||||
response.getAccessToken().getExpiresAt())).isEqualTo(Duration.ofSeconds(7200));
|
||||
assertThat(response.getAdditionalParameters().get("expireIn")).isEqualTo(7200L);
|
||||
// Verify raw_response is NOT included (sensitive data leak fix)
|
||||
assertThat(response.getAdditionalParameters().containsKey("raw_response")).isFalse();
|
||||
mockServer.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_throwsWhenAccessTokenFieldMissing() {
|
||||
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"expireIn": 7200
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
|
||||
assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("token_response_missing_field"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_throwsWhenAccessTokenIsNull() {
|
||||
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"accessToken": null,
|
||||
"expireIn": 7200
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
|
||||
assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("token_response_missing_field"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_throwsWhenAccessTokenIsEmpty() {
|
||||
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"accessToken": "",
|
||||
"expireIn": 7200
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
|
||||
assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("token_response_missing_field"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_throwsOnHttpError() {
|
||||
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
|
||||
.andRespond(withServerError().body("sensitive-upstream-response"));
|
||||
|
||||
assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> {
|
||||
OAuth2AuthenticationException oauthException = (OAuth2AuthenticationException) ex;
|
||||
assertThat(oauthException.getError().getErrorCode()).isEqualTo("token_exchange_io_error");
|
||||
assertThat(oauthException.getMessage()).doesNotContain("sensitive-upstream-response");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_throwsWhenExpireInIsMissing() {
|
||||
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"accessToken": "dt_access_token_123"
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
|
||||
assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex)
|
||||
.getError().getErrorCode()).isEqualTo("token_response_invalid_expiry"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_throwsWhenExpireInIsNonPositive() {
|
||||
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"accessToken": "dt_access_token_123",
|
||||
"expireIn": 0
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
|
||||
assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex)
|
||||
.getError().getErrorCode()).isEqualTo("token_response_invalid_expiry"));
|
||||
}
|
||||
|
||||
private OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest() {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk")
|
||||
.clientId("dingzgzf3b9k7jv74iq2")
|
||||
.clientSecret("test-secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.scope("openid")
|
||||
.authorizationUri("https://login.dingtalk.com/oauth2/auth")
|
||||
.tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")
|
||||
.userInfoUri("https://api.dingtalk.com/v1.0/contact/users/me")
|
||||
.userNameAttributeName("unionId")
|
||||
.clientName("钉钉")
|
||||
.build();
|
||||
|
||||
OAuth2AuthorizationRequest authRequest = OAuth2AuthorizationRequest.authorizationCode()
|
||||
.clientId(registration.getClientId())
|
||||
.authorizationUri(registration.getProviderDetails().getAuthorizationUri())
|
||||
.redirectUri(registration.getRedirectUri())
|
||||
.scopes(registration.getScopes())
|
||||
.state("test-state")
|
||||
.build();
|
||||
|
||||
OAuth2AuthorizationResponse authResponse = OAuth2AuthorizationResponse.success("test-code")
|
||||
.redirectUri(registration.getRedirectUri())
|
||||
.state("test-state")
|
||||
.build();
|
||||
|
||||
return new OAuth2AuthorizationCodeGrantRequest(
|
||||
registration,
|
||||
new OAuth2AuthorizationExchange(authRequest, authResponse)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_rejectsOversizedResponseBody() {
|
||||
// Uses the production template so the size-cap interceptor is in play; the tests above
|
||||
// inject a bare RestTemplate and therefore cannot reach it.
|
||||
RestTemplate productionTemplate = DingTalkTokenResponseClient.buildRestTemplate();
|
||||
MockRestServiceServer server = MockRestServiceServer.createServer(productionTemplate);
|
||||
// 64 KB cap; pad a structurally valid token payload past it so the size check fires.
|
||||
String padding = "x".repeat(70 * 1024);
|
||||
server.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
|
||||
.andRespond(withSuccess(
|
||||
"{\"accessToken\":\"" + padding + "\",\"expireIn\":7200}",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
DingTalkTokenResponseClient boundedClient = new DingTalkTokenResponseClient(productionTemplate);
|
||||
|
||||
assertThatThrownBy(() -> boundedClient.getTokenResponse(authorizationCodeGrantRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode())
|
||||
.isEqualTo("token_exchange_io_error"));
|
||||
server.verify();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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.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;
|
||||
|
||||
class DispatchingTokenResponseClientTest {
|
||||
|
||||
@Test
|
||||
void routesToProviderOverrideWhenOneClaimsTheRegistration() {
|
||||
OAuth2AccessTokenResponse overrideResponse = response("from-override");
|
||||
OAuth2AccessTokenResponse defaultResponse = response("from-default");
|
||||
DispatchingTokenResponseClient client = new DispatchingTokenResponseClient(
|
||||
List.of(stubProvider("dingtalk", overrideResponse)),
|
||||
request -> defaultResponse
|
||||
);
|
||||
|
||||
OAuth2AccessTokenResponse result = client.getTokenResponse(grantRequest("dingtalk"));
|
||||
|
||||
assertThat(result.getAccessToken().getTokenValue()).isEqualTo("from-override");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToDefaultClientForUnclaimedRegistrations() {
|
||||
OAuth2AccessTokenResponse overrideResponse = response("from-override");
|
||||
OAuth2AccessTokenResponse defaultResponse = response("from-default");
|
||||
DispatchingTokenResponseClient client = new DispatchingTokenResponseClient(
|
||||
List.of(stubProvider("dingtalk", overrideResponse)),
|
||||
request -> defaultResponse
|
||||
);
|
||||
|
||||
// GitHub must keep the standard exchange even while a DingTalk override is registered.
|
||||
OAuth2AccessTokenResponse result = client.getTokenResponse(grantRequest("github"));
|
||||
|
||||
assertThat(result.getAccessToken().getTokenValue()).isEqualTo("from-default");
|
||||
}
|
||||
|
||||
private static ProviderTokenResponseClient stubProvider(
|
||||
String provider,
|
||||
OAuth2AccessTokenResponse response
|
||||
) {
|
||||
return new ProviderTokenResponseClient() {
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return provider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest request) {
|
||||
return response;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static OAuth2AccessTokenResponse response(String tokenValue) {
|
||||
return OAuth2AccessTokenResponse.withToken(tokenValue)
|
||||
.tokenType(org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType.BEARER)
|
||||
.expiresIn(3600)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static OAuth2AuthorizationCodeGrantRequest grantRequest(String registrationId) {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId(registrationId)
|
||||
.clientId("client")
|
||||
.clientSecret("secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
|
||||
.redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId)
|
||||
.authorizationUri("https://provider.example/authorize")
|
||||
.tokenUri("https://provider.example/token")
|
||||
.userInfoUri("https://provider.example/me")
|
||||
.userNameAttributeName("id")
|
||||
.build();
|
||||
OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode()
|
||||
.authorizationUri("https://provider.example/authorize")
|
||||
.clientId("client")
|
||||
.redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId)
|
||||
.state("state-1")
|
||||
.build();
|
||||
OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponse.success("code-1")
|
||||
.redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId)
|
||||
.state("state-1")
|
||||
.build();
|
||||
return new OAuth2AuthorizationCodeGrantRequest(
|
||||
registration,
|
||||
new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
|
||||
class FeishuClaimsExtractorTest {
|
||||
|
||||
private final FeishuClaimsExtractor extractor = new FeishuClaimsExtractor();
|
||||
|
||||
@Test
|
||||
void extract_prefersEnterpriseEmailOverPersonalEmail() {
|
||||
Map<String, Object> attrs = new HashMap<>(Map.of(
|
||||
"open_id", "ou_123",
|
||||
"name", "张三",
|
||||
"email", "zhangsan@personal.example",
|
||||
"enterprise_email", "zhangsan@corp.example"
|
||||
));
|
||||
|
||||
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
|
||||
|
||||
assertThat(claims.provider()).isEqualTo("feishu");
|
||||
assertThat(claims.subject()).isEqualTo("ou_123");
|
||||
assertThat(claims.email()).isEqualTo("zhangsan@corp.example");
|
||||
// Feishu emails are admin-imported; the extractor must not claim verification.
|
||||
assertThat(claims.emailVerified()).isFalse();
|
||||
assertThat(claims.providerLogin()).isEqualTo("张三");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_allowsNullEmailAndLeavesDisplayNameUnsetWhenFeishuSendsNoName() {
|
||||
Map<String, Object> attrs = new HashMap<>(Map.of("open_id", "ou_456"));
|
||||
|
||||
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
|
||||
|
||||
assertThat(claims.subject()).isEqualTo("ou_456");
|
||||
assertThat(claims.email()).isNull();
|
||||
assertThat(claims.emailVerified()).isFalse();
|
||||
// Must not synthesize "feishu-<open_id>": providerLogin is written to displayName and into
|
||||
// UserActivatedEvent, so a synthesized value would carry the subject into event consumers.
|
||||
assertThat(claims.providerLogin()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_fallsBackToEnglishNameWhenChineseNameBlank() {
|
||||
Map<String, Object> attrs = new HashMap<>(Map.of(
|
||||
"open_id", "ou_789",
|
||||
"en_name", "Alice"
|
||||
));
|
||||
|
||||
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
|
||||
|
||||
assertThat(claims.providerLogin()).isEqualTo("Alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_rejectsBlankOpenId() {
|
||||
// Blank must fail rather than become a subject. DefaultOAuth2User already rejects a
|
||||
// wholly absent open_id, so a permissive OAuth2User is used to test this contract
|
||||
// directly instead of relying on that upstream guard.
|
||||
Map<String, Object> attrs = new HashMap<>();
|
||||
attrs.put("open_id", " ");
|
||||
attrs.put("name", "张三");
|
||||
|
||||
assertThatThrownBy(() -> extractor.extract(userRequest(), permissiveUser(attrs)))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.hasMessageContaining("open_id");
|
||||
}
|
||||
|
||||
/** An {@link OAuth2User} that does not enforce the name attribute, unlike DefaultOAuth2User. */
|
||||
private OAuth2User permissiveUser(Map<String, Object> attrs) {
|
||||
return new OAuth2User() {
|
||||
@Override
|
||||
public Map<String, Object> getAttributes() {
|
||||
return attrs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Collection<? extends org.springframework.security.core.GrantedAuthority>
|
||||
getAuthorities() {
|
||||
return java.util.List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return String.valueOf(attrs.get("open_id"));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_doesNotPromoteUnionIdToSubject() {
|
||||
// union_id stays in extra: a subject that can change between logins would split one
|
||||
// person across two platform accounts.
|
||||
Map<String, Object> attrs = new HashMap<>(Map.of(
|
||||
"open_id", "ou_abc",
|
||||
"union_id", "on_xyz"
|
||||
));
|
||||
|
||||
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
|
||||
|
||||
assertThat(claims.subject()).isEqualTo("ou_abc");
|
||||
assertThat(claims.extra()).containsEntry("union_id", "on_xyz");
|
||||
}
|
||||
|
||||
private DefaultOAuth2User user(Map<String, Object> attrs) {
|
||||
return new DefaultOAuth2User(java.util.List.of(), attrs, "open_id");
|
||||
}
|
||||
|
||||
private OAuth2UserRequest userRequest() {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId("feishu")
|
||||
.clientId("cli_test123")
|
||||
.clientSecret("client-secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.authorizationUri("https://accounts.feishu.cn/open-apis/authen/v1/authorize")
|
||||
.tokenUri("https://accounts.feishu.cn/oauth/v3/token")
|
||||
.userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info")
|
||||
.userNameAttributeName("open_id")
|
||||
.clientName("飞书")
|
||||
.build();
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"token-123",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(3600)
|
||||
);
|
||||
return new OAuth2UserRequest(registration, accessToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
class FeishuOAuth2UserServiceTest {
|
||||
|
||||
@Test
|
||||
void loadUser_unwrapsFeishuEnvelopeIntoFlatAttributes() {
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info"))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"open_id": "ou_123",
|
||||
"union_id": "on_456",
|
||||
"name": "张三",
|
||||
"avatar_url": "https://avatar.example/zhangsan.png",
|
||||
"enterprise_email": "zhangsan@corp.example",
|
||||
"email": "zhangsan@personal.example"
|
||||
}
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
|
||||
|
||||
OAuth2User user = service.loadUser(userRequest());
|
||||
|
||||
assertThat(user.getName()).isEqualTo("ou_123");
|
||||
assertThat(user.getAttributes())
|
||||
.containsEntry("open_id", "ou_123")
|
||||
.containsEntry("union_id", "on_456")
|
||||
.containsEntry("name", "张三")
|
||||
.containsEntry("avatar_url", "https://avatar.example/zhangsan.png")
|
||||
.containsEntry("enterprise_email", "zhangsan@corp.example")
|
||||
.doesNotContainKey("code")
|
||||
.doesNotContainKey("data");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_throwsWhenFeishuReportsErrorCode() {
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{"code": 99991663, "msg": "invalid access token"}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
|
||||
|
||||
assertThatThrownBy(() -> service.loadUser(userRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode())
|
||||
.isEqualTo("feishu_userinfo_error"));
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_rejectsOversizedResponseBody() {
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||
// 64 KB cap; pad a structurally valid envelope past it so the size check fires, not the parser.
|
||||
String padding = "x".repeat(70 * 1024);
|
||||
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info"))
|
||||
.andRespond(withSuccess(
|
||||
"{\"code\":0,\"msg\":\"" + padding + "\",\"data\":{\"open_id\":\"ou_123\"}}",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
|
||||
|
||||
assertThatThrownBy(() -> service.loadUser(userRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode())
|
||||
.isEqualTo("feishu_userinfo_error"));
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_logsErrorCodeButNeverUpstreamTextOrToken() {
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{"code": 99991663, "msg": "token token-123 rejected for cli_test123"}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
|
||||
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
Logger logger = (Logger) LoggerFactory.getLogger(FeishuOAuth2UserService.class);
|
||||
appender.start();
|
||||
logger.addAppender(appender);
|
||||
try {
|
||||
assertThatThrownBy(() -> service.loadUser(userRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class);
|
||||
} finally {
|
||||
logger.detachAppender(appender);
|
||||
appender.stop();
|
||||
}
|
||||
|
||||
String logged = appender.list.stream()
|
||||
.map(ILoggingEvent::getFormattedMessage)
|
||||
.collect(java.util.stream.Collectors.joining("\n"));
|
||||
// A failure must leave an operator-facing record...
|
||||
assertThat(logged).contains("99991663");
|
||||
// ...but the upstream msg can quote the access token, so it must never be logged.
|
||||
assertThat(logged).doesNotContain("token-123");
|
||||
assertThat(logged).doesNotContain("rejected");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_errorDescriptionDoesNotEchoUpstreamTextOrToken() {
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{"code": 99991663, "msg": "token token-123 rejected for cli_test123"}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
|
||||
|
||||
assertThatThrownBy(() -> service.loadUser(userRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> {
|
||||
String description = ((OAuth2AuthenticationException) ex).getError().getDescription();
|
||||
// The upstream message can quote the access token; only the code may surface.
|
||||
assertThat(description).doesNotContain("token-123");
|
||||
assertThat(description).doesNotContain("rejected");
|
||||
assertThat(description).contains("99991663");
|
||||
});
|
||||
server.verify();
|
||||
}
|
||||
|
||||
private OAuth2UserRequest userRequest() {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId("feishu")
|
||||
.clientId("cli_test123")
|
||||
.clientSecret("client-secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.authorizationUri("https://accounts.feishu.cn/open-apis/authen/v1/authorize")
|
||||
.tokenUri("https://accounts.feishu.cn/oauth/v3/token")
|
||||
.userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info")
|
||||
.userNameAttributeName("open_id")
|
||||
.clientName("飞书")
|
||||
.build();
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"token-123",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(3600)
|
||||
);
|
||||
return new OAuth2UserRequest(registration, accessToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -84,4 +84,102 @@ class OAuth2AuthorizationRequestResolverTest {
|
|||
assertThat(session).isNotNull();
|
||||
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_sendsDingTalkScopeOnTheUriButKeepsTheRequestNonOidc() {
|
||||
SkillHubOAuth2AuthorizationRequestResolver dingTalkResolver = resolverFor(
|
||||
dingTalkRegistration(),
|
||||
new DingTalkAuthorizationRequestCustomizer()
|
||||
);
|
||||
MockHttpServletRequest request =
|
||||
new MockHttpServletRequest("GET", "/oauth2/authorization/dingtalk");
|
||||
|
||||
var authorizationRequest = dingTalkResolver.resolve(request, "dingtalk");
|
||||
|
||||
assertThat(authorizationRequest).isNotNull();
|
||||
// DingTalk's authorize endpoint requires scope=openid on the wire.
|
||||
assertThat(authorizationRequest.getAuthorizationRequestUri()).contains("scope=openid");
|
||||
assertThat(authorizationRequest.getAuthorizationRequestUri()).contains("prompt=consent");
|
||||
|
||||
// But getScopes() must stay empty. OAuth2LoginAuthenticationProvider.authenticate returns
|
||||
// null when the authorization request's scopes contain "openid", which hands the callback to
|
||||
// OidcAuthorizationCodeAuthenticationProvider; that then fails with invalid_id_token because
|
||||
// DingTalk returns no id_token, and neither the token client nor the user service is reached.
|
||||
assertThat(authorizationRequest.getScopes()).doesNotContain("openid");
|
||||
|
||||
// And no nonce: a registration declaring openid in configuration would get one attached,
|
||||
// which DingTalk also rejects.
|
||||
assertThat(authorizationRequest.getAdditionalParameters()).doesNotContainKey("nonce");
|
||||
assertThat(authorizationRequest.getAttributes()).doesNotContainKey("nonce");
|
||||
assertThat(authorizationRequest.getAuthorizationRequestUri()).doesNotContain("nonce=");
|
||||
|
||||
// client-secret-post rather than none, so Spring does not apply PKCE. The DingTalk token
|
||||
// request sends no code_verifier, so a challenge on the authorize URI could not be answered.
|
||||
assertThat(authorizationRequest.getAuthorizationRequestUri()).doesNotContain("code_challenge");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_leavesOtherProvidersUntouchedWhenADingTalkCustomizerIsRegistered() {
|
||||
SkillHubOAuth2AuthorizationRequestResolver mixedResolver = resolverFor(
|
||||
githubRegistration(),
|
||||
new DingTalkAuthorizationRequestCustomizer()
|
||||
);
|
||||
MockHttpServletRequest request =
|
||||
new MockHttpServletRequest("GET", "/oauth2/authorization/github");
|
||||
|
||||
var authorizationRequest = mixedResolver.resolve(request, "github");
|
||||
|
||||
assertThat(authorizationRequest).isNotNull();
|
||||
assertThat(authorizationRequest.getScopes()).containsExactly("read:user");
|
||||
}
|
||||
|
||||
private static SkillHubOAuth2AuthorizationRequestResolver resolverFor(
|
||||
ClientRegistration registration,
|
||||
ProviderAuthorizationRequestCustomizer customizer
|
||||
) {
|
||||
OAuthLoginFlowService flowService = new OAuthLoginFlowService(
|
||||
java.util.List.of(),
|
||||
mock(AccessPolicy.class),
|
||||
mock(IdentityBindingService.class)
|
||||
);
|
||||
return new SkillHubOAuth2AuthorizationRequestResolver(
|
||||
new InMemoryClientRegistrationRepository(registration),
|
||||
flowService,
|
||||
java.util.List.of(customizer)
|
||||
);
|
||||
}
|
||||
|
||||
private static ClientRegistration githubRegistration() {
|
||||
return ClientRegistration.withRegistrationId("github")
|
||||
.clientId("client")
|
||||
.clientSecret("secret")
|
||||
.authorizationUri("https://example.test/oauth/authorize")
|
||||
.tokenUri("https://example.test/oauth/token")
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.userInfoUri("https://example.test/user")
|
||||
.userNameAttributeName("id")
|
||||
.authorizationGrantType(
|
||||
org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.scope("read:user")
|
||||
.clientName("GitHub")
|
||||
.build();
|
||||
}
|
||||
|
||||
private static ClientRegistration dingTalkRegistration() {
|
||||
// Mirrors application.yml: no scope declared, so Spring keeps this a plain OAuth2 client.
|
||||
return ClientRegistration.withRegistrationId("dingtalk")
|
||||
.clientId("dingoauth_test")
|
||||
.clientSecret("secret")
|
||||
.authorizationUri("https://login.dingtalk.com/oauth2/auth")
|
||||
.tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.userInfoUri("https://api.dingtalk.com/v1.0/contact/users/me")
|
||||
.userNameAttributeName("unionId")
|
||||
.authorizationGrantType(
|
||||
org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.clientAuthenticationMethod(
|
||||
org.springframework.security.oauth2.core.ClientAuthenticationMethod.CLIENT_SECRET_POST)
|
||||
.clientName("钉钉")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ class OAuthLoginFlowServiceTest {
|
|||
};
|
||||
OAuthLoginFlowService service = new OAuthLoginFlowService(
|
||||
List.of(extractor),
|
||||
List.of(),
|
||||
accessPolicy,
|
||||
identityBindingService,
|
||||
identityCore,
|
||||
|
|
@ -102,6 +103,148 @@ class OAuthLoginFlowServiceTest {
|
|||
verify(delegate).loadUser(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadLoginContext_prefersProviderUserServiceOverrideInsideRemoteIoBoundary() {
|
||||
OAuthClaims claims = claims("feishu", "ou_1");
|
||||
OAuthClaimsExtractor extractor = new OAuthClaimsExtractor() {
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return "feishu";
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User user) {
|
||||
return claims;
|
||||
}
|
||||
};
|
||||
OAuth2User overrideUser = new DefaultOAuth2User(
|
||||
List.of(new SimpleGrantedAuthority("OAUTH_USER")),
|
||||
Map.of("open_id", "ou_1"),
|
||||
"open_id"
|
||||
);
|
||||
AtomicInteger boundaryCalls = new AtomicInteger();
|
||||
AtomicInteger overrideCallsInsideBoundary = new AtomicInteger();
|
||||
RemoteIdentityIoExecutor remoteIdentityIo = new RemoteIdentityIoExecutor() {
|
||||
@Override
|
||||
public <T> T execute(java.util.function.Supplier<T> operation) {
|
||||
boundaryCalls.incrementAndGet();
|
||||
return operation.get();
|
||||
}
|
||||
};
|
||||
ProviderOAuth2UserService override = new ProviderOAuth2UserService() {
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return "feishu";
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2User loadUser(OAuth2UserRequest request) {
|
||||
// Records the boundary state at call time: a provider override must run inside the
|
||||
// remote-IO boundary, otherwise its HTTP call would hold the surrounding transaction.
|
||||
if (boundaryCalls.get() == 1) {
|
||||
overrideCallsInsideBoundary.incrementAndGet();
|
||||
}
|
||||
return overrideUser;
|
||||
}
|
||||
};
|
||||
AccessPolicy accessPolicy = mock(AccessPolicy.class);
|
||||
IdentityBindingService identityBindingService = mock(IdentityBindingService.class);
|
||||
LegacyPlatformIdentityCore identityCore = mock(LegacyPlatformIdentityCore.class);
|
||||
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = mock();
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"usr_2", "zhangsan", null, null, "feishu", Set.of("USER")
|
||||
);
|
||||
OAuthLoginFlowService service = new OAuthLoginFlowService(
|
||||
List.of(extractor),
|
||||
List.of(override),
|
||||
accessPolicy,
|
||||
identityBindingService,
|
||||
identityCore,
|
||||
delegate,
|
||||
remoteIdentityIo
|
||||
);
|
||||
OAuth2UserRequest request = oauthUserRequest("feishu");
|
||||
when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.ALLOW);
|
||||
when(identityCore.evaluate(claims)).thenReturn(LegacyPlatformIdentityDecision.legacy());
|
||||
when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(principal);
|
||||
|
||||
OAuthLoginFlowService.AuthenticatedLoginContext result = service.loadLoginContext(request);
|
||||
|
||||
assertThat(result.upstreamUser()).isSameAs(overrideUser);
|
||||
assertThat(result.principal()).isSameAs(principal);
|
||||
assertThat(boundaryCalls).hasValue(1);
|
||||
assertThat(overrideCallsInsideBoundary).hasValue(1);
|
||||
// The default user service must not be consulted when an override claims the registration.
|
||||
verify(delegate, never()).loadUser(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadLoginContext_fallsBackToDefaultUserServiceForUnclaimedProviders() {
|
||||
OAuthClaims claims = claims();
|
||||
OAuthClaimsExtractor extractor = new OAuthClaimsExtractor() {
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return "github";
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User user) {
|
||||
return claims;
|
||||
}
|
||||
};
|
||||
ProviderOAuth2UserService unrelatedOverride = new ProviderOAuth2UserService() {
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return "feishu";
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2User loadUser(OAuth2UserRequest request) {
|
||||
throw new AssertionError("Feishu override must not handle a GitHub login");
|
||||
}
|
||||
};
|
||||
AccessPolicy accessPolicy = mock(AccessPolicy.class);
|
||||
IdentityBindingService identityBindingService = mock(IdentityBindingService.class);
|
||||
LegacyPlatformIdentityCore identityCore = mock(LegacyPlatformIdentityCore.class);
|
||||
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = mock();
|
||||
OAuth2User upstreamUser = new DefaultOAuth2User(
|
||||
List.of(new SimpleGrantedAuthority("OAUTH_USER")),
|
||||
Map.of("id", "gh_1"),
|
||||
"id"
|
||||
);
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"usr_1", "alice", "alice@example.com", null, "github", Set.of("USER")
|
||||
);
|
||||
OAuthLoginFlowService service = new OAuthLoginFlowService(
|
||||
List.of(extractor),
|
||||
List.of(unrelatedOverride),
|
||||
accessPolicy,
|
||||
identityBindingService,
|
||||
identityCore,
|
||||
delegate,
|
||||
directRemoteIo()
|
||||
);
|
||||
OAuth2UserRequest request = oauthUserRequest();
|
||||
when(delegate.loadUser(request)).thenReturn(upstreamUser);
|
||||
when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.ALLOW);
|
||||
when(identityCore.evaluate(claims)).thenReturn(LegacyPlatformIdentityDecision.legacy());
|
||||
when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(principal);
|
||||
|
||||
OAuthLoginFlowService.AuthenticatedLoginContext result = service.loadLoginContext(request);
|
||||
|
||||
assertThat(result.upstreamUser()).isSameAs(upstreamUser);
|
||||
verify(delegate).loadUser(request);
|
||||
}
|
||||
|
||||
private static RemoteIdentityIoExecutor directRemoteIo() {
|
||||
return new RemoteIdentityIoExecutor() {
|
||||
@Override
|
||||
public <T> T execute(java.util.function.Supplier<T> operation) {
|
||||
return operation.get();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(IdentityCoreMode.class)
|
||||
void authenticate_preservesPrincipalAcrossLegacyShadowAndActiveModes(IdentityCoreMode mode) {
|
||||
|
|
@ -320,12 +463,20 @@ class OAuthLoginFlowServiceTest {
|
|||
);
|
||||
}
|
||||
|
||||
private static OAuthClaims claims(String provider, String subject) {
|
||||
return new OAuthClaims(provider, subject, null, false, subject, Map.of());
|
||||
}
|
||||
|
||||
private static OAuth2UserRequest oauthUserRequest() {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId("github")
|
||||
return oauthUserRequest("github");
|
||||
}
|
||||
|
||||
private static OAuth2UserRequest oauthUserRequest(String registrationId) {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId(registrationId)
|
||||
.clientId("client")
|
||||
.clientSecret("secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri("https://skillhub.example/login/oauth2/code/github")
|
||||
.redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId)
|
||||
.authorizationUri("https://github.example/oauth/authorize")
|
||||
.tokenUri("https://github.example/oauth/token")
|
||||
.userInfoUri("https://github.example/user")
|
||||
|
|
|
|||
67
web/e2e/markdown-mermaid.spec.ts
Normal file
67
web/e2e/markdown-mermaid.spec.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
import { registerSession } from './helpers/session'
|
||||
import { E2eTestDataBuilder } from './helpers/test-data-builder'
|
||||
|
||||
test.describe('Markdown Mermaid rendering (Real API)', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
await setEnglishLocale(page)
|
||||
await registerSession(page, testInfo)
|
||||
})
|
||||
|
||||
test('renders a valid Mermaid fence as an SVG diagram', async ({ page }, testInfo) => {
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
await builder.init()
|
||||
|
||||
try {
|
||||
const namespace = await builder.ensureWritableNamespace()
|
||||
const skill = await builder.publishSkill(namespace.slug, {
|
||||
name: `mermaid-valid-${Date.now().toString(36)}`,
|
||||
readmeBody: [
|
||||
'# Mermaid diagram',
|
||||
'',
|
||||
'```mermaid',
|
||||
'flowchart TD',
|
||||
' A[Start] --> B[Done]',
|
||||
'```',
|
||||
].join('\n'),
|
||||
})
|
||||
|
||||
await page.goto(`/space/${encodeURIComponent(namespace.slug)}/${encodeURIComponent(skill.slug)}`)
|
||||
|
||||
await expect(page.locator('[data-mermaid-diagram] svg')).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page.locator('pre code.language-mermaid')).toHaveCount(0)
|
||||
} finally {
|
||||
await builder.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps invalid Mermaid source visible when rendering fails', async ({ page }, testInfo) => {
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
await builder.init()
|
||||
|
||||
try {
|
||||
const namespace = await builder.ensureWritableNamespace()
|
||||
const skill = await builder.publishSkill(namespace.slug, {
|
||||
name: `mermaid-invalid-${Date.now().toString(36)}`,
|
||||
readmeBody: [
|
||||
'# Invalid Mermaid diagram',
|
||||
'',
|
||||
'```mermaid',
|
||||
'this is not a Mermaid diagram',
|
||||
'```',
|
||||
].join('\n'),
|
||||
})
|
||||
|
||||
await page.goto(`/space/${encodeURIComponent(namespace.slug)}/${encodeURIComponent(skill.slug)}`)
|
||||
|
||||
await expect(page.locator('[data-mermaid-error]')).toBeVisible()
|
||||
const source = page.locator('pre code.language-mermaid')
|
||||
await expect(source).toContainText('this is not a Mermaid diagram')
|
||||
await expect(page.locator('[data-mermaid-diagram]')).toHaveCount(0)
|
||||
await expect(page.locator('body > div[id^="dmermaid-"]')).toHaveCount(0)
|
||||
} finally {
|
||||
await builder.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -51,6 +51,7 @@
|
|||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"openapi-fetch": "^0.13.8",
|
||||
"react": "^19.0.0",
|
||||
"react-diff-viewer-continued": "^4.2.0",
|
||||
|
|
|
|||
836
web/pnpm-lock.yaml
generated
836
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
3
web/public/dingtalk-logo.svg
Normal file
3
web/public/dingtalk-logo.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<path fill="#118EE9" d="M573.7 252.5C422.5 197.4 201.3 96.7 201.3 96.7c-15.7-4.1-17.9 11.1-17.9 11.1c-5 61.1 33.6 160.5 53.6 182.8c19.9 22.3 319.1 113.7 319.1 113.7S326 357.9 270.5 341.9c-55.6-16-37.9 17.8-37.9 17.8c11.4 61.7 64.9 131.8 107.2 138.4c42.2 6.6 220.1 4 220.1 4s-35.5 4.1-93.2 11.9c-42.7 5.8-97 12.5-111.1 17.8c-33.1 12.5 24 62.6 24 62.6c84.7 76.8 129.7 50.5 129.7 50.5c33.3-10.7 61.4-18.5 85.2-24.2L565 743.1h84.6L603 928l205.3-271.9H700.8l22.3-38.7c.3.5.4.8.4.8S799.8 496.1 829 433.8l.6-1h-.1c5-10.8 8.6-19.7 10-25.8c17-71.3-114.5-99.4-265.8-154.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 639 B |
2
web/public/feishu-logo.svg
Normal file
2
web/public/feishu-logo.svg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Official Feishu/Lark logo, source: homarr-labs/dashboard-icons -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="62.16 52.66 407.87 407.87"><path d="M274.18 264.785q.515-.517 1.03-1.027c.685-.688 1.372-1.258 2.056-1.945l1.37-1.372 4.118-4.113 5.598-5.601 4.8-4.797 4.575-4.457 4.796-4.688 4.344-4.344 6.059-6.054c1.14-1.145 2.285-2.29 3.543-3.317 2.168-2.054 4.457-4 6.855-5.828 2.172-1.715 4.344-3.312 6.516-4.914 3.082-2.172 6.398-4.344 9.71-6.285 3.204-1.941 6.63-3.656 10.06-5.371 3.199-1.602 6.515-2.973 9.827-4.23 1.829-.684 3.774-1.372 5.602-2.055.914-.344 1.941-.688 2.856-.914-8.57-33.715-24.227-64.575-45.258-90.86-4.114-5.14-10.399-8.113-17.028-8.113H130.754c-3.203 0-4.457 4-1.945 5.941 59.543 43.66 109.144 99.887 145.03 164.801 0-.226.227-.34.34-.457m0 0" style="stroke:none;fill-rule:nonzero;fill:#00d6b9;fill-opacity:1"/><path d="M204.79 418.691c90.288 0 169.03-49.828 210.058-123.543 1.488-2.628 2.859-5.257 4.23-7.882q-3.087 6-6.86 11.312l-2.741 3.77c-1.141 1.488-2.399 2.972-3.657 4.457-1.03 1.144-2.058 2.285-3.086 3.316-2.058 2.172-4.343 4.227-6.629 6.172a53 53 0 0 1-3.886 3.2c-1.598 1.144-3.086 2.284-4.684 3.429-1.031.683-2.058 1.371-3.086 1.941-1.144.684-2.172 1.258-3.316 1.942a131 131 0 0 1-6.969 3.543c-2.059.918-4.117 1.828-6.289 2.515-2.285.801-4.57 1.602-6.969 2.285-3.543.914-7.086 1.715-10.742 2.286-2.629.457-5.258.687-8 .914-2.86.23-5.601.23-8.457.23-3.086 0-6.289-.23-9.488-.57a83 83 0 0 1-7.086-1.031c-2.055-.34-4.113-.801-6.168-1.258-1.031-.227-2.176-.57-3.203-.797-2.973-.8-6.055-1.602-9.028-2.516-1.488-.457-2.972-.914-4.457-1.258-2.172-.683-4.457-1.37-6.629-2.058-1.828-.57-3.656-1.14-5.37-1.711q-2.573-.86-5.145-1.715c-1.14-.344-2.285-.8-3.543-1.144-1.371-.457-2.856-1.028-4.227-1.485-1.027-.344-2.058-.687-2.972-1.027-1.942-.688-4-1.488-5.942-2.172-1.144-.457-2.285-.914-3.43-1.258-1.484-.57-3.085-1.144-4.57-1.828-1.601-.687-3.203-1.258-4.8-1.945-1.028-.457-2.06-.797-3.087-1.258-1.257-.57-2.628-1.027-3.886-1.598-1.028-.457-1.942-.8-2.969-1.258l-3.086-1.37c-.914-.344-1.832-.801-2.746-1.145a44 44 0 0 1-2.512-1.14c-.8-.345-1.715-.802-2.515-1.145-.914-.344-1.715-.801-2.512-1.141-1.031-.457-2.172-1.031-3.203-1.484-1.14-.575-2.285-1.032-3.426-1.602-1.258-.574-2.402-1.144-3.66-1.715-1.027-.457-2.055-1.027-3.082-1.484-54.172-26.973-102.172-63.086-143.09-106.746-2.055-2.172-5.71-.684-5.71 2.289l.112 154.398v12.57c0 7.317 3.543 14.06 9.598 18.172 38.172 24.801 83.773 39.543 132.914 39.543m0 0" style="stroke:none;fill-rule:nonzero;fill:#3370ff;fill-opacity:1"/><path d="M414.84 295.188c0 .113-.113.113-.113.226zl.8-1.489c-.343.457-.574 1.028-.8 1.488m3.793-7.05.226-.457.114-.23q-.17.513-.34.687m0 0" style="stroke:none;fill-rule:nonzero;fill:#133c9a;fill-opacity:1"/><path d="M470.035 201.121c-18.285-9.031-38.86-14.059-60.687-14.059-12.914 0-25.485 1.829-37.371 5.141-1.372.344-2.743.8-4.114 1.258-.914.344-1.941.574-2.855.914-1.945.688-3.774 1.375-5.602 2.059-3.316 1.257-6.629 2.742-9.828 4.23-3.43 1.598-6.742 3.426-10.058 5.371a128 128 0 0 0-9.715 6.285c-2.285 1.602-4.457 3.2-6.512 4.914a154 154 0 0 0-6.86 5.828c-1.14 1.141-2.398 2.172-3.542 3.313l-6.055 6.059-4.344 4.343-4.8 4.684-4.57 4.46-4.802 4.798-11.086 11.086c-.687.687-1.37 1.37-2.058 1.945l-1.028 1.027c-.457.457-1.027 1.028-1.601 1.485-.57.57-1.14 1.031-1.711 1.601a244.4 244.4 0 0 1-49.828 35.313c1.027.457 2.168 1.027 3.199 1.488.8.34 1.715.797 2.512 1.14.8.344 1.715.801 2.515 1.145.801.344 1.602.684 2.516 1.14.914.345 1.828.802 2.742 1.145l3.086 1.371c1.027.457 1.942.801 2.969 1.258 1.258.57 2.629 1.028 3.887 1.598 1.03.46 2.058.8 3.086 1.258 1.601.687 3.199 1.258 4.8 1.945 1.485.57 3.086 1.14 4.57 1.828 1.145.457 2.286.914 3.43 1.258 1.946.684 4 1.484 5.946 2.172a81 81 0 0 1 2.968 1.027c1.371.457 2.856 1.028 4.23 1.485 1.141.343 2.286.8 3.544 1.14q2.567.86 5.14 1.719c1.829.57 3.657 1.14 5.372 1.71 2.171.688 4.457 1.376 6.628 2.06 1.489.457 2.973.914 4.457 1.257 2.973.914 5.942 1.715 9.032 2.512 1.027.344 2.168.574 3.199.8 2.055.458 4.113.915 6.172 1.259 2.398.457 4.683.8 7.082 1.03 3.203.34 6.402.571 9.488.571 2.856 0 5.715 0 8.457-.23 2.63-.227 5.371-.457 8-.914 3.656-.57 7.2-1.371 10.742-2.286 2.399-.683 4.688-1.37 6.973-2.285 2.172-.8 4.227-1.601 6.285-2.515 2.399-1.028 4.684-2.285 6.973-3.543 1.14-.57 2.168-1.258 3.312-1.942 1.028-.687 2.059-1.257 3.086-1.945 1.602-1.027 3.2-2.168 4.684-3.426a52 52 0 0 0 3.887-3.203c2.289-1.941 4.457-4 6.628-6.168 1.032-1.031 2.06-2.172 3.086-3.316 1.258-1.485 2.516-2.969 3.657-4.457.918-1.258 1.828-2.512 2.742-3.77 2.515-3.543 4.8-7.316 6.86-11.199l2.284-4.688 21.145-42.171v.113c6.742-14.742 16.226-28.113 27.656-39.426m0 0" style="stroke:none;fill-rule:nonzero;fill:#133c9a;fill-opacity:1"/></svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
|
|
@ -1,10 +1,32 @@
|
|||
/** @vitest-environment jsdom */
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MARKDOWN_IMAGE_CLASS_NAME, MarkdownRenderer } from './markdown-renderer'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
const { mermaidInitialize, mermaidModuleLoaded, mermaidRender } = vi.hoisted(() => ({
|
||||
mermaidInitialize: vi.fn(),
|
||||
mermaidModuleLoaded: vi.fn(),
|
||||
mermaidRender: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('mermaid', () => {
|
||||
mermaidModuleLoaded()
|
||||
|
||||
return {
|
||||
default: {
|
||||
initialize: mermaidInitialize,
|
||||
render: mermaidRender,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
mermaidInitialize.mockClear()
|
||||
mermaidModuleLoaded.mockClear()
|
||||
mermaidRender.mockReset()
|
||||
})
|
||||
|
||||
describe('MARKDOWN_IMAGE_CLASS_NAME', () => {
|
||||
it('keeps markdown images at their intrinsic width while remaining responsive', () => {
|
||||
|
|
@ -53,3 +75,81 @@ describe('MarkdownRenderer links', () => {
|
|||
expect(container.firstElementChild).toBe(firstRoot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownRenderer Mermaid blocks', () => {
|
||||
it('does not load Mermaid for documents without Mermaid blocks', () => {
|
||||
render(<MarkdownRenderer content="Plain Markdown" />)
|
||||
|
||||
expect(mermaidModuleLoaded).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders Mermaid output outside the source code preformatted container', async () => {
|
||||
mermaidRender.mockResolvedValue({ svg: '<svg data-testid="mermaid-svg"><path /></svg>' })
|
||||
|
||||
const { container } = render(
|
||||
<MarkdownRenderer content={'```mermaid\nflowchart TD\nA-->B\n```'} />,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('[data-testid="mermaid-svg"]')).toBeTruthy())
|
||||
|
||||
expect(mermaidInitialize).toHaveBeenCalledWith({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
suppressErrorRendering: true,
|
||||
})
|
||||
expect(container.querySelector('[data-testid="mermaid-svg"]')?.closest('pre')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the original Mermaid source when rendering fails', async () => {
|
||||
mermaidRender.mockRejectedValue(new Error('invalid Mermaid syntax'))
|
||||
|
||||
const { container } = render(
|
||||
<MarkdownRenderer content={'```mermaid\nnot a valid diagram\n```'} />,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('[data-mermaid-error]')).toBeTruthy())
|
||||
|
||||
expect(container.querySelector('pre code')?.textContent).toContain('not a valid diagram')
|
||||
})
|
||||
|
||||
it('assigns different render IDs to Mermaid blocks in the same document', async () => {
|
||||
mermaidRender.mockImplementation(async (id: string) => ({ svg: `<svg data-render-id="${id}" />` }))
|
||||
|
||||
render(
|
||||
<MarkdownRenderer
|
||||
content={'```mermaid\nflowchart TD\nA-->B\n```\n\n```mermaid\nflowchart LR\nC-->D\n```'}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mermaidRender).toHaveBeenCalledTimes(2))
|
||||
|
||||
const ids = mermaidRender.mock.calls.map(([id]) => id)
|
||||
expect(new Set(ids).size).toBe(2)
|
||||
})
|
||||
|
||||
it('continues rendering later blocks after an earlier Mermaid render fails', async () => {
|
||||
mermaidRender
|
||||
.mockRejectedValueOnce(new Error('invalid Mermaid syntax'))
|
||||
.mockResolvedValueOnce({ svg: '<svg data-testid="second-mermaid-svg" />' })
|
||||
|
||||
const { container } = render(
|
||||
<MarkdownRenderer
|
||||
content={'```mermaid\ninvalid\n```\n\n```mermaid\nflowchart LR\nA-->B\n```'}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mermaidRender).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() => expect(container.querySelector('[data-testid="second-mermaid-svg"]')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('keeps ordinary fenced code in the existing preformatted container', () => {
|
||||
const { container } = render(
|
||||
<MarkdownRenderer content={'```typescript\nconst answer = 42\n```'} />,
|
||||
)
|
||||
|
||||
const code = container.querySelector('pre code')
|
||||
|
||||
expect(code).not.toBeNull()
|
||||
expect(code?.textContent).toContain('const answer = 42')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,15 @@
|
|||
import { memo, useMemo, type MouseEvent } from 'react'
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
memo,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
|
|
@ -9,6 +20,117 @@ import { stripMarkdownFrontmatter } from './markdown-frontmatter'
|
|||
|
||||
export const MARKDOWN_IMAGE_CLASS_NAME = 'h-auto max-w-full'
|
||||
|
||||
type MermaidApi = typeof import('mermaid').default
|
||||
|
||||
let mermaidPromise: Promise<MermaidApi> | undefined
|
||||
let mermaidRenderQueue: Promise<void> = Promise.resolve()
|
||||
let mermaidBlockSequence = 0
|
||||
|
||||
function loadMermaid(): Promise<MermaidApi> {
|
||||
mermaidPromise ??= import('mermaid').then(({ default: mermaid }) => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
suppressErrorRendering: true,
|
||||
})
|
||||
return mermaid
|
||||
})
|
||||
|
||||
return mermaidPromise
|
||||
}
|
||||
|
||||
function enqueueMermaidRender<T>(task: () => Promise<T>): Promise<T> {
|
||||
const render = mermaidRenderQueue.then(task, task)
|
||||
mermaidRenderQueue = render.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
return render
|
||||
}
|
||||
|
||||
function getTextContent(node: ReactNode): string {
|
||||
return Children.toArray(node)
|
||||
.map((child) => {
|
||||
if (typeof child === 'string' || typeof child === 'number') {
|
||||
return String(child)
|
||||
}
|
||||
|
||||
if (isValidElement(child)) {
|
||||
return getTextContent((child as ReactElement<{ children?: ReactNode }>).props.children)
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function CodeBlock({ children, mermaidError }: { children: ReactNode; mermaidError?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="my-4 rounded-lg border border-border/60 bg-secondary/30"
|
||||
data-mermaid-error={mermaidError || undefined}
|
||||
>
|
||||
<div className="max-w-full overflow-x-auto rounded-lg bg-background px-4 py-3">
|
||||
<pre className="m-0 min-w-max bg-transparent p-0 text-[13px] leading-6">{children}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MermaidBlockProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const MermaidBlock = memo(function MermaidBlock({ children }: MermaidBlockProps) {
|
||||
const source = useMemo(() => getTextContent(children), [children])
|
||||
const renderId = useRef(`mermaid-${++mermaidBlockSequence}`).current
|
||||
const diagramRef = useRef<HTMLDivElement>(null)
|
||||
const [svg, setSvg] = useState<string | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
setSvg(null)
|
||||
setFailed(false)
|
||||
|
||||
enqueueMermaidRender(async () => {
|
||||
const mermaid = await loadMermaid()
|
||||
return mermaid.render(renderId, source)
|
||||
})
|
||||
.then(({ svg: renderedSvg }) => {
|
||||
if (mounted) {
|
||||
setSvg(renderedSvg)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) {
|
||||
setSvg(null)
|
||||
setFailed(true)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [renderId, source])
|
||||
|
||||
useEffect(() => {
|
||||
if (svg && diagramRef.current) {
|
||||
diagramRef.current.innerHTML = svg
|
||||
}
|
||||
}, [svg])
|
||||
|
||||
if (!svg) {
|
||||
return <CodeBlock mermaidError={failed}>{children}</CodeBlock>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-4 overflow-x-auto rounded-lg border border-border/60 bg-secondary/30" data-mermaid-diagram>
|
||||
<div ref={diagramRef} className="min-w-0 bg-background px-4 py-3 [&_svg]:h-auto [&_svg]:max-w-full" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string
|
||||
className?: string
|
||||
|
|
@ -112,13 +234,18 @@ function MarkdownRendererComponent({ content, className, onLinkClick }: Markdown
|
|||
{children}
|
||||
</li>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<div className="my-4 rounded-lg border border-border/60 bg-secondary/30">
|
||||
<div className="max-w-full overflow-x-auto rounded-lg bg-background px-4 py-3">
|
||||
<pre className="m-0 min-w-max bg-transparent p-0 text-[13px] leading-6">{children}</pre>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
pre: ({ children }) => {
|
||||
const codeChild = Children.toArray(children).find(isValidElement) as
|
||||
| ReactElement<{ className?: string; children?: ReactNode }>
|
||||
| undefined
|
||||
const codeClassName = codeChild?.props.className
|
||||
|
||||
if (codeClassName?.split(/\s+/).includes('language-mermaid')) {
|
||||
return <MermaidBlock>{codeChild}</MermaidBlock>
|
||||
}
|
||||
|
||||
return <CodeBlock>{children}</CodeBlock>
|
||||
},
|
||||
code: ({ className: codeClassName, children, ...props }) => {
|
||||
const isInline = !codeClassName?.includes('language-')
|
||||
|
||||
|
|
|
|||
|
|
@ -111,6 +111,31 @@ describe('SuiteBundleImport', () => {
|
|||
}))
|
||||
})
|
||||
|
||||
it('previews and confirms on browsers without crypto.randomUUID', async () => {
|
||||
vi.stubGlobal('crypto', {
|
||||
getRandomValues: (bytes: Uint8Array) => {
|
||||
bytes.fill(1)
|
||||
return bytes
|
||||
},
|
||||
})
|
||||
mocks.preview.mutateAsync.mockResolvedValue(preview({ members: [] }))
|
||||
mocks.confirm.mutateAsync.mockResolvedValue({ operationId: 'operation-1', status: 'RUNNING' })
|
||||
render(<SuiteBundleImport expectedMode="CREATE" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'pick-zip' }))
|
||||
await waitFor(() => expect(
|
||||
screen.getByRole('button', { name: 'suite.bundle.confirm' }).hasAttribute('disabled')
|
||||
).toBe(false))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'suite.bundle.confirm' }))
|
||||
|
||||
await waitFor(() => expect(mocks.confirm.mutateAsync).toHaveBeenCalledWith({
|
||||
previewToken: 'preview-1',
|
||||
warningDigest: 'digest-1',
|
||||
idempotencyKey: '01010101010101010101010101010101',
|
||||
}))
|
||||
expect(mocks.toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires warning acceptance for every affected member', async () => {
|
||||
mocks.preview.mutateAsync.mockResolvedValue(preview({
|
||||
members: [
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { newIdempotencyKey } from '@/shared/lib/idempotency-key'
|
||||
import { validateSuiteBundleFolder, validateSuiteBundleZip } from './suite-bundle-folder'
|
||||
|
||||
type BundleMode = 'CREATE' | 'UPDATE'
|
||||
|
|
@ -136,20 +137,22 @@ export function SuiteBundleImport({ expectedMode, expectedCoordinate, returnToSu
|
|||
const controller = new AbortController()
|
||||
requestRef.current = controller
|
||||
setFileName(file.name)
|
||||
let result: SkillSuiteBundlePreview
|
||||
try {
|
||||
const result = await previewMutation.mutateAsync({ file, signal: controller.signal })
|
||||
if (!controller.signal.aborted && selectionVersion === selectionVersionRef.current) {
|
||||
idempotencyKeyRef.current = crypto.randomUUID()
|
||||
setNow(Date.now())
|
||||
setPreview(result)
|
||||
}
|
||||
result = await previewMutation.mutateAsync({ file, signal: controller.signal })
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted && selectionVersion === selectionVersionRef.current) {
|
||||
toast.error(t('suite.bundle.previewFailed'), error instanceof Error ? error.message : '')
|
||||
}
|
||||
return
|
||||
} finally {
|
||||
if (requestRef.current === controller) requestRef.current = null
|
||||
}
|
||||
if (!controller.signal.aborted && selectionVersion === selectionVersionRef.current) {
|
||||
idempotencyKeyRef.current = newIdempotencyKey()
|
||||
setNow(Date.now())
|
||||
setPreview(result)
|
||||
}
|
||||
}
|
||||
|
||||
const previewFile = async (file: File) => {
|
||||
|
|
@ -200,7 +203,7 @@ export function SuiteBundleImport({ expectedMode, expectedCoordinate, returnToSu
|
|||
|
||||
const confirm = async () => {
|
||||
if (!preview?.previewToken || !preview.warningDigest || !canConfirm) return
|
||||
const idempotencyKey = idempotencyKeyRef.current ?? crypto.randomUUID()
|
||||
const idempotencyKey = idempotencyKeyRef.current ?? newIdempotencyKey()
|
||||
idempotencyKeyRef.current = idempotencyKey
|
||||
try {
|
||||
const result = await confirmMutation.mutateAsync({
|
||||
|
|
|
|||
35
web/src/shared/lib/idempotency-key.test.ts
Normal file
35
web/src/shared/lib/idempotency-key.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/** @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { newIdempotencyKey } from './idempotency-key'
|
||||
|
||||
describe('newIdempotencyKey', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses crypto.randomUUID when it is available', () => {
|
||||
vi.stubGlobal('crypto', { randomUUID: () => 'request-1' })
|
||||
|
||||
expect(newIdempotencyKey()).toBe('request-1')
|
||||
})
|
||||
|
||||
it('uses crypto.getRandomValues when randomUUID is unavailable', () => {
|
||||
vi.stubGlobal('crypto', {
|
||||
getRandomValues: (bytes: Uint8Array) => {
|
||||
bytes.fill(1)
|
||||
return bytes
|
||||
},
|
||||
})
|
||||
|
||||
expect(newIdempotencyKey()).toBe('01010101010101010101010101010101')
|
||||
})
|
||||
|
||||
it('falls back to Math.random when Web Crypto is unavailable', () => {
|
||||
vi.stubGlobal('crypto', undefined)
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
|
||||
expect(newIdempotencyKey()).toBe('00000000000000000000000000000000')
|
||||
})
|
||||
})
|
||||
14
web/src/shared/lib/idempotency-key.ts
Normal file
14
web/src/shared/lib/idempotency-key.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export function newIdempotencyKey(): string {
|
||||
const cryptoApi = typeof globalThis.crypto !== 'undefined' ? globalThis.crypto : undefined
|
||||
if (typeof cryptoApi?.randomUUID === 'function') return cryptoApi.randomUUID()
|
||||
|
||||
const bytes = new Uint8Array(16)
|
||||
if (typeof cryptoApi?.getRandomValues === 'function') {
|
||||
cryptoApi.getRandomValues(bytes)
|
||||
} else {
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Math.floor(Math.random() * 256)
|
||||
}
|
||||
}
|
||||
return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
|
@ -95,6 +95,12 @@ export default defineConfig({
|
|||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/login/oauth2': {
|
||||
target: 'http://localhost:8080',
|
||||
// Preserve the browser-facing localhost:3000 host so Spring's
|
||||
// post-login redirect does not send the SPA to localhost:8080.
|
||||
changeOrigin: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue