From 6a89832bb7c323f4839db8fb30ff681483f7c156 Mon Sep 17 00:00:00 2001 From: dongmucat <70678707+dongmucat@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:58:12 +0800 Subject: [PATCH 01/15] feat(cli): add DeepSeek Harness agent profile (#891) * feat(cli): add DeepSeek Harness agent profile Signed-off-by: dongmucat <1127093059@qq.com> * docs(dsh): fix custom install troubleshooting command Signed-off-by: dongmucat <1127093059@qq.com> --------- Signed-off-by: dongmucat <1127093059@qq.com> --- README.md | 12 +++ README_zh.md | 12 +++ cli/CHANGELOG.md | 2 + cli/README.md | 6 ++ cli/src/agents/detector.ts | 5 +- cli/src/agents/profiles/dsh.ts | 2 + cli/test/integration/install-command.test.ts | 62 +++++++++++++++ cli/test/unit/agents/profiles.test.ts | 46 ++++++++++- docs/07-skill-protocol.md | 5 ++ docs/dsh-integration-en.md | 83 ++++++++++++++++++++ docs/dsh-integration.md | 83 ++++++++++++++++++++ docs/skillhub/en/guide/cli.md | 6 ++ docs/skillhub/guide/cli.md | 6 ++ 13 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 cli/src/agents/profiles/dsh.ts create mode 100644 docs/dsh-integration-en.md create mode 100644 docs/dsh-integration.md 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 4ea2d82b..14c2eb6e 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/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/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/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/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/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"` 安装。 + ### 安装后的文件结构 ``` From 342d59472da3b323eaca8d6cd5f0b6056cee1588 Mon Sep 17 00:00:00 2001 From: dongmucat <70678707+dongmucat@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:08:41 +0800 Subject: [PATCH 02/15] feat(frontend): render mermaid markdown diagrams (#892) * feat(frontend): render mermaid markdown diagrams Signed-off-by: dongmucat <1127093059@qq.com> * fix(frontend): clean up failed mermaid renders Signed-off-by: dongmucat <1127093059@qq.com> --------- Signed-off-by: dongmucat <1127093059@qq.com> --- web/e2e/markdown-mermaid.spec.ts | 67 ++ web/package.json | 1 + web/pnpm-lock.yaml | 836 ++++++++++++++++++ .../features/skill/markdown-renderer.test.tsx | 104 ++- web/src/features/skill/markdown-renderer.tsx | 143 ++- 5 files changed, 1141 insertions(+), 10 deletions(-) create mode 100644 web/e2e/markdown-mermaid.spec.ts 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/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-') From f5a58616b7924c310ce45dfd5b34a4237cb10673 Mon Sep 17 00:00:00 2001 From: dongmucat <70678707+dongmucat@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:14:23 +0800 Subject: [PATCH 03/15] fix(suite): support bundle import over plain HTTP (#890) Signed-off-by: dongmucat <1127093059@qq.com> --- .../suite/suite-bundle-import.test.tsx | 25 +++++++++++++ .../features/suite/suite-bundle-import.tsx | 17 +++++---- web/src/shared/lib/idempotency-key.test.ts | 35 +++++++++++++++++++ web/src/shared/lib/idempotency-key.ts | 14 ++++++++ 4 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 web/src/shared/lib/idempotency-key.test.ts create mode 100644 web/src/shared/lib/idempotency-key.ts 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('') +} From 934cfa6ded3bca0e9b5f615df9139ff098ea7c3d Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:39:22 +0800 Subject: [PATCH 04/15] feat(auth): add Feishu as a public login provider (R1-A2) (#877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): let providers override OAuth userinfo loading Some providers do not return a flat, standard userinfo payload, so DefaultOAuth2UserService cannot read them. Add ProviderOAuth2UserService so a provider can claim its own registration id and supply the loading step, while everything after it stays shared. The override runs inside the RemoteIdentityIoExecutor boundary added in R1-A, so a provider's HTTP call does not hold the surrounding transaction open. Registrations without an override keep using the default user service unchanged. Part of R1-A2 (public Provider adapters) per openspec/changes/enterprise-identity-platform/rollout-plan.md. Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * feat(auth): add Feishu as a public login provider Adds Feishu (Lark) as a public sign-in option: it authenticates a SkillHub platform account and nothing more. No Organization membership, no directory sync, no Namespace grants. Feishu deviates from standard OAuth in two ways this handles: its userinfo response is wrapped in a {code, msg, data} envelope, and it reports errors with HTTP 200. FeishuOAuth2UserService unwraps that envelope into flat attributes; FeishuClaimsExtractor maps them to the shared OAuthClaims, so account decisions still run through the unified identity core added in R1-A. Subject and email semantics, which decide whether a login can reach an existing account: - open_id is the only subject. union_id stays in extra rather than acting as a fallback: a subject that can change between logins would split one person across two platform accounts. Promoting union_id later needs an explicit alias migration. - A blank or missing open_id fails the login instead of binding the literal string "null". - emailVerified is always false. Feishu emails are imported by an organization admin and never confirmed with the user, so they carry no verification signal and cannot be used to join an existing account. Operational bounds: the userinfo call has connect and read timeouts so an unresponsive Feishu endpoint cannot hold a login thread, and the OAuth2Error description carries only the provider error code, because an upstream message can quote the request URI and with it the access token. Like the GitHub and GitLab extractors, the claims extractor logs nothing. The login button follows the existing config-driven catalog: with no client id configured, /api/v1/auth/methods does not list Feishu and no button renders. No frontend code change is needed; the icon resolves by provider name. Adapted from the implementation in #696 by @yhd4711499, re-extracted onto current main with the subject, logging and timeout changes above. Part of R1-A2 (public Provider adapters) per openspec/changes/enterprise-identity-platform/rollout-plan.md. Co-authored-by: yhd4711499 Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * fix(auth): bound Feishu userinfo response and stop subject leaking into displayName Three defects found reviewing this batch against the R1-A2 spec. Response size limit. The spec's scope line asks for "远程 I/O 超时与响应大小 限制"; only the timeouts were implemented, so a misconfigured or hostile OAUTH2_FEISHU_BASE_URI could stream an unbounded body into the parser. Reads at most 64 KB before parsing, mirroring the 10 MB cap the shared WebClientConfig already applies. Uses InputStream.readNBytes rather than adding commons-io or guava, neither of which skillhub-auth declares. Synthesized displayName. Falling back to "feishu-" wrote the external subject into UserAccount.displayName and into UserActivatedEvent, carrying it somewhere event consumers may log it -- against the R1-A gate that logs must not contain the subject. Now stops at name -> en_name like the GitHub and GitLab extractors. Unused mobile attribute. A phone number was extracted into the principal attributes and read by nothing. It is PII the spec did not ask for and it widened the redaction surface for free. Also drops a constructor overload that only passed List.of() through, and a test that duplicated the blank-subject path. Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * docs(auth): document the provider adapter contract and Feishu operator setup AGENTS.md and CONTRIBUTING.md both require docs updates when auth flows or deployment config change; this batch changed both and touched no docs. 03-authentication-design.md described adding a provider as "branch on registrationId inside CustomOAuth2UserService", which the ProviderOAuth2UserService strategy supersedes. Rewrites that recipe: register an OAuthClaimsExtractor bean per provider, add a ProviderOAuth2UserService only when the userinfo response is non-standard, and note that the login page needs no code change. Also records the provider-side obligations that are easy to get wrong -- stable subject with no fallback, emailVerified only on proven ownership, bounded remote calls, no subject in logs -- and un-comments the config example, which still listed GitLab as a future possibility. faq.md told operators to delete "the github and gitlab blocks" to hide SSO buttons. That advice was already incomplete and gets worse per provider, so it now explains the config-driven mechanism: an empty client id keeps the entry off the login page, no file edit needed. 09-deployment.md listed only the GitHub credentials. Adds GitLab and Feishu, and flags a deployment trap: Feishu emails are admin-imported so emailVerified is always false, and skillhub.access-policy.mode=EMAIL_DOMAIN denies every unverified email, which would reject all Feishu logins. Squares the Feishu logo viewBox. It was 407.87x324.19 while login-button renders it in a square w-5 h-5 box, so the mark was distorted. Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * feat(deploy): wire Feishu credentials into the release surfaces .env.release.example advertised OAUTH2_FEISHU_* knobs that no deployment path could actually deliver. compose.release.yml has no env_file, so every variable must be listed explicitly, and the Helm chart and k8s base only mapped the GitHub secret keys. Setting the documented variables therefore did nothing. Adds Feishu to compose.release.yml, the Helm secret template and values, the k8s deployment and its secret example. GitLab had the identical gap, so it is wired at the same time rather than leaving the example file half true. validate-release-config.sh only checked that GitHub's id and secret appear together. A half-configured provider renders a login button whose exchange then fails, so the check now loops over all three providers. Its test gained both-directions cases per provider plus a fully configured pass; reverting the loop to GitHub-only makes them fail. Also adds the provider's only failure log. Nothing downstream records a Feishu userinfo failure -- OAuth2LoginFailureHandler does not log either -- so the previous code was silent on error. Logs the exception class and Feishu's own error code, never the upstream msg, which can quote the access token; a test asserts the code is present and the token is not. Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * fix(auth): use JSON token exchange for Feishu OAuth Made-with: Proma Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * fix(auth): preserve Feishu OAuth browser redirect Add safe phase-level OAuth diagnostics and redact callback credentials from request logs. Made-with: Proma Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * docs(deploy): clarify Feishu OAuth configuration and validation Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * fix(deploy): pass Feishu redirect URI through releases Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * fix(deploy): pass S3 chunked encoding setting Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * fix(deploy): preserve default Feishu callback derivation Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --------- Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Co-authored-by: yhd4711499 --- .env.release.example | 21 ++ charts/skillhub/templates/secret.yaml | 8 + .../skillhub/templates/server-deployment.yaml | 26 ++ .../skillhub/tests/configuration-contracts.sh | 10 + charts/skillhub/values.schema.json | 21 ++ charts/skillhub/values.yaml | 10 + compose.release.yml | 13 + deploy/k8s/base/backend-deployment.yaml | 21 ++ deploy/k8s/base/secret.yaml.example | 4 + docs/03-authentication-design.md | 69 ++++- docs/09-deployment.md | 71 +++++- docs/skillhub/en/faq.md | 9 +- docs/skillhub/faq.md | 12 +- scripts/tests/validate-release-config-test.sh | 60 +++++ scripts/validate-release-config.sh | 33 ++- .../skillhub/filter/RequestLoggingFilter.java | 23 +- .../src/main/resources/application.yml | 18 ++ ...huOAuthBrowserCallbackIntegrationTest.java | 196 ++++++++++++++ .../filter/RequestLoggingFilterTest.java | 22 ++ .../skillhub/auth/config/SecurityConfig.java | 6 + .../auth/oauth/FeishuClaimsExtractor.java | 71 ++++++ ...FeishuOAuth2AccessTokenResponseClient.java | 241 ++++++++++++++++++ .../auth/oauth/FeishuOAuth2UserService.java | 201 +++++++++++++++ .../auth/oauth/OAuth2LoginFailureHandler.java | 6 + .../auth/oauth/OAuth2LoginSuccessHandler.java | 7 + .../auth/oauth/OAuthLoginFlowService.java | 28 +- .../auth/oauth/ProviderOAuth2UserService.java | 14 + ...HubOAuth2AuthorizationRequestResolver.java | 8 + .../auth/oauth/FeishuClaimsExtractorTest.java | 143 +++++++++++ ...huOAuth2AccessTokenResponseClientTest.java | 231 +++++++++++++++++ .../oauth/FeishuOAuth2UserServiceTest.java | 190 ++++++++++++++ .../auth/oauth/OAuthLoginFlowServiceTest.java | 155 ++++++++++- web/public/feishu-logo.svg | 2 + web/vite.config.ts | 6 + 34 files changed, 1925 insertions(+), 31 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/FeishuOAuthBrowserCallbackIntegrationTest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClient.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderOAuth2UserService.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClientTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java create mode 100644 web/public/feishu-logo.svg diff --git a/.env.release.example b/.env.release.example index c5ac104a..73800c73 100644 --- a/.env.release.example +++ b/.env.release.example @@ -117,6 +117,27 @@ 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: 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/charts/skillhub/templates/secret.yaml b/charts/skillhub/templates/secret.yaml index 8e28c911..d00c41a4 100644 --- a/charts/skillhub/templates/secret.yaml +++ b/charts/skillhub/templates/secret.yaml @@ -59,6 +59,14 @@ 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 }} + # 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..7e635fe8 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -355,6 +355,32 @@ 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 }} + {{- 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..a2c628ab 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -39,6 +39,16 @@ 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 + +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 suite-review-enabled "$CHART_DIR" \ --set server.suiteReviewWritesEnabled=true \ diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index 1bede63b..e7bb6b47 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -34,6 +34,25 @@ } } }, + "oauth2": { + "type": "object", + "additionalProperties": false, + "required": ["feishu"], + "properties": { + "feishu": { + "type": "object", + "additionalProperties": false, + "required": ["protocolVersion", "tokenUri"], + "properties": { + "authorizationUri": { "type": "string", "format": "uri" }, + "protocolVersion": { "type": "string", "enum": ["v2", "v3"] }, + "tokenUri": { "type": "string", "format": "uri" }, + "userInfoUri": { "type": "string", "format": "uri" }, + "redirectUri": { "type": "string" } + } + } + } + }, "builtinSkills": { "type": "object", "additionalProperties": false, @@ -156,6 +175,8 @@ "downloadAnonCookieSecret": { "type": "string" }, "oauth2GithubClientId": { "type": "string" }, "oauth2GithubClientSecret": { "type": "string" }, + "oauth2FeishuClientId": { "type": "string" }, + "oauth2FeishuClientSecret": { "type": "string" }, "scannerLlmApiKey": { "type": "string" }, "scannerLlmBaseUrl": { "type": "string" }, "scannerLlmModel": { "type": "string" } diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 092e73e4..f004f3f0 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -22,6 +22,14 @@ 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: "" + builtinSkills: enabled: true @@ -93,6 +101,8 @@ secrets: downloadAnonCookieSecret: "" oauth2GithubClientId: "" oauth2GithubClientSecret: "" + oauth2FeishuClientId: "" + oauth2FeishuClientSecret: "" scannerLlmApiKey: "" scannerLlmBaseUrl: "" scannerLlmModel: "" diff --git a/compose.release.yml b/compose.release.yml index c707db08..6789ddeb 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,18 @@ 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:-飞书} 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..52e53b12 100644 --- a/deploy/k8s/base/backend-deployment.yaml +++ b/deploy/k8s/base/backend-deployment.yaml @@ -227,6 +227,27 @@ 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" 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..f9c119d6 100644 --- a/deploy/k8s/base/secret.yaml.example +++ b/deploy/k8s/base/secret.yaml.example @@ -27,6 +27,10 @@ stringData: oauth2-github-client-id: "" oauth2-github-client-secret: "" + # 飞书 OAuth(可选,用于飞书登录;留空则登录页不展示该入口) + oauth2-feishu-client-id: "" + oauth2-feishu-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..ef5aa98a 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -271,18 +271,69 @@ 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 + 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 的 userinfo 响应不是标准的扁平结构(例如飞书用 +`{code, msg, data}` 信封,且以 HTTP 200 返回错误),再额外实现一个 +`ProviderOAuth2UserService`:它声明自己负责哪个 `registrationId`, +接管 userinfo 的加载步骤,其余流程不变。该覆盖运行在 +`RemoteIdentityIoExecutor` 边界内,因此 Provider 的 HTTP 调用不会持有数据库事务。 + +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/09-deployment.md b/docs/09-deployment.md index 2bbf6c8c..76117217 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,61 @@ 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。 + + 留空即不展示该入口,无需改配置文件。注意:飞书邮箱由企业管理员导入、未经用户 + 确认,因此 `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 登录已通过。 - 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md` ## 8 OIDC 登录配置 diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index acfe6d89..c00abdf8 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`) 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/faq.md b/docs/skillhub/faq.md index 8906150c..75054ca0 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`)以及对应的 `provider` 段, + Spring Boot 启动时便不会创建该注册。 ## Q: SkillHub 的安全扫描(Skill Scanner)是讯飞自研的吗?使用什么协议? diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh index 1ec54043..e18e2648 100755 --- a/scripts/tests/validate-release-config-test.sh +++ b/scripts/tests/validate-release-config-test.sh @@ -68,8 +68,45 @@ 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" + 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 +290,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; 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..e9a85899 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -380,14 +380,31 @@ 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; 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 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..0328a749 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -70,6 +70,16 @@ 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:飞书} provider: github: api-base-url: ${OAUTH2_GITHUB_API_BASE_URL:https://api.github.com} @@ -79,6 +89,14 @@ 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 servlet: multipart: max-file-size: 100MB 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..5880f58a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.auth.config; import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService; import com.iflytek.skillhub.auth.oauth.CustomOidcUserService; +import com.iflytek.skillhub.auth.oauth.FeishuOAuth2AccessTokenResponseClient; import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler; import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler; import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver; @@ -61,6 +62,7 @@ public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; private final CustomOidcUserService customOidcUserService; + private final FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient; private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver; private final OAuth2LoginSuccessHandler successHandler; private final OAuth2LoginFailureHandler failureHandler; @@ -75,6 +77,7 @@ public class SecurityConfig { public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomOidcUserService customOidcUserService, + FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient, SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver, OAuth2LoginSuccessHandler successHandler, OAuth2LoginFailureHandler failureHandler, @@ -88,6 +91,7 @@ public class SecurityConfig { @Value("${server.servlet.session.cookie.name:SESSION}") String sessionCookieName) { this.customOAuth2UserService = customOAuth2UserService; this.customOidcUserService = customOidcUserService; + this.feishuOAuth2AccessTokenResponseClient = feishuOAuth2AccessTokenResponseClient; this.authorizationRequestResolver = authorizationRequestResolver; this.successHandler = successHandler; this.failureHandler = failureHandler; @@ -132,6 +136,8 @@ public class SecurityConfig { }) .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver)) + .tokenEndpoint(tokenEndpoint -> tokenEndpoint + .accessTokenResponseClient(feishuOAuth2AccessTokenResponseClient)) .userInfoEndpoint(userInfo -> userInfo .userService(customOAuth2UserService) .oidcUserService(customOidcUserService)) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/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..401fa98e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClient.java @@ -0,0 +1,241 @@ +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 OAuth2AccessTokenResponseClient { + + 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 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/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/SkillHubOAuth2AuthorizationRequestResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java index 5cd28902..df985f8f 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,6 +1,8 @@ package com.iflytek.skillhub.auth.oauth; import jakarta.servlet.http.HttpServletRequest; +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; @@ -14,6 +16,8 @@ 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; @@ -47,6 +51,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/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/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/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/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, + }, }, }, }) From 5c92c9eeede51b64aa026267158f4f54862f12b0 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:56:33 +0800 Subject: [PATCH 05/15] feat(auth): let providers override token exchange and authorization params Extends the per-provider strategy pattern from the userinfo step to the two earlier stages of the authorization-code flow, so a provider whose endpoints deviate from the standard contract needs no branch in shared code: - ProviderTokenResponseClient for a non-standard token exchange, dispatched by DispatchingTokenResponseClient because Spring's tokenEndpoint accepts only one client - ProviderAuthorizationRequestCustomizer for authorization parameters, dispatched through the resolver's existing customizer hook Registrations without an override keep the standard Spring behaviour. Together with ProviderOAuth2UserService this covers all three stages where a provider can deviate: authorize, token, userinfo. Account decisions stay outside these hooks, in the unified identity core. Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../skillhub/auth/config/SecurityConfig.java | 11 +-- .../oauth/DispatchingTokenResponseClient.java | 48 +++++++++ ...roviderAuthorizationRequestCustomizer.java | 17 ++++ .../oauth/ProviderTokenResponseClient.java | 16 +++ ...HubOAuth2AuthorizationRequestResolver.java | 33 ++++++- .../DispatchingTokenResponseClientTest.java | 99 +++++++++++++++++++ 6 files changed, 216 insertions(+), 8 deletions(-) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClient.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAuthorizationRequestCustomizer.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderTokenResponseClient.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClientTest.java 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 5880f58a..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,7 +2,7 @@ package com.iflytek.skillhub.auth.config; import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService; import com.iflytek.skillhub.auth.oauth.CustomOidcUserService; -import com.iflytek.skillhub.auth.oauth.FeishuOAuth2AccessTokenResponseClient; +import com.iflytek.skillhub.auth.oauth.DispatchingTokenResponseClient; import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler; import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler; import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver; @@ -62,7 +62,7 @@ public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; private final CustomOidcUserService customOidcUserService; - private final FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient; + private final DispatchingTokenResponseClient tokenResponseClient; private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver; private final OAuth2LoginSuccessHandler successHandler; private final OAuth2LoginFailureHandler failureHandler; @@ -77,7 +77,7 @@ public class SecurityConfig { public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomOidcUserService customOidcUserService, - FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient, + DispatchingTokenResponseClient tokenResponseClient, SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver, OAuth2LoginSuccessHandler successHandler, OAuth2LoginFailureHandler failureHandler, @@ -91,7 +91,7 @@ public class SecurityConfig { @Value("${server.servlet.session.cookie.name:SESSION}") String sessionCookieName) { this.customOAuth2UserService = customOAuth2UserService; this.customOidcUserService = customOidcUserService; - this.feishuOAuth2AccessTokenResponseClient = feishuOAuth2AccessTokenResponseClient; + this.tokenResponseClient = tokenResponseClient; this.authorizationRequestResolver = authorizationRequestResolver; this.successHandler = successHandler; this.failureHandler = failureHandler; @@ -136,8 +136,7 @@ public class SecurityConfig { }) .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver)) - .tokenEndpoint(tokenEndpoint -> tokenEndpoint - .accessTokenResponseClient(feishuOAuth2AccessTokenResponseClient)) + .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/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/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/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 df985f8f..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,11 +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; /** @@ -21,13 +27,36 @@ public class SkillHubOAuth2AuthorizationRequestResolver 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 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) + ); + } + +} From 96f244b416fa56b077d8612390eb6e410b14ca2c Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:56:58 +0800 Subject: [PATCH 06/15] feat(auth): add DingTalk as a public login provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DingTalk (钉钉) as a public sign-in option: it authenticates a SkillHub platform account and nothing more. No Organization membership, no directory sync, no Namespace grants. DingTalk deviates from standard OAuth at all three stages, one strategy each: - authorize: its endpoint wants scope=openid, but declaring that scope in configuration makes Spring treat the registration as OIDC and attach a nonce, which DingTalk rejects. The scope is added by DingTalkAuthorizationRequestCustomizer instead, keeping this a plain OAuth2 client. A test asserts the scope is present and the nonce is not. - token: credentials go in a JSON body rather than a form, handled by DingTalkTokenResponseClient. - userinfo: the token travels in x-acs-dingtalk-access-token rather than Authorization: Bearer. Subject and email semantics, which decide whether a login can reach an existing account: - unionId is the only accepted subject. DingTalk also returns openId and userId, but they must not act as fallbacks: openId is scoped per app and userId per organization, so a login falling back to either would bind a different identity than a later login carrying unionId, splitting one person across two platform accounts. - A blank or missing unionId fails the login. - emailVerified is always false. DingTalk returns the email an organization admin recorded without attesting the user controls it. The userinfo service only fetches attributes; account matching, provisioning and session creation stay with the unified identity core. The reference implementation called OAuthLoginFlowService.authenticate() from inside loadUser, which decided the account before the core's gate ran. Operational bounds match the Feishu adapter: connect and read timeouts, a 64 KB response cap, error descriptions and logs carrying only the exception class or provider error code, and no logging in the claims extractor. Unused PII is dropped rather than carried into the principal -- notably mobile and stateCode. Adds ProviderStrategyWiringTest, which loads the real application context. The unit tests call package-visible constructors and so cannot catch Spring wiring faults; a component with two constructors and no @Autowired marker unit-tests green and then fails at startup. That happened during this work. Adapted from the implementation in #467 by @konglong87, re-extracted onto current main with the subject, structure and bounds changes above. Part of R1-A2 (public Provider adapters) per openspec/changes/enterprise-identity-platform/rollout-plan.md. Co-authored-by: konglong87 Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../src/main/resources/application.yml | 17 ++ .../oauth/ProviderStrategyWiringTest.java | 88 ++++++++ ...ingTalkAuthorizationRequestCustomizer.java | 30 +++ .../auth/oauth/DingTalkClaimsExtractor.java | 76 +++++++ .../auth/oauth/DingTalkOAuth2Constants.java | 23 ++ .../auth/oauth/DingTalkOAuth2UserService.java | 162 ++++++++++++++ .../oauth/DingTalkTokenResponseClient.java | 145 +++++++++++++ .../oauth/DingTalkClaimsExtractorTest.java | 122 +++++++++++ .../oauth/DingTalkOAuth2UserServiceTest.java | 140 ++++++++++++ .../DingTalkTokenResponseClientTest.java | 201 ++++++++++++++++++ ...Auth2AuthorizationRequestResolverTest.java | 87 ++++++++ web/public/dingtalk-logo.svg | 3 + 12 files changed, 1094 insertions(+) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/oauth/ProviderStrategyWiringTest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkAuthorizationRequestCustomizer.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2Constants.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java create mode 100644 web/public/dingtalk-logo.svg diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 0328a749..97b724f7 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -80,6 +80,18 @@ spring: 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 sends credentials in a JSON body, handled by DingTalkTokenResponseClient. + client-authentication-method: none + 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} @@ -97,6 +109,11 @@ spring: 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..c87a9ecc --- /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); + 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-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..aa5494c1 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkAuthorizationRequestCustomizer.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.auth.oauth; + +import java.util.LinkedHashSet; +import java.util.Set; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; + +/** + * Adds the {@code openid} scope DingTalk's authorize endpoint requires. + * + *

The scope cannot simply be declared in {@code application.yml}: Spring Security treats a + * registration carrying {@code openid} as an OIDC client and attaches a {@code nonce} parameter, + * which DingTalk rejects. Adding the scope here keeps the registration a plain OAuth2 client while + * still sending the parameter DingTalk expects. + */ +@Component +public class DingTalkAuthorizationRequestCustomizer implements ProviderAuthorizationRequestCustomizer { + + @Override + public String getProvider() { + return DingTalkOAuth2Constants.REGISTRATION_ID; + } + + @Override + public void customize(OAuth2AuthorizationRequest.Builder builder) { + Set scopes = new LinkedHashSet<>(builder.build().getScopes()); + scopes.add(DingTalkOAuth2Constants.AUTHORIZATION_SCOPE); + builder.scopes(scopes); + } +} 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..83026e50 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2Constants.java @@ -0,0 +1,23 @@ +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"; + + public static final String SUBJECT_ATTRIBUTE = "dingtalkSubject"; + + 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..7b8331a2 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java @@ -0,0 +1,162 @@ +package com.iflytek.skillhub.auth.oauth; + +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 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) -> 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<>() { + }); + } + + /** + * 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)) { + 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..bfb79978 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java @@ -0,0 +1,145 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +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; + } + + private static RestTemplate buildRestTemplate() { + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(5)); + factory.setReadTimeout(Duration.ofSeconds(10)); + return new RestTemplate(factory); + } + + @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/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..8bf38b18 --- /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.NONE) + .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..e5d7027a --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java @@ -0,0 +1,140 @@ +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 java.time.Instant; +import org.junit.jupiter.api.Test; +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 DingTalkOAuth2UserServiceTest { + + @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_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.NONE) + .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..ac15a660 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java @@ -0,0 +1,201 @@ +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) + ); + } +} 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..49cb92c8 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,91 @@ class OAuth2AuthorizationRequestResolverTest { assertThat(session).isNotNull(); assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); } + + @Test + void resolve_addsDingTalkScopeWithoutTurningTheRequestIntoOidc() { + 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... + assertThat(authorizationRequest.getScopes()).contains("openid"); + assertThat(authorizationRequest.getAuthorizationRequestUri()).contains("scope=openid"); + // ...but rejects the nonce Spring attaches when a registration declares openid in config. + // Declaring no scope there and adding it here is what keeps the nonce away. + assertThat(authorizationRequest.getAdditionalParameters()).doesNotContainKey("nonce"); + assertThat(authorizationRequest.getAttributes()).doesNotContainKey("nonce"); + assertThat(authorizationRequest.getAuthorizationRequestUri()).doesNotContain("nonce="); + } + + @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.NONE) + .clientName("钉钉") + .build(); + } } 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 @@ + + + From 75c7f9a88069f8f48fa8a98cd2d1bb008731f182 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:57:11 +0800 Subject: [PATCH 07/15] feat(deploy): wire DingTalk credentials into the release surfaces Adds the DingTalk credentials to every path that actually delivers configuration: compose.release.yml (which has no env_file, so variables must be listed explicitly), the Helm secret template and values, the k8s deployment and its secret example. validate-release-config.sh gains DingTalk in its provider loop, so a half-configured pair is rejected the same way. Documents the three-stage strategy contract in the authentication design: a table mapping each deviation -- authorize parameters, token exchange, userinfo loading -- to its interface and current implementations, plus the rule that a provider must never make account decisions itself. Deployment notes and both FAQs now cover DingTalk, including the shared trap with Feishu: their emails are admin-recorded and never confirmed, so emailVerified is always false and an EMAIL_DOMAIN access policy would reject every login through either provider. Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .env.release.example | 10 +++++++ charts/skillhub/templates/secret.yaml | 8 ++++++ .../skillhub/templates/server-deployment.yaml | 14 ++++++++++ charts/skillhub/values.yaml | 2 ++ compose.release.yml | 5 ++++ deploy/k8s/base/backend-deployment.yaml | 14 ++++++++++ deploy/k8s/base/secret.yaml.example | 4 +++ docs/03-authentication-design.md | 28 ++++++++++++++++--- docs/09-deployment.md | 8 ++++-- docs/skillhub/en/faq.md | 2 +- docs/skillhub/faq.md | 2 +- scripts/tests/validate-release-config-test.sh | 2 +- scripts/validate-release-config.sh | 2 +- 13 files changed, 90 insertions(+), 11 deletions(-) diff --git a/.env.release.example b/.env.release.example index 73800c73..add0f570 100644 --- a/.env.release.example +++ b/.env.release.example @@ -138,6 +138,16 @@ OAUTH2_FEISHU_USER_INFO_URI=https://open.feishu.cn/open-apis/authen/v1/user_info 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. +OAUTH2_DINGTALK_CLIENT_ID= +OAUTH2_DINGTALK_CLIENT_SECRET= +OAUTH2_DINGTALK_AUTHORIZE_URI=https://login.dingtalk.com +OAUTH2_DINGTALK_BASE_URI=https://api.dingtalk.com +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/charts/skillhub/templates/secret.yaml b/charts/skillhub/templates/secret.yaml index d00c41a4..523bc386 100644 --- a/charts/skillhub/templates/secret.yaml +++ b/charts/skillhub/templates/secret.yaml @@ -67,6 +67,14 @@ stringData: 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 7e635fe8..244bc4c9 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -381,6 +381,20 @@ spec: 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 + {{- if .Values.server.javaOpts }} - name: JAVA_OPTS value: {{ .Values.server.javaOpts }} diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index f004f3f0..555ace85 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -103,6 +103,8 @@ secrets: oauth2GithubClientSecret: "" oauth2FeishuClientId: "" oauth2FeishuClientSecret: "" + oauth2DingtalkClientId: "" + oauth2DingtalkClientSecret: "" scannerLlmApiKey: "" scannerLlmBaseUrl: "" scannerLlmModel: "" diff --git a/compose.release.yml b/compose.release.yml index 6789ddeb..77cc571a 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -128,6 +128,11 @@ services: 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_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 52e53b12..01319d16 100644 --- a/deploy/k8s/base/backend-deployment.yaml +++ b/deploy/k8s/base/backend-deployment.yaml @@ -248,6 +248,20 @@ spec: 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 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 f9c119d6..f983fa19 100644 --- a/deploy/k8s/base/secret.yaml.example +++ b/deploy/k8s/base/secret.yaml.example @@ -31,6 +31,10 @@ stringData: 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 ef5aa98a..478e3420 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -282,6 +282,14 @@ spring: # 飞书的 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 由 DingTalkAuthorizationRequestCustomizer 在请求阶段补上。 + client-authentication-method: none + authorization-grant-type: authorization_code provider: feishu: # Full endpoints are configurable for Lark, private deployments, and gateways. @@ -300,12 +308,24 @@ Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider 第 2 步是按 Provider 注册一个 Bean,而不是在某个类里按 `registrationId` 分支。 账号匹配、建号、资料权威和账号守卫都在 `OAuthClaims` 之后共享,Provider 自己不做这些决策。 -如果该 Provider 的 userinfo 响应不是标准的扁平结构(例如飞书用 -`{code, msg, data}` 信封,且以 HTTP 200 返回错误),再额外实现一个 -`ProviderOAuth2UserService`:它声明自己负责哪个 `registrationId`, -接管 userinfo 的加载步骤,其余流程不变。该覆盖运行在 +如果该 Provider 的协议有偏离标准之处,按偏离的环节实现对应的策略接口, +每个接口都声明自己负责哪个 `registrationId`,由框架分发,不需要在共享类里写分支: + +| 偏离环节 | 策略接口 | 现有实现 | +|---|---|---| +| 授权请求参数 | `ProviderAuthorizationRequestCustomizer` | 钉钉补 `openid` scope | +| 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`、远程调用要有超时与响应大小上限、 diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 76117217..24ca4121 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -314,11 +314,13 @@ services: 兼容回退。`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) - 留空即不展示该入口,无需改配置文件。注意:飞书邮箱由企业管理员导入、未经用户 - 确认,因此 `emailVerified` 恒为 false;若在 `application.yml` 中把 + 留空即不展示该入口,无需改配置文件。注意:飞书和钉钉的邮箱都由企业管理员导入、 + 未经用户确认,因此 `emailVerified` 恒为 false;若在 `application.yml` 中把 `skillhub.access-policy.mode` 设为 `EMAIL_DOMAIN`,该策略会拒绝所有未验证邮箱, - 飞书登录将一律失败。启用飞书时请保留默认的 `OPEN` 或改用其他准入模式。 + 这两个入口的登录将一律失败。启用它们时请保留默认的 `OPEN` 或改用其他准入模式。 启用飞书前,使用一个测试租户完成一次真实回调验收。不要把真实 client secret 写入仓库、报告或聊天记录;只在受控的 `.env.release`、CI Secret 或 Kubernetes diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index c00abdf8..4db5d8bb 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -197,7 +197,7 @@ A: Login entries are config-driven: `/api/v1/auth/methods` only returns registra 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`) under `spring.security.oauth2.client.registration`, along with its `provider` section. Spring Boot then won't create that registration at startup. +- 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/faq.md b/docs/skillhub/faq.md index 75054ca0..e8a094d9 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -199,7 +199,7 @@ A: 登录入口是配置驱动的:`/api/v1/auth/methods` 只返回配置了真 - 留空对应的环境变量即可(例如不设置 `OAUTH2_FEISHU_CLIENT_ID`),无需改动配置文件。 - 或修改 `application.yml`,注释/删除 `spring.security.oauth2.client.registration` - 下对应的注册块(`github`、`gitlab`、`feishu`)以及对应的 `provider` 段, + 下对应的注册块(`github`、`gitlab`、`feishu`、`dingtalk`)以及对应的 `provider` 段, Spring Boot 启动时便不会创建该注册。 ## Q: SkillHub 的安全扫描(Skill Scanner)是讯飞自研的吗?使用什么协议? diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh index e18e2648..83e73a70 100755 --- a/scripts/tests/validate-release-config-test.sh +++ b/scripts/tests/validate-release-config-test.sh @@ -292,7 +292,7 @@ expect_fail "$invalid_redis_sentinel_check_env" "SKILLHUB_REDIS_SENTINEL_CHECK_S # 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; do +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" diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index e9a85899..c7189304 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -380,7 +380,7 @@ 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 -for provider in GITHUB GITLAB FEISHU; do +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 From 629c1ced55b2d6b7624ac45f250904acecffb1cd Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:21:10 +0800 Subject: [PATCH 08/15] fix(auth): bound the DingTalk token response and log a rejected login Two gaps from reviewing this batch against the Feishu adapter it mirrors. The token exchange had no response size limit while the userinfo call did, so the same hostile or misconfigured endpoint was bounded on one call and unbounded on the other. Adds the same 64 KB cap through a RestTemplate interceptor, which keeps the existing tests working against an injected template. buildRestTemplate becomes package-visible so one test can exercise the production template, cap included; removing the interceptor makes that test fail. A missing unionId threw without logging, unlike the equivalent Feishu branch. This is a reachable failure -- DingTalk omits unionId for some app configurations -- and an operator seeing every login rejected needs to know why. Logs the claim name only, which says nothing about the user. Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../auth/oauth/DingTalkOAuth2UserService.java | 7 +++ .../oauth/DingTalkTokenResponseClient.java | 55 ++++++++++++++++++- .../DingTalkTokenResponseClientTest.java | 22 ++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) 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 index 7b8331a2..66c29036 100644 --- 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 @@ -138,6 +138,13 @@ public class DingTalkOAuth2UserService implements ProviderOAuth2UserService { 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", 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 index bfb79978..36fac975 100644 --- 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 @@ -2,9 +2,14 @@ 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; @@ -51,11 +56,57 @@ public class DingTalkTokenResponseClient implements ProviderTokenResponseClient return DingTalkOAuth2Constants.REGISTRATION_ID; } - private static RestTemplate buildRestTemplate() { + /** 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)); - return new RestTemplate(factory); + 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) { + 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 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 index ac15a660..fa127ec2 100644 --- 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 @@ -198,4 +198,26 @@ class DingTalkTokenResponseClientTest { 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(); + } } From b1f1b18737031f2aae291c17160e29d6e649a98d Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:40:25 +0800 Subject: [PATCH 09/15] fix(auth): stop the DingTalk callback being routed to the OIDC provider The DingTalk login could not complete. Adding openid to the authorization request's scope set avoided the nonce at the authorize step but broke the callback: OAuth2LoginAuthenticationProvider.authenticate returns null when getScopes() contains "openid", handing the exchange to OidcAuthorizationCodeAuthenticationProvider, which fails with invalid_id_token because DingTalk returns no id_token. Neither the token client nor the user service was ever reached. spring-security-oauth2-jose is on the runtime classpath, so that provider is registered. The scope now goes onto the outgoing authorization URI directly, leaving getScopes() empty. Both openid-keyed mechanisms are then avoided: no nonce, because the registration still declares no scope in configuration, and no OIDC routing, because the request carries no openid scope. The previous test asserted getScopes() contains "openid" -- the exact state that breaks the callback -- so it locked the bug in. It now asserts the inverse, and restoring the old implementation makes it fail. Also switches the registration from client-authentication-method: none to client-secret-post. "none" made Spring apply PKCE and emit a code_challenge that DingTalkTokenResponseClient cannot answer, since its JSON token request sends no code_verifier. It was also semantically wrong: DingTalk is a confidential client that carries its secret in the request body. Verified against a local staging instance: the authorization URI now carries scope=openid with no nonce and no code_challenge, and a callback with a fake code fails in the token exchange with no OIDC provider involvement in the logs. Drops SUBJECT_ATTRIBUTE, which lost its last reference when the user service stopped pre-resolving the subject. Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../src/main/resources/application.yml | 7 ++-- ...ingTalkAuthorizationRequestCustomizer.java | 32 +++++++++++++------ .../auth/oauth/DingTalkOAuth2Constants.java | 2 -- .../oauth/DingTalkClaimsExtractorTest.java | 2 +- .../oauth/DingTalkOAuth2UserServiceTest.java | 2 +- ...Auth2AuthorizationRequestResolverTest.java | 22 +++++++++---- 6 files changed, 45 insertions(+), 22 deletions(-) diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 97b724f7..6579da34 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -88,8 +88,11 @@ spring: # 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 sends credentials in a JSON body, handled by DingTalkTokenResponseClient. - client-authentication-method: none + # 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: "{baseUrl}/login/oauth2/code/{registrationId}" client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:钉钉} provider: 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 index aa5494c1..53b9b160 100644 --- 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 @@ -1,17 +1,26 @@ package com.iflytek.skillhub.auth.oauth; -import java.util.LinkedHashSet; -import java.util.Set; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; /** - * Adds the {@code openid} scope DingTalk's authorize endpoint requires. + * Sends the {@code scope=openid} parameter DingTalk's authorize endpoint requires, without letting + * Spring Security classify the login as OIDC. * - *

The scope cannot simply be declared in {@code application.yml}: Spring Security treats a - * registration carrying {@code openid} as an OIDC client and attaches a {@code nonce} parameter, - * which DingTalk rejects. Adding the scope here keeps the registration a plain OAuth2 client while - * still sending the parameter DingTalk expects. + *

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 { @@ -23,8 +32,11 @@ public class DingTalkAuthorizationRequestCustomizer implements ProviderAuthoriza @Override public void customize(OAuth2AuthorizationRequest.Builder builder) { - Set scopes = new LinkedHashSet<>(builder.build().getScopes()); - scopes.add(DingTalkOAuth2Constants.AUTHORIZATION_SCOPE); - builder.scopes(scopes); + String authorizationRequestUri = UriComponentsBuilder + .fromUriString(builder.build().getAuthorizationRequestUri()) + .replaceQueryParam("scope", DingTalkOAuth2Constants.AUTHORIZATION_SCOPE) + .build(true) + .toUriString(); + builder.authorizationRequestUri(authorizationRequestUri); } } 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 index 83026e50..2617ab70 100644 --- 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 @@ -16,8 +16,6 @@ public final class DingTalkOAuth2Constants { */ static final String SUBJECT_CLAIM_NAME = "unionId"; - public static final String SUBJECT_ATTRIBUTE = "dingtalkSubject"; - private DingTalkOAuth2Constants() { } } 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 index 8bf38b18..09a8ca93 100644 --- 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 @@ -103,7 +103,7 @@ class DingTalkClaimsExtractorTest { .clientId("dingoauth_test") .clientSecret("client-secret") .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) - .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .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") 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 index e5d7027a..50c0ef3e 100644 --- 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 @@ -121,7 +121,7 @@ class DingTalkOAuth2UserServiceTest { .clientId("dingoauth_test") .clientSecret("client-secret") .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) - .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .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") 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 49cb92c8..5eab1475 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 @@ -86,7 +86,7 @@ class OAuth2AuthorizationRequestResolverTest { } @Test - void resolve_addsDingTalkScopeWithoutTurningTheRequestIntoOidc() { + void resolve_sendsDingTalkScopeOnTheUriButKeepsTheRequestNonOidc() { SkillHubOAuth2AuthorizationRequestResolver dingTalkResolver = resolverFor( dingTalkRegistration(), new DingTalkAuthorizationRequestCustomizer() @@ -97,14 +97,24 @@ class OAuth2AuthorizationRequestResolverTest { var authorizationRequest = dingTalkResolver.resolve(request, "dingtalk"); assertThat(authorizationRequest).isNotNull(); - // DingTalk's authorize endpoint requires scope=openid... - assertThat(authorizationRequest.getScopes()).contains("openid"); + // DingTalk's authorize endpoint requires scope=openid on the wire. assertThat(authorizationRequest.getAuthorizationRequestUri()).contains("scope=openid"); - // ...but rejects the nonce Spring attaches when a registration declares openid in config. - // Declaring no scope there and adding it here is what keeps the nonce away. + + // 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 @@ -167,7 +177,7 @@ class OAuth2AuthorizationRequestResolverTest { .authorizationGrantType( org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE) .clientAuthenticationMethod( - org.springframework.security.oauth2.core.ClientAuthenticationMethod.NONE) + org.springframework.security.oauth2.core.ClientAuthenticationMethod.CLIENT_SECRET_POST) .clientName("钉钉") .build(); } From d92e1f82167388713877777bd05ecfd0e06d4b4d Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:59:37 +0800 Subject: [PATCH 10/15] fix(auth): preserve provider token routing and error bounds Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../skillhub/tests/configuration-contracts.sh | 11 ++++++++++ charts/skillhub/values.schema.json | 2 ++ .../oauth/ProviderStrategyWiringTest.java | 2 +- .../auth/oauth/DingTalkOAuth2UserService.java | 11 +++++++++- .../oauth/DingTalkTokenResponseClient.java | 1 + ...FeishuOAuth2AccessTokenResponseClient.java | 7 +++++- .../oauth/DingTalkOAuth2UserServiceTest.java | 22 +++++++++++++++++++ 7 files changed, 53 insertions(+), 3 deletions(-) diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index a2c628ab..d255db0a 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -74,6 +74,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 e7bb6b47..6d3bf510 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -177,6 +177,8 @@ "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/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 index c87a9ecc..4686250f 100644 --- 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 @@ -61,7 +61,7 @@ class ProviderStrategyWiringTest { // standard OAuth2 behaviour its endpoints reject. assertThat(tokenResponseClients) .extracting(ProviderTokenResponseClient::getProvider) - .contains(DingTalkOAuth2Constants.REGISTRATION_ID); + .contains(DingTalkOAuth2Constants.REGISTRATION_ID, "feishu"); assertThat(authorizationCustomizers) .extracting(ProviderAuthorizationRequestCustomizer::getProvider) .contains(DingTalkOAuth2Constants.REGISTRATION_ID); 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 index 66c29036..cb031985 100644 --- 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 @@ -90,7 +90,16 @@ public class DingTalkOAuth2UserService implements ProviderOAuth2UserService { DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER, userRequest.getAccessToken().getTokenValue() ) - .exchange((request, clientResponse) -> readBounded(clientResponse.getBody())); + .exchange((request, clientResponse) -> { + if (!clientResponse.getStatusCode().is2xxSuccessful()) { + log.warn( + "DingTalk user info returned HTTP {}; response body omitted", + clientResponse.getStatusCode().value()); + 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()); 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 index 36fac975..cea2f825 100644 --- 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 @@ -72,6 +72,7 @@ public class DingTalkTokenResponseClient implements ProviderTokenResponseClient 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); 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 index 401fa98e..5a03639a 100644 --- 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 @@ -33,7 +33,7 @@ import org.springframework.web.client.RestClient; */ @Component public class FeishuOAuth2AccessTokenResponseClient - implements OAuth2AccessTokenResponseClient { + implements ProviderTokenResponseClient { private static final Logger log = LoggerFactory.getLogger(FeishuOAuth2AccessTokenResponseClient.class); private static final String FEISHU_PROVIDER = "feishu"; @@ -78,6 +78,11 @@ public class FeishuOAuth2AccessTokenResponseClient this.protocolVersion = normalizeProtocolVersion(protocolVersion); } + @Override + public String getProvider() { + return FEISHU_PROVIDER; + } + @Override public OAuth2AccessTokenResponse getTokenResponse( OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) { 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 index 50c0ef3e..800fe73f 100644 --- 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 @@ -5,10 +5,12 @@ 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 java.time.Instant; 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; @@ -99,6 +101,26 @@ class DingTalkOAuth2UserServiceTest { 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_errorDescriptionDoesNotEchoUpstreamTextOrToken() { RestClient.Builder builder = RestClient.builder(); From 1297e87c5af567b95c769e56bc4cb7236a86c650 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:02:43 +0800 Subject: [PATCH 11/15] fix(deploy): complete DingTalk runtime configuration Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .env.release.example | 2 ++ .../skillhub/templates/server-deployment.yaml | 10 +++++++++ .../skillhub/tests/configuration-contracts.sh | 10 +++++++++ charts/skillhub/values.schema.json | 13 +++++++++++- charts/skillhub/values.yaml | 5 +++++ compose.release.yml | 1 + deploy/k8s/base/backend-deployment.yaml | 6 ++++++ docs/03-authentication-design.md | 4 +++- docs/09-deployment.md | 7 ++++++- scripts/tests/validate-release-config-test.sh | 21 +++++++++++++++++++ scripts/validate-release-config.sh | 9 ++++++++ .../src/main/resources/application.yml | 2 +- 12 files changed, 86 insertions(+), 4 deletions(-) diff --git a/.env.release.example b/.env.release.example index add0f570..ca295b72 100644 --- a/.env.release.example +++ b/.env.release.example @@ -146,6 +146,8 @@ 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). diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index 244bc4c9..c6b99807 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -394,6 +394,16 @@ spec: 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 diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index d255db0a..b1dfbbd3 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -42,6 +42,9 @@ grep -A1 -F 'name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED' "$TMP_DIR/default.yaml" 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 \ @@ -50,6 +53,13 @@ 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 \ --show-only templates/server-deployment.yaml >"$TMP_DIR/suite-review-enabled.yaml" diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index 6d3bf510..83b76ac5 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -37,7 +37,7 @@ "oauth2": { "type": "object", "additionalProperties": false, - "required": ["feishu"], + "required": ["feishu", "dingtalk"], "properties": { "feishu": { "type": "object", @@ -50,6 +50,17 @@ "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 } + } } } }, diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 555ace85..5e38e21a 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -29,6 +29,11 @@ oauth2: 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 diff --git a/compose.release.yml b/compose.release.yml index 77cc571a..bb55a8f1 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -132,6 +132,7 @@ services: 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} diff --git a/deploy/k8s/base/backend-deployment.yaml b/deploy/k8s/base/backend-deployment.yaml index 01319d16..a7a39648 100644 --- a/deploy/k8s/base/backend-deployment.yaml +++ b/deploy/k8s/base/backend-deployment.yaml @@ -262,6 +262,12 @@ spec: 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/docs/03-authentication-design.md b/docs/03-authentication-design.md index 478e3420..1d264616 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -288,7 +288,9 @@ spring: # 故意不声明 scope:钉钉的授权端点要 scope=openid,但在这里声明会让 # Spring 把该注册当成 OIDC 客户端并附加 nonce,而钉钉不接受 nonce。 # scope 由 DingTalkAuthorizationRequestCustomizer 在请求阶段补上。 - client-authentication-method: none + # 钉钉是 confidential client,只是由自定义 token client 把 secret 放进 JSON body。 + # 不使用 none,避免 Spring 自动添加本实现无法应答的 PKCE challenge。 + client-authentication-method: client_secret_post authorization-grant-type: authorization_code provider: feishu: diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 24ca4121..6b7d30b2 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -315,7 +315,12 @@ services: exchange 的 endpoint。`OAUTH2_FEISHU_PROTOCOL_VERSION` 只允许 `v2` 或 `v3`, 默认 `v3`,不会自动 fallback。 - 钉钉:`OAUTH2_DINGTALK_CLIENT_ID` / `OAUTH2_DINGTALK_CLIENT_SECRET` - (分别填应用的 AppKey 与 AppSecret) + (分别填应用的 AppKey 与 AppSecret)。在钉钉开发者后台登记 + `https://<公网域名>/login/oauth2/code/dingtalk`,并为用户信息接口开通所需权限。 + `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` 中把 diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh index 83e73a70..c241e308 100755 --- a/scripts/tests/validate-release-config-test.sh +++ b/scripts/tests/validate-release-config-test.sh @@ -107,6 +107,27 @@ write_env "$invalid_feishu_redirect_env" "release-download-secret-32-bytes-minim 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" diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index c7189304..356d241d 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -406,6 +406,15 @@ for feishu_endpoint in OAUTH2_FEISHU_AUTHORIZATION_URI OAUTH2_FEISHU_TOKEN_URI O 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 exit 1 diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 6579da34..7e580256 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -58,7 +58,7 @@ spring: scope: - read:user - user:email - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + redirect-uri: "${OAUTH2_DINGTALK_REDIRECT_URI:{baseUrl}/login/oauth2/code/{registrationId}}" client-name: GitHub authorization-grant-type: authorization_code gitlab: From ca4de37d0812faa39587a22cc351b96db366c12f Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:08:32 +0800 Subject: [PATCH 12/15] docs(deploy): add DingTalk provider acceptance steps Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/09-deployment.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 6b7d30b2..f13d752f 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -358,6 +358,23 @@ services: 本地 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`,但配置文件不能声明 `openid` scope;实现会把 + 它仅写入外发授权 URL,避免 Spring 将回调路由到 OIDC。验收时应确认 token 请求为 + JSON body,userinfo 请求使用 `x-acs-dingtalk-access-token`,重复登录仍绑定同一 + `unionId`,且日志不出现 AppSecret、access token、unionId 或上游错误 body。 + 没有钉钉测试应用凭据时,这些只能标记为“协议测试通过、真实厂商往返未验证”。 - 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md` ## 8 OIDC 登录配置 From 36f5f06d9c0a493ee75ad8b572fa5437a93ac74b Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:53:48 +0800 Subject: [PATCH 13/15] fix(auth): diagnose DingTalk userinfo failures Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../src/main/resources/application.yml | 4 +- ...ingTalkAuthorizationRequestCustomizer.java | 1 + .../auth/oauth/DingTalkOAuth2UserService.java | 95 ++++++++++++++++++- .../oauth/DingTalkOAuth2UserServiceTest.java | 50 ++++++++++ ...Auth2AuthorizationRequestResolverTest.java | 1 + 5 files changed, 147 insertions(+), 4 deletions(-) diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 7e580256..6dfbbd42 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -58,7 +58,7 @@ spring: scope: - read:user - user:email - redirect-uri: "${OAUTH2_DINGTALK_REDIRECT_URI:{baseUrl}/login/oauth2/code/{registrationId}}" + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" client-name: GitHub authorization-grant-type: authorization_code gitlab: @@ -93,7 +93,7 @@ spring: # 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: "{baseUrl}/login/oauth2/code/{registrationId}" + redirect-uri: "${OAUTH2_DINGTALK_REDIRECT_URI:{baseUrl}/login/oauth2/code/{registrationId}}" client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:钉钉} provider: github: 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 index 53b9b160..01ea237a 100644 --- 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 @@ -35,6 +35,7 @@ public class DingTalkAuthorizationRequestCustomizer implements ProviderAuthoriza 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/DingTalkOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java index cb031985..6528b2ab 100644 --- 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 @@ -1,12 +1,17 @@ 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; @@ -92,9 +97,13 @@ public class DingTalkOAuth2UserService implements ProviderOAuth2UserService { ) .exchange((request, clientResponse) -> { if (!clientResponse.getStatusCode().is2xxSuccessful()) { + SafeErrorSummary summary = readSafeErrorSummary(clientResponse.getBody()); log.warn( - "DingTalk user info returned HTTP {}; response body omitted", - clientResponse.getStatusCode().value()); + "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()); } @@ -130,6 +139,88 @@ public class DingTalkOAuth2UserService implements ProviderOAuth2UserService { }); } + /** 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 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 index 800fe73f..2abc368f 100644 --- 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 @@ -7,7 +7,12 @@ import static org.springframework.test.web.client.match.MockRestRequestMatchers. 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; @@ -20,9 +25,21 @@ 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(); @@ -121,6 +138,39 @@ class DingTalkOAuth2UserServiceTest { 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(); 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 5eab1475..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 @@ -99,6 +99,7 @@ class OAuth2AuthorizationRequestResolverTest { 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 From 00033b1b92abcc25573bb4d3d591173a41399114 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:40:23 +0800 Subject: [PATCH 14/15] docs(deploy): document DingTalk egress requirements Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/09-deployment.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/09-deployment.md b/docs/09-deployment.md index f13d752f..e36f665c 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -317,6 +317,10 @@ services: - 钉钉:`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` 与 @@ -370,10 +374,11 @@ services: OAUTH2_DINGTALK_REDIRECT_URI=https://<公网域名>/login/oauth2/code/dingtalk ``` - 登录请求必须包含 `scope=openid`,但配置文件不能声明 `openid` scope;实现会把 - 它仅写入外发授权 URL,避免 Spring 将回调路由到 OIDC。验收时应确认 token 请求为 - JSON body,userinfo 请求使用 `x-acs-dingtalk-access-token`,重复登录仍绑定同一 - `unionId`,且日志不出现 AppSecret、access token、unionId 或上游错误 body。 + 登录请求必须包含 `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 或完整错误正文。 没有钉钉测试应用凭据时,这些只能标记为“协议测试通过、真实厂商往返未验证”。 - 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md` From 50e7427596d7464fe1c35fda5599454de476e5a0 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:20:24 +0800 Subject: [PATCH 15/15] docs(deploy): complete DingTalk private deployment guide Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .env.release.example | 2 + charts/skillhub/README.md | 2 + docs/03-authentication-design.md | 5 +- docs/09-deployment.md | 125 +++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/.env.release.example b/.env.release.example index ca295b72..505c594a 100644 --- a/.env.release.example +++ b/.env.release.example @@ -142,6 +142,8 @@ OAUTH2_FEISHU_DISPLAY_NAME=飞书 # 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 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/docs/03-authentication-design.md b/docs/03-authentication-design.md index 1d264616..19f6140f 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -287,7 +287,8 @@ spring: client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET} # 故意不声明 scope:钉钉的授权端点要 scope=openid,但在这里声明会让 # Spring 把该注册当成 OIDC 客户端并附加 nonce,而钉钉不接受 nonce。 - # scope 由 DingTalkAuthorizationRequestCustomizer 在请求阶段补上。 + # scope=openid 与 prompt=consent 由 DingTalkAuthorizationRequestCustomizer + # 在请求阶段补上。 # 钉钉是 confidential client,只是由自定义 token client 把 secret 放进 JSON body。 # 不使用 none,避免 Spring 自动添加本实现无法应答的 PKCE challenge。 client-authentication-method: client_secret_post @@ -315,7 +316,7 @@ Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider | 偏离环节 | 策略接口 | 现有实现 | |---|---|---| -| 授权请求参数 | `ProviderAuthorizationRequestCustomizer` | 钉钉补 `openid` scope | +| 授权请求参数 | `ProviderAuthorizationRequestCustomizer` | 钉钉补 `scope=openid` 与 `prompt=consent` | | token 交换 | `ProviderTokenResponseClient` | 钉钉用 JSON body 而非表单 | | userinfo 加载 | `ProviderOAuth2UserService` | 飞书拆信封;钉钉用自定义 token header | diff --git a/docs/09-deployment.md b/docs/09-deployment.md index e36f665c..452f442b 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -380,6 +380,131 @@ services: 登录仍绑定同一 `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 登录配置