feat(auth): add DingTalk OAuth2 adapter to unified identity core (#677)

Merged into big-main after local validation, callback integration coverage, CI, DCO/CLA, and review. Main remains untouched; Hong Kong remote validation follows on the exact integration SHA.
This commit is contained in:
XiaoSeS 2026-08-03 13:25:39 +08:00 • committed by GitHub
commit 7b33660634
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2338 additions and 7 deletions

View file

@ -116,6 +116,19 @@ OAUTH2_GITLAB_CLIENT_SECRET=
OAUTH2_GITLAB_BASE_URI=https://gitlab.com
OAUTH2_GITLAB_DISPLAY_NAME=GitLab
# Optional: native DingTalk OAuth2 browser login. The adapter remains hidden
# unless enabled and both client credentials plus a stable operator-owned
# authority are present. The authority is a local identity namespace, not a
# URL and never comes from DingTalk claims; keep it unchanged after binding.
SKILLHUB_AUTH_DINGTALK_ENABLED=false
SKILLHUB_AUTH_DINGTALK_AUTHORITY=dingtalk.corp
SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT=PT5S
SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT=PT10S
SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES=1048576
OAUTH2_DINGTALK_CLIENT_ID=
OAUTH2_DINGTALK_CLIENT_SECRET=
OAUTH2_DINGTALK_DISPLAY_NAME=DingTalk
# 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.

View file

@ -1,4 +1,4 @@
.PHONY: build build-backend build-backend-app build-builtin-skills build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-builtin-skills test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci
.PHONY: build build-backend build-backend-app build-builtin-skills build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web dingtalk-smoke docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-builtin-skills test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci
DEV_DIR := .dev
DEV_SERVER_PID := $(DEV_DIR)/server.pid
@ -147,6 +147,9 @@ dev-server-restart: ## 重启后端开发服务器
namespace-smoke: ## 运行命名空间工作流 smoke test
./scripts/namespace-smoke-test.sh $(DEV_API_URL)
dingtalk-smoke: ## 验证 DingTalk provider 目录与 disabled route(默认要求关闭)
./scripts/dingtalk-smoke-test.sh $(DEV_API_URL)
dev-down: ## 停止本地开发环境(含 skill-scanner)
$(DEV_COMPOSE) down --remove-orphans

View file

@ -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 时 | DingTalk OAuth2 Client ID |
| `oauth2-dingtalk-client-secret` | 启用 DingTalk 时 | DingTalk OAuth2 Client Secret |
| `skill-scanner-llm-api-key` | 否 | Scanner LLM API Key |
| `skill-scanner-llm-base-url` | 否 | Scanner 自定义 LLM API 地址 |
| `skill-scanner-llm-model` | 否 | Scanner LLM 模型名称 |
@ -185,6 +187,28 @@ auth:
普通 CAS email attribute 只按 asserted 处理。完整说明见
[`docs/23-cas-integration.md`](../../docs/23-cas-integration.md)。
### DingTalk OAuth2
```yaml
auth:
dingtalk:
enabled: true
displayName: DingTalk
authority: dingtalk.corp
connectTimeout: PT5S
readTimeout: PT10S
maxResponseBytes: 1048576
secrets:
oauth2DingtalkClientId: your-client-id
oauth2DingtalkClientSecret: your-client-secret
```
使用 `existingSecret` 时,该 Secret 必须提供
`oauth2-dingtalk-client-id` 和 `oauth2-dingtalk-client-secret` 两个 key。authority 是
SkillHub 维护的稳定身份命名空间,不是 URL;产生身份绑定后不得修改。DingTalk
`unionId` 是唯一 primary subject,普通 email 不能用于自动绑定。完整说明见
[`docs/26-dingtalk-unified-identity-integration.md`](../../docs/26-dingtalk-unified-identity-integration.md)。
### 副本数配置
| 参数 | 描述 | 默认值 |

View file

@ -98,4 +98,10 @@ data:
auth-cas-attribute-display-name: {{ .Values.auth.cas.attributes.displayName | quote }}
auth-cas-attribute-email: {{ .Values.auth.cas.attributes.email | quote }}
auth-cas-attribute-avatar-url: {{ .Values.auth.cas.attributes.avatarUrl | quote }}
auth-dingtalk-enabled: {{ .Values.auth.dingtalk.enabled | quote }}
auth-dingtalk-display-name: {{ .Values.auth.dingtalk.displayName | quote }}
auth-dingtalk-authority: {{ .Values.auth.dingtalk.authority | quote }}
auth-dingtalk-connect-timeout: {{ .Values.auth.dingtalk.connectTimeout | quote }}
auth-dingtalk-read-timeout: {{ .Values.auth.dingtalk.readTimeout | quote }}
auth-dingtalk-max-response-bytes: {{ .Values.auth.dingtalk.maxResponseBytes | quote }}
builtin-skills-enabled: {{ .Values.builtinSkills.enabled | quote }}

View file

@ -59,6 +59,14 @@ stringData:
oauth2-github-client-secret: {{ .Values.secrets.oauth2GithubClientSecret | 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 }}
# LDAP service-account password (optional unless LDAP is enabled)
{{- if .Values.secrets.ldapBindPassword }}
ldap-bind-password: {{ .Values.secrets.ldapBindPassword | quote }}

View file

@ -522,6 +522,36 @@ spec:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: auth-cas-attribute-avatar-url
- name: SKILLHUB_AUTH_DINGTALK_ENABLED
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: auth-dingtalk-enabled
- name: OAUTH2_DINGTALK_DISPLAY_NAME
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: auth-dingtalk-display-name
- name: SKILLHUB_AUTH_DINGTALK_AUTHORITY
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: auth-dingtalk-authority
- name: SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: auth-dingtalk-connect-timeout
- name: SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: auth-dingtalk-read-timeout
- name: SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: auth-dingtalk-max-response-bytes
- name: SKILLHUB_BUILTIN_SKILLS_ENABLED
valueFrom:
configMapKeyRef:
@ -579,6 +609,18 @@ spec:
name: {{ include "skillhub.secretName" . }}
key: oauth2-github-client-secret
optional: true
- 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

View file

@ -62,6 +62,15 @@
{{- end -}}
{{- end -}}
{{- if .Values.auth.dingtalk.enabled -}}
{{- if and (not .Values.existingSecret) (not .Values.secrets.oauth2DingtalkClientId) -}}
{{- fail "auth.dingtalk.enabled=true requires secrets.oauth2DingtalkClientId or existingSecret key oauth2-dingtalk-client-id" -}}
{{- end -}}
{{- if and (not .Values.existingSecret) (not .Values.secrets.oauth2DingtalkClientSecret) -}}
{{- fail "auth.dingtalk.enabled=true requires secrets.oauth2DingtalkClientSecret or existingSecret key oauth2-dingtalk-client-secret" -}}
{{- end -}}
{{- end -}}
{{- if and .Values.ingress.enabled (not .Values.server.service.enabled) -}}
{{- fail "ingress.enabled=true requires server.service.enabled=true" -}}
{{- end -}}

View file

@ -219,6 +219,20 @@ if grep -Fq 'ldap-bind-password:' "$TMP_DIR/default.yaml"; then
fail "disabled LDAP must not render a bind password"
fi
render dingtalk "$CHART_DIR" \
--set auth.dingtalk.enabled=true \
--set-string secrets.oauth2DingtalkClientId=dingtalk-client-id \
--set-string secrets.oauth2DingtalkClientSecret=dingtalk-client-secret \
--show-only templates/configmap.yaml \
--show-only templates/secret.yaml \
--show-only templates/server-deployment.yaml >"$TMP_DIR/dingtalk.yaml"
grep -Fq 'auth-dingtalk-enabled: "true"' "$TMP_DIR/dingtalk.yaml"
grep -Fq 'auth-dingtalk-display-name: "DingTalk"' "$TMP_DIR/dingtalk.yaml"
grep -Fq 'auth-dingtalk-authority: "dingtalk.corp"' "$TMP_DIR/dingtalk.yaml"
grep -Fq 'oauth2-dingtalk-client-id: "dingtalk-client-id"' "$TMP_DIR/dingtalk.yaml"
grep -Fq 'oauth2-dingtalk-client-secret: "dingtalk-client-secret"' "$TMP_DIR/dingtalk.yaml"
grep -Fq 'name: OAUTH2_DINGTALK_CLIENT_SECRET' "$TMP_DIR/dingtalk.yaml"
render tls "$CHART_DIR" \
--set ingress.enabled=true \
--set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]' \
@ -317,6 +331,17 @@ assert_rejected cas-with-wrong-callback \
--set auth.cas.enabled=true \
--set auth.cas.serverUrl=https://cas.example.com/cas \
--set auth.cas.serviceUrl=https://skills.example.com/api/v1/auth/cas/other/callback
assert_rejected dingtalk-without-client-id \
--set auth.dingtalk.enabled=true \
--set-string secrets.oauth2DingtalkClientSecret=dingtalk-client-secret
assert_rejected dingtalk-without-client-secret \
--set auth.dingtalk.enabled=true \
--set-string secrets.oauth2DingtalkClientId=dingtalk-client-id
assert_rejected dingtalk-with-invalid-authority \
--set auth.dingtalk.enabled=true \
--set-string auth.dingtalk.authority=https://dingtalk.example.com \
--set-string secrets.oauth2DingtalkClientId=dingtalk-client-id \
--set-string secrets.oauth2DingtalkClientSecret=dingtalk-client-secret
assert_rejected ingress-without-server-service --set ingress.enabled=true --set server.service.enabled=false
assert_rejected ingress-without-web-service --set ingress.enabled=true --set web.service.enabled=false
assert_rejected multi-without-rwx --set server.replicaCount=2

View file

@ -21,7 +21,7 @@
"auth": {
"type": "object",
"additionalProperties": false,
"required": ["direct", "accountMerge", "ldap", "cas"],
"required": ["direct", "accountMerge", "ldap", "cas", "dingtalk"],
"properties": {
"direct": {
"type": "object",
@ -100,6 +100,19 @@
}
}
}
},
"dingtalk": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "displayName", "authority", "connectTimeout", "readTimeout", "maxResponseBytes"],
"properties": {
"enabled": { "type": "boolean" },
"displayName": { "type": "string", "minLength": 1, "maxLength": 128 },
"authority": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._:-]{0,127}$" },
"connectTimeout": { "type": "string", "minLength": 1 },
"readTimeout": { "type": "string", "minLength": 1 },
"maxResponseBytes": { "type": "integer", "minimum": 1024, "maximum": 1048576 }
}
}
}
},
@ -230,6 +243,8 @@
"downloadAnonCookieSecret": { "type": "string" },
"oauth2GithubClientId": { "type": "string" },
"oauth2GithubClientSecret": { "type": "string" },
"oauth2DingtalkClientId": { "type": "string" },
"oauth2DingtalkClientSecret": { "type": "string" },
"ldapBindPassword": { "type": "string" },
"scannerLlmApiKey": { "type": "string" },
"scannerLlmBaseUrl": { "type": "string" },

View file

@ -68,6 +68,13 @@ auth:
displayName: displayName
email: mail
avatarUrl: ""
dingtalk:
enabled: false
displayName: DingTalk
authority: dingtalk.corp
connectTimeout: PT5S
readTimeout: PT10S
maxResponseBytes: 1048576
builtinSkills:
enabled: true
@ -143,6 +150,8 @@ secrets:
downloadAnonCookieSecret: ""
oauth2GithubClientId: ""
oauth2GithubClientSecret: ""
oauth2DingtalkClientId: ""
oauth2DingtalkClientSecret: ""
ldapBindPassword: ""
scannerLlmApiKey: ""
scannerLlmBaseUrl: ""

View file

@ -136,6 +136,11 @@ services:
SKILLHUB_AUTH_CAS_ATTRIBUTES_DISPLAY_NAME: ${SKILLHUB_AUTH_CAS_ATTRIBUTES_DISPLAY_NAME:-}
SKILLHUB_AUTH_CAS_ATTRIBUTES_EMAIL: ${SKILLHUB_AUTH_CAS_ATTRIBUTES_EMAIL:-}
SKILLHUB_AUTH_CAS_ATTRIBUTES_AVATAR_URL: ${SKILLHUB_AUTH_CAS_ATTRIBUTES_AVATAR_URL:-}
SKILLHUB_AUTH_DINGTALK_ENABLED: ${SKILLHUB_AUTH_DINGTALK_ENABLED:-false}
SKILLHUB_AUTH_DINGTALK_AUTHORITY: ${SKILLHUB_AUTH_DINGTALK_AUTHORITY:-}
SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT: ${SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT:-PT5S}
SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT: ${SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT:-PT10S}
SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES: ${SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES:-1048576}
SKILLHUB_TRACING_MODE: ${SKILLHUB_TRACING_MODE:-none}
SKILLHUB_LOG_FORMAT: ${SKILLHUB_LOG_FORMAT:-json}
SKILLHUB_LOG_ASYNC_QUEUE_SIZE: ${SKILLHUB_LOG_ASYNC_QUEUE_SIZE:-1024}
@ -153,6 +158,9 @@ 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_DINGTALK_CLIENT_ID: ${OAUTH2_DINGTALK_CLIENT_ID:-local-placeholder}
OAUTH2_DINGTALK_CLIENT_SECRET: ${OAUTH2_DINGTALK_CLIENT_SECRET:-local-placeholder}
OAUTH2_DINGTALK_DISPLAY_NAME: ${OAUTH2_DINGTALK_DISPLAY_NAME:-DingTalk}
SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-}
SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-25}
SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME:-}

View file

@ -398,6 +398,38 @@ spec:
name: skillhub-config
key: auth-cas-attribute-avatar-url
# DingTalk native OAuth2 browser login
- name: SKILLHUB_AUTH_DINGTALK_ENABLED
valueFrom:
configMapKeyRef:
name: skillhub-config
key: auth-dingtalk-enabled
- name: OAUTH2_DINGTALK_DISPLAY_NAME
valueFrom:
configMapKeyRef:
name: skillhub-config
key: auth-dingtalk-display-name
- name: SKILLHUB_AUTH_DINGTALK_AUTHORITY
valueFrom:
configMapKeyRef:
name: skillhub-config
key: auth-dingtalk-authority
- name: SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT
valueFrom:
configMapKeyRef:
name: skillhub-config
key: auth-dingtalk-connect-timeout
- name: SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT
valueFrom:
configMapKeyRef:
name: skillhub-config
key: auth-dingtalk-read-timeout
- name: SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES
valueFrom:
configMapKeyRef:
name: skillhub-config
key: auth-dingtalk-max-response-bytes
# Bootstrap Admin (non-sensitive from ConfigMap)
- name: BOOTSTRAP_ADMIN_ENABLED
valueFrom:
@ -447,6 +479,20 @@ spec:
key: oauth2-github-client-secret
optional: true
# 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

View file

@ -100,6 +100,15 @@ data:
auth-cas-attribute-display-name: displayName
auth-cas-attribute-email: mail
auth-cas-attribute-avatar-url: ""
# DingTalk native OAuth2 browser login(默认关闭)
# authority 是维护者定义的稳定身份命名空间,不是上游 URL。
auth-dingtalk-enabled: "false"
auth-dingtalk-display-name: DingTalk
auth-dingtalk-authority: dingtalk.corp
auth-dingtalk-connect-timeout: PT5S
auth-dingtalk-read-timeout: PT10S
auth-dingtalk-max-response-bytes: "1048576"
---
apiVersion: v1
kind: PersistentVolumeClaim

View file

@ -27,6 +27,10 @@ stringData:
oauth2-github-client-id: ""
oauth2-github-client-secret: ""
# DingTalk OAuth2(启用 auth-dingtalk-enabled 时必填)
oauth2-dingtalk-client-id: ""
oauth2-dingtalk-client-secret: ""
# LDAP service account 密码(启用 LDAP/AD 时必填)
ldap-bind-password: ""

View file

@ -0,0 +1,221 @@
# DingTalk 与统一身份核心集成设计
> 适用范围:维护者 Issue [#675](https://github.com/iflytek/skillhub/issues/675)。
>
> 本文件描述 DingTalk Native OAuth2 Browser Adapter 的协议、信任边界、部署和验收。
> 它不替代统一身份核心的领域规则;账号、身份绑定、账号合并、角色和 Session 的唯一
> 入口仍是 `skillhub-auth` 中现有的 Registry 与 Identity Services。
## 1. 目标与非目标
### 1.1 目标
- 支持 DingTalk 官方 authorization-code OAuth2 浏览器登录。
- 将 DingTalk 响应转换成协议无关的 `ProviderAuthenticationResult`。
- 让同一个 DingTalk 身份经过现有 Provider Registry、`ExternalIdentityLoginService`、
Identity Link 和 Account Merge 流程解析到同一个平台账号。
- 在启动和运行时都对 Provider Instance、固定 endpoint、响应大小、超时和稳定 subject
实施 fail-closed 校验。
- 让 provider disabled/incomplete 时从登录目录隐藏,并且在隐藏状态不访问 DingTalk。
### 1.2 非目标
- 适配器不能创建 `UserAccount`、`PlatformPrincipal`、角色、权限或 Session。
- 不能根据 DingTalk email 自动绑定已有账号,也不能把普通 email 当作 verified email。
- 不能从 DingTalk response 推导 SkillHub authority、租户、平台角色或管理员身份。
- 不修改外部贡献者 PR [#467](https://github.com/iflytek/skillhub/pull/467) 的提交。
- 不在 `main` 上直接验证或合并;先进入 `big-main`,再做独立香港测试环境验证。
## 2. 信任模型
SkillHub 把外部登录拆成三个层次:
```text
DingTalk protocol response
│ (native adapter: transport + shape validation)
▼
ProviderAuthenticationResult
│ (trusted ProviderDescriptor from server configuration)
▼
IdentityAssertion / identity binding / account merge / platform session
```
适配器只负责第一层到第二层。第二层到第三层必须由统一身份核心完成,原因是:
1. 账号绑定需要事务、冲突检测、link intent 和一次性 proof。
2. 同一个 subject 必须经过 server-owned provider authority 才能形成全局唯一坐标。
3. 角色和 Session 是 SkillHub 的安全状态,不能由外部 IdP 的可变字段决定。
### 2.1 Provider Instance 与 authority
DingTalk 的 registration id 固定为 `dingtalk`。运维配置的
`SKILLHUB_AUTH_DINGTALK_AUTHORITY` 是 SkillHub 内部稳定的身份命名空间,例如
`dingtalk.corp`;它不是 URL,不从 `unionId`、`openId`、email 或任意上游 claim 读取。
产生身份绑定后不得修改 authority。若需要迁移到另一个 DingTalk 应用或租户,应通过显式
的 Identity Link/迁移流程证明两边账号控制权,不能覆盖旧 binding。
### 2.2 Subject 规则
| DingTalk 字段 | SkillHub 类型 | 用途 | 信任要求 |
| --- | --- | --- | --- |
| `unionId` | `dingtalk_union_id` | primary subject | 必须存在、非空、原样保留 |
| `openId` | `dingtalk_open_id` | typed alternate subject | 仅接受同一已验证响应中的值 |
| `userId` | `dingtalk_user_id` | typed alternate subject | 仅接受同一已验证响应中的值 |
`unionId` 缺失时整个登录失败;不能退回使用 `openId` 或 `userId` 作为 primary。三种
subject 都使用 `EXACT` canonicalizer,大小写和字符不能被静默改写。alternate 只能用于
统一核心已有的受控查找/迁移语义,不能绕过 authority 或 Link proof。
### 2.3 Profile 与 email
适配器只映射以下非敏感属性:
- `nick` → `dingtalk_nick`
- `name` → `dingtalk_name`
- `email` → `dingtalk_email`
- `avatarUrl` → `dingtalk_avatar_url`
属性值只接受无首尾空白的字符串。DingTalk 普通 OAuth userinfo 返回的 email 标记为
`ProviderAttributeTrust.ASSERTED`,Registry 将其上限钳制为 `PROVIDER_ASSERTED`。它不能
触发 email 自动绑定,也不能作为 verified/authoritative 邮箱。头像仍由统一核心执行
HTTP(S) URI 校验;适配器不保存 raw response。
## 3. 协议适配器
实现位于 `server/skillhub-auth/.../oauth/`:
- `DingTalkTokenResponseClient`:以 JSON body 发送 `clientId`、`clientSecret`、`code`、
`grantType=authorization_code`,解析 `accessToken` 与正整数 `expireIn`。
- `DingTalkOAuth2UserService`:固定调用 userinfo endpoint,并使用
`x-acs-dingtalk-access-token` header,不使用标准 `Authorization: Bearer` 传输。
- `DingTalkClaimsExtractor`:只把已验证的 userinfo facts 转成统一核心输入。
- `ProviderAware*`:只按 registration id 将 DingTalk 请求路由到 native adapter,其他
OAuth/OIDC registration 保持原有 Spring Security delegate。
固定 endpoint:
```text
authorization: https://login.dingtalk.com/oauth2/auth
token: https://api.dingtalk.com/v1.0/oauth2/userAccessToken
userinfo: https://api.dingtalk.com/v1.0/contact/users/me
```
所有 endpoint 都必须与 server-owned `ClientRegistration` 完全一致。重建的 registration、
改变的 user-name attribute、非 authorization-code grant 或任意 endpoint 偏差均 fail closed。
### 3.1 响应边界
- connect/read timeout 必须为正且不超过 1 分钟。
- response body 默认最多 1 MiB,允许范围为 1 KiB–1 MiB。
- HTTP 错误、空 body、超限 body、非法 JSON、缺少 token、非法 `expireIn` 或缺少
`unionId` 都转换成稳定 OAuth 错误码,不回显响应 body。
- access token、client secret、authorization code 和 raw response 不进入
`ProviderAuthenticationResult`、日志或审计字段。
- provider disabled 或 registration 不完整时,在任何 HTTP 调用前失败。
## 4. 配置与部署
### 4.1 Compose / release 环境变量
```text
SKILLHUB_AUTH_DINGTALK_ENABLED=false
SKILLHUB_AUTH_DINGTALK_AUTHORITY=dingtalk.corp
SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT=PT5S
SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT=PT10S
SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES=1048576
OAUTH2_DINGTALK_CLIENT_ID=
OAUTH2_DINGTALK_CLIENT_SECRET=
OAUTH2_DINGTALK_DISPLAY_NAME=DingTalk
```
启用前必须同时提供非 placeholder 的 client id、client secret、authority、timeout 和
response limit。`scripts/validate-release-config.sh` 会拒绝不完整凭据、非法 authority
字符和越界 response limit。
在 Compose 中,client credentials 通过环境变量注入容器;在 Kubernetes 中,authority 和
边界参数进入 ConfigMap,client id/secret 进入 Secret。不要把 Secret 提交到仓库或写入
ConfigMap。默认关闭意味着普通部署不会产生 DingTalk 网络流量。
### 4.2 启用步骤
1. 在 DingTalk 管理端创建 OAuth 应用并配置与 SkillHub 完全一致的回调 URI:
`/login/oauth2/code/dingtalk`(由 Spring `{baseUrl}` 展开)。
2. 在 secret store 写入 `OAUTH2_DINGTALK_CLIENT_ID` 和
`OAUTH2_DINGTALK_CLIENT_SECRET`。
3. 设置稳定的 `SKILLHUB_AUTH_DINGTALK_AUTHORITY`,确认该值尚未用于另一套 IdP。
4. 运行 release config validator 和 Kustomize/Compose 配置渲染检查。
5. 在 `big-main` 的精确 SHA 镜像上先部署,确认 provider 目录出现 DingTalk;再执行
operator smoke 和人工 callback 验证。
6. 只有远端验证通过、现有服务健康且本次资源已清理后,才向维护者申请合入 `main`。
## 5. 升级、绑定与回滚
### 5.1 老版本兼容性
本功能只增加代码、配置和登录目录项,不改变现有 GitHub、GitLab、OIDC、LDAP、CAS、
local password、API token、Identity Link 或 Account Merge 的数据结构。禁用 DingTalk
时老版本无需识别新环境变量即可继续运行。
新版本升级不迁移、不删除既有 `identity_binding`。DingTalk 的第一次登录如果找不到
binding,遵循现有 provider provisioning policy;若 email 与已有账号冲突,进入统一核心
的 link-required 流程,而不是自动合并。已存在的旧绑定不会因为 profile name/email 变化
而改变 subject。
### 5.2 回滚
回滚前先把 `SKILLHUB_AUTH_DINGTALK_ENABLED=false` 部署到所有新 Pod,并确认登录目录不再
提供 DingTalk。保留已有 identity binding 和审计数据;不要删除 binding、直接改 authority、
恢复角色或手工改库。旧镜像忽略新环境变量即可回滚到不支持 DingTalk 的版本。
## 6. 验收矩阵
### 6.1 自动检查
```bash
./mvnw -pl skillhub-auth -am test
bash scripts/tests/validate-release-config-test.sh
make test-backend-app
make typecheck-web
make lint-web
make staging
```
适配器测试必须覆盖:
- unionId primary、openId/userId typed aliases;
- 缺少 unionId、非法 JSON、HTTP error、空/超限响应;
- 非正或超长 token expiry;
- DingTalk 自定义 token header 和官方 endpoint;
- disabled/incomplete provider 在网络调用前失败;
- Provider Registry 对三种 subject 使用 `EXACT`,email 上限为
`PROVIDER_ASSERTED`;
- adapter bytecode 不依赖账号、principal、role、session 或 persistence 类。
### 6.2 香港测试机最小人工路径
在不重启宿主机、不中断现有 `skillhub-runtime-*` 的前提下,使用独立 project/network/
container name 部署精确 SHA:
1. `GET /actuator/health` 返回 200,provider catalog 在 disabled 时不包含 DingTalk。
2. 启用 mock DingTalk transport 后,登录 callback 只创建/解析统一核心允许的结果;重复
`unionId` 登录解析到同一 binding。
3. 修改 `nick`/`email` 不改变 `dingtalk_union_id`;普通 email 不能自动绑定冲突账号。
4. Identity Link 和 Account Merge 仍要求现有一次性 state/proof,不能通过重复 callback
或换 openId 绕过。
5. 检查容器日志、响应和审计中没有 client secret、authorization code、access token、
raw response 或 session/nonce。
6. 验证完成后只删除带本次 run id 的容器、网络、镜像和临时文件;不删除 multica 历史
记录、共享卷、其他测试资源,也不重启机器。
### 6.3 证据
进入 `main` 前应保留:feature SHA、`big-main` SHA、OCI revision、自动测试输出、配置
validator 输出、远端 health/smoke 输出、provider catalog 前后差异、日志脱敏检查和
精确清理清单。任何一项缺失都保持 DingTalk 关闭。
## 7. 参考标准
- [RFC 6749: The OAuth 2.0 Authorization Framework](https://www.rfc-editor.org/rfc/rfc6749)
- [Spring Security OAuth2 Client](https://docs.spring.io/spring-security/reference/servlet/oauth2/client/index.html)
- [OAuth 2.0 Security Best Current Practice](https://www.rfc-editor.org/rfc/rfc9700)

52
scripts/dingtalk-smoke-test.sh Executable file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-http://localhost:8080}"
EXPECTED_ENABLED="${DINGTALK_EXPECTED_ENABLED:-false}"
RESPONSE_FILE="$(mktemp)"
cleanup() {
rm -f "$RESPONSE_FILE"
}
trap cleanup EXIT
status="$(curl --max-time 10 -s -o "$RESPONSE_FILE" -w "%{http_code}" \
"$BASE_URL/api/v1/auth/providers" || true)"
if [[ "$status" != "200" ]]; then
echo "FAIL: authentication provider catalog returned HTTP $status" >&2
exit 1
fi
python3 - "$RESPONSE_FILE" "$EXPECTED_ENABLED" <<'PY'
import json
import sys
response_file, expected_enabled = sys.argv[1:]
with open(response_file, encoding="utf-8") as response:
providers = json.load(response)["data"]
dingtalk = [provider for provider in providers if provider.get("id") == "dingtalk"]
if expected_enabled == "true":
if len(dingtalk) != 1:
raise SystemExit("FAIL: enabled DingTalk provider is missing from catalog")
expected_url = "/oauth2/authorization/dingtalk"
if dingtalk[0].get("authorizationUrl") != expected_url:
raise SystemExit(
"FAIL: DingTalk authorization URL is not the trusted route: "
+ repr(dingtalk[0].get("authorizationUrl")))
print("PASS: enabled DingTalk provider exposes the trusted authorization route")
else:
if dingtalk:
raise SystemExit("FAIL: disabled DingTalk provider is visible in catalog")
print("PASS: disabled DingTalk provider is hidden from catalog")
PY
if [[ "$EXPECTED_ENABLED" == "false" ]]; then
route_status="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
"$BASE_URL/oauth2/authorization/dingtalk" || true)"
if [[ "$route_status" != "403" ]]; then
echo "FAIL: disabled DingTalk route returned HTTP $route_status (expected 403)" >&2
exit 1
fi
echo "PASS: disabled DingTalk route fails before upstream redirect (HTTP 403)"
fi

View file

@ -75,6 +75,33 @@ write_env "$disabled_builtin_skills_env" "release-download-secret-32-bytes-minim
printf '%s\n' "SKILLHUB_BUILTIN_SKILLS_ENABLED=false" >>"$disabled_builtin_skills_env"
"$SCRIPT" "$disabled_builtin_skills_env" >/dev/null
valid_dingtalk_env="$tmp/valid-dingtalk.env"
write_env "$valid_dingtalk_env" "release-download-secret-32-bytes-minimum"
cat >>"$valid_dingtalk_env" <<'EOF'
SKILLHUB_AUTH_DINGTALK_ENABLED=true
SKILLHUB_AUTH_DINGTALK_AUTHORITY=dingtalk.corp
SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT=PT5S
SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT=PT10S
SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES=1048576
OAUTH2_DINGTALK_CLIENT_ID=real-dingtalk-client
OAUTH2_DINGTALK_CLIENT_SECRET=real-dingtalk-secret
EOF
"$SCRIPT" "$valid_dingtalk_env" >/dev/null
missing_dingtalk_secret_env="$tmp/missing-dingtalk-secret.env"
grep -v '^OAUTH2_DINGTALK_CLIENT_SECRET=' "$valid_dingtalk_env" >"$missing_dingtalk_secret_env"
expect_fail "$missing_dingtalk_secret_env" "OAUTH2_DINGTALK_CLIENT_SECRET is required"
invalid_dingtalk_authority_env="$tmp/invalid-dingtalk-authority.env"
cp "$valid_dingtalk_env" "$invalid_dingtalk_authority_env"
sed -i 's/^SKILLHUB_AUTH_DINGTALK_AUTHORITY=.*/SKILLHUB_AUTH_DINGTALK_AUTHORITY=https:\/\/dingtalk.example.com/' "$invalid_dingtalk_authority_env"
expect_fail "$invalid_dingtalk_authority_env" "SKILLHUB_AUTH_DINGTALK_AUTHORITY contains invalid characters"
invalid_dingtalk_size_env="$tmp/invalid-dingtalk-size.env"
cp "$valid_dingtalk_env" "$invalid_dingtalk_size_env"
sed -i 's/^SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES=.*/SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES=512/' "$invalid_dingtalk_size_env"
expect_fail "$invalid_dingtalk_size_env" "SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES must be between 1024 and 1048576"
missing_env="$tmp/missing.env"
write_env "$missing_env" "" no
expect_fail "$missing_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET is required"

View file

@ -229,6 +229,7 @@ validate_boolean SKILLHUB_AUTH_LDAP_ALLOW_INSECURE_FOR_TESTING
validate_boolean SKILLHUB_AUTH_LDAP_EMAIL_AUTHORITATIVE
validate_boolean SKILLHUB_AUTH_CAS_ENABLED
validate_boolean SKILLHUB_AUTH_CAS_ALLOW_INSECURE_FOR_TESTING
validate_boolean SKILLHUB_AUTH_DINGTALK_ENABLED
validate_boolean SPRING_DATA_REDIS_SSL_ENABLED
validate_boolean SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST
@ -306,6 +307,43 @@ if [ "${SKILLHUB_AUTH_CAS_ENABLED:-false}" = "true" ]; then
fi
fi
if [ "${SKILLHUB_AUTH_DINGTALK_ENABLED:-false}" = "true" ]; then
require_non_empty SKILLHUB_AUTH_DINGTALK_AUTHORITY
require_non_empty SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT
require_non_empty SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT
require_non_empty SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES
require_non_empty OAUTH2_DINGTALK_CLIENT_ID
require_non_empty OAUTH2_DINGTALK_CLIENT_SECRET
reject_values OAUTH2_DINGTALK_CLIENT_ID "placeholder" "local-placeholder"
reject_values OAUTH2_DINGTALK_CLIENT_SECRET "placeholder" "local-placeholder"
case "${SKILLHUB_AUTH_DINGTALK_AUTHORITY:-}" in
[a-z0-9]*) ;;
*) error "SKILLHUB_AUTH_DINGTALK_AUTHORITY must start with a lowercase letter or digit" ;;
esac
case "${SKILLHUB_AUTH_DINGTALK_AUTHORITY:-}" in
*[!a-z0-9._:-]*) error "SKILLHUB_AUTH_DINGTALK_AUTHORITY contains invalid characters" ;;
esac
validate_non_negative_integer SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES
case "${SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES:-}" in
"") ;;
*)
if [ "$SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES" -lt 1024 ] \
|| [ "$SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES" -gt 1048576 ]; then
error "SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES must be between 1024 and 1048576"
fi
;;
esac
fi
dingtalk_id="${OAUTH2_DINGTALK_CLIENT_ID:-}"
dingtalk_secret="${OAUTH2_DINGTALK_CLIENT_SECRET:-}"
if [ -n "$dingtalk_id" ] && [ -z "$dingtalk_secret" ]; then
error "OAUTH2_DINGTALK_CLIENT_SECRET is required when OAUTH2_DINGTALK_CLIENT_ID is set"
fi
if [ -n "$dingtalk_secret" ] && [ -z "$dingtalk_id" ]; then
error "OAUTH2_DINGTALK_CLIENT_ID is required when OAUTH2_DINGTALK_CLIENT_SECRET is set"
fi
validate_port POSTGRES_PORT
validate_port REDIS_PORT
validate_port API_PORT

View file

@ -71,6 +71,14 @@ spring:
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab}
dingtalk:
client-id: ${OAUTH2_DINGTALK_CLIENT_ID:placeholder}
client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:placeholder}
scope:
- openid
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:DingTalk}
provider:
github:
user-info-uri: https://api.github.com/user
@ -79,6 +87,11 @@ 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
dingtalk:
authorization-uri: https://login.dingtalk.com/oauth2/auth
token-uri: https://api.dingtalk.com/v1.0/oauth2/userAccessToken
user-info-uri: https://api.dingtalk.com/v1.0/contact/users/me
user-name-attribute: dingtalkSubject
servlet:
multipart:
max-file-size: 100MB
@ -147,6 +160,13 @@ skillhub:
pool-wait-timeout: ${SKILLHUB_AUTH_LDAP_POOL_WAIT_TIMEOUT:PT2S}
max-concurrent-requests: ${SKILLHUB_AUTH_LDAP_MAX_CONCURRENT_REQUESTS:16}
max-attribute-values: ${SKILLHUB_AUTH_LDAP_MAX_ATTRIBUTE_VALUES:16}
dingtalk:
enabled: ${SKILLHUB_AUTH_DINGTALK_ENABLED:false}
authority: ${SKILLHUB_AUTH_DINGTALK_AUTHORITY:}
display-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:DingTalk}
connect-timeout: ${SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT:PT5S}
read-timeout: ${SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT:PT10S}
max-response-bytes: ${SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES:1048576}
session-bootstrap:
enabled: ${SKILLHUB_AUTH_SESSION_BOOTSTRAP_ENABLED:false}
cas:

View file

@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService;
import com.iflytek.skillhub.auth.oauth.CustomOidcUserService;
import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler;
import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler;
import com.iflytek.skillhub.auth.oauth.ProviderAwareAccessTokenResponseClient;
import com.iflytek.skillhub.auth.oauth.IdentityProviderRouteReadinessFilter;
import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver;
import com.iflytek.skillhub.auth.identity.IdentityProviderReadinessService;
@ -64,6 +65,8 @@ public class SecurityConfig {
private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver;
private final OAuth2LoginSuccessHandler successHandler;
private final OAuth2LoginFailureHandler failureHandler;
private final ProviderAwareAccessTokenResponseClient
accessTokenResponseClient;
private final ApiTokenAuthenticationFilter apiTokenAuthenticationFilter;
private final ApiTokenScopeFilter apiTokenScopeFilter;
private final AuthenticationEntryPoint apiAuthenticationEntryPoint;
@ -78,6 +81,8 @@ public class SecurityConfig {
SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver,
OAuth2LoginSuccessHandler successHandler,
OAuth2LoginFailureHandler failureHandler,
ProviderAwareAccessTokenResponseClient
accessTokenResponseClient,
ApiTokenAuthenticationFilter apiTokenAuthenticationFilter,
ApiTokenScopeFilter apiTokenScopeFilter,
AuthenticationEntryPoint apiAuthenticationEntryPoint,
@ -91,6 +96,7 @@ public class SecurityConfig {
this.authorizationRequestResolver = authorizationRequestResolver;
this.successHandler = successHandler;
this.failureHandler = failureHandler;
this.accessTokenResponseClient = accessTokenResponseClient;
this.apiTokenAuthenticationFilter = apiTokenAuthenticationFilter;
this.apiTokenScopeFilter = apiTokenScopeFilter;
this.apiAuthenticationEntryPoint = apiAuthenticationEntryPoint;
@ -130,6 +136,9 @@ public class SecurityConfig {
})
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver))
.tokenEndpoint(endpoint -> endpoint
.accessTokenResponseClient(
accessTokenResponseClient))
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
.oidcUserService(customOidcUserService))

View file

@ -1,5 +1,7 @@
package com.iflytek.skillhub.auth.identity;
import com.iflytek.skillhub.auth.oauth.DingTalkOAuth2Constants;
import com.iflytek.skillhub.auth.oauth.DingTalkProperties;
import com.iflytek.skillhub.auth.oauth.OAuthClaimsExtractor;
import java.net.URI;
import java.net.URISyntaxException;
@ -35,20 +37,23 @@ class StaticTrustedProviderDescriptorSource
private final Map<String, ProviderDescriptor> descriptors;
private final Map<String, ClientRegistration> trustedRegistrations;
private final IdentityProviderPolicyProperties policyProperties;
private final DingTalkProperties dingTalkProperties;
@Autowired
StaticTrustedProviderDescriptorSource(
OAuth2ClientProperties properties,
ClientRegistrationRepository registrationRepository,
List<OAuthClaimsExtractor> extractors,
IdentityProviderPolicyProperties policyProperties) {
IdentityProviderPolicyProperties policyProperties,
DingTalkProperties dingTalkProperties) {
this(
properties,
registrationRepository,
extractors.stream()
.map(OAuthClaimsExtractor::getProvider)
.collect(Collectors.toUnmodifiableSet()),
policyProperties);
policyProperties,
dingTalkProperties);
}
StaticTrustedProviderDescriptorSource(
@ -59,7 +64,8 @@ class StaticTrustedProviderDescriptorSource
properties,
registrationRepository,
extractorCodes,
new IdentityProviderPolicyProperties());
new IdentityProviderPolicyProperties(),
new DingTalkProperties());
}
StaticTrustedProviderDescriptorSource(
@ -67,7 +73,22 @@ class StaticTrustedProviderDescriptorSource
ClientRegistrationRepository registrationRepository,
Set<String> extractorCodes,
IdentityProviderPolicyProperties policyProperties) {
this(
properties,
registrationRepository,
extractorCodes,
policyProperties,
new DingTalkProperties());
}
StaticTrustedProviderDescriptorSource(
OAuth2ClientProperties properties,
ClientRegistrationRepository registrationRepository,
Set<String> extractorCodes,
IdentityProviderPolicyProperties policyProperties,
DingTalkProperties dingTalkProperties) {
this.policyProperties = policyProperties;
this.dingTalkProperties = dingTalkProperties;
Map<String, ProviderDescriptor> resolvedDescriptors =
new LinkedHashMap<>();
Map<String, ClientRegistration> resolvedRegistrations =
@ -125,6 +146,10 @@ class StaticTrustedProviderDescriptorSource
if (!hasRealClientId(properties.getClientId())) {
return Optional.empty();
}
if (DingTalkOAuth2Constants.REGISTRATION_ID.equals(providerCode)
&& !hasRealClientSecret(properties.getClientSecret())) {
return Optional.empty();
}
ClientRegistration registration;
try {
registration = registrationRepository
@ -138,6 +163,11 @@ class StaticTrustedProviderDescriptorSource
if (registration == null) {
return Optional.empty();
}
if (DingTalkOAuth2Constants.REGISTRATION_ID.equals(providerCode)
&& (!hasRealClientId(registration.getClientId())
|| !hasRealClientSecret(registration.getClientSecret()))) {
return Optional.empty();
}
try {
ProviderDescriptor descriptor = descriptorFor(
@ -202,6 +232,16 @@ class StaticTrustedProviderDescriptorSource
List.of("email"),
List.of("avatar_url"));
}
case DingTalkOAuth2Constants.REGISTRATION_ID -> {
if (!hasExtractor
|| hasIssuer
|| !dingTalkProperties.isEnabled()) {
throw new IllegalArgumentException(
"DingTalk adapter is unavailable");
}
validateDingTalkEndpoints(registration);
yield dingTalkDescriptor(providerCode);
}
default -> {
if (!hasIssuer || hasExtractor) {
throw new IllegalArgumentException(
@ -236,6 +276,30 @@ class StaticTrustedProviderDescriptorSource
List<String> displayNameAttributes,
List<String> emailAttributes,
List<String> avatarAttributes) {
return descriptor(
providerCode,
protocol,
authority,
configuredDisplayName,
subjectType,
canonicalizer,
displayNameAttributes,
emailAttributes,
avatarAttributes,
EmailAssurance.VERIFIED);
}
private ProviderDescriptor descriptor(
String providerCode,
String protocol,
String authority,
String configuredDisplayName,
String subjectType,
SubjectCanonicalizer canonicalizer,
List<String> displayNameAttributes,
List<String> emailAttributes,
List<String> avatarAttributes,
EmailAssurance emailAssuranceLimit) {
String displayName =
configuredDisplayName == null
|| configuredDisplayName.isBlank()
@ -254,11 +318,59 @@ class StaticTrustedProviderDescriptorSource
displayNameAttributes,
emailAttributes,
avatarAttributes,
EmailAssurance.VERIFIED,
emailAssuranceLimit,
policy.provisioningMode(),
policy.profileSyncPolicy());
}
private ProviderDescriptor dingTalkDescriptor(String providerCode) {
IdentityProviderPolicyProperties.ProviderIdentityPolicy policy =
policyProperties.resolve(providerCode);
String displayName = dingTalkProperties.getDisplayName();
if (displayName == null || displayName.isBlank()) {
displayName = "DingTalk";
}
return new ProviderDescriptor(
providerCode,
"dingtalk-oauth2",
requireDingTalkAuthority(),
displayName,
"dingtalk_union_id",
"dingtalk_union_id",
Map.of(
"dingtalk_union_id",
SubjectCanonicalizer.EXACT,
"dingtalk_open_id",
SubjectCanonicalizer.EXACT,
"dingtalk_user_id",
SubjectCanonicalizer.EXACT),
List.of("dingtalk_nick", "dingtalk_name"),
List.of("dingtalk_email"),
List.of("dingtalk_avatar_url"),
EmailAssurance.PROVIDER_ASSERTED,
policy.provisioningMode(),
policy.profileSyncPolicy());
}
private void validateDingTalkEndpoints(
ClientRegistration registration) {
if (!DingTalkOAuth2Constants.hasTrustedRegistration(registration)) {
throw new IllegalArgumentException(
"Invalid DingTalk provider endpoints");
}
}
private String requireDingTalkAuthority() {
String authority = dingTalkProperties.getAuthority();
if (authority == null
|| !authority.matches(
"[a-z0-9][a-z0-9._:-]{0,127}")) {
throw new IllegalArgumentException(
"Invalid DingTalk authority");
}
return authority;
}
private void validatePublicGithubEndpoints(
ClientRegistration registration) {
var details = registration.getProviderDetails();
@ -360,6 +472,13 @@ class StaticTrustedProviderDescriptorSource
.contains("placeholder");
}
private boolean hasRealClientSecret(String clientSecret) {
return clientSecret != null
&& !clientSecret.isBlank()
&& !clientSecret.toLowerCase(Locale.ROOT)
.contains("placeholder");
}
private IdentityCoreException providerDisabled() {
return new IdentityCoreException(
IdentityFailureCode.PROVIDER_DISABLED);

View file

@ -0,0 +1,141 @@
package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence;
import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust;
import com.iflytek.skillhub.auth.identity.ProviderAttributeValue;
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
import com.iflytek.skillhub.auth.identity.SubjectCandidate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
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;
/** Maps a verified DingTalk user-info response into unified identity facts. */
@Component
public class DingTalkClaimsExtractor implements OAuthClaimsExtractor {
static final String NICK_ATTRIBUTE = "dingtalk_nick";
static final String NAME_ATTRIBUTE = "dingtalk_name";
static final String EMAIL_ATTRIBUTE = "dingtalk_email";
static final String AVATAR_ATTRIBUTE = "dingtalk_avatar_url";
private static final String UNION_SUBJECT_TYPE =
"dingtalk_union_id";
private static final String OPEN_SUBJECT_TYPE =
"dingtalk_open_id";
private static final String USER_SUBJECT_TYPE =
"dingtalk_user_id";
@Override
public String getProvider() {
return DingTalkOAuth2Constants.REGISTRATION_ID;
}
@Override
public ProviderAuthenticationResult extract(
OAuth2UserRequest request,
OAuth2User oauthUser) {
Map<String, Object> source = oauthUser.getAttributes();
String unionId = requireString(
source,
DingTalkOAuth2Constants.UNION_ID_ATTRIBUTE);
List<SubjectCandidate> aliases = new ArrayList<>();
addAlias(
aliases,
OPEN_SUBJECT_TYPE,
source.get(DingTalkOAuth2Constants.OPEN_ID_ATTRIBUTE));
addAlias(
aliases,
USER_SUBJECT_TYPE,
source.get(DingTalkOAuth2Constants.USER_ID_ATTRIBUTE));
Map<String, List<ProviderAttributeValue>> attributes =
new LinkedHashMap<>();
put(
attributes,
NICK_ATTRIBUTE,
source.get(DingTalkOAuth2Constants.NICK_ATTRIBUTE));
put(
attributes,
NAME_ATTRIBUTE,
source.get(DingTalkOAuth2Constants.NAME_ATTRIBUTE));
put(
attributes,
EMAIL_ATTRIBUTE,
source.get(DingTalkOAuth2Constants.EMAIL_ATTRIBUTE));
put(
attributes,
AVATAR_ATTRIBUTE,
source.get(DingTalkOAuth2Constants.AVATAR_ATTRIBUTE));
return new ProviderAuthenticationResult(
new SubjectCandidate(UNION_SUBJECT_TYPE, unionId),
aliases,
attributes,
new ProtocolAuthenticationEvidence(
"dingtalk-oauth2",
request.getAccessToken().getIssuedAt(),
Set.of("oauth2_authorization_code")));
}
static String requireUnionId(Map<String, Object> attributes) {
return requireString(
attributes,
DingTalkOAuth2Constants.UNION_ID_ATTRIBUTE);
}
private static String requireString(
Map<String, Object> attributes,
String key) {
String value = stringValue(attributes.get(key));
if (value == null) {
throw new OAuth2AuthenticationException(
new OAuth2Error(
"missing_stable_subject",
"DingTalk unionId is required",
null));
}
return value;
}
private static void addAlias(
List<SubjectCandidate> aliases,
String type,
Object rawValue) {
String value = stringValue(rawValue);
if (value != null) {
aliases.add(new SubjectCandidate(type, value));
}
}
private static void put(
Map<String, List<ProviderAttributeValue>> attributes,
String key,
Object rawValue) {
String value = stringValue(rawValue);
if (value == null) {
return;
}
attributes.put(
key,
List.of(new ProviderAttributeValue(
value,
ProviderAttributeTrust.ASSERTED)));
}
private static String stringValue(Object rawValue) {
if (!(rawValue instanceof String value)
|| value.isBlank()
|| !value.equals(value.strip())) {
return null;
}
return value;
}
}

View file

@ -0,0 +1,54 @@
package com.iflytek.skillhub.auth.oauth;
import java.util.List;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
/** Shared protocol constants for the native 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 AUTHORIZATION_URI =
"https://login.dingtalk.com/oauth2/auth";
public static final String TOKEN_URI =
"https://api.dingtalk.com/v1.0/oauth2/userAccessToken";
public static final String USER_INFO_URI =
"https://api.dingtalk.com/v1.0/contact/users/me";
public static final String ACCESS_TOKEN_HEADER =
"x-acs-dingtalk-access-token";
public static final String SUBJECT_ATTRIBUTE = "dingtalkSubject";
public static final String UNION_ID_ATTRIBUTE = "unionId";
public static final String OPEN_ID_ATTRIBUTE = "openId";
public static final String USER_ID_ATTRIBUTE = "userId";
public static final String NICK_ATTRIBUTE = "nick";
public static final String NAME_ATTRIBUTE = "name";
public static final String EMAIL_ATTRIBUTE = "email";
public static final String AVATAR_ATTRIBUTE = "avatarUrl";
static final List<String> SUBJECT_CLAIM_NAMES = List.of(
UNION_ID_ATTRIBUTE,
OPEN_ID_ATTRIBUTE,
USER_ID_ATTRIBUTE);
/** Returns whether a registration exactly matches the server-owned flow. */
public static boolean hasTrustedRegistration(
ClientRegistration registration) {
if (registration == null
|| !REGISTRATION_ID.equals(registration.getRegistrationId())
|| !AuthorizationGrantType.AUTHORIZATION_CODE.equals(
registration.getAuthorizationGrantType())) {
return false;
}
var details = registration.getProviderDetails();
var userInfo = details.getUserInfoEndpoint();
return AUTHORIZATION_URI.equals(details.getAuthorizationUri())
&& TOKEN_URI.equals(details.getTokenUri())
&& userInfo != null
&& USER_INFO_URI.equals(userInfo.getUri())
&& SUBJECT_ATTRIBUTE.equals(
userInfo.getUserNameAttributeName());
}
private DingTalkOAuth2Constants() {
}
}

View file

@ -0,0 +1,179 @@
package com.iflytek.skillhub.auth.oauth;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpMethod;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
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.RestClientException;
import org.springframework.web.client.RestTemplate;
/**
* Loads DingTalk user info using its non-standard access-token header.
*
* <p>This class only verifies protocol transport and returns upstream facts.
* It does not create an account, principal, role or session.</p>
*/
@Component
public final class DingTalkOAuth2UserService
implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private static final Duration MAX_TIMEOUT = Duration.ofMinutes(1);
private static final int MIN_RESPONSE_BYTES = 1024;
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
private static final TypeReference<Map<String, Object>> RESPONSE_TYPE =
new TypeReference<>() {
};
private final DingTalkProperties properties;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
@Autowired
public DingTalkOAuth2UserService(
DingTalkProperties properties,
ObjectMapper objectMapper) {
this(
properties,
objectMapper,
buildRestTemplate(properties));
}
DingTalkOAuth2UserService(
DingTalkProperties properties,
ObjectMapper objectMapper,
RestTemplate restTemplate) {
this.properties = properties;
this.objectMapper = objectMapper;
this.restTemplate = restTemplate;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest request) {
requireTrustedRegistration(request);
int maximumBytes = requireMaximumBytes();
String responseBody;
try {
responseBody = restTemplate.execute(
DingTalkOAuth2Constants.USER_INFO_URI,
HttpMethod.GET,
httpRequest -> httpRequest.getHeaders().set(
DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER,
request.getAccessToken().getTokenValue()),
response -> {
if (!response.getStatusCode().is2xxSuccessful()) {
throw failure("userinfo_response_rejected");
}
return readLimited(
response.getBody(),
maximumBytes);
});
} catch (OAuth2AuthenticationException exception) {
throw exception;
} catch (RestClientException exception) {
throw failure("userinfo_request_failed");
}
if (responseBody == null) {
throw failure("userinfo_response_empty");
}
Map<String, Object> attributes;
try {
attributes = objectMapper.readValue(
responseBody,
RESPONSE_TYPE);
} catch (IOException exception) {
throw failure("userinfo_response_invalid");
}
String unionId = DingTalkClaimsExtractor.requireUnionId(attributes);
Map<String, Object> copied = new HashMap<>(attributes);
copied.put(DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE, unionId);
return new DefaultOAuth2User(
Set.of(),
copied,
DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE);
}
private void requireTrustedRegistration(OAuth2UserRequest request) {
if (!properties.isEnabled()
|| request == null
|| !DingTalkOAuth2Constants.hasTrustedRegistration(
request.getClientRegistration())) {
throw failure("dingtalk_provider_misconfigured");
}
if (request.getAccessToken() == null
|| request.getAccessToken().getTokenValue() == null
|| request.getAccessToken().getTokenValue().isBlank()) {
throw failure("access_token_missing");
}
requireDuration(properties.getConnectTimeout());
requireDuration(properties.getReadTimeout());
}
private int requireMaximumBytes() {
int value = properties.getMaxResponseBytes();
if (value < MIN_RESPONSE_BYTES || value > MAX_RESPONSE_BYTES) {
throw failure("dingtalk_provider_misconfigured");
}
return value;
}
private void requireDuration(Duration value) {
if (value == null
|| value.isZero()
|| value.isNegative()
|| value.compareTo(MAX_TIMEOUT) > 0) {
throw failure("dingtalk_provider_misconfigured");
}
}
private String readLimited(
InputStream input,
int maximumBytes) throws IOException {
if (input == null) {
throw failure("userinfo_response_empty");
}
ByteArrayOutputStream output = new ByteArrayOutputStream(
Math.min(maximumBytes, 8192));
byte[] buffer = new byte[8192];
int total = 0;
int read;
while ((read = input.read(buffer)) >= 0) {
total += read;
if (total > maximumBytes) {
throw failure("userinfo_response_too_large");
}
output.write(buffer, 0, read);
}
return output.toString(StandardCharsets.UTF_8);
}
private static RestTemplate buildRestTemplate(
DingTalkProperties properties) {
SimpleClientHttpRequestFactory factory =
new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(properties.getConnectTimeout());
factory.setReadTimeout(properties.getReadTimeout());
return new RestTemplate(factory);
}
private OAuth2AuthenticationException failure(String errorCode) {
return new OAuth2AuthenticationException(
new OAuth2Error(errorCode));
}
}

View file

@ -0,0 +1,72 @@
package com.iflytek.skillhub.auth.oauth;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* Server-owned settings for the native DingTalk browser adapter.
*
* <p>The provider is deliberately disabled by default. The authority is a
* stable enterprise/application identity domain, not a URL supplied by an
* upstream response.</p>
*/
@Component
@ConfigurationProperties(prefix = "skillhub.auth.dingtalk")
public class DingTalkProperties {
private boolean enabled;
private String authority;
private String displayName = "DingTalk";
private Duration connectTimeout = Duration.ofSeconds(5);
private Duration readTimeout = Duration.ofSeconds(10);
private int maxResponseBytes = 1024 * 1024;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getAuthority() {
return authority;
}
public void setAuthority(String authority) {
this.authority = authority;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public Duration getConnectTimeout() {
return connectTimeout;
}
public void setConnectTimeout(Duration connectTimeout) {
this.connectTimeout = connectTimeout;
}
public Duration getReadTimeout() {
return readTimeout;
}
public void setReadTimeout(Duration readTimeout) {
this.readTimeout = readTimeout;
}
public int getMaxResponseBytes() {
return maxResponseBytes;
}
public void setMaxResponseBytes(int maxResponseBytes) {
this.maxResponseBytes = maxResponseBytes;
}
}

View file

@ -0,0 +1,216 @@
package com.iflytek.skillhub.auth.oauth;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
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.OAuth2AccessToken.TokenType;
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.RestTemplate;
/** Adapts DingTalk's JSON authorization-code token exchange. */
@Component
public final class DingTalkTokenResponseClient
implements OAuth2AccessTokenResponseClient<
OAuth2AuthorizationCodeGrantRequest> {
private static final Duration MAX_TIMEOUT = Duration.ofMinutes(1);
private static final int MIN_RESPONSE_BYTES = 1024;
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
private static final long MAX_TOKEN_LIFETIME_SECONDS = 86_400L;
private final DingTalkProperties properties;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
@Autowired
public DingTalkTokenResponseClient(
DingTalkProperties properties,
ObjectMapper objectMapper) {
this(
properties,
objectMapper,
buildRestTemplate(properties));
}
DingTalkTokenResponseClient(
DingTalkProperties properties,
ObjectMapper objectMapper,
RestTemplate restTemplate) {
this.properties = properties;
this.objectMapper = objectMapper;
this.restTemplate = restTemplate;
}
@Override
public OAuth2AccessTokenResponse getTokenResponse(
OAuth2AuthorizationCodeGrantRequest request) {
if (request == null
|| !properties.isEnabled()
|| !DingTalkOAuth2Constants.hasTrustedRegistration(
request.getClientRegistration())
|| !hasRealClientCredentials(request)) {
throw failure("dingtalk_provider_misconfigured");
}
requireDuration(properties.getConnectTimeout());
requireDuration(properties.getReadTimeout());
int maximumBytes = requireMaximumBytes();
String code = request.getAuthorizationExchange() == null
|| request.getAuthorizationExchange()
.getAuthorizationResponse() == null
? null
: request.getAuthorizationExchange()
.getAuthorizationResponse()
.getCode();
if (code == null || code.isBlank()) {
throw failure("authorization_code_missing");
}
Map<String, String> body = Map.of(
"clientId",
request.getClientRegistration().getClientId(),
"clientSecret",
request.getClientRegistration().getClientSecret(),
"code",
code,
"grantType",
"authorization_code");
String responseBody;
try {
responseBody = restTemplate.execute(
DingTalkOAuth2Constants.TOKEN_URI,
HttpMethod.POST,
httpRequest -> {
httpRequest.getHeaders().setContentType(
MediaType.APPLICATION_JSON);
byte[] encoded = objectMapper.writeValueAsBytes(body);
httpRequest.getBody().write(encoded);
},
response -> {
if (!response.getStatusCode().is2xxSuccessful()) {
throw failure("token_response_rejected");
}
return readLimited(response.getBody(), maximumBytes);
});
} catch (OAuth2AuthenticationException exception) {
throw exception;
} catch (RestClientException exception) {
throw failure("token_request_failed");
}
if (responseBody == null) {
throw failure("token_response_empty");
}
JsonNode response;
try {
response = objectMapper.readTree(responseBody);
} catch (JsonProcessingException exception) {
throw failure("token_response_invalid");
}
String accessToken = text(response, "accessToken");
JsonNode expires = response.get("expireIn");
if (accessToken == null
|| expires == null
|| !expires.isIntegralNumber()
|| !expires.canConvertToLong()) {
throw failure("token_response_invalid");
}
long lifetime = expires.longValue();
if (lifetime <= 0 || lifetime > MAX_TOKEN_LIFETIME_SECONDS) {
throw failure("token_response_invalid");
}
return OAuth2AccessTokenResponse.withToken(accessToken)
.tokenType(TokenType.BEARER)
.expiresIn(lifetime)
.additionalParameters(Map.of("expireIn", lifetime))
.build();
}
private String text(JsonNode object, String field) {
JsonNode value = object == null ? null : object.get(field);
if (value == null || !value.isTextual() || value.textValue().isBlank()) {
return null;
}
return value.textValue();
}
private String readLimited(InputStream input, int maximumBytes)
throws IOException {
if (input == null) {
throw failure("token_response_empty");
}
ByteArrayOutputStream output = new ByteArrayOutputStream(
Math.min(maximumBytes, 8192));
byte[] buffer = new byte[8192];
int total = 0;
int read;
while ((read = input.read(buffer)) >= 0) {
total += read;
if (total > maximumBytes) {
throw failure("token_response_too_large");
}
output.write(buffer, 0, read);
}
return output.toString(StandardCharsets.UTF_8);
}
private int requireMaximumBytes() {
int value = properties.getMaxResponseBytes();
if (value < MIN_RESPONSE_BYTES || value > MAX_RESPONSE_BYTES) {
throw failure("dingtalk_provider_misconfigured");
}
return value;
}
private boolean hasRealClientCredentials(
OAuth2AuthorizationCodeGrantRequest request) {
String clientId = request.getClientRegistration().getClientId();
String clientSecret = request.getClientRegistration().getClientSecret();
return isRealCredential(clientId) && isRealCredential(clientSecret);
}
private boolean isRealCredential(String value) {
return value != null
&& !value.isBlank()
&& !value.toLowerCase(java.util.Locale.ROOT)
.contains("placeholder");
}
private void requireDuration(Duration value) {
if (value == null
|| value.isZero()
|| value.isNegative()
|| value.compareTo(MAX_TIMEOUT) > 0) {
throw failure("dingtalk_provider_misconfigured");
}
}
private static RestTemplate buildRestTemplate(
DingTalkProperties properties) {
SimpleClientHttpRequestFactory factory =
new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(properties.getConnectTimeout());
factory.setReadTimeout(properties.getReadTimeout());
return new RestTemplate(factory);
}
private OAuth2AuthenticationException failure(String errorCode) {
return new OAuth2AuthenticationException(new OAuth2Error(errorCode));
}
}

View file

@ -64,7 +64,6 @@ public class OAuthLoginFlowService {
private final AccountMergeProviderProofService
accountMergeProviderProofService;
@Autowired
public OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
TrustedProviderRouteResolver providerRouteResolver,
ExternalIdentityLoginService identityLoginService,
@ -84,6 +83,29 @@ public class OAuthLoginFlowService {
new DefaultOAuth2UserService());
}
@Autowired
OAuthLoginFlowService(
List<OAuthClaimsExtractor> extractorList,
TrustedProviderRouteResolver providerRouteResolver,
ExternalIdentityLoginService identityLoginService,
ExternalIdentityLinkService identityLinkService,
IdentityLinkSessionManager identityLinkSessionManager,
AccountMergeSessionManager accountMergeSessionManager,
AccountMergeProviderProofService
accountMergeProviderProofService,
ProviderAwareOAuth2UserService providerAwareUserService) {
this(
extractorList,
providerRouteResolver,
identityLoginService,
identityLinkService,
identityLinkSessionManager,
accountMergeSessionManager,
accountMergeProviderProofService,
(OAuth2UserService<OAuth2UserRequest, OAuth2User>)
providerAwareUserService);
}
OAuthLoginFlowService(
List<OAuthClaimsExtractor> extractorList,
TrustedProviderRouteResolver providerRouteResolver,

View file

@ -0,0 +1,51 @@
package com.iflytek.skillhub.auth.oauth;
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.endpoint.OAuth2AccessTokenResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
/** Selects the trusted token exchange for each browser registration. */
@Component
public final class ProviderAwareAccessTokenResponseClient
implements OAuth2AccessTokenResponseClient<
OAuth2AuthorizationCodeGrantRequest> {
private final OAuth2AccessTokenResponseClient<
OAuth2AuthorizationCodeGrantRequest> standardDelegate;
private final OAuth2AccessTokenResponseClient<
OAuth2AuthorizationCodeGrantRequest> dingTalkDelegate;
@Autowired
public ProviderAwareAccessTokenResponseClient(
@Qualifier("dingTalkTokenResponseClient")
OAuth2AccessTokenResponseClient<
OAuth2AuthorizationCodeGrantRequest> dingTalkDelegate) {
this(
new DefaultAuthorizationCodeTokenResponseClient(),
dingTalkDelegate);
}
ProviderAwareAccessTokenResponseClient(
OAuth2AccessTokenResponseClient<
OAuth2AuthorizationCodeGrantRequest> standardDelegate,
OAuth2AccessTokenResponseClient<
OAuth2AuthorizationCodeGrantRequest> dingTalkDelegate) {
this.standardDelegate = standardDelegate;
this.dingTalkDelegate = dingTalkDelegate;
}
@Override
public OAuth2AccessTokenResponse getTokenResponse(
OAuth2AuthorizationCodeGrantRequest request) {
if (request != null
&& DingTalkOAuth2Constants.REGISTRATION_ID.equals(
request.getClientRegistration().getRegistrationId())) {
return dingTalkDelegate.getTokenResponse(request);
}
return standardDelegate.getTokenResponse(request);
}
}

View file

@ -0,0 +1,47 @@
package com.iflytek.skillhub.auth.oauth;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
/** Selects the trusted user-info transport for each browser registration. */
@Component
final class ProviderAwareOAuth2UserService
implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final OAuth2UserService<OAuth2UserRequest, OAuth2User>
standardDelegate;
private final OAuth2UserService<OAuth2UserRequest, OAuth2User>
dingTalkDelegate;
@Autowired
ProviderAwareOAuth2UserService(
@Qualifier("dingTalkOAuth2UserService")
OAuth2UserService<OAuth2UserRequest, OAuth2User>
dingTalkDelegate) {
this(new DefaultOAuth2UserService(), dingTalkDelegate);
}
ProviderAwareOAuth2UserService(
OAuth2UserService<OAuth2UserRequest, OAuth2User>
standardDelegate,
OAuth2UserService<OAuth2UserRequest, OAuth2User>
dingTalkDelegate) {
this.standardDelegate = standardDelegate;
this.dingTalkDelegate = dingTalkDelegate;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest request) {
if (request != null
&& DingTalkOAuth2Constants.REGISTRATION_ID.equals(
request.getClientRegistration().getRegistrationId())) {
return dingTalkDelegate.loadUser(request);
}
return standardDelegate.loadUser(request);
}
}

View file

@ -3,6 +3,8 @@ package com.iflytek.skillhub.auth.identity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.iflytek.skillhub.auth.oauth.DingTalkOAuth2Constants;
import com.iflytek.skillhub.auth.oauth.DingTalkProperties;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
@ -159,6 +161,108 @@ class StaticTrustedProviderDescriptorSourceTest {
.isEqualTo(IdentityFailureCode.PROVIDER_DISABLED);
}
@Test
void exposesDingTalkOnlyWhenEnabledAndUsesTypedUnionIdAliases() {
ClientRegistration dingtalk = dingtalk();
DingTalkProperties dingTalkProperties = new DingTalkProperties();
dingTalkProperties.setEnabled(true);
dingTalkProperties.setAuthority("dingtalk.corp");
OAuth2ClientProperties properties =
new OAuth2ClientProperties();
properties.getRegistration().put(
"dingtalk",
properties("client-id", "client-secret", "DingTalk"));
StaticTrustedProviderDescriptorSource source =
new StaticTrustedProviderDescriptorSource(
properties,
new InMemoryClientRegistrationRepository(dingtalk),
Set.of("dingtalk"),
new IdentityProviderPolicyProperties(),
dingTalkProperties);
ProviderDescriptor descriptor = descriptor(source, "dingtalk");
assertThat(descriptor.protocol()).isEqualTo("dingtalk-oauth2");
assertThat(descriptor.canonicalAuthority())
.isEqualTo("dingtalk.corp");
assertThat(descriptor.primarySubjectType())
.isEqualTo("dingtalk_union_id");
assertThat(descriptor.canonicalizerFor("dingtalk_union_id"))
.isEqualTo(SubjectCanonicalizer.EXACT);
assertThat(descriptor.canonicalizerFor("dingtalk_open_id"))
.isEqualTo(SubjectCanonicalizer.EXACT);
assertThat(descriptor.canonicalizerFor("dingtalk_user_id"))
.isEqualTo(SubjectCanonicalizer.EXACT);
assertThat(descriptor.emailAssuranceLimit())
.isEqualTo(EmailAssurance.PROVIDER_ASSERTED);
}
@Test
void hidesDingTalkWhenDisabledOrEndpointsAreNotOfficial() {
ClientRegistration dingtalk = dingtalk();
OAuth2ClientProperties properties = new OAuth2ClientProperties();
properties.getRegistration().put(
"dingtalk",
properties("client-id", "client-secret", "DingTalk"));
StaticTrustedProviderDescriptorSource disabled =
new StaticTrustedProviderDescriptorSource(
properties,
new InMemoryClientRegistrationRepository(dingtalk),
Set.of("dingtalk"),
new IdentityProviderPolicyProperties(),
new DingTalkProperties());
assertThat(disabled.configuredDescriptors()).isEmpty();
DingTalkProperties enabled = new DingTalkProperties();
enabled.setEnabled(true);
enabled.setAuthority("dingtalk.corp");
ClientRegistration altered = ClientRegistration.withRegistrationId(
"dingtalk")
.clientId("client-id")
.clientSecret("client-secret")
.clientName("DingTalk")
.authorizationGrantType(
AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.authorizationUri("https://attacker.example/authorize")
.tokenUri(DingTalkOAuth2Constants.TOKEN_URI)
.userInfoUri(DingTalkOAuth2Constants.USER_INFO_URI)
.userNameAttributeName(
DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE)
.build();
StaticTrustedProviderDescriptorSource alteredSource =
new StaticTrustedProviderDescriptorSource(
properties,
new InMemoryClientRegistrationRepository(altered),
Set.of("dingtalk"),
new IdentityProviderPolicyProperties(),
enabled);
assertThat(alteredSource.configuredDescriptors()).isEmpty();
}
@Test
void hidesDingTalkWhenClientSecretIsMissingOrPlaceholder() {
ClientRegistration dingtalk = dingtalk();
OAuth2ClientProperties properties = new OAuth2ClientProperties();
properties.getRegistration().put(
"dingtalk",
properties("client-id", "placeholder", "DingTalk"));
DingTalkProperties enabled = new DingTalkProperties();
enabled.setEnabled(true);
enabled.setAuthority("dingtalk.corp");
StaticTrustedProviderDescriptorSource source =
new StaticTrustedProviderDescriptorSource(
properties,
new InMemoryClientRegistrationRepository(dingtalk),
Set.of("dingtalk"),
new IdentityProviderPolicyProperties(),
enabled);
assertThat(source.configuredDescriptors()).isEmpty();
}
@Test
void rejectsRegistrationThatIsBothOidcAndBackedByOAuthExtractor() {
ClientRegistration ambiguous = oidc(
@ -202,9 +306,17 @@ class StaticTrustedProviderDescriptorSourceTest {
private static OAuth2ClientProperties.Registration properties(
String clientId,
String clientName) {
return properties(clientId, "client-secret", clientName);
}
private static OAuth2ClientProperties.Registration properties(
String clientId,
String clientSecret,
String clientName) {
OAuth2ClientProperties.Registration registration =
new OAuth2ClientProperties.Registration();
registration.setClientId(clientId);
registration.setClientSecret(clientSecret);
registration.setClientName(clientName);
return registration;
}
@ -226,6 +338,24 @@ class StaticTrustedProviderDescriptorSourceTest {
.build();
}
private static ClientRegistration dingtalk() {
return ClientRegistration.withRegistrationId("dingtalk")
.clientId("client-id")
.clientSecret("client-secret")
.clientName("DingTalk")
.authorizationGrantType(
AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.scope("openid")
.authorizationUri(
DingTalkOAuth2Constants.AUTHORIZATION_URI)
.tokenUri(DingTalkOAuth2Constants.TOKEN_URI)
.userInfoUri(DingTalkOAuth2Constants.USER_INFO_URI)
.userNameAttributeName(
DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE)
.build();
}
private static ClientRegistration gitlab(String authority) {
return ClientRegistration.withRegistrationId("gitlab")
.clientId("client-id")

View file

@ -0,0 +1,101 @@
package com.iflytek.skillhub.auth.oauth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust;
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
import com.iflytek.skillhub.auth.identity.SubjectCandidate;
import java.time.Instant;
import java.util.List;
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.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
class DingTalkClaimsExtractorTest {
@Test
void mapsStableUnionIdAndSameResponseAliasesToUnifiedIdentityFacts() {
DingTalkClaimsExtractor extractor = new DingTalkClaimsExtractor();
ProviderAuthenticationResult result = extractor.extract(
userRequest(),
new DefaultOAuth2User(
List.of(),
Map.of(
"unionId", "union-123",
"openId", "open-456",
"userId", "user-789",
"nick", "Alice",
"email", "alice@example.com",
"avatarUrl", "https://example.com/alice.png"),
"unionId"));
assertThat(result.primarySubject())
.isEqualTo(new SubjectCandidate(
"dingtalk_union_id", "union-123"));
assertThat(result.alternateSubjects()).containsExactly(
new SubjectCandidate("dingtalk_open_id", "open-456"),
new SubjectCandidate("dingtalk_user_id", "user-789"));
assertThat(result.attributes().get("dingtalk_email"))
.singleElement()
.satisfies(value -> {
assertThat(value.value()).isEqualTo("alice@example.com");
assertThat(value.trust())
.isEqualTo(ProviderAttributeTrust.ASSERTED);
});
assertThat(result.evidence().protocol())
.isEqualTo("dingtalk-oauth2");
}
@Test
void rejectsResponseWithoutStableUnionIdEvenWhenOtherIdsExist() {
DingTalkClaimsExtractor extractor = new DingTalkClaimsExtractor();
DefaultOAuth2User upstreamUser = new DefaultOAuth2User(
List.of(),
Map.of(
"openId", "open-456",
"userId", "user-789"),
"openId");
assertThatThrownBy(() ->
extractor.extract(userRequest(), upstreamUser))
.isInstanceOfSatisfying(
OAuth2AuthenticationException.class,
exception -> assertThat(
exception.getError().getErrorCode())
.isEqualTo("missing_stable_subject"));
}
private static OAuth2UserRequest userRequest() {
ClientRegistration registration =
ClientRegistration.withRegistrationId("dingtalk")
.clientId("client-id")
.clientSecret("client-secret")
.authorizationGrantType(
AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri(
"{baseUrl}/login/oauth2/code/{registrationId}")
.scope("openid")
.authorizationUri(
DingTalkOAuth2Constants.AUTHORIZATION_URI)
.tokenUri(DingTalkOAuth2Constants.TOKEN_URI)
.userInfoUri(
DingTalkOAuth2Constants.USER_INFO_URI)
.userNameAttributeName("dingtalkSubject")
.clientName("DingTalk")
.build();
Instant issuedAt = Instant.parse("2026-08-03T00:00:00Z");
OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
"token-123",
issuedAt,
issuedAt.plusSeconds(3600));
return new OAuth2UserRequest(registration, accessToken);
}
}

View file

@ -0,0 +1,197 @@
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.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
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 java.util.List;
import java.util.Map;
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.registration.ClientRegistration;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
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.user.OAuth2User;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;
class DingTalkOAuth2UserServiceTest {
@Test
void loadsOfficialUserInfoWithDingTalkAccessTokenHeader() {
DingTalkProperties properties = enabledProperties();
RestTemplate restTemplate = new RestTemplate();
MockRestServiceServer server =
MockRestServiceServer.bindTo(restTemplate).build();
server.expect(requestTo(DingTalkOAuth2Constants.USER_INFO_URI))
.andExpect(method(HttpMethod.GET))
.andExpect(header(
DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER,
"token-123"))
.andRespond(withSuccess(
"""
{
"unionId":"union-123",
"openId":"open-456",
"userId":"user-789",
"nick":"Alice",
"email":"alice@example.com"
}
""",
MediaType.APPLICATION_JSON));
OAuth2User user = new DingTalkOAuth2UserService(
properties,
new com.fasterxml.jackson.databind.ObjectMapper(),
restTemplate).loadUser(userRequest());
assertThat(user.getName()).isEqualTo("union-123");
assertThat(user.getAttributes())
.containsEntry("unionId", "union-123")
.containsEntry("openId", "open-456");
server.verify();
}
@Test
void disabledProviderFailsBeforeMakingUserInfoRequest() {
DingTalkProperties properties = new DingTalkProperties();
RestTemplate restTemplate = new RestTemplate();
MockRestServiceServer server =
MockRestServiceServer.bindTo(restTemplate).build();
assertThatThrownBy(() -> new DingTalkOAuth2UserService(
properties,
new com.fasterxml.jackson.databind.ObjectMapper(),
restTemplate).loadUser(userRequest()))
.isInstanceOfSatisfying(
OAuth2AuthenticationException.class,
exception -> assertThat(
exception.getError().getErrorCode())
.isEqualTo("dingtalk_provider_misconfigured"));
server.verify();
}
@Test
void missingAccessTokenFailsBeforeMakingUserInfoRequest() {
DingTalkProperties properties = enabledProperties();
RestTemplate restTemplate = new RestTemplate();
MockRestServiceServer server =
MockRestServiceServer.bindTo(restTemplate).build();
OAuth2UserRequest request = mock(OAuth2UserRequest.class);
when(request.getClientRegistration())
.thenReturn(userRequest().getClientRegistration());
when(request.getAccessToken()).thenReturn(null);
assertThatThrownBy(() -> new DingTalkOAuth2UserService(
properties,
new com.fasterxml.jackson.databind.ObjectMapper(),
restTemplate).loadUser(request))
.isInstanceOfSatisfying(
OAuth2AuthenticationException.class,
exception -> assertThat(
exception.getError().getErrorCode())
.isEqualTo("access_token_missing"));
server.verify();
}
@Test
void oversizedUserInfoResponseFailsWithoutParsingOrReturningUpstreamData() {
DingTalkProperties properties = enabledProperties();
properties.setMaxResponseBytes(1024);
RestTemplate restTemplate = new RestTemplate();
MockRestServiceServer server =
MockRestServiceServer.bindTo(restTemplate).build();
server.expect(requestTo(DingTalkOAuth2Constants.USER_INFO_URI))
.andRespond(withSuccess(
"{" + "\"unionId\":\"union-123\",\"padding\":\""
+ "x".repeat(1100) + "\"}",
MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> new DingTalkOAuth2UserService(
properties,
new com.fasterxml.jackson.databind.ObjectMapper(),
restTemplate).loadUser(userRequest()))
.isInstanceOfSatisfying(
OAuth2AuthenticationException.class,
exception -> assertThat(
exception.getError().getErrorCode())
.isEqualTo("userinfo_response_too_large"));
server.verify();
}
@Test
void rejectsRegistrationWithUntrustedAuthorizationEndpointBeforeNetwork() {
DingTalkProperties properties = enabledProperties();
RestTemplate restTemplate = new RestTemplate();
MockRestServiceServer server =
MockRestServiceServer.bindTo(restTemplate).build();
assertThatThrownBy(() -> new DingTalkOAuth2UserService(
properties,
new com.fasterxml.jackson.databind.ObjectMapper(),
restTemplate).loadUser(userRequest(
"https://attacker.example/authorize",
DingTalkOAuth2Constants.TOKEN_URI,
DingTalkOAuth2Constants.USER_INFO_URI)))
.isInstanceOfSatisfying(
OAuth2AuthenticationException.class,
exception -> assertThat(
exception.getError().getErrorCode())
.isEqualTo("dingtalk_provider_misconfigured"));
server.verify();
}
private static DingTalkProperties enabledProperties() {
DingTalkProperties properties = new DingTalkProperties();
properties.setEnabled(true);
properties.setAuthority("dingtalk.corp");
return properties;
}
private static OAuth2UserRequest userRequest() {
return userRequest(
DingTalkOAuth2Constants.AUTHORIZATION_URI,
DingTalkOAuth2Constants.TOKEN_URI,
DingTalkOAuth2Constants.USER_INFO_URI);
}
private static OAuth2UserRequest userRequest(
String authorizationUri,
String tokenUri,
String userInfoUri) {
ClientRegistration registration =
ClientRegistration.withRegistrationId("dingtalk")
.clientId("client-id")
.clientSecret("client-secret")
.authorizationGrantType(
AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri(
"{baseUrl}/login/oauth2/code/{registrationId}")
.scope("openid")
.authorizationUri(authorizationUri)
.tokenUri(tokenUri)
.userInfoUri(userInfoUri)
.userNameAttributeName("dingtalkSubject")
.clientName("DingTalk")
.build();
Instant issuedAt = Instant.parse("2026-08-03T00:00:00Z");
OAuth2AccessToken token = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
"token-123",
issuedAt,
issuedAt.plusSeconds(3600));
return new OAuth2UserRequest(registration, token);
}
}

View file

@ -0,0 +1,194 @@
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.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
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 {
@Test
void exchangesAuthorizationCodeAsDingTalkJsonAndValidatesExpiry() {
DingTalkProperties properties = enabledProperties();
RestTemplate restTemplate = new RestTemplate();
MockRestServiceServer server =
MockRestServiceServer.bindTo(restTemplate).build();
server.expect(requestTo(DingTalkOAuth2Constants.TOKEN_URI))
.andExpect(method(HttpMethod.POST))
.andExpect(header(
HttpHeaders.CONTENT_TYPE,
MediaType.APPLICATION_JSON_VALUE))
.andExpect(content().json(
"""
{
"clientId":"client-id",
"clientSecret":"client-secret",
"code":"code-123",
"grantType":"authorization_code"
}
"""))
.andRespond(withSuccess(
"{\"accessToken\":\"access-123\",\"expireIn\":3600}",
MediaType.APPLICATION_JSON));
OAuth2AccessTokenResponse response = new DingTalkTokenResponseClient(
properties,
new com.fasterxml.jackson.databind.ObjectMapper(),
restTemplate).getTokenResponse(request("code-123"));
assertThat(response.getAccessToken().getTokenValue())
.isEqualTo("access-123");
assertThat(response.getAccessToken().getExpiresAt())
.isAfter(Instant.now());
assertThat(response.getAdditionalParameters())
.containsEntry("expireIn", 3600L);
server.verify();
}
@Test
void rejectsNonPositiveOrUnreasonablyLongExpiry() {
DingTalkProperties properties = enabledProperties();
RestTemplate restTemplate = new RestTemplate();
MockRestServiceServer server =
MockRestServiceServer.bindTo(restTemplate).build();
server.expect(requestTo(DingTalkOAuth2Constants.TOKEN_URI))
.andRespond(withSuccess(
"{\"accessToken\":\"access-123\",\"expireIn\":0}",
MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> new DingTalkTokenResponseClient(
properties,
new com.fasterxml.jackson.databind.ObjectMapper(),
restTemplate).getTokenResponse(request("code-123")))
.isInstanceOfSatisfying(
OAuth2AuthenticationException.class,
exception -> assertThat(
exception.getError().getErrorCode())
.isEqualTo("token_response_invalid"));
server.verify();
}
@Test
void rejectsOversizedTokenResponseWithoutIncludingResponseBodyInError() {
DingTalkProperties properties = enabledProperties();
properties.setMaxResponseBytes(1024);
RestTemplate restTemplate = new RestTemplate();
MockRestServiceServer server =
MockRestServiceServer.bindTo(restTemplate).build();
server.expect(requestTo(DingTalkOAuth2Constants.TOKEN_URI))
.andRespond(withSuccess(
"{\"accessToken\":\"access-123\",\"padding\":\""
+ "x".repeat(1100) + "\"}",
MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> new DingTalkTokenResponseClient(
properties,
new com.fasterxml.jackson.databind.ObjectMapper(),
restTemplate).getTokenResponse(request("code-123")))
.isInstanceOfSatisfying(
OAuth2AuthenticationException.class,
exception -> {
assertThat(exception.getError().getErrorCode())
.isEqualTo("token_response_too_large");
assertThat(exception.toString())
.doesNotContain("access-123")
.doesNotContain("padding");
});
server.verify();
}
@Test
void rejectsIncompleteClientCredentialsBeforeMakingTokenRequest() {
DingTalkProperties properties = enabledProperties();
RestTemplate restTemplate = new RestTemplate();
MockRestServiceServer server =
MockRestServiceServer.bindTo(restTemplate).build();
assertThatThrownBy(() -> new DingTalkTokenResponseClient(
properties,
new com.fasterxml.jackson.databind.ObjectMapper(),
restTemplate).getTokenResponse(
request("code-123", "client-id", "")))
.isInstanceOfSatisfying(
OAuth2AuthenticationException.class,
exception -> assertThat(
exception.getError().getErrorCode())
.isEqualTo("dingtalk_provider_misconfigured"));
server.verify();
}
private static DingTalkProperties enabledProperties() {
DingTalkProperties properties = new DingTalkProperties();
properties.setEnabled(true);
properties.setAuthority("dingtalk.corp");
return properties;
}
private static OAuth2AuthorizationCodeGrantRequest request(String code) {
return request(code, "client-id", "client-secret");
}
private static OAuth2AuthorizationCodeGrantRequest request(
String code,
String clientId,
String clientSecret) {
ClientRegistration registration =
ClientRegistration.withRegistrationId("dingtalk")
.clientId(clientId)
.clientSecret(clientSecret)
.authorizationGrantType(
AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri(
"{baseUrl}/login/oauth2/code/{registrationId}")
.scope("openid")
.authorizationUri(
DingTalkOAuth2Constants.AUTHORIZATION_URI)
.tokenUri(DingTalkOAuth2Constants.TOKEN_URI)
.userInfoUri(
DingTalkOAuth2Constants.USER_INFO_URI)
.userNameAttributeName(
DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE)
.clientName("DingTalk")
.build();
OAuth2AuthorizationRequest authorizationRequest =
OAuth2AuthorizationRequest.authorizationCode()
.authorizationUri(
DingTalkOAuth2Constants.AUTHORIZATION_URI)
.clientId("client-id")
.redirectUri(
"https://skillhub.example/login/oauth2/code/dingtalk")
.scope(DingTalkOAuth2Constants.AUTHORIZATION_SCOPE)
.state("state-123")
.build();
OAuth2AuthorizationResponse authorizationResponse =
OAuth2AuthorizationResponse.success(code)
.redirectUri(
"https://skillhub.example/login/oauth2/code/dingtalk")
.state("state-123")
.build();
return new OAuth2AuthorizationCodeGrantRequest(
registration,
new OAuth2AuthorizationExchange(
authorizationRequest,
authorizationResponse));
}
}

View file

@ -14,6 +14,9 @@ class OAuthAdapterBoundaryTest {
OAuthClaimsExtractor.class,
GitHubClaimsExtractor.class,
GitLabClaimsExtractor.class,
DingTalkClaimsExtractor.class,
DingTalkOAuth2UserService.class,
DingTalkTokenResponseClient.class,
CustomOAuth2UserService.class,
CustomOidcUserService.class);

View file

@ -11,6 +11,10 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
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 com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService;
import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService;
@ -25,6 +29,8 @@ import com.iflytek.skillhub.auth.identity.IdentityLoginOutcome;
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence;
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust;
import com.iflytek.skillhub.auth.identity.ProviderAttributeValue;
import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle;
import com.iflytek.skillhub.auth.identity.ResolvedProviderHandleTestFixture;
import com.iflytek.skillhub.auth.identity.SubjectCandidate;
@ -48,16 +54,21 @@ import java.util.UUID;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
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.OAuth2Error;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.client.RestTemplate;
class OAuthLoginFlowServiceTest {
@ -161,6 +172,95 @@ class OAuthLoginFlowServiceTest {
verify(extractor, never()).authenticate(any());
}
@Test
void dingtalkCallbackUsesNativeUserInfoAndUnifiedIdentityCore() {
OAuthClaimsExtractor extractor = new DingTalkClaimsExtractor();
TrustedProviderRouteResolver resolver =
mock(TrustedProviderRouteResolver.class);
ExternalIdentityLoginService identityLoginService =
mock(ExternalIdentityLoginService.class);
ClientRegistration registration = dingtalkRegistration();
ResolvedProviderHandle provider =
ResolvedProviderHandleTestFixture.handle("dingtalk");
when(resolver.resolve(registration)).thenReturn(provider);
when(identityLoginService.authenticate(
eq(provider),
any(ProviderAuthenticationResult.class),
eq(context())))
.thenReturn(new IdentityLoginOutcome.Authenticated(
principal(),
false,
false));
DingTalkProperties properties = new DingTalkProperties();
properties.setEnabled(true);
properties.setAuthority("dingtalk.corp");
RestTemplate restTemplate = new RestTemplate();
MockRestServiceServer server =
MockRestServiceServer.bindTo(restTemplate).build();
server.expect(requestTo(DingTalkOAuth2Constants.USER_INFO_URI))
.andExpect(method(HttpMethod.GET))
.andExpect(header(
DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER,
"access-token"))
.andRespond(withSuccess(
"""
{
"unionId":"union-123",
"openId":"open-456",
"userId":"user-789",
"nick":"Alice",
"email":"alice@example.com"
}
""",
MediaType.APPLICATION_JSON));
OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
"access-token",
Instant.parse("2026-08-03T00:00:00Z"),
Instant.parse("2026-08-03T01:00:00Z"));
OAuth2UserRequest request = new OAuth2UserRequest(
registration,
accessToken);
OAuthLoginFlowService service = new OAuthLoginFlowService(
List.of(extractor),
resolver,
identityLoginService,
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class),
mock(AccountMergeSessionManager.class),
mock(AccountMergeProviderProofService.class),
new DingTalkOAuth2UserService(
properties,
new com.fasterxml.jackson.databind.ObjectMapper(),
restTemplate));
service.loadLoginContext(request, context());
var result = org.mockito.ArgumentCaptor.forClass(
ProviderAuthenticationResult.class);
verify(identityLoginService).authenticate(
eq(provider),
result.capture(),
eq(context()));
assertThat(result.getValue().primarySubject())
.isEqualTo(new SubjectCandidate(
"dingtalk_union_id",
"union-123"));
assertThat(result.getValue().alternateSubjects())
.containsExactly(
new SubjectCandidate("dingtalk_open_id", "open-456"),
new SubjectCandidate("dingtalk_user_id", "user-789"));
assertThat(result.getValue().attributes())
.containsEntry(
"dingtalk_email",
List.of(new ProviderAttributeValue(
"alice@example.com",
ProviderAttributeTrust.ASSERTED)));
server.verify();
}
@Test
void authenticateReturnsPrincipalOnlyForAuthenticatedOutcome() {
TrustedProviderRouteResolver resolver =
@ -652,4 +752,22 @@ class OAuthLoginFlowServiceTest {
.clientName("GitHub")
.build();
}
private static ClientRegistration dingtalkRegistration() {
return ClientRegistration.withRegistrationId("dingtalk")
.clientId("client-id")
.clientSecret("client-secret")
.authorizationGrantType(
AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri(
"{baseUrl}/login/oauth2/code/{registrationId}")
.scope(DingTalkOAuth2Constants.AUTHORIZATION_SCOPE)
.authorizationUri(DingTalkOAuth2Constants.AUTHORIZATION_URI)
.tokenUri(DingTalkOAuth2Constants.TOKEN_URI)
.userInfoUri(DingTalkOAuth2Constants.USER_INFO_URI)
.userNameAttributeName(
DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE)
.clientName("DingTalk")
.build();
}
}

View file

@ -0,0 +1,99 @@
package com.iflytek.skillhub.auth.oauth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
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.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.security.oauth2.core.user.OAuth2User;
class ProviderAwareOAuthDelegatesTest {
@Test
void routesOnlyDingTalkRegistrationToNativeUserInfoAdapter() {
OAuth2UserService<OAuth2UserRequest, OAuth2User> standard = mock();
OAuth2UserService<OAuth2UserRequest, OAuth2User> dingtalk = mock();
OAuth2UserRequest request = mock();
OAuth2User result = mock();
when(request.getClientRegistration())
.thenReturn(registration("dingtalk"));
when(dingtalk.loadUser(request)).thenReturn(result);
OAuth2User actual = new ProviderAwareOAuth2UserService(
standard,
dingtalk).loadUser(request);
assertThat(actual).isSameAs(result);
verify(dingtalk).loadUser(request);
verifyNoInteractions(standard);
}
@Test
void preservesStandardUserInfoAdapterForOtherRegistrations() {
OAuth2UserService<OAuth2UserRequest, OAuth2User> standard = mock();
OAuth2UserService<OAuth2UserRequest, OAuth2User> dingtalk = mock();
OAuth2UserRequest request = mock();
OAuth2User result = mock();
when(request.getClientRegistration())
.thenReturn(registration("github"));
when(standard.loadUser(request)).thenReturn(result);
OAuth2User actual = new ProviderAwareOAuth2UserService(
standard,
dingtalk).loadUser(request);
assertThat(actual).isSameAs(result);
verify(standard).loadUser(request);
verifyNoInteractions(dingtalk);
}
@Test
void routesOnlyDingTalkRegistrationToNativeTokenAdapter() {
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest>
standard = mock();
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest>
dingtalk = mock();
OAuth2AuthorizationCodeGrantRequest request = mock();
OAuth2AccessTokenResponse result =
OAuth2AccessTokenResponse.withToken("access-123")
.tokenType(TokenType.BEARER)
.build();
when(request.getClientRegistration())
.thenReturn(registration("dingtalk"));
when(dingtalk.getTokenResponse(request)).thenReturn(result);
OAuth2AccessTokenResponse actual =
new ProviderAwareAccessTokenResponseClient(
standard,
dingtalk).getTokenResponse(request);
assertThat(actual).isSameAs(result);
verify(dingtalk).getTokenResponse(request);
verifyNoInteractions(standard);
}
private static ClientRegistration registration(String registrationId) {
return ClientRegistration.withRegistrationId(registrationId)
.clientId("client-id")
.clientSecret("client-secret")
.clientName(registrationId)
.authorizationGrantType(
AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.authorizationUri("https://login.example/authorize")
.tokenUri("https://login.example/token")
.userInfoUri("https://login.example/userinfo")
.userNameAttributeName("id")
.build();
}
}