diff --git a/.env.release.example b/.env.release.example index c5ac104a..505c594a 100644 --- a/.env.release.example +++ b/.env.release.example @@ -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. diff --git a/README.md b/README.md index 1c0f43fe..4768175f 100644 --- a/README.md +++ b/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 `/.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: diff --git a/README_zh.md b/README_zh.md index fbd59d98..7ba0d3b7 100644 --- a/README_zh.md +++ b/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 适配器: diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 1e8bc1a3..d9ffaa59 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -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 模型名称 | diff --git a/charts/skillhub/templates/secret.yaml b/charts/skillhub/templates/secret.yaml index 8e28c911..523bc386 100644 --- a/charts/skillhub/templates/secret.yaml +++ b/charts/skillhub/templates/secret.yaml @@ -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 }} diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index a6e4e788..c6b99807 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -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 }} diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index cd297881..b1dfbbd3 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -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 \ diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index 1bede63b..83b76ac5 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -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" } diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 092e73e4..5e38e21a 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -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: "" diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index b5d25ecb..dcac2dfc 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -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 diff --git a/cli/README.md b/cli/README.md index 15839e09..336b2cb5 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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` | `/.claude/skills/` | `~/.claude/skills/` | | `codex` | `/.codex/skills/` | `~/.codex/skills/` | | `cursor` | `/.cursor/skills/` | `~/.cursor/skills/` | +| `dsh` (DeepSeek Harness) | `/.dsh/skills/` | `~/.dsh/skills/` | | `github-copilot` | `/.github-copilot/skills/` | `~/.github-copilot/skills/` | | `gemini-cli` | `/.gemini/skills/` | `~/.gemini/skills/` | | `windsurf` | `/.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 ``` diff --git a/cli/src/agents/detector.ts b/cli/src/agents/detector.ts index dab2167e..5873b98c 100644 --- a/cli/src/agents/detector.ts +++ b/cli/src/agents/detector.ts @@ -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 diff --git a/cli/src/agents/profiles/dsh.ts b/cli/src/agents/profiles/dsh.ts new file mode 100644 index 00000000..fd80f0b9 --- /dev/null +++ b/cli/src/agents/profiles/dsh.ts @@ -0,0 +1,2 @@ +import { makeProfile } from './make-profile' +export const dshProfile = makeProfile('dsh', 'DeepSeek Harness', '.dsh/skills', '.dsh/skills') diff --git a/cli/test/integration/install-command.test.ts b/cli/test/integration/install-command.test.ts index 36084f33..789964d9 100644 --- a/cli/test/integration/install-command.test.ts +++ b/cli/test/integration/install-command.test.ts @@ -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({ diff --git a/cli/test/unit/agents/profiles.test.ts b/cli/test/unit/agents/profiles.test.ts index cd667520..2aabb0e0 100644 --- a/cli/test/unit/agents/profiles.test.ts +++ b/cli/test/unit/agents/profiles.test.ts @@ -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') diff --git a/compose.release.yml b/compose.release.yml index c707db08..bb55a8f1 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -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:-} diff --git a/deploy/k8s/base/backend-deployment.yaml b/deploy/k8s/base/backend-deployment.yaml index 816ffed3..a7a39648 100644 --- a/deploy/k8s/base/backend-deployment.yaml +++ b/deploy/k8s/base/backend-deployment.yaml @@ -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 diff --git a/deploy/k8s/base/secret.yaml.example b/deploy/k8s/base/secret.yaml.example index c2b93d45..f983fa19 100644 --- a/deploy/k8s/base/secret.yaml.example +++ b/deploy/k8s/base/secret.yaml.example @@ -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: "" diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 4c8ef11e..19f6140f 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -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. 核心接口设计 diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index 5694f9f6..835e5421 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -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 中生成 `` 描述块 diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 2bbf6c8c..452f442b 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -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= + OAUTH2_DINGTALK_CLIENT_SECRET= + 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 登录配置 diff --git a/docs/dsh-integration-en.md b/docs/dsh-integration-en.md new file mode 100644 index 00000000..45fc8941 --- /dev/null +++ b/docs/dsh-integration-en.md @@ -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 `/.dsh/skills` for project skills and `$DSH_HOME/skills` +(default `~/.dsh/skills`) for user skills. With the default home: + +```bash +# User scope: ~/.dsh/skills// +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.md`, matching dsh's one-level bundle discovery. +- dsh also accepts a flat `.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.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. diff --git a/docs/dsh-integration.md b/docs/dsh-integration.md new file mode 100644 index 00000000..2eaf58a1 --- /dev/null +++ b/docs/dsh-integration.md @@ -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// +skillhub install my-skill --agent dsh --scope user + +# 项目级:从仓库根目录执行,安装到 .dsh/skills// +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.md`,符合 dsh 对根目录一级技能包的发现规则。 +- dsh 还支持根目录中的单文件 `.md`;SkillHub 包仍以根级 `SKILL.md` 为规范入口。 +- `SKILL.md` 至少需要合法的 kebab-case `name` 和非空 `description` frontmatter。 +- 格式兼容不代表运行时能力完全相同。技能依赖的 Agent 专用工具、命令、MCP server、 + 环境变量和操作系统能力仍需单独验证。 +- 安装前应审查技能内容和 SkillHub 安全报告;Registry Token 不应写入技能包。 + +## 故障排查 + +如果 dsh 未发现已安装技能: + +1. 运行 `skillhub list`,确认安装目录和状态。 +2. 确认目录结构为 `<技能根>//SKILL.md`,没有额外嵌套层级。 +3. 检查 `SKILL.md` 的 `name` 与 `description` frontmatter。 +4. 项目级安装确认位于最近的 `.git` 祖先下,而不是仓库子目录。 +5. 自定义 `DSH_HOME` 或 `DSH_AGENTS_HOME` 时,确认安装命令使用了对应实际路径。 diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index acfe6d89..4db5d8bb 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -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? diff --git a/docs/skillhub/en/guide/cli.md b/docs/skillhub/en/guide/cli.md index 3fd0940b..014cc29a 100644 --- a/docs/skillhub/en/guide/cli.md +++ b/docs/skillhub/en/guide/cli.md @@ -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` | `/.claude/skills/` | `~/.claude/skills/` | | `codex` | `/.codex/skills/` | `~/.codex/skills/` | | `cursor` | `/.cursor/skills/` | `~/.cursor/skills/` | +| `dsh` (DeepSeek Harness) | `/.dsh/skills/` | `~/.dsh/skills/` | | `github-copilot` | `/.github-copilot/skills/` | `~/.github-copilot/skills/` | | `gemini-cli` | `/.gemini/skills/` | `~/.gemini/skills/` | | `windsurf` | `/.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 ``` diff --git a/docs/skillhub/faq.md b/docs/skillhub/faq.md index 8906150c..e8a094d9 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -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)是讯飞自研的吗?使用什么协议? diff --git a/docs/skillhub/guide/cli.md b/docs/skillhub/guide/cli.md index 8ae5e341..f007f22c 100644 --- a/docs/skillhub/guide/cli.md +++ b/docs/skillhub/guide/cli.md @@ -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` | `/.claude/skills/` | `~/.claude/skills/` | | `codex` | `/.codex/skills/` | `~/.codex/skills/` | | `cursor` | `/.cursor/skills/` | `~/.cursor/skills/` | +| `dsh`(DeepSeek Harness) | `/.dsh/skills/` | `~/.dsh/skills/` | | `github-copilot` | `/.github-copilot/skills/` | `~/.github-copilot/skills/` | | `gemini-cli` | `/.gemini/skills/` | `~/.gemini/skills/` | | `windsurf` | `/.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"` 安装。 + ### 安装后的文件结构 ``` diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh index 1ec54043..c241e308 100755 --- a/scripts/tests/validate-release-config-test.sh +++ b/scripts/tests/validate-release-config-test.sh @@ -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 diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index eaaca6b1..356d241d 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -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 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java index 551c7fb8..00e08dba 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java @@ -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)) { diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index c5507532..6dfbbd42 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/oauth/ProviderStrategyWiringTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/oauth/ProviderStrategyWiringTest.java new file mode 100644 index 00000000..4686250f --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/oauth/ProviderStrategyWiringTest.java @@ -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. + * + *

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

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: + * + *

    + *
  • 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}. + *
  • {@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. + *
+ */ +@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); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java new file mode 100644 index 00000000..a382f075 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java @@ -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. + * + *

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 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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2Constants.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2Constants.java new file mode 100644 index 00000000..2617ab70 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2Constants.java @@ -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() { + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java new file mode 100644 index 00000000..6528b2ab --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java @@ -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}. + * + *

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 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 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 names) { + if (node.isObject()) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry 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 textValues(JsonNode array) { + List 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 normalize(Map payload) { + Map 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 target, + Map source, + String key + ) { + Object value = source.get(key); + if (value != null && !String.valueOf(value).isBlank()) { + target.put(key, value); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java new file mode 100644 index 00000000..cea2f825 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java @@ -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 (钉钉). + * + *

DingTalk requires a JSON body for token exchange instead of the standard + * form-urlencoded format. This client adapts the request accordingly. + * + *

Request body format: + *

{ "clientId": "...", "clientSecret": "...", "code": "...", "grantType": "authorization_code" }
+ */ +@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 tokenRequest = Map.of( + "clientId", clientId, + "clientSecret", clientSecret, + "code", code, + "grantType", "authorization_code" + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity 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 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)); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClient.java new file mode 100644 index 00000000..934e89c7 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClient.java @@ -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. + * + *

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 { + + private final Map overrides; + private final OAuth2AccessTokenResponseClient delegate; + + @Autowired + public DispatchingTokenResponseClient(List providerClients) { + this(providerClients, new DefaultAuthorizationCodeTokenResponseClient()); + } + + DispatchingTokenResponseClient( + List providerClients, + OAuth2AccessTokenResponseClient 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); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java new file mode 100644 index 00000000..ba7fded0 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java @@ -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}. + * + *

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 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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClient.java new file mode 100644 index 00000000..5a03639a --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClient.java @@ -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 standardClient; + private final String protocolVersion; + + @Autowired + public FeishuOAuth2AccessTokenResponseClient( + @Value("${OAUTH2_FEISHU_PROTOCOL_VERSION:v3}") String protocolVersion) { + this(RestClient.builder().requestFactory(defaultRequestFactory()), + new DefaultAuthorizationCodeTokenResponseClient(), protocolVersion); + } + + FeishuOAuth2AccessTokenResponseClient( + RestClient.Builder restClientBuilder) { + this(restClientBuilder, new DefaultAuthorizationCodeTokenResponseClient(), V3); + } + + FeishuOAuth2AccessTokenResponseClient( + RestClient.Builder restClientBuilder, + OAuth2AccessTokenResponseClient standardClient) { + this(restClientBuilder, standardClient, V3); + } + + FeishuOAuth2AccessTokenResponseClient( + RestClient.Builder restClientBuilder, + OAuth2AccessTokenResponseClient standardClient, + String protocolVersion) { + this.restClient = restClientBuilder.build(); + this.standardClient = standardClient; + this.protocolVersion = normalizeProtocolVersion(protocolVersion); + } + + @Override + public String getProvider() { + return FEISHU_PROVIDER; + } + + @Override + public OAuth2AccessTokenResponse getTokenResponse( + OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) { + if (!FEISHU_PROVIDER.equals(authorizationCodeGrantRequest.getClientRegistration().getRegistrationId())) { + return standardClient.getTokenResponse(authorizationCodeGrantRequest); + } + + Map requestBody = new LinkedHashMap<>(); + requestBody.put("grant_type", "authorization_code"); + requestBody.put("client_id", authorizationCodeGrantRequest.getClientRegistration().getClientId()); + requestBody.put("client_secret", authorizationCodeGrantRequest.getClientRegistration().getClientSecret()); + requestBody.put("code", authorizationCodeGrantRequest.getAuthorizationExchange() + .getAuthorizationResponse().getCode()); + + String redirectUri = authorizationCodeGrantRequest.getAuthorizationExchange() + .getAuthorizationRequest().getRedirectUri(); + if (redirectUri != null && !redirectUri.isBlank()) { + requestBody.put("redirect_uri", redirectUri); + } + Object codeVerifier = authorizationCodeGrantRequest.getAuthorizationExchange() + .getAuthorizationRequest().getAttribute("code_verifier"); + if (codeVerifier instanceof String verifier && !verifier.isBlank()) { + requestBody.put("code_verifier", verifier); + } + + 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); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java new file mode 100644 index 00000000..a2608cca --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java @@ -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 attributes = flatten(response.data(), userNameAttributeName); + return new DefaultOAuth2User( + Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), + attributes, + userNameAttributeName + ); + } + + private Map flatten(FeishuUserData data, String userNameAttributeName) { + Map 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 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 + ) {} +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java index 14beac75..a6923ccf 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java @@ -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; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java index a75f2457..3f1290d1 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java @@ -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); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java index 9a2c2824..3c987dc2 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java @@ -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 extractors; + private final Map userServiceOverrides; private final AccessPolicy accessPolicy; private final IdentityBindingService identityBindingService; private final LegacyPlatformIdentityCore identityCore; @@ -44,12 +49,14 @@ public class OAuthLoginFlowService { @Autowired public OAuthLoginFlowService(List extractorList, + List userServiceList, AccessPolicy accessPolicy, IdentityBindingService identityBindingService, LegacyPlatformIdentityCore identityCore, RemoteIdentityIoExecutor remoteIdentityIo) { this( extractorList, + userServiceList, accessPolicy, identityBindingService, identityCore, @@ -59,6 +66,7 @@ public class OAuthLoginFlowService { } OAuthLoginFlowService(List extractorList, + List 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); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAuthorizationRequestCustomizer.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAuthorizationRequestCustomizer.java new file mode 100644 index 00000000..a8504181 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAuthorizationRequestCustomizer.java @@ -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. + * + *

The token and userinfo counterparts are {@link ProviderTokenResponseClient} and + * {@link ProviderOAuth2UserService}. + */ +public interface ProviderAuthorizationRequestCustomizer { + + String getProvider(); + + void customize(OAuth2AuthorizationRequest.Builder builder); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderOAuth2UserService.java new file mode 100644 index 00000000..bf587e81 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderOAuth2UserService.java @@ -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 { + String getProvider(); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderTokenResponseClient.java new file mode 100644 index 00000000..9dc85aef --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderTokenResponseClient.java @@ -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. + * + *

The userinfo counterpart is {@link ProviderOAuth2UserService}. + */ +public interface ProviderTokenResponseClient + extends OAuth2AccessTokenResponseClient { + + String getProvider(); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java index 5cd28902..04b387d1 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java @@ -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 customizers) { this.delegate = new DefaultOAuth2AuthorizationRequestResolver( clientRegistrationRepository, "/oauth2/authorization" ); this.oauthLoginFlowService = oauthLoginFlowService; + Map 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; } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java new file mode 100644 index 00000000..09a8ca93 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java @@ -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 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 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 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 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 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 attrs) { + return new OAuth2User() { + @Override + public Map getAttributes() { + return attrs; + } + + @Override + public java.util.Collection + 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); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java new file mode 100644 index 00000000..2abc368f --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java @@ -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 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); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java new file mode 100644 index 00000000..fa127ec2 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java @@ -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(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClientTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClientTest.java new file mode 100644 index 00000000..d2c18586 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClientTest.java @@ -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) + ); + } + +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java new file mode 100644 index 00000000..6cc2239b --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java @@ -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 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 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-": 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 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 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 attrs) { + return new OAuth2User() { + @Override + public Map getAttributes() { + return attrs; + } + + @Override + public java.util.Collection + 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 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 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); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClientTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClientTest.java new file mode 100644 index 00000000..1f022424 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClientTest.java @@ -0,0 +1,231 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +class FeishuOAuth2AccessTokenResponseClientTest { + + @Test + void getTokenResponse_postsFeishuJsonRequestAndParsesTokenResponse() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header(HttpHeaders.CONTENT_TYPE, "application/json;charset=utf-8")) + .andExpect(content().json(""" + { + "grant_type": "authorization_code", + "client_id": "cli_test", + "client_secret": "secret_test", + "code": "auth-code", + "redirect_uri": "https://skillhub.example.com/login/oauth2/code/feishu" + } + """, false)) + .andRespond(withSuccess(""" + { + "code": 0, + "access_token": "access-token", + "token_type": "Bearer", + "expires_in": 7200, + "refresh_token": "refresh-token", + "scope": "contact:user.base:readonly offline_access" + } + """, MediaType.APPLICATION_JSON)); + + FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder); + + var response = client.getTokenResponse(grantRequest(false)); + + assertThat(response.getAccessToken().getTokenValue()).isEqualTo("access-token"); + assertThat(response.getAccessToken().getTokenType()).isEqualTo(OAuth2AccessToken.TokenType.BEARER); + assertThat(response.getAccessToken().getScopes()) + .containsExactlyInAnyOrder("contact:user.base:readonly", "offline_access"); + assertThat(response.getRefreshToken()).isNotNull(); + assertThat(response.getRefreshToken().getTokenValue()).isEqualTo("refresh-token"); + assertThat(response.getAccessToken().getExpiresAt()).isAfter(Instant.now()); + server.verify(); + } + + @Test + void getTokenResponse_usesV2EndpointWhenConfigured() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v2/oauth/token")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header(HttpHeaders.CONTENT_TYPE, "application/json;charset=utf-8")) + .andRespond(withSuccess("{\"code\":0,\"access_token\":\"v2-access-token\"," + + "\"token_type\":\"Bearer\",\"expires_in\":3600}", + MediaType.APPLICATION_JSON)); + + FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient( + builder, request -> OAuth2AccessTokenResponse.withToken("unused").build(), "v2"); + + assertThat(client.getTokenResponse(grantRequest(false)).getAccessToken().getTokenValue()) + .isEqualTo("v2-access-token"); + server.verify(); + } + + @Test + void constructorRejectsUnsupportedProtocolVersion() { + assertThatThrownBy(() -> new FeishuOAuth2AccessTokenResponseClient( + RestClient.builder(), request -> OAuth2AccessTokenResponse.withToken("unused").build(), "v1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("v2 or v3"); + } + + @Test + void getTokenResponse_forwardsCodeVerifierWhenAuthorizationRequestContainsIt() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token")) + .andExpect(content().json(""" + { + "grant_type": "authorization_code", + "client_id": "cli_test", + "client_secret": "secret_test", + "code": "auth-code", + "redirect_uri": "https://skillhub.example.com/login/oauth2/code/feishu", + "code_verifier": "verifier-value" + } + """, false)) + .andRespond(withSuccess("{\"code\":0,\"access_token\":\"access-token\"," + + "\"token_type\":\"Bearer\",\"expires_in\":3600}", + MediaType.APPLICATION_JSON)); + + FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder); + + client.getTokenResponse(grantRequest(true)); + + server.verify(); + } + + @Test + void getTokenResponse_rejectsFeishuBusinessErrorReturnedAsHttp200() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token")) + .andRespond(withSuccess(""" + {"code": 20003, "error": "invalid_grant", "error_description": "secret_test rejected auth-code"} + """, MediaType.APPLICATION_JSON)); + + FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder); + + assertThatThrownBy(() -> client.getTokenResponse(grantRequest(false))) + .isInstanceOf(OAuth2AuthorizationException.class) + .satisfies(error -> { + var oauthError = ((OAuth2AuthorizationException) error).getError(); + assertThat(oauthError.getErrorCode()).isEqualTo("feishu_invalid_token_response"); + assertThat(oauthError.getDescription()).doesNotContain("secret_test", "auth-code", "rejected"); + }); + server.verify(); + } + + @Test + void getTokenResponse_rejectsInvalidSuccessfulResponse() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token")) + .andRespond(withSuccess("{\"code\":0,\"access_token\":\"access-token\"," + + "\"token_type\":\"mac\",\"expires_in\":3600}", MediaType.APPLICATION_JSON)); + + FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder); + + assertThatThrownBy(() -> client.getTokenResponse(grantRequest(false))) + .isInstanceOf(OAuth2AuthorizationException.class) + .satisfies(error -> assertThat(((OAuth2AuthorizationException) error).getError().getDescription()) + .contains("unsupported token type")); + server.verify(); + } + + @Test + void getTokenResponse_rejectsHttpErrorWithoutExposingResponseDetails() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token")) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withStatus(org.springframework.http.HttpStatus.BAD_REQUEST) + .body("client_secret=secret_test")); + + FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder); + + assertThatThrownBy(() -> client.getTokenResponse(grantRequest(false))) + .isInstanceOf(OAuth2AuthorizationException.class) + .satisfies(error -> assertThat(((OAuth2AuthorizationException) error).getError().getDescription()) + .doesNotContain("secret_test", "auth-code")); + server.verify(); + } + + @Test + void getTokenResponse_delegatesNonFeishuRegistrationToStandardClient() { + OAuth2AccessTokenResponseClient delegate = request -> + org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse.withToken("github-token") + .tokenType(OAuth2AccessToken.TokenType.BEARER) + .build(); + FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient( + RestClient.builder(), delegate); + + var response = client.getTokenResponse(grantRequest("github", false)); + + assertThat(response.getAccessToken().getTokenValue()).isEqualTo("github-token"); + } + + private OAuth2AuthorizationCodeGrantRequest grantRequest(boolean withCodeVerifier) { + return grantRequest("feishu", withCodeVerifier); + } + + private OAuth2AuthorizationCodeGrantRequest grantRequest(String registrationId, boolean withCodeVerifier) { + ClientRegistration registration = ClientRegistration.withRegistrationId(registrationId) + .clientId("cli_test") + .clientSecret("secret_test") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri("https://accounts.feishu.cn/open-apis/authen/v1/authorize") + .tokenUri("https://accounts.feishu.cn/oauth/v3/token") + .userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info") + .userNameAttributeName("open_id") + .clientName("飞书") + .build(); + OAuth2AuthorizationRequest.Builder request = OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri(registration.getProviderDetails().getAuthorizationUri()) + .clientId(registration.getClientId()) + .redirectUri("https://skillhub.example.com/login/oauth2/code/feishu") + .state("state") + .attributes(attributes -> { + if (withCodeVerifier) { + attributes.put("code_verifier", "verifier-value"); + } + }); + OAuth2AuthorizationResponse response = OAuth2AuthorizationResponse.success("auth-code") + .redirectUri("https://skillhub.example.com/login/oauth2/code/feishu") + .state("state") + .build(); + return new OAuth2AuthorizationCodeGrantRequest( + registration, + new OAuth2AuthorizationExchange(request.build(), response)); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java new file mode 100644 index 00000000..128d173d --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java @@ -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 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); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java index 9cef26b2..a371cf7d 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java @@ -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(); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java index 29a280f1..7ece104d 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java @@ -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 execute(java.util.function.Supplier 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 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 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 execute(java.util.function.Supplier 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") diff --git a/web/e2e/markdown-mermaid.spec.ts b/web/e2e/markdown-mermaid.spec.ts new file mode 100644 index 00000000..d4b71e34 --- /dev/null +++ b/web/e2e/markdown-mermaid.spec.ts @@ -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() + } + }) +}) diff --git a/web/package.json b/web/package.json index 9d21f860..d131b5e4 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 31886ba6..a14647c0 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -58,6 +58,9 @@ importers: lucide-react: specifier: ^0.344.0 version: 0.344.0(react@19.2.4) + mermaid: + specifier: ^11.17.2 + version: 11.17.2 openapi-fetch: specifier: ^0.13.8 version: 0.13.8 @@ -168,6 +171,9 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@antfu/install-pkg@2.1.0': + resolution: {integrity: sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==} + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -311,10 +317,16 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} @@ -606,6 +618,12 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.7': + resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -622,6 +640,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mermaid-js/parser@1.2.1': + resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1328,6 +1349,99 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.12': + resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1340,6 +1454,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1360,6 +1477,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1427,6 +1547,9 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1642,6 +1765,14 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1654,6 +1785,12 @@ packages: cookie-es@2.0.0: resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cosmiconfig@7.1.0: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} @@ -1674,10 +1811,169 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.3: + resolution: {integrity: sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1696,6 +1992,9 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1727,6 +2026,9 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dompurify@3.4.15: + resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==} + electron-to-chromium@1.5.422: resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} @@ -1740,6 +2042,9 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -1828,6 +2133,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fastdom@1.0.12: + resolution: {integrity: sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1923,6 +2231,9 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1978,6 +2289,10 @@ packages: typescript: optional: true + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1986,6 +2301,9 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -2004,6 +2322,13 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -2106,9 +2431,22 @@ packages: engines: {node: '>=6'} hasBin: true + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2124,6 +2462,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -2159,6 +2500,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -2217,6 +2563,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mermaid@11.17.2: + resolution: {integrity: sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -2379,6 +2728,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2397,6 +2749,9 @@ packages: parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2452,6 +2807,12 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -2654,14 +3015,26 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -2726,6 +3099,9 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + strictdom@1.0.1: + resolution: {integrity: sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2746,6 +3122,9 @@ packages: stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -2797,6 +3176,10 @@ packages: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -2836,6 +3219,10 @@ packages: peerDependencies: typescript: '>=4.2.0' + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -2924,6 +3311,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -3098,6 +3489,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@antfu/install-pkg@2.1.0': + dependencies: + package-manager-detector: 1.8.0 + tinyexec: 1.3.1 + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -3286,10 +3682,14 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@braintree/sanitize-url@7.1.2': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 + '@chevrotain/types@11.1.2': {} + '@csstools/color-helpers@6.0.2': {} '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -3520,6 +3920,14 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.7': + dependencies: + '@antfu/install-pkg': 2.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3539,6 +3947,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mermaid-js/parser@1.2.1': + dependencies: + '@chevrotain/types': 11.1.2 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4168,6 +4580,123 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.1': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.12': {} + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.12 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.1 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.12 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -4180,6 +4709,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -4200,6 +4731,9 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -4287,6 +4821,11 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vitejs/plugin-react@4.7.0(vite@6.4.3(jiti@1.21.7))': dependencies: '@babel/core': 7.29.7 @@ -4497,6 +5036,10 @@ snapshots: commander@4.1.1: {} + commander@7.2.0: {} + + commander@8.3.0: {} + concat-map@0.0.1: {} convert-source-map@1.9.0: {} @@ -4505,6 +5048,14 @@ snapshots: cookie-es@2.0.0: {} + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cosmiconfig@7.1.0: dependencies: '@types/parse-json': 4.0.2 @@ -4528,6 +5079,190 @@ snapshots: csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.3): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.3 + + cytoscape-fcose@2.2.0(cytoscape@3.34.3): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.3 + + cytoscape@3.34.3: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + data-urls@7.0.0: dependencies: whatwg-mimetype: 5.0.0 @@ -4535,6 +5270,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + dayjs@1.11.23: {} + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 @@ -4549,6 +5286,10 @@ snapshots: deep-is@0.1.4: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + dequal@2.0.3: {} detect-node-es@1.1.0: {} @@ -4573,6 +5314,10 @@ snapshots: dom-accessibility-api@0.5.16: {} + dompurify@3.4.15: + optionalDependencies: + '@types/trusted-types': 2.0.7 + electron-to-chromium@1.5.422: {} entities@8.0.0: {} @@ -4583,6 +5328,8 @@ snapshots: es-module-lexer@2.1.0: {} + es-toolkit@1.52.0: {} + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -4718,6 +5465,10 @@ snapshots: fast-levenshtein@2.0.6: {} + fastdom@1.0.12: + dependencies: + strictdom: 1.0.1 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -4807,6 +5558,8 @@ snapshots: graphemer@1.4.0: {} + hachure-fill@0.5.2: {} + has-flag@4.0.0: {} hasown@2.0.2: @@ -4889,6 +5642,10 @@ snapshots: optionalDependencies: typescript: 5.9.3 + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} import-fresh@3.3.1: @@ -4896,6 +5653,8 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} index-to-position@1.2.0: {} @@ -4909,6 +5668,10 @@ snapshots: inline-style-parser@0.2.7: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -4998,10 +5761,20 @@ snapshots: json5@2.2.3: {} + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -5015,6 +5788,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.18.1: {} + lodash.merge@4.6.2: {} longest-streak@3.1.0: {} @@ -5047,6 +5822,8 @@ snapshots: markdown-table@3.0.4: {} + marked@16.4.2: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -5217,6 +5994,31 @@ snapshots: merge2@1.4.1: {} + mermaid@11.17.2: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.7 + '@mermaid-js/parser': 1.2.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.3 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.3) + cytoscape-fcose: 2.2.0(cytoscape@3.34.3) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.23 + dompurify: 3.4.15 + es-toolkit: 1.52.0 + fastdom: 1.0.12 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.2 + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -5491,6 +6293,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-manager-detector@1.8.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5522,6 +6326,8 @@ snapshots: dependencies: entities: 8.0.0 + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -5554,6 +6360,13 @@ snapshots: pluralize@8.0.0: {} + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss-import@15.1.0(postcss@8.5.26): dependencies: postcss: 8.5.26 @@ -5781,6 +6594,8 @@ snapshots: dependencies: glob: 7.2.3 + robust-predicates@3.0.3: {} + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -5812,10 +6627,21 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -5857,6 +6683,8 @@ snapshots: std-env@4.1.0: {} + strictdom@1.0.1: {} + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -5878,6 +6706,8 @@ snapshots: stylis@4.2.0: {} + stylis@4.4.0: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -5946,6 +6776,8 @@ snapshots: tinyexec@1.2.4: {} + tinyexec@1.3.1: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -5979,6 +6811,8 @@ snapshots: dependencies: typescript: 5.9.3 + ts-dedent@2.3.0: {} + ts-interface-checker@0.1.13: {} tslib@2.8.1: {} @@ -6066,6 +6900,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@14.0.2: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/web/public/dingtalk-logo.svg b/web/public/dingtalk-logo.svg new file mode 100644 index 00000000..b1a268d1 --- /dev/null +++ b/web/public/dingtalk-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/public/feishu-logo.svg b/web/public/feishu-logo.svg new file mode 100644 index 00000000..f929a53d --- /dev/null +++ b/web/public/feishu-logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/web/src/features/skill/markdown-renderer.test.tsx b/web/src/features/skill/markdown-renderer.test.tsx index d7ac6366..9f596f5d 100644 --- a/web/src/features/skill/markdown-renderer.test.tsx +++ b/web/src/features/skill/markdown-renderer.test.tsx @@ -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() + + expect(mermaidModuleLoaded).not.toHaveBeenCalled() + }) + + it('renders Mermaid output outside the source code preformatted container', async () => { + mermaidRender.mockResolvedValue({ svg: '' }) + + const { container } = render( + 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( + , + ) + + 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: `` })) + + render( + 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: '' }) + + const { container } = render( + 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( + , + ) + + const code = container.querySelector('pre code') + + expect(code).not.toBeNull() + expect(code?.textContent).toContain('const answer = 42') + }) +}) diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index e9761a2e..cab7a561 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -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 | undefined +let mermaidRenderQueue: Promise = Promise.resolve() +let mermaidBlockSequence = 0 + +function loadMermaid(): Promise { + mermaidPromise ??= import('mermaid').then(({ default: mermaid }) => { + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + suppressErrorRendering: true, + }) + return mermaid + }) + + return mermaidPromise +} + +function enqueueMermaidRender(task: () => Promise): Promise { + 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 ( +

+
+
{children}
+
+
+ ) +} + +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(null) + const [svg, setSvg] = useState(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 {children} + } + + return ( +
+
+
+ ) +}) + interface MarkdownRendererProps { content: string className?: string @@ -112,13 +234,18 @@ function MarkdownRendererComponent({ content, className, onLinkClick }: Markdown {children} ), - pre: ({ children }) => ( -
-
-
{children}
-
-
- ), + 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 {codeChild} + } + + return {children} + }, code: ({ className: codeClassName, children, ...props }) => { const isInline = !codeClassName?.includes('language-') diff --git a/web/src/features/suite/suite-bundle-import.test.tsx b/web/src/features/suite/suite-bundle-import.test.tsx index 6e300de7..b6b33dc4 100644 --- a/web/src/features/suite/suite-bundle-import.test.tsx +++ b/web/src/features/suite/suite-bundle-import.test.tsx @@ -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() + + 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: [ diff --git a/web/src/features/suite/suite-bundle-import.tsx b/web/src/features/suite/suite-bundle-import.tsx index bf124ca0..099b62aa 100644 --- a/web/src/features/suite/suite-bundle-import.tsx +++ b/web/src/features/suite/suite-bundle-import.tsx @@ -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({ diff --git a/web/src/shared/lib/idempotency-key.test.ts b/web/src/shared/lib/idempotency-key.test.ts new file mode 100644 index 00000000..3eab522d --- /dev/null +++ b/web/src/shared/lib/idempotency-key.test.ts @@ -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') + }) +}) diff --git a/web/src/shared/lib/idempotency-key.ts b/web/src/shared/lib/idempotency-key.ts new file mode 100644 index 00000000..94180551 --- /dev/null +++ b/web/src/shared/lib/idempotency-key.ts @@ -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('') +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 4b852cd7..f7748420 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -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, + }, }, }, })