Merge remote-tracking branch 'upstream/main' into feat/recruit-job

This commit is contained in:
恋川 committed 2026-08-14 14:37:05 +08:00
commit 44e18a4062
32 files changed
+2158 -66

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
category: Added
---
- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow.
+1
View File
@@ -786,6 +786,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
## Reference & Docs ## Reference & Docs
- [International DingTalk (`.io`) guide](./docs/international-region-guide.md) — international login, domestic/international profile switching, isolated testing, and troubleshooting
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance - [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion - [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
- [Architecture](./docs/architecture.md) — static endpoint pipeline, command surface, transport layer - [Architecture](./docs/architecture.md) — static endpoint pipeline, command surface, transport layer
+1
View File
@@ -777,6 +777,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
## 参考与文档 ## 参考与文档
- [国际版(`.io`)使用手册](./docs/international-region-guide.zh-CN.md) — 国际版登录、国内/国际 profile 切换、隔离验证与排障
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景 - [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全 - [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
- [架构设计](./docs/architecture.md) — 静态端点管道、命令面、Transport 层 - [架构设计](./docs/architecture.md) — 静态端点管道、命令面、Transport 层
+154
View File
@@ -0,0 +1,154 @@
# International DingTalk (`.io`) Guide
This guide explains how to log in to the international DingTalk region and run DWS commands against `*.dingtalk.io` services.
## Region behavior
- `dws auth login --intl` creates or refreshes an international login using the `.io` login, OAuth, and MCP services.
- Omitting `--intl` keeps the existing domestic `.com` behavior.
- `--intl` is a login option, not a global option for business commands. After login, commands such as `contact`, `calendar`, and `doc` derive the region from the selected Token/profile.
- Each new Token records its login region. Switching profiles therefore switches the official DingTalk gateway region automatically.
- `--international` is a compatibility alias. Prefer `--intl` in new scripts.
For the complete Chinese guide, see [DWS 国际版(DingTalk `.io`)使用手册](./international-region-guide.zh-CN.md).
## Check availability
```bash
dws auth login --help
```
The help output must include `--intl` and `--international`.
When validating a source checkout, build it first and use `./dws` so an older binary on `PATH` is not invoked accidentally:
```bash
make build
./dws auth login --help
```
## Log in
Browser login:
```bash
dws auth login --intl
```
Device flow for SSH, containers, and headless environments:
```bash
dws auth login --intl --device
```
User OAuth with custom application credentials:
```bash
dws auth login --intl \
--client-id <APP_KEY> \
--client-secret <APP_SECRET>
```
This mode still requires the user to complete OAuth authorization in a browser; it is not a userless `client_credentials` login. The application must be configured on the international developer platform with the required callback and permissions. Never commit an AppSecret to source control or include it in logs.
## Verify the login
```bash
dws auth status --format json
dws profile list --format json
dws contact user get-self
```
The last command is a read-only smoke check. If the organization has not enabled CLI access, an organization administrator must enable it or approve the access request on the international developer platform.
## Use domestic and international profiles together
```bash
# Domestic (.com)
dws auth login
# International (.io)
dws auth login --intl
# Find the stable profile selectors
dws profile list --format json
```
Persistently switch profiles:
```bash
dws profile switch <corpId>:<userId>
```
Toggle back to the previous profile:
```bash
dws profile switch -
```
Select a profile for one command without changing the default:
```bash
dws --profile <corpId>:<userId> contact user get-self
```
Do not add `--intl` to business commands. DWS routes official endpoints from the selected profile's Token region.
## Isolated smoke testing
Use a separate configuration directory to avoid changing the normal `~/.dws` login state:
```bash
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self
```
Use the same `DWS_CONFIG_DIR` for every command. Use `./dws` for a source build and `dws` for an installed release.
## Pre-release overrides (maintainers only)
Normal international users need only `--intl`; they should not set `--pre-url` or `--mcp-url`.
Maintainers can test the pre-release login/MCP pair with:
```bash
dws auth login --intl --pre-url https://pre-login.dingtalk.io
```
A corresponding `pre-mcp.*` URL is also accepted, and DWS derives the paired `pre-login.*` / `pre-mcp.*` bases. `--mcp-url` explicitly overrides the MCP base URL for that login.
Pre-release services may require internal network access or allowlisted accounts. `--pre-url` is intended primarily for the MCP-managed credential flow. Do not combine it with direct custom `--client-id/--client-secret` mode unless the pre-release API contract explicitly supports that combination.
## Troubleshooting
### The browser still opens a `.com` page
1. Run `dws auth login --help` and confirm `--intl` is present.
2. For a source checkout, use `./dws` instead of an older installed binary.
3. Confirm the executed command is `dws auth login --intl`.
### A business command appears to use the wrong region
Run `dws profile list --format json`, then switch with the exact `<corpId>:<userId>` selector or use the global `--profile` option. For a legacy Token created before region metadata existed, reauthorize it with `dws auth login --intl` for an international account or `dws auth login` for a domestic account.
### Login succeeds but the command reports missing permission
This normally means the organization has not enabled CLI access or the application lacks a required permission. It does not by itself indicate a region-routing failure.
### Should I edit `~/.dws/mcp_url` manually?
No. Normal users should establish the login with `dws auth login` or `dws auth login --intl`. DWS then routes official endpoints from the selected Token/profile. Manual configuration is reserved for maintainers who explicitly control the target environment.
## Command reference
| Scenario | Command |
|---|---|
| Domestic browser login | `dws auth login` |
| International browser login | `dws auth login --intl` |
| International device login | `dws auth login --intl --device` |
| Check auth state | `dws auth status --format json` |
| List profiles | `dws profile list --format json` |
| Persistently switch profile | `dws profile switch <corpId>:<userId>` |
| Toggle to previous profile | `dws profile switch -` |
| Select a profile once | `dws --profile <corpId>:<userId> <command>` |
+185
View File
@@ -0,0 +1,185 @@
# DWS 国际版(DingTalk `.io`)使用手册
本手册适用于使用钉钉国际版账号登录并调用国际站服务的用户。
## 核心规则
- `dws auth login --intl` 创建或刷新国际版登录,使用 `*.dingtalk.io` 登录、鉴权和 MCP 服务。
- 不传 `--intl` 时仍使用国内钉钉 `*.dingtalk.com`,原有链路保持不变。
- `--intl` 只用于登录命令。登录完成后,`contact`、`calendar`、`doc` 等业务命令不需要再传该参数。
- 每个 Token 会记录登录区域。执行业务命令时,DWS 根据当前或 `--profile` 指定的账号自动选择 `.com` 或 `.io` 网关。
- `--international` 是 `--intl` 的兼容别名;新脚本推荐使用较短的 `--intl`。
## 确认当前版本支持国际版
运行:
```bash
dws auth login --help
```
帮助中应包含:
```text
--intl
--international
```
从源码分支验证时,先在仓库根目录构建,并始终使用本次构建的 `./dws`,避免误用系统中已安装的旧版本:
```bash
make build
./dws auth login --help
```
## 国际版登录
### 浏览器登录
```bash
dws auth login --intl
```
DWS 会打开国际版登录页面。完成扫码或账号授权后,登录结果会保存为本机 profile。
### 设备码登录
适用于 SSH、容器或没有可用浏览器的环境:
```bash
dws auth login --intl --device
```
按照终端提示,在另一台可打开浏览器的设备上完成授权。
### 使用自有应用凭证完成用户 OAuth
```bash
dws auth login --intl \
--client-id <APP_KEY> \
--client-secret <APP_SECRET>
```
该模式仍然需要用户在浏览器中完成 OAuth 授权,不是无用户授权的 `client_credentials` 登录。应用必须在国际版开放平台正确配置回调地址和所需权限。不要在命令历史、日志或 PR 中提交真实的 AppSecret。
## 验证登录和业务调用
查看当前登录状态:
```bash
dws auth status --format json
```
列出本机全部账号并找到当前 profile:
```bash
dws profile list --format json
```
执行一个只读命令验证国际链路,例如:
```bash
dws contact user get-self
```
登录状态正常但业务命令提示组织未开通 CLI 时,需要由国际版组织管理员在国际版开发者平台开启 CLI 访问或完成授权审批。
## 国内版和国际版账号并存
可以在同一台机器上分别登录国内版和国际版账号:
```bash
# 国内版(.com)
dws auth login
# 国际版(.io)
dws auth login --intl
# 查看稳定的 profile 选择器
dws profile list --format json
```
持久切换账号:
```bash
dws profile switch <corpId>:<userId>
```
切回上一个账号:
```bash
dws profile switch -
```
只为单次命令指定账号,不修改默认账号:
```bash
dws --profile <corpId>:<userId> contact user get-self
```
DWS 会按照选中 profile 的 Token 区域自动选择 `.com` 或 `.io`,不需要在业务命令上追加 `--intl`。
## 使用独立配置目录进行验证
如果不希望测试登录影响日常使用的 `~/.dws`,可以指定独立配置目录:
```bash
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self
```
请在三条命令中使用同一个 `DWS_CONFIG_DIR`。验证源码分支时使用 `./dws`;验证已安装版本时可改为 `dws`。
## 预发参数(仅维护者)
普通国际版用户只需要 `--intl`,不要配置 `--pre-url` 或 `--mcp-url`。
维护者验证预发登录/MCP 链路时可以使用:
```bash
dws auth login --intl --pre-url https://pre-login.dingtalk.io
```
也可以传入对应的 `pre-mcp.*` 地址;DWS 会推导配套的 `pre-login.*` / `pre-mcp.*` 地址。`--mcp-url` 用于显式覆盖本次登录的 MCP base URL。
预发环境可能只对内网或特定测试账号开放。`--pre-url` 主要服务于 MCP 托管凭证登录流程;除非预发 API 契约已经明确支持,否则不要把它与自有 `--client-id/--client-secret` 直连模式组合使用。
## 常见问题
### 仍然打开 `.com` 登录页面
1. 运行 `dws auth login --help`,确认当前二进制包含 `--intl`。
2. 从源码验证时使用 `./dws`,不要误用 PATH 中的旧版本。
3. 确认实际执行的是 `dws auth login --intl`,而不是普通 `dws auth login`。
### 业务命令似乎使用了错误区域
先检查当前账号:
```bash
dws profile list --format json
```
然后使用精确的 `<corpId>:<userId>` 切换或通过全局 `--profile` 单次指定。对于在区域字段引入前生成的历史 Token,建议使用正确的登录方式重新授权:国际账号执行 `dws auth login --intl`,国内账号执行 `dws auth login`。
### 登录成功但提示没有权限
这通常是组织 CLI 准入或应用授权问题,不代表区域路由失败。请确认目标组织已开启 CLI 访问,并且当前应用拥有命令所需权限。
### 是否需要手工修改 `~/.dws/mcp_url`
不需要。正常使用应通过 `dws auth login` 或 `dws auth login --intl` 建立登录态;业务命令会根据选中的 Token/profile 自动路由。手工修改配置只适用于明确了解目标环境的维护者调试场景。
## 命令速查
| 场景 | 命令 |
|---|---|
| 国内版浏览器登录 | `dws auth login` |
| 国际版浏览器登录 | `dws auth login --intl` |
| 国际版设备码登录 | `dws auth login --intl --device` |
| 查看登录状态 | `dws auth status --format json` |
| 查看所有账号 | `dws profile list --format json` |
| 持久切换账号 | `dws profile switch <corpId>:<userId>` |
| 切回上一个账号 | `dws profile switch -` |
| 单次指定账号 | `dws --profile <corpId>:<userId> <command>` |
+6 -2
View File
@@ -65,12 +65,16 @@ func TestCrossPlatformCoverageTokenManagerCachesUntilMarkerRevisionChanges(t *te
token := "token-a" token := "token-a"
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1) calls.Add(1)
return &authpkg.TokenData{AccessToken: token, ExpiresAt: time.Now().Add(time.Hour)}, nil return &authpkg.TokenData{
AccessToken: token,
ExpiresAt: time.Now().Add(time.Hour),
LoginRegion: string(authpkg.LoginRegionInternational),
}, nil
}) })
manager := NewTokenManager() manager := NewTokenManager()
first, err := manager.Get(context.Background(), configDir, "") first, err := manager.Get(context.Background(), configDir, "")
if err != nil || first.AccessToken != "token-a" { if err != nil || first.AccessToken != "token-a" || first.LoginRegion != authpkg.LoginRegionInternational || !first.LoginRegionKnown {
t.Fatalf("first token = %#v, %v", first, err) t.Fatalf("first token = %#v, %v", first, err)
} }
second, err := manager.Get(context.Background(), configDir, "") second, err := manager.Get(context.Background(), configDir, "")
+10 -6
View File
@@ -41,9 +41,11 @@ type accessTokenSnapshotGetter interface {
// AccessTokenSnapshot is the minimal bearer view needed by the process cache. // AccessTokenSnapshot is the minimal bearer view needed by the process cache.
// Refresh-token material never leaves the auth package. // Refresh-token material never leaves the auth package.
type AccessTokenSnapshot struct { type AccessTokenSnapshot struct {
AccessToken string AccessToken string
ExpiresAt time.Time ExpiresAt time.Time
Source string Source string
LoginRegion authpkg.LoginRegion
LoginRegionKnown bool
} }
type tokenManagerKey struct { type tokenManagerKey struct {
@@ -223,9 +225,11 @@ func resolveAccessTokenSnapshotFromDir(ctx context.Context, configDir, profile s
data, err := snapshotProvider.GetTokenSnapshot(ctx) data, err := snapshotProvider.GetTokenSnapshot(ctx)
if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" { if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" {
return AccessTokenSnapshot{ return AccessTokenSnapshot{
AccessToken: strings.TrimSpace(data.AccessToken), AccessToken: strings.TrimSpace(data.AccessToken),
ExpiresAt: data.ExpiresAt, ExpiresAt: data.ExpiresAt,
Source: "oauth", Source: "oauth",
LoginRegion: authpkg.LoginRegion(strings.TrimSpace(data.LoginRegion)),
LoginRegionKnown: true,
}, nil }, nil
} }
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) { if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
+2 -2
View File
@@ -712,9 +712,9 @@ func TestCrossPlatformCoverageAgentMetadataMCPAndPluginScoping(t *testing.T) {
// calls must fail before preflight or transport while anonymous plugins remain // calls must fail before preflight or transport while anonymous plugins remain
// valid above. // valid above.
resolveCalled := false resolveCalled := false
testseam.Swap(t, &runnerResolveAuthToken, func(*runtimeRunner, context.Context) (string, error) { testseam.Swap(t, &runnerResolveAuthSnapshot, func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) {
resolveCalled = true resolveCalled = true
return "", nil return AccessTokenSnapshot{}, nil
}) })
callsBefore := len(captured) callsBefore := len(captured)
unauthenticated := &runtimeRunner{ unauthenticated := &runtimeRunner{
+241 -1
View File
@@ -20,6 +20,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -48,8 +50,24 @@ type authLoginConfig struct {
TargetCorpID string TargetCorpID string
HistoryProfileSelector string HistoryProfileSelector string
HistoryProfileSelectorExplicit bool HistoryProfileSelectorExplicit bool
International bool
PreURL string
MCPURL string
} }
type authLoginEndpointOverrides struct {
LoginURL string
MCPURL string
}
type authLoginMCPPersistence uint8
const (
authLoginMCPUseDefault authLoginMCPPersistence = iota
authLoginMCPUseManagedRegion
authLoginMCPUseExplicitOverride
)
type authLoginGuideAction string type authLoginGuideAction string
const ( const (
@@ -103,12 +121,17 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
支持的登录方式: 支持的登录方式:
- OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成 - OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成
- OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境 - OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境
- 自有应用 OAuth (--client-id/--client-secret): 使用指定应用完成用户授权
- 直接提供 Token (--token): 跳过授权,使用已有 token - 直接提供 Token (--token): 跳过授权,使用已有 token
不支持的登录方式: 不支持的登录方式:
- 邮箱/密码登录 - 邮箱/密码登录
- 手机号/验证码登录 - 手机号/验证码登录
- 应用凭证 (AppKey/AppSecret) 直接登录 - 无用户授权的纯应用凭证 (client_credentials) 登录
区域:
- 默认使用国内钉钉 .com 登录与服务端点
- --intl(或 --international)使用国际版 .io 登录;后续业务命令按所选 profile 自动路由
注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device, 注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device,
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。 否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
@@ -116,6 +139,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
示例: 示例:
dws auth login # 本机登录并新增/刷新一个组织 profile dws auth login # 本机登录并新增/刷新一个组织 profile
dws auth login --profile <corpId> # 指定本次授权目标组织,不持久切换当前组织 dws auth login --profile <corpId> # 指定本次授权目标组织,不持久切换当前组织
dws auth login --intl # 使用钉钉国际版 .io 登录入口
dws auth login --intl --pre-url https://pre-login.dingtalk.io
dws auth login --intl --pre-url https://pre-mcp.dingtalk.io
dws auth login --recommend # 无交互批量授权服务端推荐权限 dws auth login --recommend # 无交互批量授权服务端推荐权限
dws auth login --device # SSH 远程 / 无头环境登录 (设备流) dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程 dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程
@@ -126,6 +152,22 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
var preOverrides authLoginEndpointOverrides
if cfg.PreURL != "" {
var err error
preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL)
if err != nil {
return err
}
restoreLoginBaseURL := authpkg.PushLoginBaseURLOverride(preOverrides.LoginURL)
defer restoreLoginBaseURL()
}
mcpBaseURL, mcpPersistence, err := authLoginMCPBaseURLForConfig(cfg, preOverrides)
if err != nil {
return err
}
restoreMCPBaseURL := authpkg.PushMCPBaseURLOverride(mcpBaseURL)
defer restoreMCPBaseURL()
configDir := defaultConfigDir() configDir := defaultConfigDir()
var tokenData *authpkg.TokenData var tokenData *authpkg.TokenData
format, _ := cmd.Root().PersistentFlags().GetString("format") format, _ := cmd.Root().PersistentFlags().GetString("format")
@@ -139,6 +181,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
AccessToken: cfg.Token, AccessToken: cfg.Token,
ExpiresAt: time.Now().Add(config.ManualTokenExpiry), ExpiresAt: time.Now().Add(config.ManualTokenExpiry),
} }
if cfg.International {
tokenData.LoginRegion = string(authpkg.LoginRegionInternational)
}
if err := authSaveTokenData(configDir, tokenData); err != nil { if err := authSaveTokenData(configDir, tokenData); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err)) return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err))
} }
@@ -149,6 +194,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
provider := authpkg.NewDeviceFlowProvider(configDir, nil) provider := authpkg.NewDeviceFlowProvider(configDir, nil)
provider.Output = cmd.ErrOrStderr() provider.Output = cmd.ErrOrStderr()
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser") provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
if cfg.International {
provider.SetLoginRegion(authpkg.LoginRegionInternational)
}
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error { provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{ return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
Selector: cfg.HistoryProfileSelector, Selector: cfg.HistoryProfileSelector,
@@ -167,6 +215,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
provider.Output = cmd.ErrOrStderr() provider.Output = cmd.ErrOrStderr()
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser") provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
provider.TargetCorpID = cfg.TargetCorpID provider.TargetCorpID = cfg.TargetCorpID
if cfg.International {
provider.LoginRegion = authpkg.LoginRegionInternational
}
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error { provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{ return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
Selector: cfg.HistoryProfileSelector, Selector: cfg.HistoryProfileSelector,
@@ -180,6 +231,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
} }
} }
if tokenData != nil {
if err := persistAuthLoginMCPBaseURL(configDir, mcpBaseURL, mcpPersistence); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to persist MCP URL: %v", err))
}
}
ResetRuntimeTokenCache() ResetRuntimeTokenCache()
clearCompatCache() clearCompatCache()
w := cmd.OutOrStdout() w := cmd.OutOrStdout()
@@ -278,6 +334,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
} }
cmd.Flags().String("token", "", "Access token") cmd.Flags().String("token", "", "Access token")
cmd.Flags().Bool("device", false, "Use device authorization flow") cmd.Flags().Bool("device", false, "Use device authorization flow")
cmd.Flags().Bool("intl", false, "Use DingTalk international (.io) login and service endpoints")
cmd.Flags().Bool("international", false, "Use DingTalk international (.io) login and service endpoints")
cmd.Flags().String("pre-url", "", "Override pre-release login/MCP base URL for this login")
cmd.Flags().String("mcp-url", "", "Override MCP base URL for this login")
cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程") cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程")
cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限") cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限")
// Hidden compatibility flags // Hidden compatibility flags
@@ -967,6 +1027,7 @@ func newAuthResetCommand() *cobra.Command {
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err)) return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
} }
_ = authRemove(filepath.Join(configDir, "mcp_url")) _ = authRemove(filepath.Join(configDir, "mcp_url"))
_ = authRemove(filepath.Join(configDir, config.ManagedMCPURLRegionFileName))
_ = authRemove(filepath.Join(configDir, "token")) _ = authRemove(filepath.Join(configDir, "token"))
_ = authDeleteAppConfig(configDir) _ = authDeleteAppConfig(configDir)
ResetRuntimeTokenCache() ResetRuntimeTokenCache()
@@ -1225,6 +1286,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
if err != nil { if err != nil {
return authLoginConfig{}, apperrors.NewInternal("failed to read --device") return authLoginConfig{}, apperrors.NewInternal("failed to read --device")
} }
intl, err := cmd.Flags().GetBool("intl")
if err != nil {
return authLoginConfig{}, apperrors.NewInternal("failed to read --intl")
}
international, err := cmd.Flags().GetBool("international")
if err != nil {
return authLoginConfig{}, apperrors.NewInternal("failed to read --international")
}
force, err := cmd.Flags().GetBool("force") force, err := cmd.Flags().GetBool("force")
if err != nil { if err != nil {
return authLoginConfig{}, apperrors.NewInternal("failed to read --force") return authLoginConfig{}, apperrors.NewInternal("failed to read --force")
@@ -1233,6 +1302,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
if err != nil { if err != nil {
return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend") return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend")
} }
preURL, err := cmd.Flags().GetString("pre-url")
if err != nil {
return authLoginConfig{}, apperrors.NewInternal("failed to read --pre-url")
}
mcpURL, err := cmd.Flags().GetString("mcp-url")
if err != nil {
return authLoginConfig{}, apperrors.NewInternal("failed to read --mcp-url")
}
yes := false yes := false
profileSelector := "" profileSelector := ""
if cmd.Root() != nil { if cmd.Root() != nil {
@@ -1266,9 +1343,172 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
TargetCorpID: targetCorpID, TargetCorpID: targetCorpID,
HistoryProfileSelector: historyProfileSelector, HistoryProfileSelector: historyProfileSelector,
HistoryProfileSelectorExplicit: historyProfileSelectorExplicit, HistoryProfileSelectorExplicit: historyProfileSelectorExplicit,
International: intl || international,
PreURL: strings.TrimSpace(preURL),
MCPURL: strings.TrimSpace(mcpURL),
}, nil }, nil
} }
func authLoginEndpointOverridesForPreURL(raw string) (authLoginEndpointOverrides, error) {
parsed, normalized, err := normalizeAuthLoginBaseURL(raw, "--pre-url")
if err != nil {
return authLoginEndpointOverrides{}, err
}
host := strings.ToLower(parsed.Hostname())
switch {
case strings.HasPrefix(host, "pre-login."):
return authLoginEndpointOverrides{
LoginURL: normalized,
MCPURL: authLoginURLWithHost(parsed, "pre-mcp."+strings.TrimPrefix(host, "pre-login.")),
}, nil
case strings.HasPrefix(host, "pre-mcp."):
return authLoginEndpointOverrides{
LoginURL: authLoginURLWithHost(parsed, "pre-login."+strings.TrimPrefix(host, "pre-mcp.")),
MCPURL: normalized,
}, nil
default:
return authLoginEndpointOverrides{}, apperrors.NewValidation("--pre-url must be a pre-login.* or pre-mcp.* URL")
}
}
func authLoginMCPBaseURLForConfig(cfg authLoginConfig, preOverrides authLoginEndpointOverrides) (string, authLoginMCPPersistence, error) {
if cfg.MCPURL != "" {
_, normalized, err := normalizeAuthLoginBaseURL(cfg.MCPURL, "--mcp-url")
if err != nil {
return "", authLoginMCPUseDefault, err
}
return normalized, authLoginMCPUseExplicitOverride, nil
}
if cfg.PreURL != "" {
if preOverrides.MCPURL == "" {
var err error
preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL)
if err != nil {
return "", authLoginMCPUseDefault, err
}
}
return preOverrides.MCPURL, authLoginMCPUseExplicitOverride, nil
}
if cfg.International {
return authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion, nil
}
return authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault, nil
}
func persistAuthLoginMCPBaseURL(configDir, mcpBaseURL string, persistence authLoginMCPPersistence) error {
mcpURLPath := filepath.Join(configDir, "mcp_url")
managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName)
switch persistence {
case authLoginMCPUseExplicitOverride:
if err := removeAuthLoginManagedMCPRegion(managedRegionPath); err != nil {
return fmt.Errorf("clear managed MCP region: %w", err)
}
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
return fmt.Errorf("save explicit MCP URL: %w", err)
}
return nil
case authLoginMCPUseManagedRegion:
managedURL, managedErr := authReadFile(managedRegionPath)
if managedErr != nil && !os.IsNotExist(managedErr) {
return fmt.Errorf("read managed MCP region: %w", managedErr)
}
currentURL, currentErr := authReadFile(mcpURLPath)
switch {
case currentErr == nil && os.IsNotExist(managedErr):
return nil
case currentErr == nil && strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)):
return removeAuthLoginManagedMCPRegion(managedRegionPath)
case currentErr != nil && !os.IsNotExist(currentErr):
return fmt.Errorf("read MCP URL: %w", currentErr)
}
if err := authAtomicWrite(managedRegionPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
return fmt.Errorf("save managed MCP region: %w", err)
}
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
_ = authRemove(managedRegionPath)
return fmt.Errorf("save managed MCP URL: %w", err)
}
return nil
case authLoginMCPUseDefault:
managedURL, err := authReadFile(managedRegionPath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read managed MCP region: %w", err)
}
currentURL, err := authReadFile(mcpURLPath)
if os.IsNotExist(err) {
return removeAuthLoginManagedMCPRegion(managedRegionPath)
}
if err != nil {
return fmt.Errorf("read MCP URL: %w", err)
}
if strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)) {
return removeAuthLoginManagedMCPRegion(managedRegionPath)
}
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
return fmt.Errorf("restore default MCP URL: %w", err)
}
return removeAuthLoginManagedMCPRegion(managedRegionPath)
default:
return fmt.Errorf("unsupported MCP persistence mode %d", persistence)
}
}
func removeAuthLoginManagedMCPRegion(path string) error {
if err := authRemove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func normalizeAuthLoginBaseURL(raw, flagName string) (*url.URL, string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return nil, "", apperrors.NewValidation(flagName + " cannot be empty")
}
if !strings.Contains(value, "://") {
value = "https://" + value
}
parsed, err := url.Parse(value)
if err != nil {
return nil, "", apperrors.NewValidation(fmt.Sprintf("invalid %s: %v", flagName, err))
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, "", apperrors.NewValidation(flagName + " must use http or https")
}
if parsed.Hostname() == "" {
return nil, "", apperrors.NewValidation(flagName + " must include a host")
}
if parsed.Scheme == "http" && !isAuthLoginLoopbackHost(parsed.Hostname()) {
return nil, "", apperrors.NewValidation(flagName + " must use HTTPS, except for a loopback HTTP test endpoint")
}
parsed.RawQuery = ""
parsed.Fragment = ""
parsed.Path = strings.TrimRight(parsed.Path, "/")
return parsed, strings.TrimRight(parsed.String(), "/"), nil
}
func isAuthLoginLoopbackHost(host string) bool {
if strings.EqualFold(strings.TrimSpace(host), "localhost") {
return true
}
ip := net.ParseIP(strings.TrimSpace(host))
return ip != nil && ip.IsLoopback()
}
func authLoginURLWithHost(parsed *url.URL, host string) string {
copyURL := *parsed
if port := parsed.Port(); port != "" {
copyURL.Host = net.JoinHostPort(host, port)
} else {
copyURL.Host = host
}
return strings.TrimRight(copyURL.String(), "/")
}
func authLoginForcesAuthorization(_ authLoginConfig) bool { func authLoginForcesAuthorization(_ authLoginConfig) bool {
return true return true
} }
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -214,7 +215,8 @@ func TestCrossPlatformCoverageAuthCoverageFormsParentAndTargets(t *testing.T) {
} }
func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) { func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir()) configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
oldSave := authSaveTokenData oldSave := authSaveTokenData
oldDevice := authDeviceLogin oldDevice := authDeviceLogin
oldOAuth := authOAuthLogin oldOAuth := authOAuthLogin
@@ -255,6 +257,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
if out, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "token"}); err != nil || !strings.Contains(out, `"token_valid": true`) { if out, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "token"}); err != nil || !strings.Contains(out, `"token_valid": true`) {
t.Fatalf("json token login = %q, %v", out, err) t.Fatalf("json token login = %q, %v", out, err)
} }
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://example.com"}); err == nil {
t.Fatal("invalid pre-release host should fail")
}
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "mcp-url": "http://remote.example.com"}); err == nil {
t.Fatal("remote plaintext MCP URL should fail")
}
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://pre-login.dingtalk.io"}); err != nil {
t.Fatalf("pre-release token login = %v", err)
}
authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) { authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) {
return nil, errors.New("device") return nil, errors.New("device")
@@ -271,6 +282,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil { if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, _ context.Context) (*authpkg.TokenData, error) {
if provider.LoginRegion != authpkg.LoginRegionInternational {
t.Errorf("device login region = %q, want international", provider.LoginRegion)
}
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
}
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "intl": "true"}); err != nil {
t.Fatalf("international device login = %v", err)
}
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) { authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
return nil, errors.New("oauth") return nil, errors.New("oauth")
@@ -291,6 +311,25 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
if out, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{"no-browser": "true"}); err != nil || !strings.Contains(out, "Corp") { if out, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{"no-browser": "true"}); err != nil || !strings.Contains(out, "Corp") {
t.Fatalf("oauth success = %q, %v", out, err) t.Fatalf("oauth success = %q, %v", out, err)
} }
authOAuthLogin = func(provider *authpkg.OAuthProvider, _ context.Context, _ bool) (*authpkg.TokenData, error) {
if provider.LoginRegion != authpkg.LoginRegionInternational {
t.Errorf("OAuth login region = %q, want international", provider.LoginRegion)
}
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
}
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"intl": "true"}); err != nil {
t.Fatalf("international OAuth login = %v", err)
}
blockedConfigDir := t.TempDir()
if err := os.Mkdir(filepath.Join(blockedConfigDir, "mcp_url"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("DWS_CONFIG_DIR", blockedConfigDir)
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "intl": "true"}); err == nil || !strings.Contains(err.Error(), "failed to persist MCP URL") {
t.Fatalf("MCP URL persist failure = %v", err)
}
t.Setenv("DWS_CONFIG_DIR", configDir)
authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error { authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error {
return errors.New("recommend") return errors.New("recommend")
@@ -1418,7 +1457,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
authRemove = func(string) error { removed++; return errors.New("ignored") } authRemove = func(string) error { removed++; return errors.New("ignored") }
authDeleteAppConfig = func(string) error { removed++; return errors.New("ignored") } authDeleteAppConfig = func(string) error { removed++; return errors.New("ignored") }
edition.Override(&edition.Hooks{}) edition.Override(&edition.Hooks{})
if err := reset.RunE(reset, nil); err != nil || removed != 3 || !strings.Contains(out.String(), "重新登录") { if err := reset.RunE(reset, nil); err != nil || removed != 4 || !strings.Contains(out.String(), "重新登录") {
t.Fatalf("reset = %q, %v, removed=%d", out.String(), err, removed) t.Fatalf("reset = %q, %v, removed=%d", out.String(), err, removed)
} }
edition.Override(&edition.Hooks{IsEmbedded: true}) edition.Override(&edition.Hooks{IsEmbedded: true})
+560
View File
@@ -33,6 +33,8 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -1033,8 +1035,12 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
login := &cobra.Command{Use: "login"} login := &cobra.Command{Use: "login"}
login.Flags().String("token", "", "") login.Flags().String("token", "", "")
login.Flags().Bool("device", false, "") login.Flags().Bool("device", false, "")
login.Flags().Bool("intl", false, "")
login.Flags().Bool("international", false, "")
login.Flags().Bool("force", false, "") login.Flags().Bool("force", false, "")
login.Flags().Bool("recommend", false, "") login.Flags().Bool("recommend", false, "")
login.Flags().String("pre-url", "", "")
login.Flags().String("mcp-url", "", "")
root.AddCommand(login) root.AddCommand(login)
if err := root.PersistentFlags().Set("yes", "true"); err != nil { if err := root.PersistentFlags().Set("yes", "true"); err != nil {
@@ -1061,6 +1067,560 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
} }
} }
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsInternationalAliases(t *testing.T) {
for _, flag := range []string{"intl", "international"} {
t.Run(flag, func(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("yes", false, "")
login := &cobra.Command{Use: "login"}
login.Flags().String("token", "", "")
login.Flags().Bool("device", false, "")
login.Flags().Bool("intl", false, "")
login.Flags().Bool("international", false, "")
login.Flags().Bool("force", false, "")
login.Flags().Bool("recommend", false, "")
login.Flags().String("pre-url", "", "")
login.Flags().String("mcp-url", "", "")
root.AddCommand(login)
if err := login.Flags().Set(flag, "true"); err != nil {
t.Fatalf("set %s: %v", flag, err)
}
cfg, err := resolveAuthLoginConfig(login)
if err != nil {
t.Fatalf("resolveAuthLoginConfig error = %v", err)
}
if !cfg.International {
t.Fatalf("International = false for --%s, want true", flag)
}
})
}
for _, tc := range []struct {
name string
setup func(*cobra.Command)
}{
{
name: "missing intl",
setup: func(cmd *cobra.Command) {
cmd.Flags().String("token", "", "")
cmd.Flags().Bool("device", false, "")
},
},
{
name: "missing international",
setup: func(cmd *cobra.Command) {
cmd.Flags().String("token", "", "")
cmd.Flags().Bool("device", false, "")
cmd.Flags().Bool("intl", false, "")
},
},
{
name: "missing pre url",
setup: func(cmd *cobra.Command) {
cmd.Flags().String("token", "", "")
cmd.Flags().Bool("device", false, "")
cmd.Flags().Bool("intl", false, "")
cmd.Flags().Bool("international", false, "")
cmd.Flags().Bool("force", false, "")
cmd.Flags().Bool("recommend", false, "")
},
},
{
name: "missing mcp url",
setup: func(cmd *cobra.Command) {
cmd.Flags().String("token", "", "")
cmd.Flags().Bool("device", false, "")
cmd.Flags().Bool("intl", false, "")
cmd.Flags().Bool("international", false, "")
cmd.Flags().Bool("force", false, "")
cmd.Flags().Bool("recommend", false, "")
cmd.Flags().String("pre-url", "", "")
},
},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "login"}
tc.setup(cmd)
if _, err := resolveAuthLoginConfig(cmd); err == nil {
t.Fatal("resolveAuthLoginConfig succeeded with an incomplete flag set")
}
})
}
}
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsMCPURL(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("yes", false, "")
login := &cobra.Command{Use: "login"}
login.Flags().String("token", "", "")
login.Flags().Bool("device", false, "")
login.Flags().Bool("intl", false, "")
login.Flags().Bool("international", false, "")
login.Flags().Bool("force", false, "")
login.Flags().Bool("recommend", false, "")
login.Flags().String("pre-url", "", "")
login.Flags().String("mcp-url", "", "")
root.AddCommand(login)
if err := login.Flags().Set("mcp-url", " https://pre-mcp.dingtalk.io/ "); err != nil {
t.Fatalf("set mcp-url: %v", err)
}
cfg, err := resolveAuthLoginConfig(login)
if err != nil {
t.Fatalf("resolveAuthLoginConfig error = %v", err)
}
if cfg.MCPURL != "https://pre-mcp.dingtalk.io/" {
t.Fatalf("MCPURL = %q, want trimmed flag value", cfg.MCPURL)
}
}
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsPreURL(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("yes", false, "")
login := &cobra.Command{Use: "login"}
login.Flags().String("token", "", "")
login.Flags().Bool("device", false, "")
login.Flags().Bool("intl", false, "")
login.Flags().Bool("international", false, "")
login.Flags().Bool("force", false, "")
login.Flags().Bool("recommend", false, "")
login.Flags().String("pre-url", "", "")
login.Flags().String("mcp-url", "", "")
root.AddCommand(login)
if err := login.Flags().Set("pre-url", " pre-login.dingtalk.io "); err != nil {
t.Fatalf("set pre-url: %v", err)
}
cfg, err := resolveAuthLoginConfig(login)
if err != nil {
t.Fatalf("resolveAuthLoginConfig error = %v", err)
}
if cfg.PreURL != "pre-login.dingtalk.io" {
t.Fatalf("PreURL = %q, want trimmed flag value", cfg.PreURL)
}
}
func TestCrossPlatformCoverageAuthLoginEndpointOverridesForPreURL(t *testing.T) {
tests := []struct {
name string
raw string
wantLogin string
wantMCP string
}{
{
name: "pre login",
raw: "https://pre-login.dingtalk.io/",
wantLogin: "https://pre-login.dingtalk.io",
wantMCP: "https://pre-mcp.dingtalk.io",
},
{
name: "pre mcp",
raw: "pre-mcp.dingtalk.io",
wantLogin: "https://pre-login.dingtalk.io",
wantMCP: "https://pre-mcp.dingtalk.io",
},
{
name: "pre login with port",
raw: "https://pre-login.dingtalk.io:8443/path/",
wantLogin: "https://pre-login.dingtalk.io:8443/path",
wantMCP: "https://pre-mcp.dingtalk.io:8443/path",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := authLoginEndpointOverridesForPreURL(tc.raw)
if err != nil {
t.Fatalf("authLoginEndpointOverridesForPreURL error = %v", err)
}
if got.LoginURL != tc.wantLogin || got.MCPURL != tc.wantMCP {
t.Fatalf("overrides = %#v, want login %q mcp %q", got, tc.wantLogin, tc.wantMCP)
}
})
}
for _, raw := range []string{"https://example.com", "http://pre-login.example.com"} {
if _, err := authLoginEndpointOverridesForPreURL(raw); err == nil {
t.Fatalf("authLoginEndpointOverridesForPreURL(%q) succeeded", raw)
}
}
}
func TestCrossPlatformCoverageAuthLoginMCPBaseURLForConfig(t *testing.T) {
tests := []struct {
name string
cfg authLoginConfig
preOverride authLoginEndpointOverrides
wantURL string
wantPersistence authLoginMCPPersistence
}{
{
name: "default center login uses com and resets only managed region",
cfg: authLoginConfig{},
wantURL: authpkg.DefaultMCPBaseURL,
wantPersistence: authLoginMCPUseDefault,
},
{
name: "international login persists managed io",
cfg: authLoginConfig{International: true},
wantURL: authpkg.InternationalMCPBaseURL,
wantPersistence: authLoginMCPUseManagedRegion,
},
{
name: "pre login persists mapped pre mcp",
cfg: authLoginConfig{PreURL: "pre-login.dingtalk.io"},
preOverride: authLoginEndpointOverrides{
LoginURL: "https://pre-login.dingtalk.io",
MCPURL: "https://pre-mcp.dingtalk.io",
},
wantURL: "https://pre-mcp.dingtalk.io",
wantPersistence: authLoginMCPUseExplicitOverride,
},
{
name: "explicit mcp url wins over pre url",
cfg: authLoginConfig{
PreURL: "pre-login.dingtalk.io",
MCPURL: " https://custom-mcp.example.com/ ",
},
preOverride: authLoginEndpointOverrides{
LoginURL: "https://pre-login.dingtalk.io",
MCPURL: "https://pre-mcp.dingtalk.io",
},
wantURL: "https://custom-mcp.example.com",
wantPersistence: authLoginMCPUseExplicitOverride,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gotURL, gotPersistence, err := authLoginMCPBaseURLForConfig(tc.cfg, tc.preOverride)
if err != nil {
t.Fatalf("authLoginMCPBaseURLForConfig error = %v", err)
}
if gotURL != tc.wantURL || gotPersistence != tc.wantPersistence {
t.Fatalf("got url=%q persistence=%v, want url=%q persistence=%v", gotURL, gotPersistence, tc.wantURL, tc.wantPersistence)
}
})
}
for _, cfg := range []authLoginConfig{
{MCPURL: "http://remote.example.com"},
{PreURL: "https://example.com"},
} {
if _, _, err := authLoginMCPBaseURLForConfig(cfg, authLoginEndpointOverrides{}); err == nil {
t.Fatalf("authLoginMCPBaseURLForConfig(%#v) succeeded", cfg)
}
}
}
func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURL(t *testing.T) {
t.Run("persists selected io mcp url", func(t *testing.T) {
configDir := t.TempDir()
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err)
}
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
if err != nil {
t.Fatalf("ReadFile(mcp_url) error = %v", err)
}
if string(data) != authpkg.InternationalMCPBaseURL {
t.Fatalf("mcp_url = %q, want %q", string(data), authpkg.InternationalMCPBaseURL)
}
managed, err := os.ReadFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName))
if err != nil || string(managed) != authpkg.InternationalMCPBaseURL {
t.Fatalf("managed MCP region = %q, %v", string(managed), err)
}
})
t.Run("center login preserves previous persisted override", func(t *testing.T) {
configDir := t.TempDir()
mcpURLPath := filepath.Join(configDir, "mcp_url")
const customURL = "https://custom-mcp.example.com"
if err := os.WriteFile(mcpURLPath, []byte(customURL), 0o600); err != nil {
t.Fatalf("WriteFile(mcp_url) error = %v", err)
}
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err)
}
data, err := os.ReadFile(mcpURLPath)
if err != nil {
t.Fatalf("ReadFile(mcp_url) error = %v", err)
}
if string(data) != customURL {
t.Fatalf("mcp_url = %q, want preserved override %q", string(data), customURL)
}
})
t.Run("international login preserves an unmanaged explicit override", func(t *testing.T) {
configDir := t.TempDir()
mcpURLPath := filepath.Join(configDir, "mcp_url")
const customURL = "https://private-mcp.example.com"
if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil {
t.Fatal(err)
}
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(mcpURLPath)
if err != nil || string(data) != customURL {
t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err)
}
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
t.Fatalf("unmanaged override acquired a managed marker: %v", err)
}
})
t.Run("center login resets a managed international URL", func(t *testing.T) {
configDir := t.TempDir()
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
t.Fatal(err)
}
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
if err != nil || string(data) != authpkg.DefaultMCPBaseURL {
t.Fatalf("mcp_url = %q, %v; want domestic default", string(data), err)
}
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
t.Fatalf("managed region marker remains after domestic login: %v", err)
}
})
t.Run("explicit override clears region ownership", func(t *testing.T) {
configDir := t.TempDir()
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
t.Fatal(err)
}
const customURL = "https://custom-mcp.example.com"
if err := persistAuthLoginMCPBaseURL(configDir, customURL, authLoginMCPUseExplicitOverride); err != nil {
t.Fatal(err)
}
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
if err != nil || string(data) != customURL {
t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err)
}
})
t.Run("stale marker never deletes a different custom URL", func(t *testing.T) {
configDir := t.TempDir()
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://custom.example.com"), config.FilePerm); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName), []byte(authpkg.InternationalMCPBaseURL), config.FilePerm); err != nil {
t.Fatal(err)
}
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
if err != nil || string(data) != "https://custom.example.com" {
t.Fatalf("custom mcp_url = %q, %v", string(data), err)
}
})
t.Run("international login clears a stale marker without changing a custom URL", func(t *testing.T) {
configDir := t.TempDir()
mcpURLPath := filepath.Join(configDir, "mcp_url")
managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName)
const customURL = "https://private-mcp.example.com"
if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(managedRegionPath, []byte(authpkg.DefaultMCPBaseURL), config.FilePerm); err != nil {
t.Fatal(err)
}
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(mcpURLPath)
if err != nil || string(data) != customURL {
t.Fatalf("custom mcp_url = %q, %v", string(data), err)
}
if _, err := os.Stat(managedRegionPath); !os.IsNotExist(err) {
t.Fatalf("stale managed marker remains: %v", err)
}
})
}
func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURLErrors(t *testing.T) {
fail := errors.New("persist failure")
t.Run("explicit marker cleanup", func(t *testing.T) {
testseam.Swap(t, &authRemove, func(string) error { return fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) {
t.Fatalf("explicit marker cleanup error = %v", err)
}
})
t.Run("explicit URL write", func(t *testing.T) {
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) {
t.Fatalf("explicit URL write error = %v", err)
}
})
t.Run("managed marker write", func(t *testing.T) {
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
t.Fatalf("managed marker write error = %v", err)
}
})
t.Run("managed marker read", func(t *testing.T) {
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
t.Fatalf("managed marker read error = %v", err)
}
})
t.Run("managed MCP URL read", func(t *testing.T) {
reads := 0
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
reads++
if reads == 1 {
return nil, os.ErrNotExist
}
return nil, fail
})
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
t.Fatalf("managed MCP URL read error = %v", err)
}
})
t.Run("managed stale marker cleanup", func(t *testing.T) {
reads := 0
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
reads++
if reads == 1 {
return []byte(authpkg.DefaultMCPBaseURL), nil
}
return []byte("https://private-mcp.example.com"), nil
})
testseam.Swap(t, &authRemove, func(string) error { return fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
t.Fatalf("managed stale marker cleanup error = %v", err)
}
})
t.Run("managed URL write cleans marker", func(t *testing.T) {
writes := 0
removed := false
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error {
writes++
if writes == 2 {
return fail
}
return nil
})
testseam.Swap(t, &authRemove, func(string) error { removed = true; return nil })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) || !removed {
t.Fatalf("managed URL write error = %v, marker removed=%v", err, removed)
}
})
t.Run("default managed marker read", func(t *testing.T) {
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
t.Fatalf("managed marker read error = %v", err)
}
})
t.Run("missing MCP URL cleans marker", func(t *testing.T) {
reads := 0
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
reads++
if reads == 1 {
return []byte(authpkg.InternationalMCPBaseURL), nil
}
return nil, os.ErrNotExist
})
testseam.Swap(t, &authRemove, func(string) error { return nil })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
t.Fatalf("missing MCP URL cleanup error = %v", err)
}
})
t.Run("MCP URL read", func(t *testing.T) {
reads := 0
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
reads++
if reads == 1 {
return []byte(authpkg.InternationalMCPBaseURL), nil
}
return nil, fail
})
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
t.Fatalf("MCP URL read error = %v", err)
}
})
t.Run("default URL write", func(t *testing.T) {
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil })
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
t.Fatalf("default URL write error = %v", err)
}
})
t.Run("final marker cleanup", func(t *testing.T) {
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil })
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return nil })
testseam.Swap(t, &authRemove, func(string) error { return fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
t.Fatalf("final marker cleanup error = %v", err)
}
})
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPPersistence(255)); err == nil {
t.Fatal("unsupported MCP persistence mode succeeded")
}
}
func TestCrossPlatformCoverageNormalizeAuthLoginBaseURLTransportSecurity(t *testing.T) {
tests := []struct {
name string
raw string
wantURL string
wantErr string
}{
{name: "https remote", raw: "https://pre-mcp.example.com/path/?secret=drop#fragment", wantURL: "https://pre-mcp.example.com/path"},
{name: "http localhost", raw: "http://localhost:8080/", wantURL: "http://localhost:8080"},
{name: "http IPv4 loopback", raw: "http://127.0.0.1:8080", wantURL: "http://127.0.0.1:8080"},
{name: "http IPv6 loopback", raw: "http://[::1]:8080", wantURL: "http://[::1]:8080"},
{name: "http remote", raw: "http://pre-mcp.example.com", wantErr: "must use HTTPS"},
{name: "empty", raw: " ", wantErr: "cannot be empty"},
{name: "invalid URL", raw: "https://%", wantErr: "invalid --mcp-url"},
{name: "invalid scheme", raw: "ftp://pre-mcp.example.com", wantErr: "must use http or https"},
{name: "missing host", raw: "https:///path", wantErr: "must include a host"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, got, err := normalizeAuthLoginBaseURL(tc.raw, "--mcp-url")
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("normalizeAuthLoginBaseURL error = %v, want containing %q", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("normalizeAuthLoginBaseURL error = %v", err)
}
if got != tc.wantURL {
t.Fatalf("normalized URL = %q, want %q", got, tc.wantURL)
}
})
}
}
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) { func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
if !authLoginForcesAuthorization(authLoginConfig{}) { if !authLoginForcesAuthorization(authLoginConfig{}) {
t.Fatal("auth login should force authorization by default so each login can add an organization profile") t.Fatal("auth login should force authorization by default so each login can add an organization profile")
+111 -4
View File
@@ -36,6 +36,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -326,6 +327,29 @@ func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) { func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
oldEdition := edition.Get() oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) }) t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) })
for _, tc := range []struct {
raw string
region authpkg.LoginRegion
want string
}{
{raw: "%", want: "%"},
{raw: "https://dingtalk.io/path", want: "https://dingtalk.com/path"},
{raw: "https://mcp.dingtalk.com:8443/path", region: authpkg.LoginRegionInternational, want: "https://mcp.dingtalk.io:8443/path"},
} {
if got := mcpBaseURLForLoginRegion(tc.raw, tc.region); got != tc.want {
t.Fatalf("mcpBaseURLForLoginRegion(%q, %q) = %q, want %q", tc.raw, tc.region, got, tc.want)
}
}
if hasDirectRuntimeEndpointOverride("") {
t.Fatal("blank product unexpectedly has an endpoint override")
}
t.Setenv("DINGTALK_COVERAGE_PRODUCT_MCP_URL", "https://override.test")
if !hasDirectRuntimeEndpointOverride("coverage-product") {
t.Fatal("configured product endpoint override was not detected")
}
if got := activeDingTalkGatewayEndpointWithBase("https://mcp-gw.dingtalk.com/server/contact", "%"); got != "https://mcp-gw.dingtalk.com/server/contact" {
t.Fatalf("invalid gateway base rewrote endpoint to %q", got)
}
server := mcptypes.ServerDescriptor{ server := mcptypes.ServerDescriptor{
Endpoint: "https://one.test", Endpoint: "https://one.test",
CLI: mcptypes.CLIOverlay{ CLI: mcptypes.CLIOverlay{
@@ -913,12 +937,21 @@ func TestCrossPlatformCoverageAuthCommandPureCoverage(t *testing.T) {
} }
func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) { func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
oldInteractive := authLoginInteractiveTerminal oldInteractive := authLoginInteractiveTerminal
authLoginInteractiveTerminal = func() bool { return false } authLoginInteractiveTerminal = func() bool { return false }
t.Cleanup(func() { authLoginInteractiveTerminal = oldInteractive; authpkg.SetRuntimeProfile("") }) t.Cleanup(func() { authLoginInteractiveTerminal = oldInteractive; authpkg.SetRuntimeProfile("") })
for _, format := range []string{"table", "json"} { for _, tc := range []struct {
t.Run(format, func(t *testing.T) { format string
t.Setenv("DWS_CONFIG_DIR", t.TempDir()) international bool
}{
{format: "table"},
{format: "json", international: true},
} {
t.Run(tc.format, func(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
root := &cobra.Command{Use: "dws"} root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "table", "") root.PersistentFlags().String("format", "table", "")
root.PersistentFlags().Bool("yes", false, "") root.PersistentFlags().Bool("yes", false, "")
@@ -929,16 +962,90 @@ func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) {
root.SetOut(&output) root.SetOut(&output)
root.SetErr(io.Discard) root.SetErr(io.Discard)
args := []string{"login", "--token", "manual-token", "--yes"} args := []string{"login", "--token", "manual-token", "--yes"}
if format == "json" { if tc.international {
args = append(args, "--intl")
}
if tc.format == "json" {
args = append(args, "--format", "json") args = append(args, "--format", "json")
} }
root.SetArgs(args) root.SetArgs(args)
if err := root.Execute(); err != nil || output.Len() == 0 { if err := root.Execute(); err != nil || output.Len() == 0 {
t.Fatalf("token login = %q %v", output.String(), err) t.Fatalf("token login = %q %v", output.String(), err)
} }
data, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData error = %v", err)
}
wantRegion := ""
if tc.international {
wantRegion = string(authpkg.LoginRegionInternational)
}
if data.LoginRegion != wantRegion {
t.Fatalf("LoginRegion = %q, want %q", data.LoginRegion, wantRegion)
}
if tc.international {
snapshot, err := resolveAccessTokenSnapshotFromDir(context.Background(), configDir, "")
if err != nil {
t.Fatalf("resolveAccessTokenSnapshotFromDir error = %v", err)
}
gotEndpoint := activeDingTalkGatewayEndpointForLoginRegion(
"https://mcp-gw.dingtalk.com/server/contact",
snapshot.LoginRegion,
)
if wantEndpoint := "https://mcp-gw.dingtalk.io/server/contact"; gotEndpoint != wantEndpoint {
t.Fatalf("international manual-token endpoint = %q, want %q", gotEndpoint, wantEndpoint)
}
}
}) })
} }
t.Run("international then domestic login restores domestic MCP URL", func(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
runLogin := func(international bool) {
t.Helper()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "table", "")
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().String("profile", "", "")
root.AddCommand(newAuthLoginCommand(nil))
args := []string{"login", "--token", "manual-token", "--yes"}
if international {
args = append(args, "--intl")
}
root.SetArgs(args)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
if err := root.Execute(); err != nil {
t.Fatalf("international=%v login error = %v", international, err)
}
}
runLogin(true)
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.InternationalMCPBaseURL {
t.Fatalf("international mcp_url = %q, %v", string(data), err)
}
runLogin(false)
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.DefaultMCPBaseURL {
t.Fatalf("domestic mcp_url = %q, %v", string(data), err)
}
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
t.Fatalf("managed MCP region marker remains: %v", err)
}
const customURL = "https://private-mcp.example.com"
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(customURL), config.FilePerm); err != nil {
t.Fatal(err)
}
runLogin(true)
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != customURL {
t.Fatalf("explicit mcp_url after international login = %q, %v; want preserved custom URL", string(data), err)
}
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
t.Fatalf("explicit mcp_url acquired a managed marker: %v", err)
}
})
for _, hidden := range []bool{false, true} { for _, hidden := range []bool{false, true} {
old := edition.Get() old := edition.Get()
edition.Override(&edition.Hooks{HideAuthLogin: hidden}) edition.Override(&edition.Hooks{HideAuthLogin: hidden})
+96 -2
View File
@@ -74,6 +74,20 @@ func defaultPATMCPEndpoint() string {
func defaultPATGatewayBaseURL() string { func defaultPATGatewayBaseURL() string {
raw := strings.TrimSpace(authpkg.GetMCPBaseURL()) raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
return mcpGatewayBaseURL(raw)
}
func defaultPATGatewayBaseURLForLoginRegion(region authpkg.LoginRegion) string {
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
if override := authpkg.MCPBaseURLOverride(); override != "" {
raw = override
} else {
raw = mcpBaseURLForLoginRegion(raw, region)
}
return mcpGatewayBaseURL(raw)
}
func mcpGatewayBaseURL(raw string) string {
parsed, err := url.Parse(raw) parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Host == "" { if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return strings.TrimRight(raw, "/") return strings.TrimRight(raw, "/")
@@ -100,6 +114,33 @@ func defaultPATGatewayBaseURL() string {
return strings.TrimRight(parsed.String(), "/") return strings.TrimRight(parsed.String(), "/")
} }
func mcpBaseURLForLoginRegion(raw string, region authpkg.LoginRegion) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return raw
}
host := strings.ToLower(parsed.Hostname())
fromSuffix, toSuffix := ".dingtalk.io", ".dingtalk.com"
if region.IsInternational() {
fromSuffix, toSuffix = toSuffix, fromSuffix
}
bareFrom := strings.TrimPrefix(fromSuffix, ".")
if host != bareFrom && !strings.HasSuffix(host, fromSuffix) {
return raw
}
if host == bareFrom {
host = strings.TrimPrefix(toSuffix, ".")
} else {
host = strings.TrimSuffix(host, fromSuffix) + toSuffix
}
if port := parsed.Port(); port != "" {
parsed.Host = net.JoinHostPort(host, port)
} else {
parsed.Host = host
}
return parsed.String()
}
// SetDynamicServers injects server data discovered from servers.json. // SetDynamicServers injects server data discovered from servers.json.
// All product endpoints are resolved dynamically from this data. // All product endpoints are resolved dynamically from this data.
func SetDynamicServers(servers []mcptypes.ServerDescriptor) { func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
@@ -131,7 +172,7 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin
return return
} }
id := strings.TrimSpace(server.CLI.ID) id := strings.TrimSpace(server.CLI.ID)
endpoint := strings.TrimSpace(server.Endpoint) endpoint := activeDingTalkGatewayEndpoint(server.Endpoint)
if id != "" && endpoint != "" { if id != "" && endpoint != "" {
endpoints[id] = endpoint endpoints[id] = endpoint
products[id] = true products[id] = true
@@ -262,6 +303,19 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
return "", false return "", false
} }
func hasDirectRuntimeEndpointOverride(productID string) bool {
normalized := normalizeDirectRuntimeProductID(productID)
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
if candidate == "" {
continue
}
if _, ok := productEndpointOverride(candidate); ok {
return true
}
}
return false
}
func editionServerEndpoint(productID string) (string, bool) { func editionServerEndpoint(productID string) (string, bool) {
productID = strings.TrimSpace(productID) productID = strings.TrimSpace(productID)
if productID == "" { if productID == "" {
@@ -282,7 +336,7 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo
return "", false return "", false
} }
for _, server := range fn() { for _, server := range fn() {
endpoint := strings.TrimSpace(server.Endpoint) endpoint := activeDingTalkGatewayEndpoint(server.Endpoint)
if endpoint == "" { if endpoint == "" {
continue continue
} }
@@ -298,6 +352,46 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo
return "", false return "", false
} }
func activeDingTalkGatewayEndpoint(endpoint string) string {
return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURL())
}
func activeDingTalkGatewayEndpointForLoginRegion(endpoint string, region authpkg.LoginRegion) string {
return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURLForLoginRegion(region))
}
func activeDingTalkGatewayEndpointWithBase(endpoint, gatewayBaseURL string) string {
endpoint = strings.TrimSpace(endpoint)
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return endpoint
}
if !isDingTalkMCPGatewayHost(parsed.Hostname()) {
return endpoint
}
base, err := url.Parse(gatewayBaseURL)
if err != nil || base.Scheme == "" || base.Host == "" {
return endpoint
}
parsed.Scheme = base.Scheme
parsed.Host = base.Host
return strings.TrimRight(parsed.String(), "/")
}
func isDingTalkMCPGatewayHost(host string) bool {
switch strings.ToLower(strings.TrimSpace(host)) {
case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com", "mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io":
return true
default:
return false
}
}
func isDingTalkMCPGatewayEndpoint(endpoint string) bool {
parsed, err := url.Parse(strings.TrimSpace(endpoint))
return err == nil && isDingTalkMCPGatewayHost(parsed.Hostname())
}
// DirectRuntimeProductIDs returns product IDs that should stay visible for // DirectRuntimeProductIDs returns product IDs that should stay visible for
// direct runtime execution. Dynamic products come from MCP discovery/plugin // direct runtime execution. Dynamic products come from MCP discovery/plugin
// registration; built-in helper products such as devapp resolve their endpoint // registration; built-in helper products such as devapp resolve their endpoint
+75
View File
@@ -22,6 +22,7 @@ import (
"strings" "strings"
"testing" "testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
@@ -488,6 +489,80 @@ func TestInjectStaticServersMergesStaticAndSupplementServers(t *testing.T) {
} }
} }
func TestCrossPlatformCoverageStaticDingTalkEndpointsFollowConfiguredMCPBaseURL(t *testing.T) {
previous := edition.Get()
defer edition.Override(previous)
defer SetDynamicServers(nil)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil {
t.Fatalf("WriteFile(mcp_url) error = %v", err)
}
edition.Override(&edition.Hooks{
Name: "test",
StaticServers: func() []edition.ServerInfo {
return []edition.ServerInfo{{
ID: "contact",
Name: "Contact",
Endpoint: "https://mcp-gw.dingtalk.com/server/contact?key=abc",
Prefixes: []string{"user"},
}}
},
})
injectStaticServers()
for _, productID := range []string{"contact", "user"} {
got, ok := directRuntimeEndpoint(productID, "")
want := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc"
if !ok || got != want {
t.Fatalf("directRuntimeEndpoint(%q) = %q, %v; want %q, true", productID, got, ok, want)
}
}
}
func TestCrossPlatformCoverageDingTalkEndpointsFollowSelectedTokenRegion(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
mcpURLPath := filepath.Join(configDir, "mcp_url")
if err := os.WriteFile(mcpURLPath, []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil {
t.Fatalf("WriteFile(mcp_url) error = %v", err)
}
endpoint := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc"
if got, want := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionDefault), "https://pre-mcp-gw.dingtalk.com/server/contact?key=abc"; got != want {
t.Fatalf("domestic profile endpoint = %q, want %q", got, want)
}
if got := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionInternational); got != endpoint {
t.Fatalf("international profile endpoint = %q, want %q", got, endpoint)
}
if err := os.WriteFile(mcpURLPath, []byte("https://mcp.dingtalk.com\n"), 0o600); err != nil {
t.Fatalf("WriteFile(mcp_url) error = %v", err)
}
if got, want := activeDingTalkGatewayEndpointForLoginRegion("https://mcp-gw.dingtalk.com/server/contact", authpkg.LoginRegionInternational), "https://mcp-gw.dingtalk.io/server/contact"; got != want {
t.Fatalf("international profile endpoint from domestic config = %q, want %q", got, want)
}
}
func TestCrossPlatformCoverageDingTalkEndpointUsesLoginScopedMCPOverride(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
restore := authpkg.PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io")
defer restore()
got := activeDingTalkGatewayEndpointForLoginRegion(
"https://mcp-gw.dingtalk.com/server/contact",
authpkg.LoginRegionDefault,
)
want := "https://pre-mcp-gw.dingtalk.io/server/contact"
if got != want {
t.Fatalf("login-scoped endpoint = %q, want %q", got, want)
}
}
func mustFindCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command { func mustFindCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command {
t.Helper() t.Helper()
cmd := root cmd := root
+29 -12
View File
@@ -162,7 +162,7 @@ var (
runnerResolveMultiProfileSelections = resolveMultiProfileSelections runnerResolveMultiProfileSelections = resolveMultiProfileSelections
runnerResolveProfile = authpkg.ResolveProfile runnerResolveProfile = authpkg.ResolveProfile
runnerGetCachedRuntimeToken = getCachedRuntimeToken runnerGetCachedRuntimeToken = getCachedRuntimeToken
runnerResolveAuthToken = (*runtimeRunner).resolveAuthToken runnerResolveAuthSnapshot = (*runtimeRunner).resolveAuthSnapshot
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
runnerCallTool = (*transport.Client).CallTool runnerCallTool = (*transport.Client).CallTool
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
@@ -552,11 +552,16 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if hasPluginAuth { if hasPluginAuth {
authToken = pluginAuth.Token authToken = pluginAuth.Token
} else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) { } else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) {
var tokenErr error snapshot, tokenErr := runnerResolveAuthSnapshot(r, ctx)
authToken, tokenErr = runnerResolveAuthToken(r, ctx)
if tokenErr != nil { if tokenErr != nil {
return executor.Result{}, tokenResolutionError(tokenErr) return executor.Result{}, tokenResolutionError(tokenErr)
} }
authToken = snapshot.AccessToken
if !hasDirectRuntimeEndpointOverride(invocation.CanonicalProduct) &&
isDingTalkMCPGatewayEndpoint(endpoint) &&
(snapshot.LoginRegionKnown || authpkg.MCPBaseURLOverride() != "") {
endpoint = activeDingTalkGatewayEndpointForLoginRegion(endpoint, snapshot.LoginRegion)
}
} }
var timeoutSec int var timeoutSec int
@@ -877,21 +882,33 @@ func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
} }
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) { func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) {
explicitToken := "" snapshot, err := r.resolveAuthSnapshot(ctx)
if r != nil && r.globalFlags != nil {
explicitToken = r.globalFlags.Token
}
return resolveRuntimeAuthToken(ctx, explicitToken)
}
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
snapshot, err := runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
if err != nil { if err != nil {
return "", err return "", err
} }
return snapshot.AccessToken, nil return snapshot.AccessToken, nil
} }
func (r *runtimeRunner) resolveAuthSnapshot(ctx context.Context) (AccessTokenSnapshot, error) {
explicitToken := ""
if r != nil && r.globalFlags != nil {
explicitToken = r.globalFlags.Token
}
return resolveRuntimeAuthSnapshot(ctx, explicitToken)
}
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
snapshot, err := resolveRuntimeAuthSnapshot(ctx, explicitToken)
if err != nil {
return "", err
}
return snapshot.AccessToken, nil
}
func resolveRuntimeAuthSnapshot(ctx context.Context, explicitToken string) (AccessTokenSnapshot, error) {
return runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
}
// getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The // getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The
// cache itself lives exclusively in TokenManager. // cache itself lives exclusively in TokenManager.
func getCachedRuntimeToken(ctx context.Context) (string, error) { func getCachedRuntimeToken(ctx context.Context) (string, error) {
+30
View File
@@ -127,12 +127,14 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
} }
func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) { func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get() oldEdition := edition.Get()
oldPreflight := runnerPreflightDocDownload oldPreflight := runnerPreflightDocDownload
oldCall := runnerCallTool oldCall := runnerCallTool
oldHandle := runnerHandlePatAuthCheck oldHandle := runnerHandlePatAuthCheck
oldRetry := runnerRetryWithPatAuthRetry oldRetry := runnerRetryWithPatAuthRetry
oldCapture := runnerCaptureRuntimeFailure oldCapture := runnerCaptureRuntimeFailure
oldResolveSnapshot := runnerResolveAuthSnapshot
t.Cleanup(func() { t.Cleanup(func() {
edition.Override(oldEdition) edition.Override(oldEdition)
runnerPreflightDocDownload = oldPreflight runnerPreflightDocDownload = oldPreflight
@@ -140,6 +142,7 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
runnerHandlePatAuthCheck = oldHandle runnerHandlePatAuthCheck = oldHandle
runnerRetryWithPatAuthRetry = oldRetry runnerRetryWithPatAuthRetry = oldRetry
runnerCaptureRuntimeFailure = oldCapture runnerCaptureRuntimeFailure = oldCapture
runnerResolveAuthSnapshot = oldResolveSnapshot
}) })
pluginAuthMu.Lock() pluginAuthMu.Lock()
@@ -168,6 +171,27 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
if got, err := r.executeInvocation(context.Background(), "https://example.test", inv); err != nil || !got.Invocation.Implemented { if got, err := r.executeInvocation(context.Background(), "https://example.test", inv); err != nil || !got.Invocation.Implemented {
t.Fatalf("default auth execution = %#v, %v", got, err) t.Fatalf("default auth execution = %#v, %v", got, err)
} }
runnerResolveAuthSnapshot = func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) {
return AccessTokenSnapshot{
AccessToken: "international-token",
LoginRegion: authpkg.LoginRegionInternational,
LoginRegionKnown: true,
}, nil
}
successfulCall := runnerCallTool
routedEndpoint := ""
runnerCallTool = func(_ *transport.Client, _ context.Context, endpoint, _ string, _ map[string]any) (transport.ToolCallResult, error) {
routedEndpoint = endpoint
return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil
}
if _, err := r.executeInvocation(context.Background(), "https://mcp-gw.dingtalk.com/server/contact", inv); err != nil {
t.Fatalf("international auth execution = %v", err)
}
if routedEndpoint != "https://mcp-gw.dingtalk.io/server/contact" {
t.Fatalf("international routed endpoint = %q", routedEndpoint)
}
runnerResolveAuthSnapshot = oldResolveSnapshot
runnerCallTool = successfulCall
wantErr := errors.New("preflight") wantErr := errors.New("preflight")
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error { runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
@@ -362,6 +386,12 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" { if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" {
t.Fatalf("runtime explicit token = %q, %v", got, err) t.Fatalf("runtime explicit token = %q, %v", got, err)
} }
edition.Override(&edition.Hooks{TokenProvider: func(context.Context, func() (string, error)) (string, error) {
return "", errors.New("snapshot failed")
}})
if _, err := r.resolveAuthToken(context.Background()); err == nil || !strings.Contains(err.Error(), "snapshot failed") {
t.Fatalf("resolveAuthToken error = %v", err)
}
t.Setenv(envDWSChannel, "channel") t.Setenv(envDWSChannel, "channel")
edition.Override(&edition.Hooks{ edition.Override(&edition.Hooks{
+81
View File
@@ -424,6 +424,87 @@ func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) {
} }
} }
func TestCrossPlatformCoverageBuildAuthURLForInternationalRegion(t *testing.T) {
authURL := buildAuthURLForRegion("client-id", "http://127.0.0.1:1234/callback", "", LoginRegionInternational)
if !strings.HasPrefix(authURL, InternationalAuthorizeURL+"?") {
t.Fatalf("auth URL = %s, want international authorize host", authURL)
}
}
func TestCrossPlatformCoverageNotEnabledHTMLUsesRegionAwareAuthorizeURL(t *testing.T) {
if !strings.Contains(notEnabledHTML, "status.authorizeUrl") {
t.Fatal("not-enabled page must read the authorize URL from the regional login status")
}
if strings.Contains(notEnabledHTML, `"https://login.dingtalk.com/oauth2/auth?client_id="`) {
t.Fatal("not-enabled page must not hard-code the domestic authorize URL")
}
}
func TestCrossPlatformCoverageLoginRegionEndpointDefaults(t *testing.T) {
if got := AuthorizeURLForLoginRegion(LoginRegionDefault); got != AuthorizeURL {
t.Fatalf("default authorize URL = %q, want %q", got, AuthorizeURL)
}
if got := DeviceBaseURLForLoginRegion(LoginRegionDefault); got != DefaultDeviceBaseURL {
t.Fatalf("default device base URL = %q, want %q", got, DefaultDeviceBaseURL)
}
if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != InternationalUserAccessTokenURL {
t.Fatalf("international user access token URL = %q, want %q", got, InternationalUserAccessTokenURL)
}
if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != InternationalMCPBaseURL {
t.Fatalf("international MCP base URL = %q, want %q", got, InternationalMCPBaseURL)
}
if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != InternationalDeviceBaseURL {
t.Fatalf("international device base URL = %q, want %q", got, InternationalDeviceBaseURL)
}
}
func TestCrossPlatformCoverageOAuthProviderLoginRegionHelpers(t *testing.T) {
var nilProvider *OAuthProvider
if got := nilProvider.loginRegion(); got != LoginRegionDefault {
t.Fatalf("nil provider login region = %q", got)
}
nilProvider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)})
nilProvider.applyLoginRegionToToken(&TokenData{})
provider := &OAuthProvider{}
provider.useTokenLoginRegion(nil)
provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)})
if provider.LoginRegion != LoginRegionInternational {
t.Fatalf("provider login region = %q, want international", provider.LoginRegion)
}
provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionDefault)})
provider.applyLoginRegionToToken(nil)
token := &TokenData{}
provider.applyLoginRegionToToken(token)
if token.LoginRegion != string(LoginRegionInternational) {
t.Fatalf("token login region = %q, want international", token.LoginRegion)
}
}
func TestCrossPlatformCoverageMCPBaseURLOverrideAffectsInternationalRegion(t *testing.T) {
restore := PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io/")
defer restore()
if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-mcp.dingtalk.io" {
t.Fatalf("international MCP base URL = %q, want override", got)
}
}
func TestCrossPlatformCoverageLoginBaseURLOverrideAffectsInternationalRegion(t *testing.T) {
restore := PushLoginBaseURLOverride("https://pre-login.dingtalk.io/")
defer restore()
if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io" {
t.Fatalf("international device base URL = %q, want override", got)
}
if got := AuthorizeURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/oauth2/auth" {
t.Fatalf("international authorize URL = %q, want override", got)
}
if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/v1.0/oauth2/userAccessToken" {
t.Fatalf("international user access token URL = %q, want override", got)
}
}
func buildTokenDataFromResponse(resp tokenResponse) *TokenData { func buildTokenDataFromResponse(resp tokenResponse) *TokenData {
if resp.AccessToken == "" { if resp.AccessToken == "" {
return nil return nil
+101
View File
@@ -14,10 +14,12 @@
package auth package auth
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -206,6 +208,105 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) {
t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled) t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
} }
func TestCrossPlatformCoverageCheckCLIAuthEnabledTraceHeadersAndDebugLog(t *testing.T) {
t.Setenv("DINGTALK_TRACE_ID", "trace-for-cli-auth-test")
applyCLIAuthTraceHeaders(nil, "trace-for-cli-auth-test")
applyCLIAuthTraceHeaders(httptest.NewRequest(http.MethodGet, "https://example.com", nil), "")
var logBuf bytes.Buffer
previousLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})))
defer slog.SetDefault(previousLogger)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("EagleEye-TraceId"); got != "trace-for-cli-auth-test" {
t.Errorf("EagleEye-TraceId = %q, want trace-for-cli-auth-test", got)
}
if got := r.Header.Get("X-Dingtalk-Trace-Id"); got != "trace-for-cli-auth-test" {
t.Errorf("X-Dingtalk-Trace-Id = %q, want trace-for-cli-auth-test", got)
}
w.Header().Set("EagleEye-TraceId", "server-trace-001")
w.Header().Set("EagleEye-RpcId", "rpc-001")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{CLIAuthEnabled: true},
})
}))
defer srv.Close()
configDir := setupMCPConfigDir(t, srv.URL)
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
status, err := p.CheckCLIAuthEnabled(context.Background(), "sensitive-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status.Result == nil || !status.Result.CLIAuthEnabled {
t.Fatal("expected CLIAuthEnabled=true")
}
logs := logBuf.String()
for _, want := range []string{
"auth.cli_auth_enabled.request",
"auth.cli_auth_enabled.response",
"trace-for-cli-auth-test",
"server-trace-001",
"rpc-001",
} {
if !strings.Contains(logs, want) {
t.Fatalf("debug logs missing %q, got: %s", want, logs)
}
}
if strings.Contains(logs, "sensitive-token") {
t.Fatalf("debug logs leaked access token: %s", logs)
}
}
func TestCrossPlatformCoverageInternationalLoginRegionRequestWrappers(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case ClientIDPath:
_ = json.NewEncoder(w).Encode(ClientIDResponse{Success: true, Result: "international-client"})
case SuperAdminPath:
_ = json.NewEncoder(w).Encode(SuperAdminResponse{Success: true, Result: []SuperAdmin{{StaffID: "admin"}}})
case SendCliAuthApplyPath:
_ = json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
oldClient := oauthHTTPClient
oauthHTTPClient = server.Client()
restoreBaseURL := PushMCPBaseURLOverride(server.URL)
t.Cleanup(func() {
restoreBaseURL()
oauthHTTPClient = oldClient
})
ctx := context.Background()
for name, fetch := range map[string]func() (string, error){
"device": func() (string, error) { return deviceFetchClientIDForLoginRegion(ctx, LoginRegionInternational) },
"oauth": func() (string, error) { return oauthFetchClientIDForLoginRegion(ctx, LoginRegionInternational) },
} {
if got, err := fetch(); err != nil || got != "international-client" {
t.Fatalf("%s client ID = %q, %v", name, got, err)
}
}
if got, err := deviceGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success {
t.Fatalf("device admins = %#v, %v", got, err)
}
if got, err := oauthGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success {
t.Fatalf("OAuth admins = %#v, %v", got, err)
}
if got, err := oauthSendApplyForLoginRegion(ctx, "token", "admin", LoginRegionInternational); err != nil || !got.Success {
t.Fatalf("OAuth apply = %#v, %v", got, err)
}
}
func TestCheckCLIAuthEnabled_Disabled(t *testing.T) { func TestCheckCLIAuthEnabled_Disabled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
+11 -1
View File
@@ -22,6 +22,7 @@ import (
"time" "time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
) )
@@ -1211,7 +1212,13 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) {
if err := os.Symlink(filepath.Join(keyDir, "dek"), filepath.Join(keyDir, "link")); err != nil { if err := os.Symlink(filepath.Join(keyDir, "dek"), filepath.Join(keyDir, "link")); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for name, value := range map[string]string{"app.json": "{}", profilesJSONFile: "{}", "mcp_url": "https://mcp.test", "terminal_url": "https://terminal.test"} { for name, value := range map[string]string{
"app.json": "{}",
profilesJSONFile: "{}",
"mcp_url": "https://mcp.test",
config.ManagedMCPURLRegionFileName: "https://mcp.test",
"terminal_url": "https://terminal.test",
} {
if err := os.WriteFile(filepath.Join(configDir, name), []byte(value), 0o600); err != nil { if err := os.WriteFile(filepath.Join(configDir, name), []byte(value), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1234,6 +1241,9 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) {
if _, err := os.Stat(filepath.Join(importDir, "app.json")); err != nil { if _, err := os.Stat(filepath.Join(importDir, "app.json")); err != nil {
t.Fatal("config file was not imported") t.Fatal("config file was not imported")
} }
if _, err := os.Stat(filepath.Join(importDir, config.ManagedMCPURLRegionFileName)); err != nil {
t.Fatal("managed MCP region marker was not imported")
}
if _, err := os.Stat(filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")); err != nil { if _, err := os.Stat(filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")); err != nil {
t.Fatal("encrypted token was not imported") t.Fatal("encrypted token was not imported")
} }
+24 -2
View File
@@ -74,6 +74,20 @@ var (
} }
) )
func deviceFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
if region.IsInternational() {
return FetchClientIDFromMCPForLoginRegion(ctx, region)
}
return deviceFetchClientID(ctx)
}
func deviceGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
if region.IsInternational() {
return GetSuperAdminsForLoginRegion(ctx, accessToken, region)
}
return deviceGetAdmins(ctx, accessToken)
}
type DeviceFlowProvider struct { type DeviceFlowProvider struct {
configDir string configDir string
clientID string clientID string
@@ -85,6 +99,7 @@ type DeviceFlowProvider struct {
httpClient *http.Client httpClient *http.Client
NoBrowser bool NoBrowser bool
IdentityEnricher func(context.Context, *TokenData) error IdentityEnricher func(context.Context, *TokenData) error
LoginRegion LoginRegion
} }
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider { func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
@@ -104,6 +119,12 @@ func (p *DeviceFlowProvider) SetBaseURL(baseURL string) {
p.baseURL = strings.TrimRight(baseURL, "/") p.baseURL = strings.TrimRight(baseURL, "/")
} }
func (p *DeviceFlowProvider) SetLoginRegion(region LoginRegion) {
p.LoginRegion = region
p.baseURL = DeviceBaseURLForLoginRegion(region)
p.terminalBaseURL = MCPBaseURLForLoginRegion(region)
}
// SetTerminalBaseURL sets the terminal API base URL for device flow polling. // SetTerminalBaseURL sets the terminal API base URL for device flow polling.
func (p *DeviceFlowProvider) SetTerminalBaseURL(baseURL string) { func (p *DeviceFlowProvider) SetTerminalBaseURL(baseURL string) {
p.terminalBaseURL = strings.TrimRight(baseURL, "/") p.terminalBaseURL = strings.TrimRight(baseURL, "/")
@@ -213,7 +234,7 @@ func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
if p.logger != nil { if p.logger != nil {
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)") p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
} }
mcpClientID, mcpErr := deviceFetchClientID(ctx) mcpClientID, mcpErr := deviceFetchClientIDForLoginRegion(ctx, p.LoginRegion)
if mcpErr != nil { if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
} }
@@ -272,6 +293,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
clientID: p.clientID, clientID: p.clientID,
logger: p.logger, logger: p.logger,
IdentityEnricher: p.IdentityEnricher, IdentityEnricher: p.IdentityEnricher,
LoginRegion: p.LoginRegion,
} }
tokenData, err := deviceExchangeCode(oauthProvider, ctx, tokenResult.AuthCode) tokenData, err := deviceExchangeCode(oauthProvider, ctx, tokenResult.AuthCode)
if err != nil { if err != nil {
@@ -331,7 +353,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。")) _, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
_, _ = fmt.Fprintln(p.output(), "") _, _ = fmt.Fprintln(p.output(), "")
admins, adminErr := deviceGetAdmins(ctx, tokenData.AccessToken) admins, adminErr := deviceGetAdminsForLoginRegion(ctx, tokenData.AccessToken, p.LoginRegion)
if adminErr == nil && admins.Success && len(admins.Result) > 0 { if adminErr == nil && admins.Success && len(admins.Result) > 0 {
maxAdmins := 3 maxAdmins := 3
if len(admins.Result) < maxAdmins { if len(admins.Result) < maxAdmins {
+11
View File
@@ -90,6 +90,17 @@ func TestRequestDeviceCodeSuccess(t *testing.T) {
} }
} }
func TestCrossPlatformCoverageDeviceFlowSetLoginRegionUsesInternationalBaseURL(t *testing.T) {
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
provider.SetLoginRegion(LoginRegionInternational)
if provider.baseURL != InternationalDeviceBaseURL {
t.Fatalf("baseURL = %q, want %q", provider.baseURL, InternationalDeviceBaseURL)
}
if provider.terminalBaseURL != InternationalMCPBaseURL {
t.Fatalf("terminalBaseURL = %q, want %q", provider.terminalBaseURL, InternationalMCPBaseURL)
}
}
func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) { func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) {
t.Parallel() t.Parallel()
+112 -5
View File
@@ -50,9 +50,15 @@ const (
// AuthorizeURL is the DingTalk OAuth authorization page. // AuthorizeURL is the DingTalk OAuth authorization page.
AuthorizeURL = "https://login.dingtalk.com/oauth2/auth" AuthorizeURL = "https://login.dingtalk.com/oauth2/auth"
// InternationalAuthorizeURL is the DingTalk international OAuth authorization page.
InternationalAuthorizeURL = "https://login.dingtalk.io/oauth2/auth"
// UserAccessTokenURL exchanges an authorization code for user tokens. // UserAccessTokenURL exchanges an authorization code for user tokens.
UserAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken" UserAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken"
// InternationalUserAccessTokenURL exchanges authorization codes for international user tokens.
InternationalUserAccessTokenURL = "https://api.dingtalk.io/v1.0/oauth2/userAccessToken"
// UserInfoURL fetches the authenticated user's profile. // UserInfoURL fetches the authenticated user's profile.
UserInfoURL = "https://api.dingtalk.com/v1.0/contact/users/me" UserInfoURL = "https://api.dingtalk.com/v1.0/contact/users/me"
@@ -75,6 +81,9 @@ const (
// DefaultDeviceBaseURL is the login server base URL for device flow. // DefaultDeviceBaseURL is the login server base URL for device flow.
DefaultDeviceBaseURL = "https://login.dingtalk.com" DefaultDeviceBaseURL = "https://login.dingtalk.com"
// InternationalDeviceBaseURL is the international login server base URL for device flow.
InternationalDeviceBaseURL = "https://login.dingtalk.io"
// DeviceCodePath requests a device_code and user_code. // DeviceCodePath requests a device_code and user_code.
DeviceCodePath = "/oauth2/device/code.json" DeviceCodePath = "/oauth2/device/code.json"
@@ -96,11 +105,12 @@ const (
LogoutContinueURL = "https://login.dingtalk.com" LogoutContinueURL = "https://login.dingtalk.com"
// MCP API endpoints for CLI authorization management. // MCP API endpoints for CLI authorization management.
DefaultMCPBaseURL = config.DefaultMCPBaseURL DefaultMCPBaseURL = config.DefaultMCPBaseURL
CLIAuthEnabledPath = "/cli/cliAuthEnabled" InternationalMCPBaseURL = "https://mcp.dingtalk.io"
SuperAdminPath = "/cli/superAdmin" CLIAuthEnabledPath = "/cli/cliAuthEnabled"
SendCliAuthApplyPath = "/cli/sendCliAuthApply" SuperAdminPath = "/cli/superAdmin"
ClientIDPath = "/cli/clientId" SendCliAuthApplyPath = "/cli/sendCliAuthApply"
ClientIDPath = "/cli/clientId"
// MCP OAuth endpoints (used when clientId is fetched from MCP). // MCP OAuth endpoints (used when clientId is fetched from MCP).
MCPOAuthTokenPath = "/oauth2/getToken" MCPOAuthTokenPath = "/oauth2/getToken"
@@ -114,6 +124,57 @@ const (
AppAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/accessToken" AppAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
) )
type LoginRegion string
const (
LoginRegionDefault LoginRegion = ""
LoginRegionInternational LoginRegion = "international"
)
func (r LoginRegion) IsInternational() bool {
return r == LoginRegionInternational
}
func AuthorizeURLForLoginRegion(region LoginRegion) string {
if override := LoginBaseURLOverride(); override != "" {
return override + "/oauth2/auth"
}
if region.IsInternational() {
return InternationalAuthorizeURL
}
return AuthorizeURL
}
func DeviceBaseURLForLoginRegion(region LoginRegion) string {
if override := LoginBaseURLOverride(); override != "" {
return override
}
if region.IsInternational() {
return InternationalDeviceBaseURL
}
return DefaultDeviceBaseURL
}
func MCPBaseURLForLoginRegion(region LoginRegion) string {
if override := MCPBaseURLOverride(); override != "" {
return override
}
if region.IsInternational() {
return InternationalMCPBaseURL
}
return GetMCPBaseURL()
}
func UserAccessTokenURLForLoginRegion(region LoginRegion) string {
if override := LoginBaseURLOverride(); override != "" {
return override + "/v1.0/oauth2/userAccessToken"
}
if region.IsInternational() {
return InternationalUserAccessTokenURL
}
return UserAccessTokenURL
}
// GetTerminalBaseURL returns the terminal base URL with priority: // GetTerminalBaseURL returns the terminal base URL with priority:
// 1. ~/.dws/terminal_url file content (for pre-release environment) // 1. ~/.dws/terminal_url file content (for pre-release environment)
// 2. Default value (https://open-dev.dingtalk.com) // 2. Default value (https://open-dev.dingtalk.com)
@@ -146,8 +207,54 @@ var (
// clientIDFromMCP indicates whether the clientID was fetched from MCP server. // clientIDFromMCP indicates whether the clientID was fetched from MCP server.
// When true, MCP OAuth endpoints should be used instead of direct DingTalk API. // When true, MCP OAuth endpoints should be used instead of direct DingTalk API.
clientIDFromMCP bool clientIDFromMCP bool
loginBaseURLMu sync.RWMutex
loginBaseURLOverride string
mcpBaseURLMu sync.RWMutex
mcpBaseURLOverride string
) )
// PushLoginBaseURLOverride sets a process-local DingTalk login base URL
// override and returns a restore function.
func PushLoginBaseURLOverride(baseURL string) func() {
loginBaseURLMu.Lock()
previous := loginBaseURLOverride
loginBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/")
loginBaseURLMu.Unlock()
return func() {
loginBaseURLMu.Lock()
loginBaseURLOverride = previous
loginBaseURLMu.Unlock()
}
}
func LoginBaseURLOverride() string {
loginBaseURLMu.RLock()
defer loginBaseURLMu.RUnlock()
return loginBaseURLOverride
}
// PushMCPBaseURLOverride sets a process-local MCP base URL override and returns
// a restore function. It is intended for one command invocation, such as
// pre-release smoke testing.
func PushMCPBaseURLOverride(baseURL string) func() {
mcpBaseURLMu.Lock()
previous := mcpBaseURLOverride
mcpBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/")
mcpBaseURLMu.Unlock()
return func() {
mcpBaseURLMu.Lock()
mcpBaseURLOverride = previous
mcpBaseURLMu.Unlock()
}
}
func MCPBaseURLOverride() string {
mcpBaseURLMu.RLock()
defer mcpBaseURLMu.RUnlock()
return mcpBaseURLOverride
}
// SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced. // SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced.
func SetClientIDFromMCP(id string) { func SetClientIDFromMCP(id string) {
clientMu.Lock() clientMu.Lock()
+132 -15
View File
@@ -20,6 +20,7 @@ import (
"fmt" "fmt"
"html" "html"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -28,6 +29,7 @@ import (
"time" "time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/google/uuid"
) )
var ( var (
@@ -51,7 +53,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
"code": code, "code": code,
"grantType": "authorization_code", "grantType": "authorization_code",
} }
resp, err := p.postJSON(ctx, UserAccessTokenURL, body) resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -62,6 +64,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
// Snapshot credentials used for this token (for refresh) // Snapshot credentials used for this token (for refresh)
data.ClientID = clientID data.ClientID = clientID
data.Source = resolveCredentialSource() data.Source = resolveCredentialSource()
p.applyLoginRegionToToken(data)
// Save clientSecret for future refresh (even if env changes) // Save clientSecret for future refresh (even if env changes)
if err := oauthSaveClientSecret(clientID, clientSecret); err != nil { if err := oauthSaveClientSecret(clientID, clientSecret); err != nil {
// Log warning but don't fail login // Log warning but don't fail login
@@ -91,11 +94,36 @@ func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenDa
return data, nil return data, nil
} }
func (p *OAuthProvider) loginRegion() LoginRegion {
if p == nil {
return LoginRegionDefault
}
return p.LoginRegion
}
func (p *OAuthProvider) useTokenLoginRegion(data *TokenData) {
if p == nil || p.LoginRegion != LoginRegionDefault || data == nil {
return
}
if region := LoginRegion(strings.TrimSpace(data.LoginRegion)); region != LoginRegionDefault {
p.LoginRegion = region
}
}
func (p *OAuthProvider) applyLoginRegionToToken(data *TokenData) {
if data == nil {
return
}
if region := p.loginRegion(); region != LoginRegionDefault {
data.LoginRegion = string(region)
}
}
// exchangeCodeViaMCP exchanges auth code for token via MCP proxy. // exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
// This is used when client secret is not available (server-side secret management). // This is used when client secret is not available (server-side secret management).
func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) { func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) {
clientID := ClientID() clientID := ClientID()
url := GetMCPBaseURL() + MCPOAuthTokenPath url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPOAuthTokenPath
body := map[string]string{ body := map[string]string{
"clientId": clientID, "clientId": clientID,
"authCode": code, "authCode": code,
@@ -112,6 +140,7 @@ func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*T
// Snapshot credentials used for this token (for refresh) // Snapshot credentials used for this token (for refresh)
data.ClientID = clientID data.ClientID = clientID
data.Source = "mcp" data.Source = "mcp"
p.applyLoginRegionToToken(data)
// MCP mode doesn't need to save clientSecret (server-side managed) // MCP mode doesn't need to save clientSecret (server-side managed)
return data, nil return data, nil
} }
@@ -120,8 +149,10 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
// Use stored Source to determine refresh path (not current runtime state) // Use stored Source to determine refresh path (not current runtime state)
// This ensures refresh works even if environment variables changed since login // This ensures refresh works even if environment variables changed since login
if data.Source == "mcp" { if data.Source == "mcp" {
p.useTokenLoginRegion(data)
return p.refreshViaMCP(ctx, data) return p.refreshViaMCP(ctx, data)
} }
p.useTokenLoginRegion(data)
// Direct mode: use stored clientId and load saved clientSecret // Direct mode: use stored clientId and load saved clientSecret
clientID := data.ClientID clientID := data.ClientID
@@ -145,7 +176,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
"refreshToken": data.RefreshToken, "refreshToken": data.RefreshToken,
"grantType": "refresh_token", "grantType": "refresh_token",
} }
resp, err := p.postJSON(ctx, UserAccessTokenURL, body) resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -156,6 +187,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
// Preserve original credentials info // Preserve original credentials info
updated.ClientID = data.ClientID updated.ClientID = data.ClientID
updated.Source = data.Source updated.Source = data.Source
updated.LoginRegion = data.LoginRegion
updated.PersistentCode = data.PersistentCode updated.PersistentCode = data.PersistentCode
updated.CorpID = data.CorpID updated.CorpID = data.CorpID
updated.UserID = data.UserID updated.UserID = data.UserID
@@ -185,7 +217,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
return nil, fmt.Errorf("无法刷新 token: 缺少 clientId,请重新登录") return nil, fmt.Errorf("无法刷新 token: 缺少 clientId,请重新登录")
} }
url := GetMCPBaseURL() + MCPRefreshTokenPath url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPRefreshTokenPath
body := map[string]string{ body := map[string]string{
"clientId": clientID, "clientId": clientID,
"refreshToken": data.RefreshToken, "refreshToken": data.RefreshToken,
@@ -202,6 +234,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
// Preserve original credentials info // Preserve original credentials info
updated.ClientID = data.ClientID updated.ClientID = data.ClientID
updated.Source = data.Source updated.Source = data.Source
updated.LoginRegion = data.LoginRegion
updated.PersistentCode = data.PersistentCode updated.PersistentCode = data.PersistentCode
updated.CorpID = data.CorpID updated.CorpID = data.CorpID
updated.UserID = data.UserID updated.UserID = data.UserID
@@ -371,6 +404,10 @@ func firstNonEmpty(values ...string) string {
} }
func buildAuthURL(clientID, redirectURI, targetCorpID string) string { func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
return buildAuthURLForRegion(clientID, redirectURI, targetCorpID, LoginRegionDefault)
}
func buildAuthURLForRegion(clientID, redirectURI, targetCorpID string, region LoginRegion) string {
params := url.Values{ params := url.Values{
"client_id": {clientID}, "client_id": {clientID},
"redirect_uri": {redirectURI}, "redirect_uri": {redirectURI},
@@ -381,7 +418,7 @@ func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" { if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" {
params.Set("corpId", targetCorpID) params.Set("corpId", targetCorpID)
} }
return AuthorizeURL + "?" + params.Encode() return AuthorizeURLForLoginRegion(region) + "?" + params.Encode()
} }
const successHTML = `<!doctype html> const successHTML = `<!doctype html>
@@ -937,14 +974,15 @@ const notEnabledHTML = `<!doctype html>
clientId = status.clientId || ""; clientId = status.clientId || "";
applySent = status.applySent || false; applySent = status.applySent || false;
selectedAdminId = status.selectedAdminId || ""; selectedAdminId = status.selectedAdminId || "";
const authorizeUrl = status.authorizeUrl || "";
if (clientId) { if (clientId && authorizeUrl) {
const port = location.port; const port = location.port;
const redirectUri = encodeURIComponent( const redirectUri = encodeURIComponent(
"http://127.0.0.1:" + port + "/callback" "http://127.0.0.1:" + port + "/callback"
); );
backLink.href = backLink.href =
"https://login.dingtalk.com/oauth2/auth?client_id=" + authorizeUrl + "?client_id=" +
clientId + clientId +
"&prompt=consent&redirect_uri=" + "&prompt=consent&redirect_uri=" +
redirectUri + redirectUri +
@@ -1398,6 +1436,7 @@ const mcpRequestMaxRetries = 3
// false negatives caused by momentary network issues. // false negatives caused by momentary network issues.
func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) { func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
var lastErr error var lastErr error
traceID := cliAuthTraceID()
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
if attempt > 0 { if attempt > 0 {
select { select {
@@ -1406,7 +1445,7 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
case <-oauthRetryAfter(time.Duration(attempt) * time.Second): case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
} }
} }
status, err := p.doCheckCLIAuthEnabled(ctx, accessToken) status, err := p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, attempt+1, traceID)
if err == nil { if err == nil {
return status, nil return status, nil
} }
@@ -1416,16 +1455,27 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
} }
func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) { func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
url := GetMCPBaseURL() + CLIAuthEnabledPath return p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, 1, cliAuthTraceID())
}
func (p *OAuthProvider) doCheckCLIAuthEnabledAttempt(ctx context.Context, accessToken string, attempt int, traceID string) (*CLIAuthStatus, error) {
url := MCPBaseURLForLoginRegion(p.loginRegion()) + CLIAuthEnabledPath
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil) req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("creating request: %w", err) return nil, fmt.Errorf("creating request: %w", err)
} }
req.Header.Set("x-user-access-token", accessToken) req.Header.Set("x-user-access-token", accessToken)
applyCLIAuthTraceHeaders(req, traceID)
if ch := os.Getenv("DWS_CHANNEL"); ch != "" { if ch := os.Getenv("DWS_CHANNEL"); ch != "" {
req.Header.Set("x-dws-channel", ch) req.Header.Set("x-dws-channel", ch)
} }
applyEditionEnterpriseCredentialHeaders(req) applyEditionEnterpriseCredentialHeaders(req)
slog.Debug("auth.cli_auth_enabled.request",
"attempt", attempt,
"url", url,
"trace_id", traceID,
"channel", os.Getenv("DWS_CHANNEL"),
)
client := p.httpClient client := p.httpClient
if client == nil { if client == nil {
@@ -1433,9 +1483,23 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
} }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
slog.Debug("auth.cli_auth_enabled.error",
"attempt", attempt,
"url", url,
"trace_id", traceID,
"error", err,
)
return nil, fmt.Errorf("sending request: %w", err) return nil, fmt.Errorf("sending request: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
slog.Debug("auth.cli_auth_enabled.response",
"attempt", attempt,
"url", url,
"status", resp.StatusCode,
"trace_id", traceID,
"response_trace_id", cliAuthResponseTraceID(resp.Header),
"eagleeye_rpc_id", resp.Header.Get("EagleEye-RpcId"),
)
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil { if err != nil {
@@ -1449,9 +1513,42 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
return &status, nil return &status, nil
} }
func cliAuthTraceID() string {
if traceID := strings.TrimSpace(os.Getenv("DINGTALK_TRACE_ID")); traceID != "" {
return traceID
}
return strings.ReplaceAll(uuid.NewString(), "-", "")
}
func applyCLIAuthTraceHeaders(req *http.Request, traceID string) {
if req == nil || traceID == "" {
return
}
req.Header.Set("EagleEye-TraceId", traceID)
req.Header.Set("X-Dingtalk-Trace-Id", traceID)
}
func cliAuthResponseTraceID(headers http.Header) string {
for _, key := range []string{
"EagleEye-TraceId",
"X-Trace-Id",
"X-Request-Id",
"X-Dingtalk-Trace-Id",
} {
if value := strings.TrimSpace(headers.Get(key)); value != "" {
return value
}
}
return ""
}
// GetSuperAdmins fetches the list of corp super admins. // GetSuperAdmins fetches the list of corp super admins.
// It retries up to mcpRequestMaxRetries times on transient errors. // It retries up to mcpRequestMaxRetries times on transient errors.
func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) { func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
return GetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault)
}
func GetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
var lastErr error var lastErr error
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
if attempt > 0 { if attempt > 0 {
@@ -1461,7 +1558,7 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
case <-oauthRetryAfter(time.Duration(attempt) * time.Second): case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
} }
} }
result, err := doGetSuperAdmins(ctx, accessToken) result, err := doGetSuperAdminsForLoginRegion(ctx, accessToken, region)
if err == nil { if err == nil {
return result, nil return result, nil
} }
@@ -1471,7 +1568,11 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
} }
func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) { func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
url := GetMCPBaseURL() + SuperAdminPath return doGetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault)
}
func doGetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
url := MCPBaseURLForLoginRegion(region) + SuperAdminPath
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil) req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("creating request: %w", err) return nil, fmt.Errorf("creating request: %w", err)
@@ -1500,6 +1601,10 @@ func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespo
// SendCliAuthApply sends a CLI auth apply request to the specified admin. // SendCliAuthApply sends a CLI auth apply request to the specified admin.
// It retries up to mcpRequestMaxRetries times on transient errors. // It retries up to mcpRequestMaxRetries times on transient errors.
func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) { func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault)
}
func SendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
var lastErr error var lastErr error
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
if attempt > 0 { if attempt > 0 {
@@ -1509,7 +1614,7 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
case <-oauthRetryAfter(time.Duration(attempt) * time.Second): case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
} }
} }
result, err := doSendCliAuthApply(ctx, accessToken, adminStaffID) result, err := doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region)
if err == nil { if err == nil {
return result, nil return result, nil
} }
@@ -1519,7 +1624,11 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
} }
func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) { func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID return doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault)
}
func doSendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
url := MCPBaseURLForLoginRegion(region) + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil) req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("creating request: %w", err) return nil, fmt.Errorf("creating request: %w", err)
@@ -1557,6 +1666,10 @@ type ClientIDResponse struct {
// This is used when no client ID is provided via flags, config, or env vars. // This is used when no client ID is provided via flags, config, or env vars.
// It retries up to mcpRequestMaxRetries times on transient errors. // It retries up to mcpRequestMaxRetries times on transient errors.
func FetchClientIDFromMCP(ctx context.Context) (string, error) { func FetchClientIDFromMCP(ctx context.Context) (string, error) {
return FetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault)
}
func FetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
var lastErr error var lastErr error
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
if attempt > 0 { if attempt > 0 {
@@ -1566,7 +1679,7 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
case <-oauthRetryAfter(time.Duration(attempt) * time.Second): case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
} }
} }
id, err := doFetchClientIDFromMCP(ctx) id, err := doFetchClientIDFromMCPForLoginRegion(ctx, region)
if err == nil { if err == nil {
return id, nil return id, nil
} }
@@ -1576,7 +1689,11 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
} }
func doFetchClientIDFromMCP(ctx context.Context) (string, error) { func doFetchClientIDFromMCP(ctx context.Context) (string, error) {
url := GetMCPBaseURL() + ClientIDPath return doFetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault)
}
func doFetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
url := MCPBaseURLForLoginRegion(region) + ClientIDPath
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil) req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return "", fmt.Errorf("creating request: %w", err) return "", fmt.Errorf("creating request: %w", err)
+33 -5
View File
@@ -68,6 +68,27 @@ var (
oauthSleep = time.Sleep oauthSleep = time.Sleep
) )
func oauthFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
if region.IsInternational() {
return FetchClientIDFromMCPForLoginRegion(ctx, region)
}
return oauthFetchClientID(ctx)
}
func oauthGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
if region.IsInternational() {
return GetSuperAdminsForLoginRegion(ctx, accessToken, region)
}
return oauthGetAdmins(ctx, accessToken)
}
func oauthSendApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
if region.IsInternational() {
return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region)
}
return oauthSendApply(ctx, accessToken, adminStaffID)
}
// OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow. // OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow.
type OAuthProvider struct { type OAuthProvider struct {
configDir string configDir string
@@ -80,6 +101,7 @@ type OAuthProvider struct {
// IdentityEnricher resolves userId/userName/corpName while the freshly // IdentityEnricher resolves userId/userName/corpName while the freshly
// exchanged access token is still only in memory. // exchanged access token is still only in memory.
IdentityEnricher func(context.Context, *TokenData) error IdentityEnricher func(context.Context, *TokenData) error
LoginRegion LoginRegion
} }
// NewOAuthProvider creates a new OAuth provider. // NewOAuthProvider creates a new OAuth provider.
@@ -173,7 +195,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
if p.logger != nil { if p.logger != nil {
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)") p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
} }
mcpClientID, mcpErr := oauthFetchClientID(ctx) mcpClientID, mcpErr := oauthFetchClientIDForLoginRegion(ctx, p.LoginRegion)
if mcpErr != nil { if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
} }
@@ -401,7 +423,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`)) _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return return
} }
result, err := oauthGetAdmins(ctx, token.AccessToken) result, err := oauthGetAdminsForLoginRegion(ctx, token.AccessToken, p.LoginRegion)
if err != nil { if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error()) _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return return
@@ -425,7 +447,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`)) _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return return
} }
result, err := oauthSendApply(ctx, token.AccessToken, adminStaffID) result, err := oauthSendApplyForLoginRegion(ctx, token.AccessToken, adminStaffID, p.LoginRegion)
if err != nil { if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error()) _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return return
@@ -448,7 +470,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
applySent := callbackApplySent applySent := callbackApplySent
selectedAdminId := callbackSelectedAdminId selectedAdminId := callbackSelectedAdminId
callbackTokenMu.Unlock() callbackTokenMu.Unlock()
_, _ = fmt.Fprintf(w, `{"clientId":"%s","applySent":%t,"selectedAdminId":"%s"}`, p.clientID, applySent, selectedAdminId) data, _ := json.Marshal(map[string]any{
"clientId": p.clientID,
"authorizeUrl": AuthorizeURLForLoginRegion(p.LoginRegion),
"applySent": applySent,
"selectedAdminId": selectedAdminId,
})
_, _ = w.Write(data)
}) })
// API endpoint: check CLI auth enabled status // API endpoint: check CLI auth enabled status
@@ -491,7 +519,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_ = server.Shutdown(shutCtx) _ = server.Shutdown(shutCtx)
}() }()
authURL := buildAuthURL(p.clientID, redirectURI, p.TargetCorpID) authURL := buildAuthURLForRegion(p.clientID, redirectURI, p.TargetCorpID, p.LoginRegion)
if p.logger != nil { if p.logger != nil {
p.logger.Debug("authorization URL", "url", authURL) p.logger.Debug("authorization URL", "url", authURL)
} }
+1 -1
View File
@@ -272,7 +272,7 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo
func portableConfigFiles(configDir string) ([]string, error) { func portableConfigFiles(configDir string) ([]string, error) {
var files []string var files []string
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", "terminal_url"} patterns := []string{"app*.json", profilesJSONFile, "mcp_url", config.ManagedMCPURLRegionFileName, "terminal_url"}
for _, pattern := range patterns { for _, pattern := range patterns {
matches, err := portableGlob(filepath.Join(configDir, pattern)) matches, err := portableGlob(filepath.Join(configDir, pattern))
if err != nil { if err != nil {
+1
View File
@@ -97,6 +97,7 @@ type TokenData struct {
ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh
UpdatedAt string `json:"updated_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"`
Source string `json:"source,omitempty"` Source string `json:"source,omitempty"`
LoginRegion string `json:"login_region,omitempty"`
// LegacyOrgScopedProfile is an in-memory destination for an explicitly // LegacyOrgScopedProfile is an in-memory destination for an explicitly
// matched historical profile whose userId was never resolved. It is never // matched historical profile whose userId was never resolved. It is never
// persisted as token material. // persisted as token material.
+25
View File
@@ -15,9 +15,13 @@ package errors
import ( import (
stderrors "errors" stderrors "errors"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
) )
func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) { func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) {
@@ -382,6 +386,27 @@ func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing
} }
} }
func TestCrossPlatformCoveragePrintJSONCLIOrgNotAuthorizedUsesInternationalActionURL(t *testing.T) {
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), config.FilePerm); err != nil {
t.Fatalf("WriteFile(mcp_url) error = %v", err)
}
var b strings.Builder
if err := PrintJSON(&b, NewAPI(
"business error",
WithServerDiag(ServerDiagnostics{ServerErrorCode: "CLI_ORG_NOT_AUTHORIZED"}),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
want := `"action_url": "https://open-dev.dingtalk.io/fe/old#/developerSettings"`
if got := b.String(); !strings.Contains(got, want) {
t.Fatalf("expected international action_url %q, got %q", want, got)
}
}
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) { func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
t.Parallel() t.Parallel()
+4 -4
View File
@@ -50,14 +50,14 @@ func init() {
Name: "DWS_TRUSTED_DOMAINS", Name: "DWS_TRUSTED_DOMAINS",
Category: configmeta.CategoryNetwork, Category: configmeta.CategoryNetwork,
Description: "信任的 HTTPS 域名白名单 (逗号分隔,* 信任所有)", Description: "信任的 HTTPS 域名白名单 (逗号分隔,* 信任所有)",
DefaultValue: "*.dingtalk.com", DefaultValue: "*.dingtalk.com,*.dingtalk.io",
Example: "*.dingtalk.com,custom.example.com", Example: "*.dingtalk.com,*.dingtalk.io,custom.example.com",
}) })
} }
const ( const (
trustedDomainsEnv = "DWS_TRUSTED_DOMAINS" trustedDomainsEnv = "DWS_TRUSTED_DOMAINS"
defaultTrustedDomains = "*.dingtalk.com" defaultTrustedDomains = "*.dingtalk.com,*.dingtalk.io"
// defaultHTTPTimeout is the default timeout for HTTP transport requests. // defaultHTTPTimeout is the default timeout for HTTP transport requests.
defaultHTTPTimeout = 30 * time.Second defaultHTTPTimeout = 30 * time.Second
@@ -654,7 +654,7 @@ func shouldPreserveEndpointQuery(parsed *url.URL) bool {
return false return false
} }
switch strings.ToLower(parsed.Hostname()) { switch strings.ToLower(parsed.Hostname()) {
case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com": case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com", "mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io":
return true return true
default: default:
return false return false
+18
View File
@@ -6,6 +6,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"strings" "strings"
"testing" "testing"
) )
@@ -234,6 +235,23 @@ func TestIsEndpointTrusted_DomainMatch(t *testing.T) {
} }
} }
func TestCrossPlatformCoverageIsEndpointTrustedDefaultIncludesDingTalkInternational(t *testing.T) {
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "")
t.Setenv("DWS_TRUSTED_DOMAINS", "")
c := NewClient(nil)
if !c.isEndpointTrusted("https://mcp-gw.dingtalk.com/server/contact") {
t.Fatal("default trusted domains should trust dingtalk.com")
}
if !c.isEndpointTrusted("https://pre-mcp-gw.dingtalk.io/server/contact") {
t.Fatal("default trusted domains should trust dingtalk.io")
}
for _, host := range []string{"mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io"} {
if !shouldPreserveEndpointQuery(&url.URL{Scheme: "https", Host: host}) {
t.Fatalf("international gateway %q should preserve endpoint query", host)
}
}
}
func TestHttpStatusError(t *testing.T) { func TestHttpStatusError(t *testing.T) {
t.Parallel() t.Parallel()
codes := []int{ codes := []int{
+10
View File
@@ -562,6 +562,16 @@ func TestSanitizeJSONRPCEndpointPreservesDingTalkMCPGatewayQuery(t *testing.T) {
endpoint: "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", endpoint: "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag",
want: "https://mcp-gw.dingtalk.com/server/demo?key=secret", want: "https://mcp-gw.dingtalk.com/server/demo?key=secret",
}, },
{
name: "prepub international gateway",
endpoint: "https://pre-mcp-gw.dingtalk.io/server/demo?key=secret#frag",
want: "https://pre-mcp-gw.dingtalk.io/server/demo?key=secret",
},
{
name: "prod international gateway",
endpoint: "https://mcp-gw.dingtalk.io/server/demo?key=secret#frag",
want: "https://mcp-gw.dingtalk.io/server/demo?key=secret",
},
} }
for _, tt := range cases { for _, tt := range cases {
+19 -2
View File
@@ -17,6 +17,7 @@
package config package config
import ( import (
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -165,10 +166,19 @@ const (
// Override at runtime via ~/.dws/mcp_url file. // Override at runtime via ~/.dws/mcp_url file.
DefaultMCPBaseURL = "https://mcp.dingtalk.com" DefaultMCPBaseURL = "https://mcp.dingtalk.com"
// ManagedMCPURLRegionFileName records an MCP URL written automatically by
// login region selection, so a later region change does not delete a user's
// explicit MCP override.
ManagedMCPURLRegionFileName = "mcp_url.login_region"
// DefaultTerminalBaseURL is the DingTalk developer platform base URL. // DefaultTerminalBaseURL is the DingTalk developer platform base URL.
// Override at runtime via ~/.dws/terminal_url file. // Override at runtime via ~/.dws/terminal_url file.
DefaultTerminalBaseURL = "https://open-dev.dingtalk.com" DefaultTerminalBaseURL = "https://open-dev.dingtalk.com"
// InternationalTerminalBaseURL is the DingTalk international developer
// platform base URL.
InternationalTerminalBaseURL = "https://open-dev.dingtalk.io"
// DeveloperSettingsPath is the path to the organization developer // DeveloperSettingsPath is the path to the organization developer
// settings page (CLI access management). // settings page (CLI access management).
DeveloperSettingsPath = "/fe/old#/developerSettings" DeveloperSettingsPath = "/fe/old#/developerSettings"
@@ -201,8 +211,9 @@ func GetMCPBaseURL() string {
} }
// GetTerminalBaseURL returns the terminal base URL with priority: // GetTerminalBaseURL returns the terminal base URL with priority:
// 1. ~/.dws/terminal_url file content (for pre-release environment) // 1. ~/.dws/terminal_url file content (for custom environments)
// 2. Default value (https://open-dev.dingtalk.com) // 2. International terminal when the configured MCP endpoint uses .io
// 3. Default value (https://open-dev.dingtalk.com)
func GetTerminalBaseURL() string { func GetTerminalBaseURL() string {
terminalURLPath := filepath.Join(DefaultConfigDir(), "terminal_url") terminalURLPath := filepath.Join(DefaultConfigDir(), "terminal_url")
if data, err := os.ReadFile(terminalURLPath); err == nil { if data, err := os.ReadFile(terminalURLPath); err == nil {
@@ -210,6 +221,12 @@ func GetTerminalBaseURL() string {
return u return u
} }
} }
if parsed, err := url.Parse(GetMCPBaseURL()); err == nil {
host := strings.ToLower(parsed.Hostname())
if host == "dingtalk.io" || strings.HasSuffix(host, ".dingtalk.io") {
return InternationalTerminalBaseURL
}
}
return DefaultTerminalBaseURL return DefaultTerminalBaseURL
} }
+28
View File
@@ -219,6 +219,34 @@ func TestGetMCPBaseURLUsesConfigFile(t *testing.T) {
} }
} }
func TestCrossPlatformCoverageGetDeveloperSettingsURLUsesInternationalMCPRegion(t *testing.T) {
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), FilePerm); err != nil {
t.Fatalf("WriteFile(mcp_url) error = %v", err)
}
want := "https://open-dev.dingtalk.io/fe/old#/developerSettings"
if got := GetDeveloperSettingsURL(); got != want {
t.Fatalf("GetDeveloperSettingsURL() = %q, want %q", got, want)
}
}
func TestCrossPlatformCoverageGetTerminalBaseURLPrefersExplicitConfig(t *testing.T) {
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), FilePerm); err != nil {
t.Fatalf("WriteFile(mcp_url) error = %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "terminal_url"), []byte("https://custom-open-dev.example.com\n"), FilePerm); err != nil {
t.Fatalf("WriteFile(terminal_url) error = %v", err)
}
if got := GetTerminalBaseURL(); got != "https://custom-open-dev.example.com" {
t.Fatalf("GetTerminalBaseURL() = %q, want explicit terminal URL", got)
}
}
func TestMaxUploadFileSize(t *testing.T) { func TestMaxUploadFileSize(t *testing.T) {
t.Parallel() t.Parallel()
var want int64 = 100 * 1024 * 1024 var want int64 = 100 * 1024 * 1024