From d7ffdc0ecba088252178e7debd3ad829deab7c77 Mon Sep 17 00:00:00 2001 From: WangWeijia Date: Tue, 26 May 2026 19:37:43 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E4=B8=B4=E6=97=B6=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Icba517d070fca04bbd42d47c81ec7961c7cb0401 --- README.md | 29 ++ README.zh.md | 28 ++ ...lient_assertion_keyless_python_tasks.zh.md | 239 ++++++++++++ ...lient_assertion_keyless_python_tests.zh.md | 268 ++++++++++++++ ...registration_app_preset_python_tasks.zh.md | 231 ++++++++++++ ...registration_app_preset_python_tests.zh.md | 341 ++++++++++++++++++ lark_oapi/client.py | 11 + lark_oapi/core/__init__.py | 1 + lark_oapi/core/access_token/__init__.py | 2 + lark_oapi/core/access_token/client.py | 116 ++++++ lark_oapi/core/access_token/model.py | 19 + lark_oapi/core/client_assertion.py | 66 ++++ lark_oapi/core/const.py | 17 + lark_oapi/core/exception.py | 23 ++ lark_oapi/core/http/transport.py | 2 + lark_oapi/core/model/config.py | 4 +- lark_oapi/core/tests/__init__.py | 1 + lark_oapi/core/tests/e2e/__init__.py | 1 + .../e2e/test_client_assertion_keyless_live.py | 80 ++++ .../test_client_assertion_keyless_local.py | 145 ++++++++ .../test_client_assertion_access_token.py | 179 +++++++++ .../core/tests/test_client_assertion_auth.py | 95 +++++ .../core/tests/test_client_assertion_core.py | 74 ++++ .../test_client_assertion_token_manager.py | 174 +++++++++ .../core/tests/test_transport_absolute_url.py | 31 ++ lark_oapi/core/token/auth.py | 28 +- lark_oapi/core/token/manager.py | 74 +++- lark_oapi/ws/client.py | 41 ++- lark_oapi/ws/tests/test_client_assertion.py | 156 ++++++++ .../access_token_authorization_code_sample.py | 27 ++ .../client_assertion_provider_sample.py | 21 ++ samples/ws/client_assertion_sample.py | 18 + 32 files changed, 2528 insertions(+), 14 deletions(-) create mode 100644 doc/client_assertion_keyless_python_tasks.zh.md create mode 100644 doc/client_assertion_keyless_python_tests.zh.md create mode 100644 doc/registration_app_preset_python_tasks.zh.md create mode 100644 doc/registration_app_preset_python_tests.zh.md create mode 100644 lark_oapi/core/access_token/__init__.py create mode 100644 lark_oapi/core/access_token/client.py create mode 100644 lark_oapi/core/access_token/model.py create mode 100644 lark_oapi/core/client_assertion.py create mode 100644 lark_oapi/core/tests/__init__.py create mode 100644 lark_oapi/core/tests/e2e/__init__.py create mode 100644 lark_oapi/core/tests/e2e/test_client_assertion_keyless_live.py create mode 100644 lark_oapi/core/tests/e2e/test_client_assertion_keyless_local.py create mode 100644 lark_oapi/core/tests/test_client_assertion_access_token.py create mode 100644 lark_oapi/core/tests/test_client_assertion_auth.py create mode 100644 lark_oapi/core/tests/test_client_assertion_core.py create mode 100644 lark_oapi/core/tests/test_client_assertion_token_manager.py create mode 100644 lark_oapi/core/tests/test_transport_absolute_url.py create mode 100644 lark_oapi/ws/tests/test_client_assertion.py create mode 100644 samples/client_assertion/access_token_authorization_code_sample.py create mode 100644 samples/client_assertion/client_assertion_provider_sample.py create mode 100644 samples/ws/client_assertion_sample.py diff --git a/README.md b/README.md index 5d892874a..bf61b04e1 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,35 @@ request = CreateMessageRequest.builder() \ response = client.im.v1.message.create(request) ``` +## ClientAssertion Keyless Mode + +For self-built apps that use an external signing service, the SDK can fetch +tenant tokens with `client_assertion` instead of `app_secret`. The SDK does not +generate, parse, sign, or store JWT private keys; your provider supplies the +final assertion string. + +```python +import os + +import lark_oapi as lark +from lark_oapi.core.client_assertion import ClientAssertionToken + + +class EnvClientAssertionProvider: + def retrieve_token(self, aud: str) -> ClientAssertionToken: + return ClientAssertionToken(os.environ["LARK_CLIENT_ASSERTION"]) + + +client = lark.Client.builder() \ + .app_id(os.environ["LARK_APP_ID"]) \ + .client_assertion_provider(EnvClientAssertionProvider()) \ + .build() +``` + +If you use a custom OpenAPI domain, also configure `oauth_base_url(...)` so the +SDK can derive the OAuth audience correctly. Keyless mode is for self-built +apps only and does not support AppAccessToken-only APIs. + ## Channel Module `lark_oapi.channel` is a high-level module built on top of the OpenAPI client diff --git a/README.zh.md b/README.zh.md index 8a1f3122a..cf7a93e93 100644 --- a/README.zh.md +++ b/README.zh.md @@ -43,6 +43,34 @@ request = CreateMessageRequest.builder() \ response = client.im.v1.message.create(request) ``` +## ClientAssertion 无密钥模式 + +自建应用如果通过外部签发服务提供 `client_assertion`,SDK 可以在不配置 +`app_secret` 的情况下换取 tenant token。SDK 不生成、不解析、不签名 JWT, +也不保存私钥;provider 只需要返回最终的 assertion 字符串。 + +```python +import os + +import lark_oapi as lark +from lark_oapi.core.client_assertion import ClientAssertionToken + + +class EnvClientAssertionProvider: + def retrieve_token(self, aud: str) -> ClientAssertionToken: + return ClientAssertionToken(os.environ["LARK_CLIENT_ASSERTION"]) + + +client = lark.Client.builder() \ + .app_id(os.environ["LARK_APP_ID"]) \ + .client_assertion_provider(EnvClientAssertionProvider()) \ + .build() +``` + +如果使用自定义 OpenAPI 域名,需要同时配置 `oauth_base_url(...)`,以便 SDK +正确生成 OAuth audience。无密钥模式仅支持自建应用,不支持只依赖 +AppAccessToken 的 API。 + ## Channel 模块 `lark_oapi.channel` 是基于 OpenAPI Client 和事件传输封装的高层模块。它把机器人接入中的事件监听、消息归一化、安全策略、出站发送、媒体上传下载、卡片交互、流式回复等能力收敛到 `FeishuChannel` 一个入口。 diff --git a/doc/client_assertion_keyless_python_tasks.zh.md b/doc/client_assertion_keyless_python_tasks.zh.md new file mode 100644 index 000000000..e1c7d1fe4 --- /dev/null +++ b/doc/client_assertion_keyless_python_tasks.zh.md @@ -0,0 +1,239 @@ +# Python ClientAssertion 无密钥改造任务清单 + +> 后续 AGENT 执行时,请逐项完成并把 `- [ ]` 改成 `- [x]`,或在条目后追加完成标记。实现以 `oapi-sdk-go` 当前 `v3_main` 的 ClientAssertion 行为为准,GO 文档仅作辅助。 + +## 目标 + +为 Python SDK 增加 ClientAssertion 无密钥模式:调用方注入 `ClientAssertionProvider`,SDK 在需要应用侧凭证时向 provider 获取 `client_assertion`,再向 OAuth 服务换取 tenant access token 或用户 access token。SDK 不生成 JWT、不签名、不保存私钥。 + +## 当前 Python SDK 现状 + +| 模块 | 当前职责 | 改造关注点 | +| --- | --- | --- | +| `lark_oapi/client.py` | `ClientBuilder`、全局 `Config`、服务初始化 | 增加 provider、OAuth base URL builder;初始化 `client.access_token` | +| `lark_oapi/core/model/config.py` | 保存 `app_id`、`app_secret`、`domain`、`app_type`、cache 等配置 | 新增 provider 和 OAuth base URL 字段 | +| `lark_oapi/core/token/auth.py` | 请求前鉴权、选择 app/tenant/user token | 加入 ClientAssertion 模式 token type 决策 | +| `lark_oapi/core/token/manager.py` | 获取并缓存 app/tenant token | 增加 OAuth JWT bearer tenant token exchange;ClientAssertion 模式禁止 app token | +| `lark_oapi/core/http/transport.py` | 组装 URL/header/body 并发送 sync/async 请求 | 支持 absolute URL,供 OAuth/proxy endpoint 使用 | +| `lark_oapi/ws/client.py` | WebSocket endpoint bootstrap 和连接管理 | 支持 provider、TargetInfo 代理、自定义 header 覆盖规则 | + +## GO SDK 对齐约束 + +- [x] ✅ `app_id` 仍然必填;配置 provider 后 `app_secret` 可以为空。 +- [x] ✅ `ClientAssertionProvider.retrieve_token(aud)` 每次需要 assertion 时调用;SDK 不缓存 assertion。 +- [x] ✅ ClientAssertion 模式只支持自建应用;Python 中对应 `AppType.SELF`。`AppType.ISV` 直接拒绝。 +- [x] ✅ 普通 OpenAPI 请求在 ClientAssertion 模式下优先使用 tenant token;显式传入 user token 且接口支持 user token 时继续使用 user token。 +- [x] ✅ AppAccessToken-only API 在 ClientAssertion 模式下报 `7103`。 +- [x] ✅ tenant token 使用 `POST {oauth_base_url}/oauth/v3/token`,grant type 为 `urn:ietf:params:oauth:grant-type:jwt-bearer`。 +- [x] ✅ OAuth audience 使用 OAuth host;WS audience 使用 WS domain host。 +- [x] ✅ `TargetInfo` 只做朴素拼接:`target_service + target_prefix + api_path`;`target_service` 无 scheme 时补 `https://`。 +- [x] ✅ tenant token 缓存 TTL 按 `expires_in - 3min`;Python 当前 cache 接口接收绝对 Unix 过期时间,因此写入 `time.time() + max(expires_in - 180, 0)`。 +- [x] ✅ WS provider 获取失败时直接返回原始错误,不包装成 `7102`;空 token 仍返回 `7101`。 + +## 建议新增公共类型 + +文件:`lark_oapi/core/client_assertion.py` + +```python +from dataclasses import dataclass +from typing import Optional, Protocol + + +@dataclass +class TargetInfo: + target_service: str + target_prefix: str = "" + + +@dataclass +class ClientAssertionToken: + value: str + target_info: Optional[TargetInfo] = None + + +class ClientAssertionProvider(Protocol): + def retrieve_token(self, aud: str) -> ClientAssertionToken: + raise NotImplementedError +``` + +## 实现任务点 + +### 任务 1:常量、错误和 URL 工具 + +涉及文件: +- `lark_oapi/core/const.py` +- `lark_oapi/core/exception.py` +- `lark_oapi/core/client_assertion.py` +- `lark_oapi/core/__init__.py` + +- [x] ✅ 增加 OAuth 域名常量:`FEISHU_OAUTH_DOMAIN = "https://accounts.feishu.cn"`、`LARK_OAUTH_DOMAIN = "https://accounts.larksuite.com"`。 +- [x] ✅ 增加 OAuth token path:`OAUTH_TOKEN_URI = "/oauth/v3/token"`。 +- [x] ✅ 增加 grant/type 常量:`GRANT_TYPE_JWT_BEARER`、`CLIENT_ASSERTION_TYPE_JWT_BEARER`。 +- [x] ✅ 增加 header 常量:`X_TARGET_SERVICE = "X-Target-Service"`。 +- [x] ✅ 增加错误码常量:`7100` 到 `7104`,命名对齐 GO SDK。 +- [x] ✅ 新增 `TargetInfo`、`ClientAssertionToken`、`ClientAssertionProvider`。 +- [x] ✅ 新增 `ClientAssertionException(code, msg)`,用于 provider 为空、token 为空、模式不支持等本地错误。 +- [x] ✅ 新增 `AccessTokenException(status_code, code, error, error_description)`,用于 OAuth user token API 非 200 响应。 +- [x] ✅ 新增 `extract_aud_from_url(raw_url)`:无 scheme 时补 `https://`,返回 host,支持端口。 +- [x] ✅ 新增 `resolve_oauth_base_url(config)`:显式 `config.oauth_base_url` 优先,否则默认 OpenAPI host 映射 accounts host。 +- [x] ✅ 新增 `resolve_oauth_aud(config)`:从 OAuth base URL 解析 host。 +- [x] ✅ 新增 `build_proxy_url(target_info, api_path)`:无 scheme 时补 `https://`,然后朴素拼接。 +- [x] ✅ 在 `lark_oapi/core/__init__.py` 导出新增类型和工具。 + +验收方框: +- [x] ✅ 默认 Feishu/Lark audience 正确。 +- [x] ✅ 自定义 `oauth_base_url="http://127.0.0.1:18080"` 时 aud 为 `127.0.0.1:18080`。 +- [x] ✅ 自定义 OpenAPI domain 且未配置 OAuth base URL 时抛出清晰错误。 +- [x] ✅ proxy URL 拼接不额外修正斜杠,保持 GO 行为。 + +### 任务 2:ClientBuilder 和 Config 入口 + +涉及文件: +- `lark_oapi/core/model/config.py` +- `lark_oapi/client.py` + +- [x] ✅ `Config` 增加 `client_assertion_provider` 字段。 +- [x] ✅ `Config` 增加 `oauth_base_url` 字段。 +- [x] ✅ `ClientBuilder` 增加 `client_assertion_provider(provider)`。 +- [x] ✅ `ClientBuilder` 增加 `oauth_base_url(oauth_base_url: str)`。 +- [x] ✅ `Client` 增加 `access_token` 属性,命名遵循现有 lowercase service 风格。 +- [x] ✅ `ClientBuilder.build()` 初始化 `client.access_token` 服务。 +- [x] ✅ provider 和 `app_secret` 同时配置时,OAuth/token/WS 路径优先使用 provider。 + +验收方框: +- [x] ✅ `Client.builder().app_id("cli_example").client_assertion_provider(provider).build()` 成功。 +- [x] ✅ provider 存在时 `app_secret` 为空不会在 build 阶段失败。 +- [x] ✅ 未配置 provider 的 app_secret 模式保持现有行为。 + +### 任务 3:鉴权选择逻辑 + +涉及文件: +- `lark_oapi/core/token/auth.py` + +- [x] ✅ 在 `verify(config, request, option)` 开头加入 ClientAssertion 分支。 +- [x] ✅ provider 存在且 `config.app_id` 为空时报清晰错误:`app_id not found`。 +- [x] ✅ provider 存在且 `config.app_type == AppType.ISV` 时返回 `7100`。 +- [x] ✅ `option.user_access_token` 非空且接口支持 `AccessTokenType.USER` 时选择 user token,并且不调用 provider。 +- [x] ✅ 接口支持 `AccessTokenType.TENANT` 时调用 `TokenManager.get_self_tenant_token(config)`。 +- [x] ✅ tenant token 获取成功后写入 `option.tenant_access_token`,并将 `request.token_types` 改为 `{AccessTokenType.TENANT}`。 +- [x] ✅ 仅支持 `AccessTokenType.APP` 时返回 `7103`。 +- [x] ✅ provider 不存在时保持现有 app_secret 模式兼容。 + +验收方框: +- [x] ✅ tenant+app 混合 token types 在 provider 模式下选择 tenant。 +- [x] ✅ 显式 user token 在 provider 模式下不触发 tenant token exchange。 +- [x] ✅ app-only API 在 provider 模式下返回 `7103`。 +- [x] ✅ ISV + provider 返回 `7100`。 + +### 任务 4:tenant token 的 ClientAssertion exchange + +涉及文件: +- `lark_oapi/core/token/manager.py` +- `lark_oapi/core/token/__init__.py` + +- [x] ✅ `TokenManager.get_self_app_token(config)` 在 provider 存在时直接返回 `7100`,信息对齐 GO:ClientAssertion 模式不支持 AppAccessToken。 +- [x] ✅ `TokenManager.get_self_tenant_token(config)` 在 provider 存在时先按当前 Python tenant token cache key 读取缓存。 +- [x] ✅ cache miss 时解析 OAuth base URL 和 aud。 +- [x] ✅ cache miss 时调用 `config.client_assertion_provider.retrieve_token(aud)`。 +- [x] ✅ provider 抛错时包装为 `7102`,message 保留原始错误。 +- [x] ✅ token 为 `None` 或 `value` 为空时报 `7101`。 +- [x] ✅ 请求 `POST {oauth_base_url}/oauth/v3/token`。 +- [x] ✅ 请求 body 包含 `grant_type`、`client_assertion_type`、`client_assertion`、`client_id`。 +- [x] ✅ `TargetInfo` 存在时改走 proxy URL,并设置 `X-Target-Service` 为真实 OAuth aud。 +- [x] ✅ 响应无 `access_token` 时,错误 message 优先 `error_description`,其次 `error`,最后 `oauth token response missing access token`。 +- [x] ✅ 成功后缓存 tenant token,过期时间为 `time.time() + max(expires_in - 180, 0)`。 +- [x] ✅ cache key 先沿用现有 `self_tenant_token:{app_id}`;如产品明确要求隔离,再加 mode suffix。 + +验收方框: +- [x] ✅ 请求路径是 `/oauth/v3/token`。 +- [x] ✅ 请求体字段和 GO SDK 完全一致。 +- [x] ✅ provider 每次 cache miss 被调用;cache hit 不调用 provider。 +- [x] ✅ `expires_in < 180` 时不会写入过去时间导致异常。 + +### 任务 5:Transport 支持 absolute URL + +涉及文件: +- `lark_oapi/core/http/transport.py` + +- [x] ✅ `_build_url(domain, uri, paths)` 支持 `uri` 为 `http://` 或 `https://` 开头的完整 URL。 +- [x] ✅ absolute URL 只替换 path params,不再拼接 `domain`。 +- [x] ✅ 相对 URL 仍按现有逻辑拼接 `domain + uri`。 +- [x] ✅ OAuth token exchange 调用方直接解析 OAuth 响应,不走 `Client.request()` 的 `BaseResponse` 语义。 +- [x] ✅ 保持自定义 headers、User-Agent、Content-Type 行为不回退。 + +验收方框: +- [x] ✅ absolute OAuth URL 不被拼成 `https://open.feishu.cnhttps://accounts.example.com/oauth/v3/token`。 +- [x] ✅ 普通 OpenAPI 相对路径行为不变。 + +### 任务 6:OAuth user AccessToken 服务 + +新增文件建议: +- `lark_oapi/core/access_token/__init__.py` +- `lark_oapi/core/access_token/client.py` +- `lark_oapi/core/access_token/model.py` + +修改文件: +- `lark_oapi/client.py` + +- [x] ✅ 新增 `client.access_token` 服务。 +- [x] ✅ 提供 `retrieve_by_authorization_code(code, redirect_uri=None, code_verifier=None, scope=None)`。 +- [x] ✅ 提供 `refresh(refresh_token, scope=None)`。 +- [x] ✅ provider 存在时 body 使用 `client_assertion_type` 和 `client_assertion`。 +- [x] ✅ provider 不存在且 `app_secret` 存在时 body 使用 `client_secret`。 +- [x] ✅ provider 和 `app_secret` 都为空时报 `7104`。 +- [x] ✅ 成功响应暴露 `access_token`、`token_type`、`expires_in`、`refresh_token`、`refresh_token_expires_in`、`scope`。 +- [x] ✅ 非 200 响应抛 `AccessTokenException`,保留 HTTP status code、`code`、`error`、`error_description`。 +- [x] ✅ `TargetInfo` 存在时走 proxy URL,并设置 `X-Target-Service` 为真实 OAuth aud。 + +验收方框: +- [x] ✅ authorization code 与 refresh token 的 body 字段分别正确。 +- [x] ✅ app_secret fallback 不包含 client assertion 字段。 +- [x] ✅ PKCE 字段 `code_verifier` 透传。 +- [x] ✅ OAuth error response 不被当成普通 OpenAPI `{code,msg}`。 + +### 任务 7:WebSocket ClientAssertion bootstrap + +涉及文件: +- `lark_oapi/ws/client.py` +- `lark_oapi/ws/exception.py` + +- [x] ✅ `Client.__init__` 增加 `client_assertion_provider=None`。 +- [x] ✅ `_get_conn_url()` 中 provider 和 `app_secret` 不能同时都为空。 +- [x] ✅ provider 存在时 aud 使用 `_domain` 的 host。 +- [x] ✅ provider 抛错时直接抛原始错误,保持 GO 细节。 +- [x] ✅ token 为空时报 `ClientException(7101, "client assertion token is empty")`。 +- [x] ✅ provider 模式 body 发送 `{"AppID": app_id, "ClientAssertion": token.value}`,不发送 `AppSecret`。 +- [x] ✅ `TargetInfo` 存在时 URL 改为 proxy URL,并设置 `X-Target-Service` 为真实 aud。 +- [x] ✅ 用户 headers 先合并,SDK 注入的 `locale`、`User-Agent`、`X-Target-Service` 后覆盖。 +- [x] ✅ 非 200 响应如果 body 可解析出 `msg`,使用服务端 msg;否则使用 `system busy`。 + +验收方框: +- [x] ✅ app_secret bootstrap 旧行为不变。 +- [x] ✅ provider bootstrap 不传 `AppSecret`。 +- [x] ✅ 每次 `_get_conn_url()` 都调用 provider。 +- [x] ✅ 自定义 header 不丢失,冲突时 `X-Target-Service` 使用 SDK 注入值。 + +### 任务 8:样例和文档 + +新增文件建议: +- `samples/client_assertion/client_assertion_provider_sample.py` +- `samples/client_assertion/access_token_authorization_code_sample.py` +- `samples/ws/client_assertion_sample.py` + +修改文件: +- `README.md` +- `README.zh.md` + +- [x] ✅ 给出最小 provider 样例,从环境变量读取已生成的 assertion。 +- [x] ✅ 明确 SDK 不生成 JWT;生产环境建议 provider 对接 KMS、Vault 或内部签发服务。 +- [x] ✅ 写清 `oauth_base_url` 何时需要配置。 +- [x] ✅ 写清 ISV / app-only API 不支持。 +- [x] ✅ README 中 app_secret 模式说明不被破坏。 + +## 建议实现顺序 + +- [x] ✅ 先做任务 1 和任务 2,只暴露类型与配置入口。 +- [x] ✅ 再做任务 3 到任务 5,让普通 OpenAPI 的 tenant token 链路跑通。 +- [x] ✅ 然后做任务 6,补齐 OAuth user AccessToken API。 +- [x] ✅ 再做任务 7,补齐 WS。 +- [x] ✅ 最后做任务 8,并跑完整回归和本地 mock E2E。 diff --git a/doc/client_assertion_keyless_python_tests.zh.md b/doc/client_assertion_keyless_python_tests.zh.md new file mode 100644 index 000000000..d63fa0138 --- /dev/null +++ b/doc/client_assertion_keyless_python_tests.zh.md @@ -0,0 +1,268 @@ +# Python ClientAssertion 无密钥测试清单 + +> 后续 AGENT 执行时,请逐项完成并把 `- [ ]` 改成 `- [x]`,或在条目后追加完成标记。本文件只描述测试与 E2E,任务实现见 `doc/client_assertion_keyless_python_tasks.zh.md`。 + +## 测试文件规划 + +| 文件 | 覆盖内容 | +| --- | --- | +| `lark_oapi/core/tests/test_client_assertion_core.py` | provider 类型、错误码、OAuth aud/base URL、proxy URL | +| `lark_oapi/core/tests/test_client_assertion_auth.py` | token type 选择、app-only 拒绝、ISV 拒绝、manual user token 优先 | +| `lark_oapi/core/tests/test_client_assertion_token_manager.py` | tenant token OAuth exchange、缓存、provider 错误、空 token、OAuth 错误响应 | +| `lark_oapi/core/tests/test_client_assertion_access_token.py` | authorization code、refresh token、app_secret fallback、OAuth error、TargetInfo proxy | +| `lark_oapi/ws/tests/test_client_assertion.py` | WS bootstrap provider、proxy、headers、空 token、provider error 原样抛出 | +| `lark_oapi/core/tests/test_transport_absolute_url.py` | absolute URL 拼接与相对 URL 兼容 | +| `lark_oapi/core/tests/e2e/test_client_assertion_keyless_local.py` | 本地 mock E2E | +| `lark_oapi/core/tests/e2e/test_client_assertion_keyless_live.py` | 真实环境 smoke E2E,默认跳过 | + +## 单元与集成测试用例 + +### Core / URL 工具 + +- [x] ✅ `test_resolve_oauth_base_url_default_feishu` + - 输入:`Config.domain = "https://open.feishu.cn"` + - 期望:OAuth base URL 为 `https://accounts.feishu.cn`,aud 为 `accounts.feishu.cn`。 + +- [x] ✅ `test_resolve_oauth_base_url_default_lark` + - 输入:`Config.domain = "https://open.larksuite.com"` + - 期望:OAuth base URL 为 `https://accounts.larksuite.com`,aud 为 `accounts.larksuite.com`。 + +- [x] ✅ `test_resolve_oauth_base_url_explicit_localhost` + - 输入:`Config.oauth_base_url = "http://127.0.0.1:18080"` + - 期望:OAuth base URL 保留 http scheme,aud 为 `127.0.0.1:18080`。 + +- [x] ✅ `test_resolve_oauth_base_url_requires_explicit_for_custom_domain` + - 输入:`Config.domain = "https://open.feishu-boe.cn"` + - 期望:未配置 `oauth_base_url` 时抛错。 + +- [x] ✅ `test_build_proxy_url_adds_https_when_scheme_missing` + - 输入:`TargetInfo(target_service="proxy.example.com", target_prefix="/proxy")` 和 `/oauth/v3/token` + - 期望:`https://proxy.example.com/proxy/oauth/v3/token`。 + +### 鉴权选择 + +- [x] ✅ `test_verify_client_assertion_prefers_tenant_over_app` + - request token types 为 `{AccessTokenType.APP, AccessTokenType.TENANT}`。 + - provider 存在。 + - 期望:选择 tenant,`option.tenant_access_token` 被写入。 + +- [x] ✅ `test_verify_client_assertion_manual_user_token_wins` + - request token types 为 `{AccessTokenType.TENANT, AccessTokenType.USER}`。 + - 传入 `option.user_access_token`。 + - 期望:不调用 provider,不请求 OAuth token endpoint。 + +- [x] ✅ `test_verify_client_assertion_rejects_app_only` + - request token types 为 `{AccessTokenType.APP}`。 + - provider 存在。 + - 期望:返回 `7103`。 + +- [x] ✅ `test_verify_client_assertion_rejects_isv` + - `config.app_type = AppType.ISV`。 + - provider 存在。 + - 期望:返回 `7100`。 + +- [x] ✅ `test_verify_app_secret_mode_still_requires_app_secret` + - provider 不存在,`app_secret` 为空。 + - 期望:保持现有 `NoAuthorizationException("app_id or app_secret not found")` 行为。 + +### Tenant token manager + +- [x] ✅ `test_get_self_tenant_token_by_client_assertion_requests_oauth_token` + - fake server 校验 `/oauth/v3/token` body。 + - 期望:body 包含 JWT bearer grant type、client assertion、client id;返回 tenant token 并写缓存。 + +- [x] ✅ `test_get_self_tenant_token_by_client_assertion_cache_hit_skips_provider` + - 第一次请求写入缓存,第二次请求同一 app。 + - 期望:第二次不调用 provider。 + +- [x] ✅ `test_get_self_tenant_token_by_client_assertion_without_cache_miss_calls_provider` + - 清空 cache 后请求两次。 + - 期望:每次 cache miss 都调用 provider。 + +- [x] ✅ `test_get_self_tenant_token_by_client_assertion_with_proxy` + - provider 返回 `TargetInfo(target_service=proxy, target_prefix="/proxy")`。 + - 期望:fake proxy 收到 `/proxy/oauth/v3/token` 和 `X-Target-Service: accounts.feishu.cn`。 + +- [x] ✅ `test_get_self_tenant_token_by_client_assertion_empty_token` + - provider 返回 `None` 或 `ClientAssertionToken(value="")`。 + - 期望:返回 `7101`。 + +- [x] ✅ `test_get_self_tenant_token_by_client_assertion_provider_error` + - provider 抛 `RuntimeError("boom")`。 + - 期望:返回 `7102`,message 包含 `boom`。 + +- [x] ✅ `test_get_self_tenant_token_by_client_assertion_oauth_error_message_priority` + - OAuth 响应无 `access_token`,包含 `error_description` 和 `error`。 + - 期望:错误 message 优先使用 `error_description`。 + +- [x] ✅ `test_get_self_app_token_blocked_in_client_assertion_mode` + - provider 存在时调用 `TokenManager.get_self_app_token(config)`。 + - 期望:返回 `7100`。 + +### Transport + +- [x] ✅ `test_build_url_keeps_absolute_http_url` + - 输入:`uri = "http://127.0.0.1:18080/oauth/v3/token"`。 + - 期望:不拼接 `conf.domain`。 + +- [x] ✅ `test_build_url_keeps_absolute_https_url` + - 输入:`uri = "https://accounts.feishu.cn/oauth/v3/token"`。 + - 期望:不拼接 `conf.domain`。 + +- [x] ✅ `test_build_url_relative_path_unchanged` + - 输入:`domain = "https://open.feishu.cn"`、`uri = "/open-apis/mock/v1/ping"`。 + - 期望:输出 `https://open.feishu.cn/open-apis/mock/v1/ping`。 + +### OAuth user AccessToken + +- [x] ✅ `test_access_token_authorization_code_with_client_assertion` + - 调用 `client.access_token.retrieve_by_authorization_code(code="code", redirect_uri="https://example.com/cb", code_verifier="verifier")`。 + - 期望:body 包含 `grant_type=authorization_code`、`client_assertion_type`、`client_assertion`、`client_id`、`code`、`redirect_uri`、`code_verifier`。 + +- [x] ✅ `test_access_token_refresh_with_client_assertion` + - 调用 `client.access_token.refresh(refresh_token="refresh-token")`。 + - 期望:body 包含 `grant_type=refresh_token`、`refresh_token` 和 client assertion 字段。 + +- [x] ✅ `test_access_token_authorization_code_with_app_secret_fallback` + - provider 为空,`app_secret` 存在。 + - 期望:body 包含 `client_secret`,不包含 client assertion 字段。 + +- [x] ✅ `test_access_token_refresh_with_app_secret_fallback` + - provider 为空,`app_secret` 存在。 + - 期望:body 包含 `client_secret` 和 `refresh_token`。 + +- [x] ✅ `test_access_token_rejects_missing_credentials` + - provider 和 `app_secret` 都为空。 + - 期望:返回 `7104`。 + +- [x] ✅ `test_access_token_returns_access_token_exception_for_non_200` + - OAuth endpoint 返回 HTTP 401,body 包含 `code`、`error`、`error_description`。 + - 期望:抛 `AccessTokenException`,保留所有字段。 + +- [x] ✅ `test_access_token_proxy_keeps_custom_headers` + - provider 返回 `TargetInfo`,调用时传自定义 headers。 + - 期望:proxy 收到自定义 header 和 SDK 注入的 `X-Target-Service`。 + +### WebSocket + +- [x] ✅ `test_ws_get_conn_url_with_app_secret_keeps_existing_behavior` + - 使用 `Client("app_id", "app_secret")`。 + - 期望:body 为 `{"AppID": "app_id", "AppSecret": "app_secret"}`。 + +- [x] ✅ `test_ws_get_conn_url_with_client_assertion` + - 使用 `Client("app_id", "", client_assertion_provider=provider)`。 + - 期望:body 为 `{"AppID": "app_id", "ClientAssertion": "assertion"}`,不包含 `AppSecret`。 + +- [x] ✅ `test_ws_get_conn_url_with_client_assertion_proxy` + - provider 返回 `TargetInfo`。 + - 期望:请求 URL 为 proxy URL,header `X-Target-Service` 为 WS domain host。 + +- [x] ✅ `test_ws_get_conn_url_retrieves_token_each_time` + - provider 依次返回 `assertion-1`、`assertion-2`。 + - 期望:连续两次 `_get_conn_url()` 分别发送两个 assertion。 + +- [x] ✅ `test_ws_get_conn_url_empty_client_assertion_token` + - provider 返回空 token。 + - 期望:抛 `ClientException`,code 为 `7101`。 + +- [x] ✅ `test_ws_provider_error_is_not_wrapped` + - provider 抛出 `RuntimeError("boom")`。 + - 期望:`_get_conn_url()` 抛出的就是原始 `RuntimeError`。 + +- [x] ✅ `test_ws_non_200_uses_server_msg_when_available` + - bootstrap 返回 HTTP 500,body 为 `{"code":20050,"msg":"target service unavailable"}`。 + - 期望:抛 `ServerException(500, "target service unavailable")`。 + +## 本地 mock E2E + +文件:`lark_oapi/core/tests/e2e/test_client_assertion_keyless_local.py` + +目标: +- [x] ✅ 覆盖 `provider -> OAuth token exchange -> 普通 OpenAPI 请求带 tenant token` 的完整链路。 +- [x] ✅ 覆盖 `client.access_token` authorization code / refresh token 的完整链路。 +- [x] ✅ 覆盖 WS bootstrap 的 provider、TargetInfo、headers 链路。 + +本地 server 需要提供: +- [x] ✅ `POST /oauth/v3/token`:校验 request body,返回 `{"access_token":"tenant-token","expires_in":7200}` 或用户 token 响应。 +- [x] ✅ `GET /open-apis/mock/v1/ping`:校验 `Authorization: Bearer tenant-token`,返回 `{"code":0,"msg":"ok"}`。 +- [x] ✅ `POST /callback/ws/endpoint`:校验 `ClientAssertion` body,返回 WS endpoint JSON。 + +本地 E2E 关键断言: +- [x] ✅ provider 收到的 OAuth aud 是 `127.0.0.1:`。 +- [x] ✅ OAuth exchange body 使用 JWT bearer grant type。 +- [x] ✅ 普通 OpenAPI 请求最终带 tenant token。 +- [x] ✅ 第二次普通请求命中 tenant token cache,不再次调用 provider。 +- [x] ✅ WS bootstrap 使用 domain host 作为 aud,且 body 不含 `AppSecret`。 + +推荐命令: + +```bash +python -m pytest lark_oapi/core/tests/e2e/test_client_assertion_keyless_local.py -v +``` + +## 真实环境 E2E + +文件:`lark_oapi/core/tests/e2e/test_client_assertion_keyless_live.py` + +状态:真实环境 smoke 用例已实现;本机未设置 `LARK_CLIENT_ASSERTION_E2E=1` 和真实凭证,完整回归中按设计跳过。 + +默认跳过条件: +- [x] ✅ 未设置 `LARK_CLIENT_ASSERTION_E2E=1` 时跳过。 +- [x] ✅ 缺少必要环境变量时跳过。 + +环境变量: +- `LARK_APP_ID`:应用 app id。 +- `LARK_CLIENT_ASSERTION`:外部系统已签发好的 assertion。SDK 测试只消费它,不生成它。 +- `LARK_OPENAPI_DOMAIN`:可选,默认 `https://open.feishu.cn`。 +- `LARK_OAUTH_BASE_URL`:自定义域或 BOE 环境必填,默认按 domain 映射。 +- `LARK_OAUTH_CODE`:可选,一次性 authorization code,用于 user token authorization code E2E。 +- `LARK_REFRESH_TOKEN`:可选,用于 refresh token E2E。 + +真实 E2E 分组: +- [x] ✅ tenant token exchange smoke:provider 从 `LARK_CLIENT_ASSERTION` 返回 assertion,断言拿到非空 tenant token。 +- [x] ✅ authorization code exchange smoke:仅当 `LARK_OAUTH_CODE` 存在时运行,断言 `access_token` 非空。 +- [x] ✅ refresh token smoke:仅当 `LARK_REFRESH_TOKEN` 存在时运行,断言 `access_token` 非空。 +- [x] ✅ WS bootstrap smoke:仅当 `LARK_WS_CLIENT_ASSERTION_E2E=1` 存在时运行,只调用 `_get_conn_url()`,不进入长期 `start()` 阻塞循环。 + +推荐命令: + +```bash +LARK_CLIENT_ASSERTION_E2E=1 \ +LARK_APP_ID=cli_example \ +LARK_CLIENT_ASSERTION=example_assertion \ +python -m pytest lark_oapi/core/tests/e2e/test_client_assertion_keyless_live.py -v +``` + +## 回归命令 + +新增测试定向运行: + +```bash +python -m pytest \ + lark_oapi/core/tests/test_client_assertion_core.py \ + lark_oapi/core/tests/test_client_assertion_auth.py \ + lark_oapi/core/tests/test_client_assertion_token_manager.py \ + lark_oapi/core/tests/test_client_assertion_access_token.py \ + lark_oapi/core/tests/test_transport_absolute_url.py \ + lark_oapi/ws/tests/test_client_assertion.py -v +``` + +完整回归: + +```bash +python -m pytest lark_oapi/core/tests lark_oapi/ws/tests lark_oapi/channel/tests -v +``` + +## 验收清单 + +- [x] ✅ app_secret 模式现有测试全部通过。 +- [x] ✅ provider 模式允许 app_secret 为空。 +- [x] ✅ SDK 不生成、不解析、不签名 JWT。 +- [x] ✅ tenant token OAuth exchange 请求体与 GO SDK 一致。 +- [x] ✅ OAuth audience 与 WS audience 规则分别正确。 +- [x] ✅ TargetInfo proxy URL 和 `X-Target-Service` 与 GO SDK 一致。 +- [x] ✅ app-only API 在 provider 模式下返回 `7103`。 +- [x] ✅ ISV 应用在 provider 模式下返回 `7100`。 +- [x] ✅ WS provider error 原样抛出,空 token 返回 `7101`。 +- [x] ✅ 本地 mock E2E 覆盖 OpenAPI、AccessToken、WS 三条链路。 +- [x] ✅ 真实环境 E2E 默认跳过,只有显式环境变量开启时才运行。 diff --git a/doc/registration_app_preset_python_tasks.zh.md b/doc/registration_app_preset_python_tasks.zh.md new file mode 100644 index 000000000..bf52bc452 --- /dev/null +++ b/doc/registration_app_preset_python_tasks.zh.md @@ -0,0 +1,231 @@ +# Python 一键创建应用 app_preset 实现任务点 + +## 背景 + +Node SDK commit `984bb8d80aa98c5d873ec094287330a377689161` 为 `registerApp` 新增了 `appPreset`,用于在扫码创建应用页面预填应用头像、名称和描述。Python SDK 需要实现同等能力,但使用 Python 风格参数名 `app_preset`。 + +该能力只影响二维码 URL,不影响 `/oauth/v1/app/registration` 的 `begin` / `poll` 请求体。SDK 接收原始参数值,由 SDK 负责 URL Encode;Web 创建页负责展示、`{user}` 替换、图片处理、用户编辑以及最终提交。 + +## 设计原则 + +- 对齐 Node 行为,Python API 使用 snake_case。 +- `app_preset` 是创建页初始化预填值,不是最终创建结果的强约束。 +- 用户传原始值,例如 `{user}的应用`,不要要求用户预先 URL Encode。 +- 保留已有二维码参数:`from=sdk`、`tp=sdk`、`source=python-sdk[/source]`。 +- 只做 SDK 侧轻量校验,不探测图片 URL、不校验图片格式、不校验名称和描述长度。 + +## 公开 API + +同步接口新增参数: + +```python +def register_app( + on_qr_code, + on_status_change=None, + source=None, + cancel_event=None, + domain="https://accounts.feishu.cn", + lark_domain="https://accounts.larksuite.com", + app_preset=None, +): +``` + +异步接口新增参数: + +```python +async def aregister_app( + on_qr_code, + on_status_change=None, + source=None, + domain="https://accounts.feishu.cn", + lark_domain="https://accounts.larksuite.com", + app_preset=None, +): +``` + +`app_preset` 使用 dict: + +```python +{ + "avatar": "https://example.com/a.png", + "name": "{user}的应用", + "desc": "由业务平台自动生成", +} +``` + +多个头像: + +```python +{ + "avatar": [ + "https://example.com/a.png", + "https://example.com/b.webp", + "https://example.com/c.gif", + ], +} +``` + +## 任务 1:扩展 flow 构造参数 + +修改文件:`lark_oapi/scene/registration/__init__.py` + +- `_RegistrationFlow.__init__` 新增 `app_preset=None` 参数。 +- 保存为 `self._app_preset`。 +- `_SyncFlow.__init__` 接收并透传 `app_preset`。 +- `_AsyncFlow` 继续复用 `_RegistrationFlow.__init__`。 +- `register_app` 创建 `_SyncFlow` 时传入 `app_preset`。 +- `aregister_app` 创建 `_AsyncFlow` 时传入 `app_preset`。 + +建议结构: + +```python +class _RegistrationFlow: + def __init__(self, on_qr_code, on_status_change, source, domain, lark_domain, app_preset=None): + self._on_qr_code = on_qr_code + self._on_status_change = on_status_change + self._source = source + self._base_url = domain + self._lark_url = lark_domain + self._app_preset = app_preset +``` + +## 任务 2:新增 app_preset URL 参数追加逻辑 + +修改文件:`lark_oapi/scene/registration/__init__.py` + +新增常量: + +```python +_AVATAR_MAX_COUNT = 6 +``` + +新增私有方法: + +```python +def _apply_app_preset(self, params): + if not self._app_preset: + return + + avatar = self._app_preset.get("avatar") + name = self._app_preset.get("name") + desc = self._app_preset.get("desc") + + if avatar is not None: + avatars = avatar if isinstance(avatar, list) else [avatar] + if len(avatars) == 0: + raise ValueError("app_preset.avatar must contain at least 1 URL") + if len(avatars) > _AVATAR_MAX_COUNT: + raise ValueError( + f"app_preset.avatar supports at most {_AVATAR_MAX_COUNT} URLs, got {len(avatars)}" + ) + for index, url in enumerate(avatars): + if not isinstance(url, str) or url == "": + raise ValueError(f"app_preset.avatar[{index}] must be a non-empty string") + params["avatar"] = avatars + + if name is not None: + params["name"] = name + + if desc is not None: + params["desc"] = desc +``` + +如果需要更严格的 Python 运行时类型防御,可以额外校验 `self._app_preset` 必须是 dict、`name` / `desc` 必须是 str。但为了贴近 Node,最小实现只要求 `avatar` 行为一致。 + +## 任务 3:接入 _build_qr_url + +修改文件:`lark_oapi/scene/registration/__init__.py` + +在 `_build_qr_url` 设置完已有参数后调用 `_apply_app_preset(params)`: + +```python +def _build_qr_url(self, uri): + parsed = urlparse(uri) + params = parse_qs(parsed.query) + params["from"] = "sdk" + params["tp"] = "sdk" + params["source"] = f"{_SDK_NAME}/{self._source}" if self._source else _SDK_NAME + self._apply_app_preset(params) + return urlunparse(parsed._replace(query=urlencode(params, doseq=True))) +``` + +保留 `doseq=True`。这是多头像生成重复 query 参数的关键: + +```text +avatar=https%3A%2F%2Fexample.com%2Fa.png&avatar=https%3A%2F%2Fexample.com%2Fb.webp +``` + +## 任务 4:补充代码注释 + +修改文件:`lark_oapi/scene/registration/__init__.py` + +在 `_apply_app_preset` 或 `_build_qr_url` 附近增加简短注释,表达 Node 注释中的关键原因: + +```python +# app_preset values are pre-fill values for the app-creation page. +# urlencode handles URL encoding; callers should pass raw values. +``` + +不要把注释写成“最终应用信息”,因为最终值以用户在 Web 页面提交为准。 + +## 任务 5:更新 README + +修改文件: + +- `README.md` +- `README.zh.md` + +在一键创建应用参数表增加: + +- `app_preset` +- `app_preset.avatar` +- `app_preset.name` +- `app_preset.desc` + +英文描述要包含: + +- all fields are optional +- users can still edit them on the app-creation page +- SDK handles URL encoding automatically + +中文描述要包含: + +- 所有字段选填 +- 用户扫码后仍可在页面修改 +- SDK 自动 URL Encode,调用方传原始值 + +## 任务 6:补充使用示例 + +可选修改文件: + +- `README.md` +- `README.zh.md` +- 或新增 `samples/registration/app_preset_sample.py` + +示例应展示原始值传入: + +```python +register_app( + on_qr_code=lambda info: print(info["url"]), + app_preset={ + "avatar": [ + "https://example.com/a.png", + "https://example.com/b.webp", + ], + "name": "{user}的应用", + "desc": "由业务平台自动生成", + }, +) +``` + +不要在示例中手动调用 `quote` 或预先传 `%7Buser%7D...`。 + +## 非目标 + +- 不改 `/oauth/v1/app/registration` 的 begin/poll 请求参数。 +- 不上传头像文件。 +- 不下载或探测头像 URL。 +- 不校验图片格式、跨域、重定向、过期链接。 +- 不校验 `name` / `desc` 的中英文长度。 +- 不在 SDK 内替换 `{user}`。 + diff --git a/doc/registration_app_preset_python_tests.zh.md b/doc/registration_app_preset_python_tests.zh.md new file mode 100644 index 000000000..8e6d5cad9 --- /dev/null +++ b/doc/registration_app_preset_python_tests.zh.md @@ -0,0 +1,341 @@ +# Python 一键创建应用 app_preset 测试用例 + +## 单元测试范围 + +建议新增文件: + +- `lark_oapi/scene/registration/tests/test_app_preset.py` + +测试重点是二维码 URL 构造。同步和异步流程都复用 `_RegistrationFlow._build_qr_url`,所以大部分用例可直接测试内部 flow,端到端用例再覆盖 `register_app` / `aregister_app` 的参数透传。 + +## 测试辅助函数 + +建议在测试文件中定义: + +```python +from urllib.parse import parse_qs, urlparse + +from lark_oapi.scene.registration import _RegistrationFlow + + +def build_url(app_preset=None, source=None, raw_url="https://accounts.feishu.cn/page/launcher?ticket=abc"): + flow = _RegistrationFlow( + on_qr_code=lambda info: None, + on_status_change=None, + source=source, + domain="https://accounts.feishu.cn", + lark_domain="https://accounts.larksuite.com", + app_preset=app_preset, + ) + return flow._build_qr_url(raw_url) + + +def parse_query(url): + return parse_qs(urlparse(url).query) +``` + +如果不希望测试直接导入私有类,可改为通过 mock `_post` 的方式跑 `register_app`,但直接测 `_build_qr_url` 更聚焦、速度更快。 + +## 单元测试用例 + +### 1. 不传 app_preset 时不追加新参数 + +```python +def test_build_qr_url_omits_app_preset_params_when_not_provided(): + url = build_url() + query = parse_query(url) + + assert "avatar" not in query + assert "name" not in query + assert "desc" not in query + assert query["from"] == ["sdk"] + assert query["tp"] == ["sdk"] + assert query["source"] == ["python-sdk"] + assert query["ticket"] == ["abc"] +``` + +### 2. source 不受 app_preset 影响 + +```python +def test_build_qr_url_keeps_source_with_app_preset(): + url = build_url(app_preset={"name": "X"}, source="lark-cli") + query = parse_query(url) + + assert query["source"] == ["python-sdk/lark-cli"] + assert query["name"] == ["X"] +``` + +### 3. 支持单个头像字符串 + +```python +def test_build_qr_url_accepts_single_avatar_string(): + url = build_url(app_preset={"avatar": "https://example.com/a.png"}) + query = parse_query(url) + + assert query["avatar"] == ["https://example.com/a.png"] +``` + +### 4. 支持多个头像且保持顺序 + +```python +def test_build_qr_url_accepts_avatar_list_and_preserves_order(): + avatars = [ + "https://example.com/a.png", + "https://example.com/b.webp", + "https://example.com/c.gif", + ] + + url = build_url(app_preset={"avatar": avatars}) + query = parse_query(url) + + assert query["avatar"] == avatars +``` + +### 5. 恰好 6 个头像通过 + +```python +def test_build_qr_url_accepts_exactly_six_avatars(): + avatars = [f"https://example.com/{index}.png" for index in range(6)] + + url = build_url(app_preset={"avatar": avatars}) + query = parse_query(url) + + assert query["avatar"] == avatars +``` + +### 6. 超过 6 个头像报错 + +```python +import pytest + + +def test_build_qr_url_rejects_more_than_six_avatars(): + avatars = [f"https://example.com/{index}.png" for index in range(7)] + + with pytest.raises(ValueError, match=r"at most 6 URLs, got 7"): + build_url(app_preset={"avatar": avatars}) +``` + +### 7. 空头像数组报错 + +```python +def test_build_qr_url_rejects_empty_avatar_list(): + with pytest.raises(ValueError, match=r"at least 1 URL"): + build_url(app_preset={"avatar": []}) +``` + +### 8. 空头像字符串报错 + +```python +def test_build_qr_url_rejects_empty_avatar_string(): + with pytest.raises(ValueError, match=r"avatar\[0\].*non-empty string"): + build_url(app_preset={"avatar": ""}) +``` + +### 9. 头像数组中的空字符串报错且包含索引 + +```python +def test_build_qr_url_rejects_empty_avatar_list_item_with_index(): + with pytest.raises(ValueError, match=r"avatar\[1\].*non-empty string"): + build_url(app_preset={"avatar": ["https://example.com/a.png", ""]}) +``` + +### 10. name 支持原始值并由 SDK 编码 + +```python +def test_build_qr_url_url_encodes_name_with_user_placeholder(): + url = build_url(app_preset={"name": "{user}的应用"}) + query = parse_query(url) + + assert query["name"] == ["{user}的应用"] + assert "name=%7Buser%7D%E7%9A%84%E5%BA%94%E7%94%A8" in url +``` + +### 11. desc 支持原始值并由 SDK 编码 + +```python +def test_build_qr_url_url_encodes_desc(): + url = build_url(app_preset={"desc": "由业务平台自动生成"}) + query = parse_query(url) + + assert query["desc"] == ["由业务平台自动生成"] + assert "%E7%94%B1%E4%B8%9A%E5%8A%A1%E5%B9%B3%E5%8F%B0" in url +``` + +### 12. avatar、name、desc 可同时存在 + +```python +def test_build_qr_url_emits_all_app_preset_fields(): + url = build_url( + app_preset={ + "avatar": ["https://example.com/a.png", "https://example.com/b.png"], + "name": "MyApp", + "desc": "demo", + } + ) + query = parse_query(url) + + assert query["avatar"] == ["https://example.com/a.png", "https://example.com/b.png"] + assert query["name"] == ["MyApp"] + assert query["desc"] == ["demo"] +``` + +## 同步端到端测试用例 + +目标:验证 `register_app(..., app_preset=...)` 能把参数透传到二维码回调。 + +建议使用 monkeypatch 替换 `_SyncFlow._post` 和 `time.sleep`,避免真实网络和等待。 + +```python +import time +from urllib.parse import parse_qs, urlparse + +import pytest + +from lark_oapi.scene import registration + + +def test_register_app_sync_passes_app_preset_to_qr_url(monkeypatch): + responses = [ + {"supported_auth_methods": ["client_secret"]}, + { + "device_code": "dev-1", + "verification_uri_complete": "https://accounts.feishu.cn/page/launcher", + "interval": 1, + "expires_in": 60, + }, + { + "client_id": "cli_a", + "client_secret": "sec_a", + "user_info": {"open_id": "ou_x", "tenant_brand": "feishu"}, + }, + ] + + def fake_post(self, data): + return responses.pop(0) + + monkeypatch.setattr(registration._SyncFlow, "_post", fake_post) + monkeypatch.setattr(time, "sleep", lambda seconds: None) + + captured = {} + + result = registration.register_app( + on_qr_code=lambda info: captured.update(info), + app_preset={ + "avatar": ["https://example.com/a.png", "https://example.com/b.webp"], + "name": "{user}的应用", + "desc": "由业务平台自动生成", + }, + ) + + query = parse_qs(urlparse(captured["url"]).query) + assert query["avatar"] == ["https://example.com/a.png", "https://example.com/b.webp"] + assert query["name"] == ["{user}的应用"] + assert query["desc"] == ["由业务平台自动生成"] + assert result["client_id"] == "cli_a" + assert result["client_secret"] == "sec_a" +``` + +## 异步端到端测试用例 + +目标:验证 `aregister_app(..., app_preset=...)` 与同步接口行为一致。 + +```python +from urllib.parse import parse_qs, urlparse + +import pytest + +from lark_oapi.scene import registration + + +@pytest.mark.asyncio +async def test_register_app_async_passes_app_preset_to_qr_url(monkeypatch): + responses = [ + {"supported_auth_methods": ["client_secret"]}, + { + "device_code": "dev-1", + "verification_uri_complete": "https://accounts.feishu.cn/page/launcher", + "interval": 1, + "expires_in": 60, + }, + { + "client_id": "cli_a", + "client_secret": "sec_a", + "user_info": {"open_id": "ou_x", "tenant_brand": "feishu"}, + }, + ] + + async def fake_post(self, data): + return responses.pop(0) + + async def fake_sleep(seconds): + return None + + monkeypatch.setattr(registration._AsyncFlow, "_post", fake_post) + monkeypatch.setattr(registration.asyncio, "sleep", fake_sleep) + + captured = {} + + result = await registration.aregister_app( + on_qr_code=lambda info: captured.update(info), + app_preset={ + "avatar": "https://example.com/a.png", + "name": "{user}的应用", + "desc": "由业务平台自动生成", + }, + ) + + query = parse_qs(urlparse(captured["url"]).query) + assert query["avatar"] == ["https://example.com/a.png"] + assert query["name"] == ["{user}的应用"] + assert query["desc"] == ["由业务平台自动生成"] + assert result["client_id"] == "cli_a" + assert result["client_secret"] == "sec_a" +``` + +## 真实环境端到端验证 + +真实环境 E2E 不建议默认进入 CI,因为需要扫码人工确认。建议作为手动样例或 gated 测试。 + +步骤: + +1. 调用 `register_app`,传入 `app_preset`。 +2. 在 `on_qr_code` 中打印 URL 或生成二维码。 +3. 打开 URL,确认创建页中头像候选、名称、描述已预填。 +4. 确认用户仍可修改这些字段。 +5. 提交创建,确认 SDK 返回 `client_id` 和 `client_secret`。 + +手动脚本示例: + +```python +import lark_oapi as lark + + +def on_qr_code(info): + print("Open this URL or render it as QR code:") + print(info["url"]) + + +result = lark.register_app( + on_qr_code=on_qr_code, + app_preset={ + "avatar": [ + "https://example.com/a.png", + "https://example.com/b.webp", + ], + "name": "{user}的应用", + "desc": "由业务平台自动生成", + }, +) + +print(result["client_id"]) +``` + +验收点: + +- URL 中包含 `avatar`、`name`、`desc`。 +- URL 中 `{user}` 被百分号编码,页面展示时由 Web 端替换。 +- 多头像按传入顺序展示,第一个默认选中。 +- Web 页面允许用户修改预填值。 +- 创建成功后返回应用凭证。 + diff --git a/lark_oapi/client.py b/lark_oapi/client.py index 115175088..59e7c49d3 100644 --- a/lark_oapi/client.py +++ b/lark_oapi/client.py @@ -8,6 +8,7 @@ from .core import logger, JSON from .core.model import * from .core.token import TokenManager, verify +from .core.access_token import AccessToken from .core.http import Transport from .api.board.service import BoardService from .api.cardkit.service import CardkitService @@ -149,6 +150,7 @@ def __init__(self) -> None: self.docs: Optional[DocsService] = None self.drive: Optional[DriveService] = None self.performance: Optional[PerformanceService] = None + self.access_token: Optional[AccessToken] = None @staticmethod def builder() -> "ClientBuilder": @@ -223,6 +225,14 @@ def app_secret(self, app_secret: str) -> "ClientBuilder": self._config.app_secret = app_secret return self + def client_assertion_provider(self, provider) -> "ClientBuilder": + self._config.client_assertion_provider = provider + return self + + def oauth_base_url(self, oauth_base_url: str) -> "ClientBuilder": + self._config.oauth_base_url = oauth_base_url + return self + def domain(self, domain: str) -> "ClientBuilder": _validate_domain(domain) self._config.domain = domain @@ -322,6 +332,7 @@ def build(self) -> Client: client.docs = DocsService(self._config) client.drive = DriveService(self._config) client.performance = PerformanceService(self._config) + client.access_token = AccessToken(self._config) return client diff --git a/lark_oapi/core/__init__.py b/lark_oapi/core/__init__.py index 2da1ab6d5..8dde57e8c 100644 --- a/lark_oapi/core/__init__.py +++ b/lark_oapi/core/__init__.py @@ -1,4 +1,5 @@ from .cache import ICache +from .client_assertion import * from .const import * from .enum import * from .env_var import * diff --git a/lark_oapi/core/access_token/__init__.py b/lark_oapi/core/access_token/__init__.py new file mode 100644 index 000000000..1c249e9ff --- /dev/null +++ b/lark_oapi/core/access_token/__init__.py @@ -0,0 +1,2 @@ +from .client import * +from .model import * diff --git a/lark_oapi/core/access_token/client.py b/lark_oapi/core/access_token/client.py new file mode 100644 index 000000000..44c579dd4 --- /dev/null +++ b/lark_oapi/core/access_token/client.py @@ -0,0 +1,116 @@ +import json +from typing import Dict, Optional + +from lark_oapi.core.client_assertion import build_proxy_url, resolve_oauth_aud, resolve_oauth_base_url +from lark_oapi.core.const import ( + APPLICATION_JSON, + CLIENT_ASSERTION_TYPE_JWT_BEARER, + CONTENT_TYPE, + ERR_CODE_APP_SECRET_AND_CLIENT_ASSERTION_EMPTY, + ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED, + ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY, + GRANT_TYPE_AUTHORIZATION_CODE, + GRANT_TYPE_REFRESH_TOKEN, + OAUTH_TOKEN_URI, + UTF_8, + X_TARGET_SERVICE, +) +from lark_oapi.core.enum import HttpMethod +from lark_oapi.core.exception import AccessTokenException, ClientAssertionException +from lark_oapi.core.http import Transport +from lark_oapi.core.model import BaseRequest, Config, RequestOption +from lark_oapi.core.utils import Strings +from .model import AccessTokenResponse, value_if_not_empty + + +class AccessToken(object): + def __init__(self, config: Config) -> None: + self._config = config + + def retrieve_by_authorization_code( + self, + code: str, + redirect_uri: Optional[str] = None, + code_verifier: Optional[str] = None, + scope: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + ) -> AccessTokenResponse: + return self._do_request( + { + "grant_type": GRANT_TYPE_AUTHORIZATION_CODE, + "code": code, + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + "scope": scope, + }, + headers=headers, + ) + + def refresh( + self, + refresh_token: str, + scope: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + ) -> AccessTokenResponse: + return self._do_request( + { + "grant_type": GRANT_TYPE_REFRESH_TOKEN, + "refresh_token": refresh_token, + "scope": scope, + }, + headers=headers, + ) + + def _do_request(self, body: Dict[str, object], headers: Optional[Dict[str, str]] = None) -> AccessTokenResponse: + oauth_base_url = resolve_oauth_base_url(self._config) + aud = resolve_oauth_aud(self._config) + request_url = oauth_base_url + OAUTH_TOKEN_URI + body = {k: v for k, v in body.items() if v is not None} + body["client_id"] = self._config.app_id + option = RequestOption() + if headers: + option.headers.update(headers) + + if self._config.client_assertion_provider is not None: + try: + assertion_token = self._config.client_assertion_provider.retrieve_token(aud) + except Exception as e: + raise ClientAssertionException(ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED, str(e)) + if assertion_token is None or Strings.is_empty(assertion_token.value): + raise ClientAssertionException(ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY, "client assertion token is empty") + body["client_assertion_type"] = CLIENT_ASSERTION_TYPE_JWT_BEARER + body["client_assertion"] = assertion_token.value + if assertion_token.target_info is not None: + request_url = build_proxy_url(assertion_token.target_info, OAUTH_TOKEN_URI) + option.headers[X_TARGET_SERVICE] = aud + elif Strings.is_not_empty(self._config.app_secret): + body["client_secret"] = self._config.app_secret + else: + raise ClientAssertionException( + ERR_CODE_APP_SECRET_AND_CLIENT_ASSERTION_EMPTY, + "AppSecret and ClientAssertionProvider cannot both be empty for AccessToken APIs", + ) + + req = BaseRequest() + req.http_method = HttpMethod.POST + req.uri = request_url + req.headers = {CONTENT_TYPE: APPLICATION_JSON} + req.body = body + raw = Transport.execute(self._config, req, option) + resp = json.loads(str(raw.content, UTF_8)) + if raw.status_code != 200: + raise AccessTokenException( + raw.status_code, + resp.get("code") or 0, + resp.get("error") or "", + resp.get("error_description") or "", + ) + return AccessTokenResponse( + access_token=value_if_not_empty(resp.get("access_token")), + token_type=value_if_not_empty(resp.get("token_type")), + expires_in=value_if_not_empty(resp.get("expires_in")), + refresh_token=value_if_not_empty(resp.get("refresh_token")), + refresh_token_expires_in=value_if_not_empty(resp.get("refresh_token_expires_in")), + scope=value_if_not_empty(resp.get("scope")), + raw=raw, + ) diff --git a/lark_oapi/core/access_token/model.py b/lark_oapi/core/access_token/model.py new file mode 100644 index 000000000..73aac9d29 --- /dev/null +++ b/lark_oapi/core/access_token/model.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import Optional + +from lark_oapi.core.model import RawResponse + + +@dataclass +class AccessTokenResponse: + access_token: Optional[str] = None + token_type: Optional[str] = None + expires_in: Optional[int] = None + refresh_token: Optional[str] = None + refresh_token_expires_in: Optional[int] = None + scope: Optional[str] = None + raw: Optional[RawResponse] = None + + +def value_if_not_empty(value): + return value if value not in ("", 0, None) else None diff --git a/lark_oapi/core/client_assertion.py b/lark_oapi/core/client_assertion.py new file mode 100644 index 000000000..9867408d5 --- /dev/null +++ b/lark_oapi/core/client_assertion.py @@ -0,0 +1,66 @@ +from dataclasses import dataclass +from typing import Optional, Protocol +from urllib.parse import urlparse + +from lark_oapi.core.const import FEISHU_DOMAIN, FEISHU_OAUTH_DOMAIN, LARK_DOMAIN, LARK_OAUTH_DOMAIN + + +@dataclass +class TargetInfo: + target_service: str + target_prefix: str = "" + + +@dataclass +class ClientAssertionToken: + value: str + target_info: Optional[TargetInfo] = None + + +class ClientAssertionProvider(Protocol): + def retrieve_token(self, aud: str) -> ClientAssertionToken: + raise NotImplementedError + + +def _normalize_base_url(base_url: str) -> str: + if "://" not in base_url: + base_url = "https://" + base_url + return base_url.rstrip("/") + + +def extract_aud_from_url(raw_url: str) -> str: + if "://" not in raw_url: + raw_url = "https://" + raw_url + parsed = urlparse(raw_url) + if parsed.netloc: + return parsed.netloc + if parsed.path and "/" not in parsed.path: + return parsed.path + raise ValueError(f"invalid url: {raw_url}") + + +def resolve_oauth_base_url(config) -> str: + oauth_base_url = getattr(config, "oauth_base_url", None) + if oauth_base_url: + return _normalize_base_url(oauth_base_url) + + aud = extract_aud_from_url(config.domain) + if aud == extract_aud_from_url(FEISHU_DOMAIN): + return FEISHU_OAUTH_DOMAIN + if aud == extract_aud_from_url(LARK_DOMAIN): + return LARK_OAUTH_DOMAIN + raise ValueError( + "OAuthBaseUrl is not configured. When domain is set to a non-default value " + "(neither open.feishu.cn nor open.larksuite.com), configure oauth_base_url explicitly." + ) + + +def resolve_oauth_aud(config) -> str: + return extract_aud_from_url(resolve_oauth_base_url(config)) + + +def build_proxy_url(target_info: TargetInfo, api_path: str) -> str: + target_service = target_info.target_service + if "://" not in target_service: + target_service = "https://" + target_service + return target_service + target_info.target_prefix + api_path diff --git a/lark_oapi/core/const.py b/lark_oapi/core/const.py index 9a43de11b..22358bde2 100644 --- a/lark_oapi/core/const.py +++ b/lark_oapi/core/const.py @@ -5,10 +5,13 @@ # Domain FEISHU_DOMAIN = "https://open.feishu.cn" LARK_DOMAIN = "https://open.larksuite.com" +FEISHU_OAUTH_DOMAIN = "https://accounts.feishu.cn" +LARK_OAUTH_DOMAIN = "https://accounts.larksuite.com" # Header USER_AGENT = "User-Agent" AUTHORIZATION = "Authorization" +X_TARGET_SERVICE = "X-Target-Service" X_TT_LOGID = "X-Tt-Logid" X_REQUEST_ID = "X-Request-Id" CONTENT_TYPE = "Content-Type" @@ -24,3 +27,17 @@ URL_VERIFICATION = "url_verification" UTF_8 = "UTF-8" + +# OAuth / ClientAssertion +OAUTH_TOKEN_URI = "/oauth/v3/token" +GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" +GRANT_TYPE_REFRESH_TOKEN = "refresh_token" +GRANT_TYPE_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer" +CLIENT_ASSERTION_TYPE_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + +# ClientAssertion error codes, aligned with oapi-sdk-go. +ERR_CODE_CLIENT_ASSERTION_PROVIDER_NOT_CONFIGURED = 7100 +ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY = 7101 +ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED = 7102 +ERR_CODE_CLIENT_ASSERTION_MODE_NOT_SUPPORTED = 7103 +ERR_CODE_APP_SECRET_AND_CLIENT_ASSERTION_EMPTY = 7104 diff --git a/lark_oapi/core/exception.py b/lark_oapi/core/exception.py index a55aaafc2..db8db19ae 100644 --- a/lark_oapi/core/exception.py +++ b/lark_oapi/core/exception.py @@ -3,6 +3,29 @@ def __init__(self, msg: str): self.msg: str = msg +class ClientAssertionException(Exception): + def __init__(self, code: int, msg: str): + super().__init__(msg) + self.code = code + self.msg = msg + + def __str__(self): + return f"{self.code}: {self.msg}" + + +class AccessTokenException(Exception): + def __init__(self, status_code: int, code: int, error: str, error_description: str): + super().__init__(error_description or error or "access token request failed") + self.status_code = status_code + self.code = code + self.error = error + self.error_description = error_description + + def __str__(self): + msg = self.error_description or self.error or "access token request failed" + return f"statusCode:{self.status_code}, code:{self.code}, msg:{msg}" + + class ObtainAccessTokenException(Exception): def __init__(self, desc: str, code: int, msg: str): self.desc = desc diff --git a/lark_oapi/core/http/transport.py b/lark_oapi/core/http/transport.py index 271c1efdd..82a93efd6 100644 --- a/lark_oapi/core/http/transport.py +++ b/lark_oapi/core/http/transport.py @@ -110,6 +110,8 @@ def _build_url(domain: str, uri: str, paths: Dict[str, str]) -> str: encoded = urllib.parse.quote(str(value), safe="") uri = uri.replace(":" + key, encoded) + if uri.startswith("http://") or uri.startswith("https://"): + return uri return domain + uri diff --git a/lark_oapi/core/model/config.py b/lark_oapi/core/model/config.py index 455a25338..911719c55 100644 --- a/lark_oapi/core/model/config.py +++ b/lark_oapi/core/model/config.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Any, List, Optional from lark_oapi.core import AppType, LogLevel from lark_oapi.core.cache import ICache @@ -10,6 +10,8 @@ def __init__(self) -> None: self.app_id: Optional[str] = None self.app_secret: Optional[str] = None self.domain: str = FEISHU_DOMAIN # 域名, 默认为 https://open.feishu.cn + self.oauth_base_url: Optional[str] = None + self.client_assertion_provider: Optional[Any] = None self.timeout: Optional[float] = 30 # client timeout in seconds (default 30s); override via ClientBuilder.timeout() self.app_type: AppType = AppType.SELF # 应用类型, 默认为自建应用; 若设为 ISV 需在 request_option 中配置 tenant_key self.enable_set_token: bool = False # 是否允许手动设置 token, 默认不开启; 开启后需在 request_option 中配置 token diff --git a/lark_oapi/core/tests/__init__.py b/lark_oapi/core/tests/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/lark_oapi/core/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/lark_oapi/core/tests/e2e/__init__.py b/lark_oapi/core/tests/e2e/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/lark_oapi/core/tests/e2e/__init__.py @@ -0,0 +1 @@ + diff --git a/lark_oapi/core/tests/e2e/test_client_assertion_keyless_live.py b/lark_oapi/core/tests/e2e/test_client_assertion_keyless_live.py new file mode 100644 index 000000000..f74a553b9 --- /dev/null +++ b/lark_oapi/core/tests/e2e/test_client_assertion_keyless_live.py @@ -0,0 +1,80 @@ +import os + +import pytest + +from lark_oapi import Client +from lark_oapi.core.client_assertion import ClientAssertionToken +from lark_oapi.core.token import TokenManager +from lark_oapi.ws import client as ws_client + + +class EnvProvider: + def retrieve_token(self, aud): + return ClientAssertionToken(os.environ["LARK_CLIENT_ASSERTION"]) + + +pytestmark = pytest.mark.skipif( + os.environ.get("LARK_CLIENT_ASSERTION_E2E") != "1", + reason="set LARK_CLIENT_ASSERTION_E2E=1 to run live keyless E2E", +) + + +def _client(): + app_id = os.environ.get("LARK_APP_ID") + assertion = os.environ.get("LARK_CLIENT_ASSERTION") + if not app_id or not assertion: + pytest.skip("LARK_APP_ID and LARK_CLIENT_ASSERTION are required") + + builder = Client.builder().app_id(app_id).client_assertion_provider(EnvProvider()) + if os.environ.get("LARK_OPENAPI_DOMAIN"): + builder.domain(os.environ["LARK_OPENAPI_DOMAIN"]) + if os.environ.get("LARK_OAUTH_BASE_URL"): + builder.oauth_base_url(os.environ["LARK_OAUTH_BASE_URL"]) + return builder.build() + + +def test_live_tenant_token_exchange_smoke(): + client = _client() + + token = TokenManager.get_self_tenant_token(client.config) + + assert token + + +def test_live_authorization_code_exchange_smoke(): + code = os.environ.get("LARK_OAUTH_CODE") + if not code: + pytest.skip("LARK_OAUTH_CODE is required") + + resp = _client().access_token.retrieve_by_authorization_code(code=code) + + assert resp.access_token + + +def test_live_refresh_token_smoke(): + refresh_token = os.environ.get("LARK_REFRESH_TOKEN") + if not refresh_token: + pytest.skip("LARK_REFRESH_TOKEN is required") + + resp = _client().access_token.refresh(refresh_token=refresh_token) + + assert resp.access_token + + +def test_live_ws_bootstrap_smoke(): + if os.environ.get("LARK_WS_CLIENT_ASSERTION_E2E") != "1": + pytest.skip("set LARK_WS_CLIENT_ASSERTION_E2E=1 to run WS live smoke") + app_id = os.environ.get("LARK_APP_ID") + if not app_id or not os.environ.get("LARK_CLIENT_ASSERTION"): + pytest.skip("LARK_APP_ID and LARK_CLIENT_ASSERTION are required") + + client = ws_client.Client( + app_id, + "", + domain=os.environ.get("LARK_OPENAPI_DOMAIN", "https://open.feishu.cn"), + client_assertion_provider=EnvProvider(), + ) + + conn_url = client._get_conn_url() + + assert conn_url.startswith(("ws://", "wss://")) diff --git a/lark_oapi/core/tests/e2e/test_client_assertion_keyless_local.py b/lark_oapi/core/tests/e2e/test_client_assertion_keyless_local.py new file mode 100644 index 000000000..a799b0b75 --- /dev/null +++ b/lark_oapi/core/tests/e2e/test_client_assertion_keyless_local.py @@ -0,0 +1,145 @@ +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from lark_oapi import Client +from lark_oapi.core import AccessTokenType, HttpMethod +from lark_oapi.core.cache import ICache +from lark_oapi.core.client_assertion import ClientAssertionToken +from lark_oapi.core.model import BaseRequest +from lark_oapi.ws import client as ws_client + + +class AbsoluteCache(ICache): + def __init__(self): + self.data = {} + + def get(self, key): + item = self.data.get(key) + return None if item is None else item[0] + + def set(self, key, value, expire): + self.data[key] = (value, expire) + + +class RecordingProvider: + def __init__(self): + self.auds = [] + + def retrieve_token(self, aud): + self.auds.append(aud) + return ClientAssertionToken("local-assertion") + + +class LocalState: + def __init__(self): + self.oauth_bodies = [] + self.ping_authorizations = [] + self.ws_bodies = [] + + +def _serve(state): + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length") or "0") + body = json.loads(self.rfile.read(length) or b"{}") + if self.path == "/oauth/v3/token": + state.oauth_bodies.append(body) + if body["grant_type"] == "authorization_code": + self._json({ + "access_token": "user-token", + "token_type": "Bearer", + "expires_in": 7200, + "refresh_token": "refresh-token", + }) + return + if body["grant_type"] == "refresh_token": + self._json({"access_token": "refreshed-user-token", "expires_in": 7200}) + return + self._json({"access_token": "tenant-token", "expires_in": 7200}) + return + if self.path == "/callback/ws/endpoint": + state.ws_bodies.append(body) + self._json({"code": 0, "data": {"URL": "ws://example.test/callback?device_id=device&service_id=42"}}) + return + self.send_response(404) + self.end_headers() + + def do_GET(self): + if self.path == "/open-apis/mock/v1/ping": + state.ping_authorizations.append(self.headers.get("Authorization")) + self._json({"code": 0, "msg": "ok"}) + return + self.send_response(404) + self.end_headers() + + def _json(self, payload): + data = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, fmt, *args): + return + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server + + +def _ping_request(): + req = BaseRequest() + req.http_method = HttpMethod.GET + req.uri = "/open-apis/mock/v1/ping" + req.token_types = {AccessTokenType.TENANT} + return req + + +def test_local_keyless_openapi_access_token_and_ws_e2e(): + state = LocalState() + server = _serve(state) + base_url = f"http://127.0.0.1:{server.server_port}" + provider = RecordingProvider() + cache = AbsoluteCache() + try: + client = ( + Client.builder() + .app_id("cli_local") + .domain(base_url) + .oauth_base_url(base_url) + .client_assertion_provider(provider) + .cache(cache) + .build() + ) + + first = client.request(_ping_request()) + second = client.request(_ping_request()) + + auth_code = client.access_token.retrieve_by_authorization_code(code="code") + refreshed = client.access_token.refresh(refresh_token="refresh-token") + + ws = ws_client.Client("cli_local", "", domain=base_url, client_assertion_provider=provider) + conn_url = ws._get_conn_url() + + assert first.code == 0 + assert second.code == 0 + assert state.ping_authorizations == ["Bearer tenant-token", "Bearer tenant-token"] + assert auth_code.access_token == "user-token" + assert refreshed.access_token == "refreshed-user-token" + assert conn_url == "ws://example.test/callback?device_id=device&service_id=42" + assert provider.auds == [ + f"127.0.0.1:{server.server_port}", + f"127.0.0.1:{server.server_port}", + f"127.0.0.1:{server.server_port}", + f"127.0.0.1:{server.server_port}", + ] + assert len(state.oauth_bodies) == 3 + assert state.oauth_bodies[0]["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" + assert state.oauth_bodies[0]["client_assertion"] == "local-assertion" + assert state.ws_bodies == [{"AppID": "cli_local", "ClientAssertion": "local-assertion"}] + finally: + server.shutdown() + server.server_close() diff --git a/lark_oapi/core/tests/test_client_assertion_access_token.py b/lark_oapi/core/tests/test_client_assertion_access_token.py new file mode 100644 index 000000000..be81c1a4d --- /dev/null +++ b/lark_oapi/core/tests/test_client_assertion_access_token.py @@ -0,0 +1,179 @@ +import json +from types import SimpleNamespace + +import pytest + +from lark_oapi import Client +from lark_oapi.core.client_assertion import ClientAssertionToken, TargetInfo +from lark_oapi.core.exception import AccessTokenException, ClientAssertionException + + +class RecordingProvider: + def __init__(self, token=None): + self.token = token or ClientAssertionToken("client-assertion") + self.calls = [] + + def retrieve_token(self, aud): + self.calls.append(aud) + return self.token + + +def _response(payload, status=200): + return SimpleNamespace(status_code=status, headers={"Content-Type": "application/json"}, content=json.dumps(payload).encode()) + + +def _client(provider=None, app_secret="", oauth_base_url="https://accounts.feishu.cn"): + builder = Client.builder().app_id("cli_a").oauth_base_url(oauth_base_url) + if provider is not None: + builder.client_assertion_provider(provider) + if app_secret: + builder.app_secret(app_secret) + return builder.build() + + +def test_access_token_authorization_code_with_client_assertion(monkeypatch): + provider = RecordingProvider() + client = _client(provider) + captured = {} + + def fake_request(method, url, headers=None, params=None, data=None, timeout=None): + captured["url"] = url + captured["body"] = json.loads(data.decode()) + return _response({"access_token": "user-token", "token_type": "Bearer", "expires_in": 7200}) + + import lark_oapi.core.http.transport as transport + + monkeypatch.setattr(transport.requests, "request", fake_request) + + resp = client.access_token.retrieve_by_authorization_code( + code="code", + redirect_uri="https://example.com/cb", + code_verifier="verifier", + ) + + assert resp.access_token == "user-token" + assert captured["url"] == "https://accounts.feishu.cn/oauth/v3/token" + assert captured["body"]["grant_type"] == "authorization_code" + assert captured["body"]["client_id"] == "cli_a" + assert captured["body"]["client_assertion"] == "client-assertion" + assert captured["body"]["client_assertion_type"] == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + assert captured["body"]["code"] == "code" + assert captured["body"]["redirect_uri"] == "https://example.com/cb" + assert captured["body"]["code_verifier"] == "verifier" + assert provider.calls == ["accounts.feishu.cn"] + + +def test_access_token_refresh_with_client_assertion(monkeypatch): + client = _client(RecordingProvider()) + captured = {} + + def fake_request(method, url, headers=None, params=None, data=None, timeout=None): + captured["body"] = json.loads(data.decode()) + return _response({"access_token": "user-token", "expires_in": 7200}) + + import lark_oapi.core.http.transport as transport + + monkeypatch.setattr(transport.requests, "request", fake_request) + + assert client.access_token.refresh(refresh_token="refresh-token").access_token == "user-token" + assert captured["body"]["grant_type"] == "refresh_token" + assert captured["body"]["refresh_token"] == "refresh-token" + assert captured["body"]["client_assertion"] == "client-assertion" + + +def test_access_token_authorization_code_with_app_secret_fallback(monkeypatch): + client = _client(provider=None, app_secret="app-secret") + captured = {} + + def fake_request(method, url, headers=None, params=None, data=None, timeout=None): + captured["body"] = json.loads(data.decode()) + return _response({"access_token": "user-token", "expires_in": 7200}) + + import lark_oapi.core.http.transport as transport + + monkeypatch.setattr(transport.requests, "request", fake_request) + + client.access_token.retrieve_by_authorization_code(code="code") + + assert captured["body"]["client_secret"] == "app-secret" + assert "client_assertion" not in captured["body"] + assert "client_assertion_type" not in captured["body"] + + +def test_access_token_refresh_with_app_secret_fallback(monkeypatch): + client = _client(provider=None, app_secret="app-secret") + captured = {} + + def fake_request(method, url, headers=None, params=None, data=None, timeout=None): + captured["body"] = json.loads(data.decode()) + return _response({"access_token": "user-token", "expires_in": 7200}) + + import lark_oapi.core.http.transport as transport + + monkeypatch.setattr(transport.requests, "request", fake_request) + + client.access_token.refresh(refresh_token="refresh-token") + + assert captured["body"]["client_secret"] == "app-secret" + assert captured["body"]["refresh_token"] == "refresh-token" + + +def test_access_token_rejects_missing_credentials(): + client = _client(provider=None, app_secret="") + + with pytest.raises(ClientAssertionException) as err: + client.access_token.retrieve_by_authorization_code(code="code") + + assert err.value.code == 7104 + + +def test_access_token_returns_access_token_exception_for_non_200(monkeypatch): + client = _client(RecordingProvider()) + + def fake_request(method, url, headers=None, params=None, data=None, timeout=None): + return _response({ + "code": 20001, + "error": "invalid_client", + "error_description": "client assertion invalid", + }, status=401) + + import lark_oapi.core.http.transport as transport + + monkeypatch.setattr(transport.requests, "request", fake_request) + + with pytest.raises(AccessTokenException) as err: + client.access_token.retrieve_by_authorization_code(code="code") + + assert err.value.status_code == 401 + assert err.value.code == 20001 + assert err.value.error == "invalid_client" + assert err.value.error_description == "client assertion invalid" + + +def test_access_token_proxy_keeps_custom_headers(monkeypatch): + provider = RecordingProvider( + ClientAssertionToken( + "client-assertion", + TargetInfo(target_service="proxy.example.com", target_prefix="/proxy"), + ) + ) + client = _client(provider) + captured = {} + + def fake_request(method, url, headers=None, params=None, data=None, timeout=None): + captured["url"] = url + captured["headers"] = headers + return _response({"access_token": "user-token", "expires_in": 7200}) + + import lark_oapi.core.http.transport as transport + + monkeypatch.setattr(transport.requests, "request", fake_request) + + client.access_token.retrieve_by_authorization_code( + code="code", + headers={"X-Custom": "custom-value"}, + ) + + assert captured["url"] == "https://proxy.example.com/proxy/oauth/v3/token" + assert captured["headers"]["X-Custom"] == "custom-value" + assert captured["headers"]["X-Target-Service"] == "accounts.feishu.cn" diff --git a/lark_oapi/core/tests/test_client_assertion_auth.py b/lark_oapi/core/tests/test_client_assertion_auth.py new file mode 100644 index 000000000..4f3d3c4c8 --- /dev/null +++ b/lark_oapi/core/tests/test_client_assertion_auth.py @@ -0,0 +1,95 @@ +import pytest + +from lark_oapi.core import AccessTokenType, AppType +from lark_oapi.core.client_assertion import ClientAssertionToken +from lark_oapi.core.exception import ClientAssertionException, NoAuthorizationException +from lark_oapi.core.model import BaseRequest, Config, RequestOption +from lark_oapi.core.token import TokenManager, verify + + +class RecordingProvider: + def __init__(self): + self.calls = [] + + def retrieve_token(self, aud): + self.calls.append(aud) + return ClientAssertionToken("assertion") + + +def _request(*token_types): + req = BaseRequest() + req.token_types = set(token_types) + return req + + +def _config(provider=None): + config = Config() + config.app_id = "cli_a" + config.app_secret = "" + config.client_assertion_provider = provider + return config + + +def test_verify_client_assertion_prefers_tenant_over_app(monkeypatch): + provider = RecordingProvider() + config = _config(provider) + option = RequestOption() + req = _request(AccessTokenType.APP, AccessTokenType.TENANT) + + monkeypatch.setattr(TokenManager, "get_self_tenant_token", staticmethod(lambda conf: "tenant-token")) + + verify(config, req, option) + + assert req.token_types == {AccessTokenType.TENANT} + assert option.tenant_access_token == "tenant-token" + + +def test_verify_client_assertion_manual_user_token_wins(monkeypatch): + provider = RecordingProvider() + config = _config(provider) + option = RequestOption() + option.user_access_token = "user-token" + req = _request(AccessTokenType.TENANT, AccessTokenType.USER) + + def fail_if_called(conf): + raise AssertionError("tenant token should not be requested") + + monkeypatch.setattr(TokenManager, "get_self_tenant_token", staticmethod(fail_if_called)) + + verify(config, req, option) + + assert req.token_types == {AccessTokenType.USER} + assert provider.calls == [] + + +def test_verify_client_assertion_rejects_app_only(): + config = _config(RecordingProvider()) + req = _request(AccessTokenType.APP) + + with pytest.raises(ClientAssertionException) as err: + verify(config, req, RequestOption()) + + assert err.value.code == 7103 + + +def test_verify_client_assertion_rejects_isv(): + config = _config(RecordingProvider()) + config.app_type = AppType.ISV + req = _request(AccessTokenType.TENANT) + + with pytest.raises(ClientAssertionException) as err: + verify(config, req, RequestOption()) + + assert err.value.code == 7100 + + +def test_verify_app_secret_mode_still_requires_app_secret(): + config = Config() + config.app_id = "cli_a" + config.app_secret = "" + req = _request(AccessTokenType.TENANT) + + with pytest.raises(NoAuthorizationException) as err: + verify(config, req, RequestOption()) + + assert err.value.msg == "app_id or app_secret not found" diff --git a/lark_oapi/core/tests/test_client_assertion_core.py b/lark_oapi/core/tests/test_client_assertion_core.py new file mode 100644 index 000000000..7420b8614 --- /dev/null +++ b/lark_oapi/core/tests/test_client_assertion_core.py @@ -0,0 +1,74 @@ +import pytest + +from lark_oapi.core.const import ( + CLIENT_ASSERTION_TYPE_JWT_BEARER, + ERR_CODE_APP_SECRET_AND_CLIENT_ASSERTION_EMPTY, + ERR_CODE_CLIENT_ASSERTION_MODE_NOT_SUPPORTED, + ERR_CODE_CLIENT_ASSERTION_PROVIDER_NOT_CONFIGURED, + ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED, + ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY, + FEISHU_OAUTH_DOMAIN, + GRANT_TYPE_JWT_BEARER, + LARK_OAUTH_DOMAIN, + OAUTH_TOKEN_URI, + X_TARGET_SERVICE, +) +from lark_oapi.core.client_assertion import ( + TargetInfo, + build_proxy_url, + resolve_oauth_aud, + resolve_oauth_base_url, +) +from lark_oapi.core.model import Config + + +def test_client_assertion_constants_match_go_sdk(): + assert FEISHU_OAUTH_DOMAIN == "https://accounts.feishu.cn" + assert LARK_OAUTH_DOMAIN == "https://accounts.larksuite.com" + assert OAUTH_TOKEN_URI == "/oauth/v3/token" + assert GRANT_TYPE_JWT_BEARER == "urn:ietf:params:oauth:grant-type:jwt-bearer" + assert CLIENT_ASSERTION_TYPE_JWT_BEARER == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + assert X_TARGET_SERVICE == "X-Target-Service" + assert ERR_CODE_CLIENT_ASSERTION_PROVIDER_NOT_CONFIGURED == 7100 + assert ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY == 7101 + assert ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED == 7102 + assert ERR_CODE_CLIENT_ASSERTION_MODE_NOT_SUPPORTED == 7103 + assert ERR_CODE_APP_SECRET_AND_CLIENT_ASSERTION_EMPTY == 7104 + + +def test_resolve_oauth_base_url_default_feishu(): + config = Config() + config.domain = "https://open.feishu.cn" + + assert resolve_oauth_base_url(config) == "https://accounts.feishu.cn" + assert resolve_oauth_aud(config) == "accounts.feishu.cn" + + +def test_resolve_oauth_base_url_default_lark(): + config = Config() + config.domain = "https://open.larksuite.com" + + assert resolve_oauth_base_url(config) == "https://accounts.larksuite.com" + assert resolve_oauth_aud(config) == "accounts.larksuite.com" + + +def test_resolve_oauth_base_url_explicit_localhost(): + config = Config() + config.oauth_base_url = "http://127.0.0.1:18080/" + + assert resolve_oauth_base_url(config) == "http://127.0.0.1:18080" + assert resolve_oauth_aud(config) == "127.0.0.1:18080" + + +def test_resolve_oauth_base_url_requires_explicit_for_custom_domain(): + config = Config() + config.domain = "https://open.feishu-boe.cn" + + with pytest.raises(ValueError, match="OAuthBaseUrl is not configured"): + resolve_oauth_base_url(config) + + +def test_build_proxy_url_adds_https_when_scheme_missing(): + target_info = TargetInfo(target_service="proxy.example.com", target_prefix="/proxy") + + assert build_proxy_url(target_info, "/oauth/v3/token") == "https://proxy.example.com/proxy/oauth/v3/token" diff --git a/lark_oapi/core/tests/test_client_assertion_token_manager.py b/lark_oapi/core/tests/test_client_assertion_token_manager.py new file mode 100644 index 000000000..688f33d97 --- /dev/null +++ b/lark_oapi/core/tests/test_client_assertion_token_manager.py @@ -0,0 +1,174 @@ +import json +from types import SimpleNamespace + +import pytest + +from lark_oapi.core.client_assertion import ClientAssertionToken, TargetInfo +from lark_oapi.core.exception import ClientAssertionException +from lark_oapi.core.model import Config +from lark_oapi.core.token import TokenManager + + +class DictCache: + def __init__(self): + self.data = {} + self.expires = {} + + def get(self, key): + return self.data.get(key) + + def set(self, key, value, expire): + self.data[key] = value + self.expires[key] = expire + + +_DEFAULT = object() + + +class RecordingProvider: + def __init__(self, token=_DEFAULT, err=None): + self.token = ClientAssertionToken("client-assertion") if token is _DEFAULT else token + self.err = err + self.calls = [] + + def retrieve_token(self, aud): + self.calls.append(aud) + if self.err: + raise self.err + return self.token + + +def _config(provider): + config = Config() + config.app_id = "cli_a" + config.app_secret = "" + config.domain = "https://open.feishu.cn" + config.oauth_base_url = "https://accounts.feishu.cn" + config.client_assertion_provider = provider + return config + + +def _response(payload, status=200): + return SimpleNamespace(status_code=status, headers={"Content-Type": "application/json"}, content=json.dumps(payload).encode()) + + +def test_get_self_tenant_token_by_client_assertion_requests_oauth_token(monkeypatch): + provider = RecordingProvider() + config = _config(provider) + cache = DictCache() + monkeypatch.setattr(TokenManager, "cache", cache) + captured = {} + + def fake_request(method, url, headers=None, params=None, data=None, timeout=None): + captured["method"] = method + captured["url"] = url + captured["headers"] = headers + captured["body"] = json.loads(data.decode()) + return _response({"access_token": "tenant-token", "expires_in": 7200}) + + import lark_oapi.core.http.transport as transport + + monkeypatch.setattr(transport.requests, "request", fake_request) + + token = TokenManager.get_self_tenant_token(config) + + assert token == "tenant-token" + assert captured["url"] == "https://accounts.feishu.cn/oauth/v3/token" + assert captured["body"] == { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": "client-assertion", + "client_id": "cli_a", + } + assert provider.calls == ["accounts.feishu.cn"] + assert cache.data["self_tenant_token:cli_a"] == "tenant-token" + + +def test_get_self_tenant_token_by_client_assertion_cache_hit_skips_provider(monkeypatch): + provider = RecordingProvider() + config = _config(provider) + cache = DictCache() + cache.data["self_tenant_token:cli_a"] = "cached-token" + monkeypatch.setattr(TokenManager, "cache", cache) + + assert TokenManager.get_self_tenant_token(config) == "cached-token" + assert provider.calls == [] + + +def test_get_self_tenant_token_by_client_assertion_with_proxy(monkeypatch): + provider = RecordingProvider( + ClientAssertionToken( + "client-assertion", + TargetInfo(target_service="proxy.example.com", target_prefix="/proxy"), + ) + ) + config = _config(provider) + cache = DictCache() + monkeypatch.setattr(TokenManager, "cache", cache) + captured = {} + + def fake_request(method, url, headers=None, params=None, data=None, timeout=None): + captured["url"] = url + captured["headers"] = headers + return _response({"access_token": "tenant-token", "expires_in": 7200}) + + import lark_oapi.core.http.transport as transport + + monkeypatch.setattr(transport.requests, "request", fake_request) + + assert TokenManager.get_self_tenant_token(config) == "tenant-token" + assert captured["url"] == "https://proxy.example.com/proxy/oauth/v3/token" + assert captured["headers"]["X-Target-Service"] == "accounts.feishu.cn" + + +@pytest.mark.parametrize("token", [None, ClientAssertionToken("")]) +def test_get_self_tenant_token_by_client_assertion_empty_token(monkeypatch, token): + config = _config(RecordingProvider(token=token)) + monkeypatch.setattr(TokenManager, "cache", DictCache()) + + with pytest.raises(ClientAssertionException) as err: + TokenManager.get_self_tenant_token(config) + + assert err.value.code == 7101 + + +def test_get_self_tenant_token_by_client_assertion_provider_error(monkeypatch): + config = _config(RecordingProvider(err=RuntimeError("boom"))) + monkeypatch.setattr(TokenManager, "cache", DictCache()) + + with pytest.raises(ClientAssertionException) as err: + TokenManager.get_self_tenant_token(config) + + assert err.value.code == 7102 + assert "boom" in err.value.msg + + +def test_get_self_tenant_token_by_client_assertion_oauth_error_message_priority(monkeypatch): + config = _config(RecordingProvider()) + monkeypatch.setattr(TokenManager, "cache", DictCache()) + + def fake_request(method, url, headers=None, params=None, data=None, timeout=None): + return _response({ + "code": 20001, + "error": "invalid_client", + "error_description": "client assertion invalid", + }) + + import lark_oapi.core.http.transport as transport + + monkeypatch.setattr(transport.requests, "request", fake_request) + + with pytest.raises(ClientAssertionException) as err: + TokenManager.get_self_tenant_token(config) + + assert err.value.code == 20001 + assert err.value.msg == "client assertion invalid" + + +def test_get_self_app_token_blocked_in_client_assertion_mode(): + config = _config(RecordingProvider()) + + with pytest.raises(ClientAssertionException) as err: + TokenManager.get_self_app_token(config) + + assert err.value.code == 7100 diff --git a/lark_oapi/core/tests/test_transport_absolute_url.py b/lark_oapi/core/tests/test_transport_absolute_url.py new file mode 100644 index 000000000..fff8a03f2 --- /dev/null +++ b/lark_oapi/core/tests/test_transport_absolute_url.py @@ -0,0 +1,31 @@ +from lark_oapi.core.http.transport import _build_url + + +def test_build_url_keeps_absolute_http_url(): + url = _build_url( + "https://open.feishu.cn", + "http://127.0.0.1:18080/oauth/v3/token", + {}, + ) + + assert url == "http://127.0.0.1:18080/oauth/v3/token" + + +def test_build_url_keeps_absolute_https_url(): + url = _build_url( + "https://open.feishu.cn", + "https://accounts.feishu.cn/oauth/v3/token", + {}, + ) + + assert url == "https://accounts.feishu.cn/oauth/v3/token" + + +def test_build_url_relative_path_unchanged(): + url = _build_url( + "https://open.feishu.cn", + "/open-apis/mock/v1/ping", + {}, + ) + + assert url == "https://open.feishu.cn/open-apis/mock/v1/ping" diff --git a/lark_oapi/core/token/auth.py b/lark_oapi/core/token/auth.py index 13836c352..2898a78bd 100644 --- a/lark_oapi/core/token/auth.py +++ b/lark_oapi/core/token/auth.py @@ -1,4 +1,8 @@ -from lark_oapi.core.exception import NoAuthorizationException +from lark_oapi.core.const import ( + ERR_CODE_CLIENT_ASSERTION_MODE_NOT_SUPPORTED, + ERR_CODE_CLIENT_ASSERTION_PROVIDER_NOT_CONFIGURED, +) +from lark_oapi.core.exception import ClientAssertionException, NoAuthorizationException from lark_oapi.core.model import * from lark_oapi.core.utils import Strings from .manager import TokenManager @@ -9,6 +13,28 @@ def verify(config: Config, request: BaseRequest, option: RequestOption) -> None: if len(request.token_types) == 0: return + if config.client_assertion_provider is not None: + if Strings.is_empty(config.app_id): + raise NoAuthorizationException("app_id not found") + if AppType.ISV == config.app_type: + raise ClientAssertionException( + ERR_CODE_CLIENT_ASSERTION_PROVIDER_NOT_CONFIGURED, + "ClientAssertion mode is not supported for ISV apps", + ) + if Strings.is_not_empty(option.user_access_token) and AccessTokenType.USER in request.token_types: + request.token_types = {AccessTokenType.USER} + return + if AccessTokenType.TENANT in request.token_types: + option.tenant_access_token = TokenManager.get_self_tenant_token(config) + request.token_types = {AccessTokenType.TENANT} + return + if AccessTokenType.APP in request.token_types: + raise ClientAssertionException( + ERR_CODE_CLIENT_ASSERTION_MODE_NOT_SUPPORTED, + "AppAccessToken APIs are not available in ClientAssertion mode", + ) + return + # 如开启token配置,需手动传入token if config.enable_set_token: if Strings.is_not_empty(option.tenant_access_token) and AccessTokenType.TENANT in request.token_types: diff --git a/lark_oapi/core/token/manager.py b/lark_oapi/core/token/manager.py index f637a70c3..0d5491223 100644 --- a/lark_oapi/core/token/manager.py +++ b/lark_oapi/core/token/manager.py @@ -1,9 +1,25 @@ +import json +import time + from lark_oapi.core import JSON, Strings from lark_oapi.core.cache import * -from lark_oapi.core.const import UTF_8 -from lark_oapi.core.exception import ObtainAccessTokenException +from lark_oapi.core.client_assertion import build_proxy_url, resolve_oauth_aud, resolve_oauth_base_url +from lark_oapi.core.const import ( + APPLICATION_JSON, + CLIENT_ASSERTION_TYPE_JWT_BEARER, + CONTENT_TYPE, + ERR_CODE_CLIENT_ASSERTION_PROVIDER_NOT_CONFIGURED, + ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED, + ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY, + GRANT_TYPE_JWT_BEARER, + OAUTH_TOKEN_URI, + UTF_8, + X_TARGET_SERVICE, +) +from lark_oapi.core.exception import ClientAssertionException, ObtainAccessTokenException from lark_oapi.core.http import Transport -from lark_oapi.core.model import Config, RawResponse +from lark_oapi.core.model import BaseRequest, Config, RawResponse, RequestOption +from lark_oapi.core.enum import HttpMethod from .access_token_response import AccessTokenResponse from .create_isv_app_token_request import CreateIsvAppTokenRequest from .create_isv_tenant_token_request import CreateIsvTenantTokenRequest @@ -17,6 +33,12 @@ class TokenManager(object): @staticmethod def get_self_app_token(conf: Config) -> str: + if conf.client_assertion_provider is not None: + raise ClientAssertionException( + ERR_CODE_CLIENT_ASSERTION_PROVIDER_NOT_CONFIGURED, + "AppAccessToken is not available in ClientAssertion mode", + ) + # 读缓存 cache_key = f"self_app_token:{conf.app_id}" token = TokenManager.cache.get(cache_key) @@ -45,6 +67,9 @@ def get_self_app_token(conf: Config) -> str: @staticmethod def get_self_tenant_token(config: Config) -> str: + if config.client_assertion_provider is not None: + return TokenManager._get_self_tenant_token_by_client_assertion(config) + # 读缓存 cache_key = f"self_tenant_token:{config.app_id}" token = TokenManager.cache.get(cache_key) @@ -71,6 +96,49 @@ def get_self_tenant_token(config: Config) -> str: return token + @staticmethod + def _get_self_tenant_token_by_client_assertion(config: Config) -> str: + cache_key = f"self_tenant_token:{config.app_id}" + token = TokenManager.cache.get(cache_key) + if Strings.is_not_empty(token): + return token + + oauth_base_url = resolve_oauth_base_url(config) + aud = resolve_oauth_aud(config) + try: + assertion_token = config.client_assertion_provider.retrieve_token(aud) + except Exception as e: + raise ClientAssertionException(ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED, str(e)) + if assertion_token is None or Strings.is_empty(assertion_token.value): + raise ClientAssertionException(ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY, "client assertion token is empty") + + req = BaseRequest() + req.http_method = HttpMethod.POST + req.uri = oauth_base_url + OAUTH_TOKEN_URI + req.headers = {CONTENT_TYPE: APPLICATION_JSON} + req.body = { + "grant_type": GRANT_TYPE_JWT_BEARER, + "client_assertion_type": CLIENT_ASSERTION_TYPE_JWT_BEARER, + "client_assertion": assertion_token.value, + "client_id": config.app_id, + } + option = RequestOption() + if assertion_token.target_info is not None: + req.uri = build_proxy_url(assertion_token.target_info, OAUTH_TOKEN_URI) + option.headers[X_TARGET_SERVICE] = aud + + raw = Transport.execute(config, req, option) + resp = json.loads(str(raw.content, UTF_8)) + access_token = resp.get("access_token") + if Strings.is_empty(access_token): + msg = resp.get("error_description") or resp.get("error") or "oauth token response missing access token" + raise ClientAssertionException(resp.get("code") or 0, msg) + + expires_in = int(resp.get("expires_in") or 0) + expire = time.time() + max(expires_in - 180, 0) + TokenManager.cache.set(cache_key, access_token, int(expire)) + return access_token + @staticmethod def get_isv_app_token(config: Config, app_ticket: str) -> str: # 读缓存 diff --git a/lark_oapi/ws/client.py b/lark_oapi/ws/client.py index fc7da7eb0..725eba5b4 100644 --- a/lark_oapi/ws/client.py +++ b/lark_oapi/ws/client.py @@ -2,6 +2,7 @@ import base64 import http import inspect +import json import random import time from typing import Callable, Dict, Mapping, Optional @@ -12,7 +13,8 @@ from websockets.exceptions import InvalidHandshake from lark_oapi.core.cache import ExpiringCache -from lark_oapi.core.const import UTF_8, FEISHU_DOMAIN, USER_AGENT +from lark_oapi.core.client_assertion import build_proxy_url, extract_aud_from_url +from lark_oapi.core.const import UTF_8, FEISHU_DOMAIN, USER_AGENT, X_TARGET_SERVICE from lark_oapi.core.enum import LogLevel from lark_oapi.core.json import JSON from lark_oapi.core.log import logger @@ -123,13 +125,15 @@ def __init__(self, auto_reconnect: bool = True, source: Optional[str] = None, extra_ua_tags: Optional[list] = None, - headers: Optional[Mapping[str, str]] = None) -> None: + headers: Optional[Mapping[str, str]] = None, + client_assertion_provider=None) -> None: self._app_id: str = app_id self._app_secret: str = app_secret self._log_level: LogLevel = log_level self._event_handler: EventDispatcherHandler = event_handler self._auto_reconnect: bool = auto_reconnect self._domain: str = domain + self._client_assertion_provider = client_assertion_provider self._headers: Dict[str, str] = dict(headers or {}) # UA used on the endpoint-discovery POST (and any future HTTP/WS # handshakes from this client). ``extra_ua_tags`` is internal — sub- @@ -227,7 +231,9 @@ async def _receive_message_loop(self): raise e def _get_conn_url(self) -> str: - if Strings.is_empty(self._app_id) or Strings.is_empty(self._app_secret): + if Strings.is_empty(self._app_id) or ( + self._client_assertion_provider is None and Strings.is_empty(self._app_secret) + ): raise ClientException(NO_CREDENTIAL, "app_id or app_secret is null") headers = dict(self._headers) @@ -235,16 +241,33 @@ def _get_conn_url(self) -> str: "locale": "zh", USER_AGENT: self._user_agent, }) + url = self._domain + GEN_ENDPOINT_URI + body = {"AppID": self._app_id} + if self._client_assertion_provider is not None: + aud = extract_aud_from_url(self._domain) + assertion_token = self._client_assertion_provider.retrieve_token(aud) + if assertion_token is None or Strings.is_empty(assertion_token.value): + raise ClientException(7101, "client assertion token is empty") + body["ClientAssertion"] = assertion_token.value + if assertion_token.target_info is not None: + url = build_proxy_url(assertion_token.target_info, GEN_ENDPOINT_URI) + headers[X_TARGET_SERVICE] = aud + else: + body["AppSecret"] = self._app_secret + response = requests.post( - self._domain + GEN_ENDPOINT_URI, + url, headers=headers, - json={ - "AppID": self._app_id, - "AppSecret": self._app_secret, - }, + json=body, ) if response.status_code != http.HTTPStatus.OK: - raise ServerException(response.status_code, "system busy") + msg = "system busy" + try: + payload = json.loads(str(response.content, UTF_8)) + msg = payload.get("msg") or msg + except Exception: + pass + raise ServerException(response.status_code, msg) resp = JSON.unmarshal(str(response.content, UTF_8), EndpointResp) if resp.code == OK: diff --git a/lark_oapi/ws/tests/test_client_assertion.py b/lark_oapi/ws/tests/test_client_assertion.py new file mode 100644 index 000000000..405f13447 --- /dev/null +++ b/lark_oapi/ws/tests/test_client_assertion.py @@ -0,0 +1,156 @@ +from types import SimpleNamespace + +import pytest + +from lark_oapi.core.client_assertion import ClientAssertionToken, TargetInfo +from lark_oapi.ws import client as ws_client +from lark_oapi.ws.exception import ClientException, ServerException + + +class RecordingProvider: + def __init__(self, tokens=None, err=None): + self.tokens = list(tokens or [ClientAssertionToken("assertion")]) + self.err = err + self.calls = [] + + def retrieve_token(self, aud): + self.calls.append(aud) + if self.err: + raise self.err + if len(self.tokens) == 1: + return self.tokens[0] + return self.tokens.pop(0) + + +def _ok_response(): + return SimpleNamespace( + status_code=200, + content=b'{"code":0,"data":{"URL":"ws://example.test/callback?device_id=device&service_id=42"}}', + ) + + +def test_ws_get_conn_url_with_app_secret_keeps_existing_behavior(monkeypatch): + captured = {} + + def fake_post(url, *, headers=None, json=None): + captured["url"] = url + captured["headers"] = headers + captured["json"] = json + return _ok_response() + + monkeypatch.setattr(ws_client.requests, "post", fake_post) + client = ws_client.Client("app_id", "app_secret") + + assert client._get_conn_url() == "ws://example.test/callback?device_id=device&service_id=42" + assert captured["json"] == {"AppID": "app_id", "AppSecret": "app_secret"} + + +def test_ws_get_conn_url_with_client_assertion(monkeypatch): + captured = {} + provider = RecordingProvider() + + def fake_post(url, *, headers=None, json=None): + captured["url"] = url + captured["headers"] = headers + captured["json"] = json + return _ok_response() + + monkeypatch.setattr(ws_client.requests, "post", fake_post) + client = ws_client.Client("app_id", "", client_assertion_provider=provider) + + assert client._get_conn_url() == "ws://example.test/callback?device_id=device&service_id=42" + assert captured["json"] == {"AppID": "app_id", "ClientAssertion": "assertion"} + assert provider.calls == ["open.feishu.cn"] + + +def test_ws_get_conn_url_with_client_assertion_proxy(monkeypatch): + captured = {} + provider = RecordingProvider([ + ClientAssertionToken( + "assertion", + TargetInfo(target_service="proxy.example.com", target_prefix="/proxy"), + ) + ]) + + def fake_post(url, *, headers=None, json=None): + captured["url"] = url + captured["headers"] = headers + captured["json"] = json + return _ok_response() + + monkeypatch.setattr(ws_client.requests, "post", fake_post) + client = ws_client.Client( + "app_id", + "", + domain="https://open.feishu.cn", + headers={"X-Target-Service": "caller-value", "X-Custom": "custom-value"}, + client_assertion_provider=provider, + ) + + client._get_conn_url() + + assert captured["url"] == "https://proxy.example.com/proxy/callback/ws/endpoint" + assert captured["headers"]["X-Target-Service"] == "open.feishu.cn" + assert captured["headers"]["X-Custom"] == "custom-value" + assert captured["json"] == {"AppID": "app_id", "ClientAssertion": "assertion"} + + +def test_ws_get_conn_url_retrieves_token_each_time(monkeypatch): + assertions = [] + provider = RecordingProvider([ + ClientAssertionToken("assertion-1"), + ClientAssertionToken("assertion-2"), + ]) + + def fake_post(url, *, headers=None, json=None): + assertions.append(json["ClientAssertion"]) + return _ok_response() + + monkeypatch.setattr(ws_client.requests, "post", fake_post) + client = ws_client.Client("app_id", "", client_assertion_provider=provider) + + client._get_conn_url() + client._get_conn_url() + + assert assertions == ["assertion-1", "assertion-2"] + assert provider.calls == ["open.feishu.cn", "open.feishu.cn"] + + +def test_ws_get_conn_url_empty_client_assertion_token(): + provider = RecordingProvider([ClientAssertionToken("")]) + client = ws_client.Client("app_id", "", client_assertion_provider=provider) + + with pytest.raises(ClientException) as err: + client._get_conn_url() + + assert err.value.code == 7101 + + +def test_ws_provider_error_is_not_wrapped(): + err = RuntimeError("boom") + provider = RecordingProvider(err=err) + client = ws_client.Client("app_id", "", client_assertion_provider=provider) + + with pytest.raises(RuntimeError) as raised: + client._get_conn_url() + + assert raised.value is err + + +def test_ws_non_200_uses_server_msg_when_available(monkeypatch): + provider = RecordingProvider() + + def fake_post(url, *, headers=None, json=None): + return SimpleNamespace( + status_code=500, + content=b'{"code":20050,"msg":"target service unavailable"}', + ) + + monkeypatch.setattr(ws_client.requests, "post", fake_post) + client = ws_client.Client("app_id", "", client_assertion_provider=provider) + + with pytest.raises(ServerException) as err: + client._get_conn_url() + + assert err.value.code == 500 + assert str(err.value) == "500: target service unavailable" diff --git a/samples/client_assertion/access_token_authorization_code_sample.py b/samples/client_assertion/access_token_authorization_code_sample.py new file mode 100644 index 000000000..e1c5ede44 --- /dev/null +++ b/samples/client_assertion/access_token_authorization_code_sample.py @@ -0,0 +1,27 @@ +import os + +import lark_oapi as lark +from lark_oapi.core.client_assertion import ClientAssertionToken + + +class EnvClientAssertionProvider: + def retrieve_token(self, aud: str) -> ClientAssertionToken: + return ClientAssertionToken(os.environ["LARK_CLIENT_ASSERTION"]) + + +builder = ( + lark.Client.builder() + .app_id(os.environ["LARK_APP_ID"]) + .client_assertion_provider(EnvClientAssertionProvider()) +) +if os.environ.get("LARK_OAUTH_BASE_URL"): + builder.oauth_base_url(os.environ["LARK_OAUTH_BASE_URL"]) + +client = builder.build() +resp = client.access_token.retrieve_by_authorization_code( + code=os.environ["LARK_OAUTH_CODE"], + redirect_uri=os.environ.get("LARK_REDIRECT_URI"), + code_verifier=os.environ.get("LARK_CODE_VERIFIER"), +) + +print(resp.access_token) diff --git a/samples/client_assertion/client_assertion_provider_sample.py b/samples/client_assertion/client_assertion_provider_sample.py new file mode 100644 index 000000000..3d17c0aab --- /dev/null +++ b/samples/client_assertion/client_assertion_provider_sample.py @@ -0,0 +1,21 @@ +import os + +import lark_oapi as lark +from lark_oapi.core.client_assertion import ClientAssertionToken + + +class EnvClientAssertionProvider: + def retrieve_token(self, aud: str) -> ClientAssertionToken: + # The SDK does not generate or sign JWTs. Fetch the assertion from + # your signing service, KMS, Vault, or another secure source. + return ClientAssertionToken(os.environ["LARK_CLIENT_ASSERTION"]) + + +client = ( + lark.Client.builder() + .app_id(os.environ["LARK_APP_ID"]) + .client_assertion_provider(EnvClientAssertionProvider()) + .build() +) + +print(client.config.app_id) diff --git a/samples/ws/client_assertion_sample.py b/samples/ws/client_assertion_sample.py new file mode 100644 index 000000000..32acb15fa --- /dev/null +++ b/samples/ws/client_assertion_sample.py @@ -0,0 +1,18 @@ +import os + +from lark_oapi.core.client_assertion import ClientAssertionToken +from lark_oapi.ws import Client + + +class EnvClientAssertionProvider: + def retrieve_token(self, aud: str) -> ClientAssertionToken: + return ClientAssertionToken(os.environ["LARK_CLIENT_ASSERTION"]) + + +client = Client( + os.environ["LARK_APP_ID"], + "", + client_assertion_provider=EnvClientAssertionProvider(), +) + +client.start() From 642fcee26412bc7699eeb05c24e9794277c809e7 Mon Sep 17 00:00:00 2001 From: WangWeijia Date: Tue, 9 Jun 2026 18:11:45 +0800 Subject: [PATCH 2/6] fix: redact sensitive transport debug logs Change-Id: Iec327ff1eefdce7af2c1fcbf52e28ca2ea4c77f2 --- ...lient_assertion_keyless_python_tasks.zh.md | 10 +- ...lient_assertion_keyless_python_tests.zh.md | 18 ++- lark_oapi/core/http/transport.py | 80 ++++++++++++-- lark_oapi/core/model/config.py | 2 - .../test_client_assertion_keyless_local.py | 2 +- .../tests/test_transport_log_redaction.py | 103 ++++++++++++++++++ lark_oapi/ws/client.py | 6 +- lark_oapi/ws/tests/test_client_assertion.py | 15 ++- 8 files changed, 217 insertions(+), 19 deletions(-) create mode 100644 lark_oapi/core/tests/test_transport_log_redaction.py diff --git a/doc/client_assertion_keyless_python_tasks.zh.md b/doc/client_assertion_keyless_python_tasks.zh.md index e1c7d1fe4..8f9f8cb14 100644 --- a/doc/client_assertion_keyless_python_tasks.zh.md +++ b/doc/client_assertion_keyless_python_tasks.zh.md @@ -14,7 +14,7 @@ | `lark_oapi/core/model/config.py` | 保存 `app_id`、`app_secret`、`domain`、`app_type`、cache 等配置 | 新增 provider 和 OAuth base URL 字段 | | `lark_oapi/core/token/auth.py` | 请求前鉴权、选择 app/tenant/user token | 加入 ClientAssertion 模式 token type 决策 | | `lark_oapi/core/token/manager.py` | 获取并缓存 app/tenant token | 增加 OAuth JWT bearer tenant token exchange;ClientAssertion 模式禁止 app token | -| `lark_oapi/core/http/transport.py` | 组装 URL/header/body 并发送 sync/async 请求 | 支持 absolute URL,供 OAuth/proxy endpoint 使用 | +| `lark_oapi/core/http/transport.py` | 组装 URL/header/body 并发送 sync/async 请求 | 支持 absolute URL,供 OAuth/proxy endpoint 使用;Debug 日志输出前递归脱敏敏感凭证 | | `lark_oapi/ws/client.py` | WebSocket endpoint bootstrap 和连接管理 | 支持 provider、TargetInfo 代理、自定义 header 覆盖规则 | ## GO SDK 对齐约束 @@ -160,10 +160,13 @@ class ClientAssertionProvider(Protocol): - [x] ✅ 相对 URL 仍按现有逻辑拼接 `domain + uri`。 - [x] ✅ OAuth token exchange 调用方直接解析 OAuth 响应,不走 `Client.request()` 的 `BaseResponse` 语义。 - [x] ✅ 保持自定义 headers、User-Agent、Content-Type 行为不回退。 +- [x] ✅ Debug 日志在序列化前递归脱敏 `Authorization`、`client_assertion`、`ClientAssertion`、`client_secret`、`AppSecret`、`*token*` 等敏感字段。 +- [x] ✅ 脱敏只作用于日志副本,不修改真实请求 headers/body。 验收方框: - [x] ✅ absolute OAuth URL 不被拼成 `https://open.feishu.cnhttps://accounts.example.com/oauth/v3/token`。 - [x] ✅ 普通 OpenAPI 相对路径行为不变。 +- [x] ✅ 同步和异步 Transport Debug 日志均不会输出 ClientAssertion、AppSecret、Authorization bearer token 等原文。 ### 任务 6:OAuth user AccessToken 服务 @@ -202,14 +205,15 @@ class ClientAssertionProvider(Protocol): - [x] ✅ provider 存在时 aud 使用 `_domain` 的 host。 - [x] ✅ provider 抛错时直接抛原始错误,保持 GO 细节。 - [x] ✅ token 为空时报 `ClientException(7101, "client assertion token is empty")`。 -- [x] ✅ provider 模式 body 发送 `{"AppID": app_id, "ClientAssertion": token.value}`,不发送 `AppSecret`。 +- [x] ✅ provider 模式 body 发送 `{"AppID": app_id, "AppSecret": "", "ClientAssertion": token.value}`,保留必填的 `AppSecret` 字段为空串。 - [x] ✅ `TargetInfo` 存在时 URL 改为 proxy URL,并设置 `X-Target-Service` 为真实 aud。 - [x] ✅ 用户 headers 先合并,SDK 注入的 `locale`、`User-Agent`、`X-Target-Service` 后覆盖。 - [x] ✅ 非 200 响应如果 body 可解析出 `msg`,使用服务端 msg;否则使用 `system busy`。 +- [x] ✅ 缺少凭证时错误文案说明 `app_id` 必填,且 `app_secret` / `client_assertion_provider` 至少提供一个。 验收方框: - [x] ✅ app_secret bootstrap 旧行为不变。 -- [x] ✅ provider bootstrap 不传 `AppSecret`。 +- [x] ✅ provider bootstrap 传空串 `AppSecret`,不传真实密钥。 - [x] ✅ 每次 `_get_conn_url()` 都调用 provider。 - [x] ✅ 自定义 header 不丢失,冲突时 `X-Target-Service` 使用 SDK 注入值。 diff --git a/doc/client_assertion_keyless_python_tests.zh.md b/doc/client_assertion_keyless_python_tests.zh.md index d63fa0138..c843bc7ac 100644 --- a/doc/client_assertion_keyless_python_tests.zh.md +++ b/doc/client_assertion_keyless_python_tests.zh.md @@ -12,6 +12,7 @@ | `lark_oapi/core/tests/test_client_assertion_access_token.py` | authorization code、refresh token、app_secret fallback、OAuth error、TargetInfo proxy | | `lark_oapi/ws/tests/test_client_assertion.py` | WS bootstrap provider、proxy、headers、空 token、provider error 原样抛出 | | `lark_oapi/core/tests/test_transport_absolute_url.py` | absolute URL 拼接与相对 URL 兼容 | +| `lark_oapi/core/tests/test_transport_log_redaction.py` | Transport Debug 日志敏感字段脱敏,覆盖同步和异步请求 | | `lark_oapi/core/tests/e2e/test_client_assertion_keyless_local.py` | 本地 mock E2E | | `lark_oapi/core/tests/e2e/test_client_assertion_keyless_live.py` | 真实环境 smoke E2E,默认跳过 | @@ -113,6 +114,14 @@ - 输入:`domain = "https://open.feishu.cn"`、`uri = "/open-apis/mock/v1/ping"`。 - 期望:输出 `https://open.feishu.cn/open-apis/mock/v1/ping`。 +- [x] ✅ `test_execute_redacts_sensitive_headers_and_body_from_debug_log` + - 输入:同步请求 headers/body 中包含 `Authorization`、`client_assertion`、`client_secret`、`AppSecret`、`refresh_token` 等敏感字段。 + - 期望:Debug 日志只输出 `***`,真实请求 headers/body 仍保留原值。 + +- [x] ✅ `test_aexecute_redacts_sensitive_headers_and_body_from_debug_log` + - 输入:异步请求 headers/body 中包含 user token、`client_assertion` 和 `client_secret`。 + - 期望:Debug 日志不包含原始凭证,真实请求 payload 不被脱敏副本污染。 + ### OAuth user AccessToken - [x] ✅ `test_access_token_authorization_code_with_client_assertion` @@ -151,7 +160,7 @@ - [x] ✅ `test_ws_get_conn_url_with_client_assertion` - 使用 `Client("app_id", "", client_assertion_provider=provider)`。 - - 期望:body 为 `{"AppID": "app_id", "ClientAssertion": "assertion"}`,不包含 `AppSecret`。 + - 期望:body 为 `{"AppID": "app_id", "AppSecret": "", "ClientAssertion": "assertion"}`,保留必填的 `AppSecret` 字段为空串。 - [x] ✅ `test_ws_get_conn_url_with_client_assertion_proxy` - provider 返回 `TargetInfo`。 @@ -165,6 +174,10 @@ - provider 返回空 token。 - 期望:抛 `ClientException`,code 为 `7101`。 +- [x] ✅ `test_ws_get_conn_url_missing_credentials_message` + - 使用 `Client("app_id", "")` 且不提供 provider。 + - 期望:错误文案为 `app_id is required and either app_secret or client_assertion_provider is required`。 + - [x] ✅ `test_ws_provider_error_is_not_wrapped` - provider 抛出 `RuntimeError("boom")`。 - 期望:`_get_conn_url()` 抛出的就是原始 `RuntimeError`。 @@ -192,7 +205,7 @@ - [x] ✅ OAuth exchange body 使用 JWT bearer grant type。 - [x] ✅ 普通 OpenAPI 请求最终带 tenant token。 - [x] ✅ 第二次普通请求命中 tenant token cache,不再次调用 provider。 -- [x] ✅ WS bootstrap 使用 domain host 作为 aud,且 body 不含 `AppSecret`。 +- [x] ✅ WS bootstrap 使用 domain host 作为 aud,且 body 中 `AppSecret` 为空串。 推荐命令: @@ -244,6 +257,7 @@ python -m pytest \ lark_oapi/core/tests/test_client_assertion_token_manager.py \ lark_oapi/core/tests/test_client_assertion_access_token.py \ lark_oapi/core/tests/test_transport_absolute_url.py \ + lark_oapi/core/tests/test_transport_log_redaction.py \ lark_oapi/ws/tests/test_client_assertion.py -v ``` diff --git a/lark_oapi/core/http/transport.py b/lark_oapi/core/http/transport.py index 82a93efd6..a9f72796f 100644 --- a/lark_oapi/core/http/transport.py +++ b/lark_oapi/core/http/transport.py @@ -1,5 +1,6 @@ import json import urllib.parse +from typing import Any, Optional import httpx import requests @@ -12,6 +13,10 @@ from lark_oapi.core.utils.user_agent import build_user_agent +_REDACTED = "***" +_SENSITIVE_KEYWORDS = ("authorization", "token", "secret", "assertion", "password", "ticket") + + class Transport(object): @staticmethod @@ -38,10 +43,10 @@ def execute(conf: Config, req: BaseRequest, option: Optional[RequestOption] = No timeout=conf.timeout, ) - logger.debug(f"{str(req.http_method.name)} {url} {response.status_code}, " - f"headers: {JSON.marshal(headers)}, " - f"params: {JSON.marshal(req.queries)}, " - f"body: {str(data, UTF_8) if isinstance(data, bytes) else data}") + logger.debug(f"{str(req.http_method.name)} {_redact_url(url)} {response.status_code}, " + f"headers: {_marshal_log_value(headers)}, " + f"params: {_marshal_log_value(req.queries)}, " + f"body: {_marshal_log_body(data)}") resp = RawResponse() resp.status_code = response.status_code @@ -84,10 +89,10 @@ async def aexecute(conf: Config, req: BaseRequest, option: Optional[RequestOptio ) logger.debug( - f"{str(req.http_method.name)} {url} {response.status_code}" - f"{f', headers: {JSON.marshal(headers)}' if headers else ''}" - f"{f', params: {JSON.marshal(req.queries)}' if req.queries else ''}" - f"{f', body: {JSON.marshal(_merge_dicts(json_, files, data))}' if json_ or files or data else ''}" + f"{str(req.http_method.name)} {_redact_url(url)} {response.status_code}" + f"{f', headers: {_marshal_log_value(headers)}' if headers else ''}" + f"{f', params: {_marshal_log_value(req.queries)}' if req.queries else ''}" + f"{f', body: {_marshal_log_value(_merge_dicts(json_, files, data))}' if json_ or files or data else ''}" ) resp = RawResponse() @@ -140,6 +145,65 @@ def _build_header(request: BaseRequest, option: RequestOption, conf: Optional[Co return headers +def _is_sensitive_key(key: Any) -> bool: + key = str(key).lower() + return any(keyword in key for keyword in _SENSITIVE_KEYWORDS) + + +def _redact_sensitive(value: Any) -> Any: + if isinstance(value, dict): + return { + key: _REDACTED if _is_sensitive_key(key) else _redact_sensitive(item) + for key, item in value.items() + } + if isinstance(value, tuple): + if len(value) == 2 and not isinstance(value[0], (dict, list, tuple)) and _is_sensitive_key(value[0]): + return value[0], _REDACTED + return tuple(_redact_sensitive(item) for item in value) + if isinstance(value, list): + if len(value) == 2 and not isinstance(value[0], (dict, list, tuple)) and _is_sensitive_key(value[0]): + return [value[0], _REDACTED] + return [_redact_sensitive(item) for item in value] + return value + + +def _marshal_log_value(value: Any) -> Optional[str]: + return JSON.marshal(_redact_sensitive(value)) + + +def _marshal_log_body(data: Any) -> str: + if data is None: + return "None" + if isinstance(data, MultipartEncoder): + return _REDACTED + if isinstance(data, bytes): + try: + return _marshal_log_value(json.loads(str(data, UTF_8))) + except Exception: + return _REDACTED + value = _marshal_log_value(data) + return value if value is not None else "None" + + +def _redact_url(url: str) -> str: + parsed = urllib.parse.urlsplit(url) + if not parsed.query: + return url + + query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) + redacted_query = [ + (key, _REDACTED if _is_sensitive_key(key) else value) + for key, value in query + ] + return urllib.parse.urlunsplit(( + parsed.scheme, + parsed.netloc, + parsed.path, + urllib.parse.urlencode(redacted_query), + parsed.fragment, + )) + + def _merge_dicts(*dicts): res = {} for d in dicts: diff --git a/lark_oapi/core/model/config.py b/lark_oapi/core/model/config.py index e1e02b552..911719c55 100644 --- a/lark_oapi/core/model/config.py +++ b/lark_oapi/core/model/config.py @@ -10,8 +10,6 @@ def __init__(self) -> None: self.app_id: Optional[str] = None self.app_secret: Optional[str] = None self.domain: str = FEISHU_DOMAIN # 域名, 默认为 https://open.feishu.cn - self.timeout: Optional[ - float] = 30 # client timeout in seconds (default 30s); override via ClientBuilder.timeout() self.oauth_base_url: Optional[str] = None self.client_assertion_provider: Optional[Any] = None self.timeout: Optional[float] = 30 # client timeout in seconds (default 30s); override via ClientBuilder.timeout() diff --git a/lark_oapi/core/tests/e2e/test_client_assertion_keyless_local.py b/lark_oapi/core/tests/e2e/test_client_assertion_keyless_local.py index a799b0b75..631a23830 100644 --- a/lark_oapi/core/tests/e2e/test_client_assertion_keyless_local.py +++ b/lark_oapi/core/tests/e2e/test_client_assertion_keyless_local.py @@ -139,7 +139,7 @@ def test_local_keyless_openapi_access_token_and_ws_e2e(): assert len(state.oauth_bodies) == 3 assert state.oauth_bodies[0]["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" assert state.oauth_bodies[0]["client_assertion"] == "local-assertion" - assert state.ws_bodies == [{"AppID": "cli_local", "ClientAssertion": "local-assertion"}] + assert state.ws_bodies == [{"AppID": "cli_local", "AppSecret": "", "ClientAssertion": "local-assertion"}] finally: server.shutdown() server.server_close() diff --git a/lark_oapi/core/tests/test_transport_log_redaction.py b/lark_oapi/core/tests/test_transport_log_redaction.py new file mode 100644 index 000000000..0c578a486 --- /dev/null +++ b/lark_oapi/core/tests/test_transport_log_redaction.py @@ -0,0 +1,103 @@ +from types import SimpleNamespace + +import pytest + +from lark_oapi.core import AccessTokenType, HttpMethod +from lark_oapi.core.http import transport +from lark_oapi.core.json import JSON +from lark_oapi.core.model import BaseRequest, Config, RequestOption + + +def _request(body): + req = BaseRequest() + req.http_method = HttpMethod.POST + req.uri = "/open-apis/mock" + req.token_types = {AccessTokenType.TENANT} + req.body = body + return req + + +def test_execute_redacts_sensitive_headers_and_body_from_debug_log(monkeypatch): + captured = {} + debug_logs = [] + body = { + "client_assertion": "assertion-secret", + "client_secret": "client-secret", + "nested": {"refresh_token": "refresh-secret"}, + "items": [{"AppSecret": "app-secret"}, {"ClientAssertion": "ws-assertion"}], + } + + def fake_request(method, url, *, headers=None, params=None, data=None, timeout=None): + captured["headers"] = dict(headers) + captured["data"] = data + return SimpleNamespace(status_code=200, headers={}, content=b"{}") + + monkeypatch.setattr(transport.requests, "request", fake_request) + monkeypatch.setattr(transport.logger, "debug", lambda msg: debug_logs.append(msg)) + + conf = Config() + req = _request(body) + option = RequestOption() + option.tenant_access_token = "tenant-token-secret" + option.headers = {"X-Api-Token": "header-token-secret"} + + transport.Transport.execute(conf, req, option) + + log_output = "\n".join(debug_logs) + for secret in ( + "assertion-secret", + "client-secret", + "refresh-secret", + "app-secret", + "ws-assertion", + "tenant-token-secret", + "header-token-secret", + ): + assert secret not in log_output + assert '"Authorization": "***"' in log_output + assert '"client_assertion": "***"' in log_output + + assert captured["headers"]["Authorization"] == "Bearer tenant-token-secret" + assert captured["headers"]["X-Api-Token"] == "header-token-secret" + assert JSON.unmarshal(captured["data"].decode("utf-8"), dict)["client_assertion"] == "assertion-secret" + + +@pytest.mark.asyncio +async def test_aexecute_redacts_sensitive_headers_and_body_from_debug_log(monkeypatch): + captured = {} + debug_logs = [] + body = {"client_assertion": "async-assertion-secret", "client_secret": "async-client-secret"} + + class FakeAsyncClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def request(self, method, url, *, headers=None, params=None, json=None, data=None, files=None, + timeout=None): + captured["headers"] = dict(headers) + captured["json"] = json + return SimpleNamespace(status_code=200, headers={}, content=b"{}") + + monkeypatch.setattr(transport.httpx, "AsyncClient", FakeAsyncClient) + monkeypatch.setattr(transport.logger, "debug", lambda msg: debug_logs.append(msg)) + + conf = Config() + req = _request(body) + req.token_types = {AccessTokenType.USER} + option = RequestOption() + option.user_access_token = "user-token-secret" + + await transport.Transport.aexecute(conf, req, option) + + log_output = "\n".join(debug_logs) + assert "async-assertion-secret" not in log_output + assert "async-client-secret" not in log_output + assert "user-token-secret" not in log_output + assert '"Authorization": "***"' in log_output + assert '"client_secret": "***"' in log_output + + assert captured["headers"]["Authorization"] == "Bearer user-token-secret" + assert captured["json"]["client_assertion"] == "async-assertion-secret" diff --git a/lark_oapi/ws/client.py b/lark_oapi/ws/client.py index 5c0057364..8ee991838 100644 --- a/lark_oapi/ws/client.py +++ b/lark_oapi/ws/client.py @@ -233,7 +233,10 @@ def _get_conn_url(self) -> str: if Strings.is_empty(self._app_id) or ( self._client_assertion_provider is None and Strings.is_empty(self._app_secret) ): - raise ClientException(NO_CREDENTIAL, "app_id or app_secret is null") + raise ClientException( + NO_CREDENTIAL, + "app_id is required and either app_secret or client_assertion_provider is required", + ) headers = dict(self._headers) headers.update({ @@ -247,6 +250,7 @@ def _get_conn_url(self) -> str: assertion_token = self._client_assertion_provider.retrieve_token(aud) if assertion_token is None or Strings.is_empty(assertion_token.value): raise ClientException(7101, "client assertion token is empty") + body["AppSecret"] = "" body["ClientAssertion"] = assertion_token.value if assertion_token.target_info is not None: url = build_proxy_url(assertion_token.target_info, GEN_ENDPOINT_URI) diff --git a/lark_oapi/ws/tests/test_client_assertion.py b/lark_oapi/ws/tests/test_client_assertion.py index 405f13447..9d412f1f3 100644 --- a/lark_oapi/ws/tests/test_client_assertion.py +++ b/lark_oapi/ws/tests/test_client_assertion.py @@ -59,7 +59,7 @@ def fake_post(url, *, headers=None, json=None): client = ws_client.Client("app_id", "", client_assertion_provider=provider) assert client._get_conn_url() == "ws://example.test/callback?device_id=device&service_id=42" - assert captured["json"] == {"AppID": "app_id", "ClientAssertion": "assertion"} + assert captured["json"] == {"AppID": "app_id", "AppSecret": "", "ClientAssertion": "assertion"} assert provider.calls == ["open.feishu.cn"] @@ -92,7 +92,7 @@ def fake_post(url, *, headers=None, json=None): assert captured["url"] == "https://proxy.example.com/proxy/callback/ws/endpoint" assert captured["headers"]["X-Target-Service"] == "open.feishu.cn" assert captured["headers"]["X-Custom"] == "custom-value" - assert captured["json"] == {"AppID": "app_id", "ClientAssertion": "assertion"} + assert captured["json"] == {"AppID": "app_id", "AppSecret": "", "ClientAssertion": "assertion"} def test_ws_get_conn_url_retrieves_token_each_time(monkeypatch): @@ -126,6 +126,17 @@ def test_ws_get_conn_url_empty_client_assertion_token(): assert err.value.code == 7101 +def test_ws_get_conn_url_missing_credentials_message(): + client = ws_client.Client("app_id", "") + + with pytest.raises(ClientException) as err: + client._get_conn_url() + + assert str(err.value) == ( + "1000040344: app_id is required and either app_secret or client_assertion_provider is required" + ) + + def test_ws_provider_error_is_not_wrapped(): err = RuntimeError("boom") provider = RecordingProvider(err=err) From 340cde170f0314423a4ba4540a2e3eac6b3d01f0 Mon Sep 17 00:00:00 2001 From: WangWeijia Date: Tue, 9 Jun 2026 20:39:10 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=E5=88=A0=E9=99=A4=20doc=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I4f85ab186ff9a76b1159e685439029990c892329 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index de6ab7555..0f2480d89 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ upload.sh /tests/ .env sensitive_info_result.txt +doc/ From 1fa97fe805a8c2455948cfa0c45c6e2bfcd8b86f Mon Sep 17 00:00:00 2001 From: WangWeijia Date: Wed, 10 Jun 2026 16:22:07 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20Harness=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Ib0f36530f5833be1b84fadd91426f6a7dfdcf87c --- .gitignore | 3 + .../e2e/client_assertion_live_harness.py | 182 +++++++++ .../e2e/test_client_assertion_keyless_live.py | 372 ++++++++++++++++-- .../test_client_assertion_live_harness.py | 124 ++++++ 4 files changed, 639 insertions(+), 42 deletions(-) create mode 100644 lark_oapi/core/tests/e2e/client_assertion_live_harness.py create mode 100644 lark_oapi/core/tests/test_client_assertion_live_harness.py diff --git a/.gitignore b/.gitignore index 0f2480d89..e084aa9d0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ upload.sh .env sensitive_info_result.txt doc/ +.env.e2e +.env_e2e +.env.e2e.example diff --git a/lark_oapi/core/tests/e2e/client_assertion_live_harness.py b/lark_oapi/core/tests/e2e/client_assertion_live_harness.py new file mode 100644 index 000000000..f255ef2ac --- /dev/null +++ b/lark_oapi/core/tests/e2e/client_assertion_live_harness.py @@ -0,0 +1,182 @@ +import base64 +import json +import os +import shlex +import socket +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Dict, Mapping, MutableMapping, Optional +from urllib.parse import urlencode + +from lark_oapi.core.cache import ICache +from lark_oapi.core.client_assertion import ClientAssertionToken, TargetInfo + + +DEFAULT_ENV_FILES = (".env.e2e", ".env.e2e.example") +_ORIGINAL_GETADDRINFO = None + + +@dataclass(frozen=True) +class DeployDomains: + openapi_domain: str + oauth_base_url: str + + +ONLINE_DOMAINS = DeployDomains( + openapi_domain="https://open.feishu.cn", + oauth_base_url="https://accounts.feishu.cn", +) +BOE_DOMAINS = DeployDomains( + openapi_domain="https://open.feishu-boe.cn", + oauth_base_url="https://accounts.feishu-boe.cn", +) + + +class MemoryCache(ICache): + def __init__(self) -> None: + self._data: Dict[str, str] = {} + + def get(self, key: str) -> str: + return self._data.get(key) + + def set(self, key: str, value: str, expire: int): + self._data[key] = value + + +class ModeEnvProvider: + def __init__(self, mode: str, env: Optional[Mapping[str, str]] = None) -> None: + if mode not in ("zti", "gdpr"): + raise ValueError("mode must be zti or gdpr") + self.mode = mode + self.env = env if env is not None else os.environ + self.auds = [] + + def retrieve_token(self, aud: str) -> ClientAssertionToken: + self.auds.append(aud) + if self.mode == "zti": + assertion = self.env.get("LARK_ZTI_CLIENT_ASSERTION") + if not assertion: + raise RuntimeError("LARK_ZTI_CLIENT_ASSERTION is required") + return ClientAssertionToken(assertion) + + assertion = self.env.get("LARK_GDPR_CLIENT_ASSERTION") + if not assertion: + raise RuntimeError("LARK_GDPR_CLIENT_ASSERTION is required") + target_service = self.env.get("LARK_GDPR_PROXY_SERVICE") + target_prefix = self.env.get("LARK_GDPR_PROXY_PREFIX") + if not target_service or not target_prefix: + raise RuntimeError("LARK_GDPR_PROXY_SERVICE and LARK_GDPR_PROXY_PREFIX are required") + if not target_prefix.startswith("/"): + raise RuntimeError("LARK_GDPR_PROXY_PREFIX must start with /") + return ClientAssertionToken(assertion, TargetInfo(target_service, target_prefix)) + + +def parse_bool(value: Optional[str], default: bool = False) -> bool: + if value is None: + return default + normalized = value.strip().lower() + if normalized in ("1", "true", "yes", "y", "on"): + return True + if normalized in ("0", "false", "no", "n", "off"): + return False + return default + + +def deploy_domains(deploy_env: Optional[str]) -> DeployDomains: + normalized = (deploy_env or "online").strip().lower() + if normalized in ("online", "prod", "production", "cn"): + return ONLINE_DOMAINS + if normalized == "boe": + return BOE_DOMAINS + raise ValueError("LARK_DEPLOY_ENV must be online or boe") + + +def load_env_file( + path: Path, + env: Optional[MutableMapping[str, str]] = None, + override: bool = False, +) -> bool: + env = env if env is not None else os.environ + if not path.exists(): + return False + + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[len("export "):].strip() + parts = shlex.split(line, comments=True, posix=True) + if not parts or "=" not in parts[0]: + continue + key, value = parts[0].split("=", 1) + if not override and key in env: + continue + env[key] = value + return True + + +def load_e2e_env( + root: Optional[Path] = None, + env: Optional[MutableMapping[str, str]] = None, + override: bool = False, +) -> Optional[Path]: + root = root or Path.cwd() + env = env if env is not None else os.environ + for name in DEFAULT_ENV_FILES: + path = root / name + if load_env_file(path, env=env, override=override): + return path + return None + + +def build_host_override_getaddrinfo( + original_getaddrinfo: Callable, + target_host: str, + target_ip: str, +) -> Callable: + family = socket.AF_INET6 if ":" in target_ip else socket.AF_INET + + def getaddrinfo(host, port, family_arg=0, type_arg=0, proto_arg=0, flags_arg=0): + if host == target_host: + return original_getaddrinfo(target_ip, port, family, type_arg, proto_arg, flags_arg) + return original_getaddrinfo(host, port, family_arg, type_arg, proto_arg, flags_arg) + + return getaddrinfo + + +def install_host_resolver_override(target_host: str, target_ip: Optional[str]) -> bool: + if not target_ip: + return False + + global _ORIGINAL_GETADDRINFO + if _ORIGINAL_GETADDRINFO is None: + _ORIGINAL_GETADDRINFO = socket.getaddrinfo + socket.getaddrinfo = build_host_override_getaddrinfo(_ORIGINAL_GETADDRINFO, target_host, target_ip) + return True + + +def build_authorize_url( + oauth_base_url: str, + app_id: str, + redirect_uri: str, + scope: str, + state: str, +) -> str: + query = urlencode({ + "app_id": app_id, + "redirect_uri": redirect_uri, + "scope": scope, + "state": state, + }) + return oauth_base_url.rstrip("/") + "/open-apis/authen/v1/authorize?" + query + + +def decode_jwt_payload_unverified(token: str) -> Dict[str, object]: + parts = token.split(".") + if len(parts) < 2: + raise ValueError("invalid jwt") + payload = parts[1] + padding = "=" * (-len(payload) % 4) + decoded = base64.urlsafe_b64decode((payload + padding).encode("ascii")) + return json.loads(decoded.decode("utf-8")) diff --git a/lark_oapi/core/tests/e2e/test_client_assertion_keyless_live.py b/lark_oapi/core/tests/e2e/test_client_assertion_keyless_live.py index f74a553b9..e4a6d96e3 100644 --- a/lark_oapi/core/tests/e2e/test_client_assertion_keyless_live.py +++ b/lark_oapi/core/tests/e2e/test_client_assertion_keyless_live.py @@ -1,80 +1,368 @@ +import asyncio +import json import os +import queue +import secrets +import threading +import time +import uuid +import webbrowser +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Dict, Iterable +from urllib.parse import parse_qs, urlsplit import pytest +import websockets from lark_oapi import Client -from lark_oapi.core.client_assertion import ClientAssertionToken +from lark_oapi.api.contact.v3.model.basic_batch_user_request import BasicBatchUserRequest +from lark_oapi.api.contact.v3.model.basic_batch_user_request_body import BasicBatchUserRequestBody +from lark_oapi.api.im.v1.model.create_message_request import CreateMessageRequest +from lark_oapi.api.im.v1.model.create_message_request_body import CreateMessageRequestBody +from lark_oapi.core.client_assertion import extract_aud_from_url +from lark_oapi.core.model import RequestOption from lark_oapi.core.token import TokenManager +from lark_oapi.core.tests.e2e.client_assertion_live_harness import ( + MemoryCache, + ModeEnvProvider, + build_authorize_url, + deploy_domains, + install_host_resolver_override, + load_e2e_env, + parse_bool, +) from lark_oapi.ws import client as ws_client -class EnvProvider: - def retrieve_token(self, aud): - return ClientAssertionToken(os.environ["LARK_CLIENT_ASSERTION"]) - +load_e2e_env() +install_host_resolver_override(os.environ.get("LARK_GDPR_PROXY_SERVICE"), os.environ.get("LARK_GDPR_PROXY_RESOLVE_IP")) pytestmark = pytest.mark.skipif( os.environ.get("LARK_CLIENT_ASSERTION_E2E") != "1", - reason="set LARK_CLIENT_ASSERTION_E2E=1 to run live keyless E2E", + reason="set LARK_CLIENT_ASSERTION_E2E=1 to run live ClientAssertion E2E", ) -def _client(): - app_id = os.environ.get("LARK_APP_ID") - assertion = os.environ.get("LARK_CLIENT_ASSERTION") - if not app_id or not assertion: - pytest.skip("LARK_APP_ID and LARK_CLIENT_ASSERTION are required") +def _require_env(names: Iterable[str]) -> Dict[str, str]: + missing = [name for name in names if not os.environ.get(name)] + if missing: + pytest.skip("missing env: " + ", ".join(missing)) + return {name: os.environ[name] for name in names} + + +def _domains(): + return deploy_domains(os.environ.get("LARK_DEPLOY_ENV")) + + +def _build_app_secret_client(): + env = _require_env(["LARK_APP_ID", "LARK_APP_SECRET"]) + domains = _domains() + return ( + Client.builder() + .app_id(env["LARK_APP_ID"]) + .app_secret(env["LARK_APP_SECRET"]) + .domain(domains.openapi_domain) + .oauth_base_url(domains.oauth_base_url) + .cache(MemoryCache()) + .build() + ) + + +def _build_client_assertion_client(mode: str, provider: ModeEnvProvider = None): + env = _require_env(["LARK_APP_ID"]) + if mode == "zti": + _require_env(["LARK_ZTI_CLIENT_ASSERTION"]) + elif mode == "gdpr": + _require_env([ + "LARK_GDPR_CLIENT_ASSERTION", + "LARK_GDPR_PROXY_SERVICE", + "LARK_GDPR_PROXY_PREFIX", + ]) + if not os.environ["LARK_GDPR_PROXY_PREFIX"].startswith("/"): + pytest.fail("LARK_GDPR_PROXY_PREFIX must start with /") + if (os.environ.get("LARK_DEPLOY_ENV") or "online").lower() not in ("online", "prod", "production", "cn"): + pytest.fail("GDPR proxy E2E must use LARK_DEPLOY_ENV=online") + else: + raise ValueError("mode must be zti or gdpr") + + provider = provider or ModeEnvProvider(mode) + domains = _domains() + client = ( + Client.builder() + .app_id(env["LARK_APP_ID"]) + .client_assertion_provider(provider) + .domain(domains.openapi_domain) + .oauth_base_url(domains.oauth_base_url) + .cache(MemoryCache()) + .build() + ) + return client, provider + + +def _build_client_with_app_secret_and_provider(mode: str): + env = _require_env(["LARK_APP_ID", "LARK_APP_SECRET"]) + provider = ModeEnvProvider(mode) + domains = _domains() + client = ( + Client.builder() + .app_id(env["LARK_APP_ID"]) + .app_secret(env["LARK_APP_SECRET"]) + .client_assertion_provider(provider) + .domain(domains.openapi_domain) + .oauth_base_url(domains.oauth_base_url) + .cache(MemoryCache()) + .build() + ) + return client, provider + + +def _response_summary(resp) -> str: + return "code={}, msg={}, log_id={}".format(resp.code, resp.msg, resp.get_log_id()) + + +def _case_id(prefix: str) -> str: + return "{}-{}".format(prefix, uuid.uuid4().hex[:8]) + + +def _send_tat_message(client: Client, case_id: str) -> str: + env = _require_env(["LARK_OPEN_ID"]) + body = ( + CreateMessageRequestBody.builder() + .receive_id(env["LARK_OPEN_ID"]) + .msg_type("text") + .content(json.dumps({"text": "ClientAssertion E2E TAT message: " + case_id})) + .uuid(str(uuid.uuid4())) + .build() + ) + req = CreateMessageRequest.builder().receive_id_type("open_id").request_body(body).build() + + resp = client.im.v1.message.create(req) + + assert resp.success(), _response_summary(resp) + assert resp.data is not None and resp.data.message_id + return resp.data.message_id + + +def _call_basic_batch_with_uat(client: Client, user_access_token: str): + env = _require_env(["LARK_OPEN_ID"]) + body = BasicBatchUserRequestBody.builder().user_ids([env["LARK_OPEN_ID"]]).build() + req = BasicBatchUserRequest.builder().user_id_type("open_id").request_body(body).build() + option = RequestOption.builder().user_access_token(user_access_token).build() + + resp = client.contact.v3.user.basic_batch(req, option) + + assert resp.success(), _response_summary(resp) + assert resp.data is not None and resp.data.users + assert resp.data.users[0].user_id + + +def _oauth_timeout_seconds() -> int: + return int(os.environ.get("LARK_OAUTH_TIMEOUT_SECONDS") or "180") + + +@contextmanager +def _oauth_callback_server(redirect_uri: str, expected_state: str): + parsed = urlsplit(redirect_uri) + if parsed.scheme != "http" or parsed.hostname not in ("127.0.0.1", "localhost"): + raise ValueError("LARK_OAUTH_REDIRECT_URI must be a local http callback") + if not parsed.port: + raise ValueError("LARK_OAUTH_REDIRECT_URI must include a port") + + result_queue = queue.Queue(maxsize=1) + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + callback = urlsplit(self.path) + if callback.path != parsed.path: + self.send_response(404) + self.end_headers() + return - builder = Client.builder().app_id(app_id).client_assertion_provider(EnvProvider()) - if os.environ.get("LARK_OPENAPI_DOMAIN"): - builder.domain(os.environ["LARK_OPENAPI_DOMAIN"]) - if os.environ.get("LARK_OAUTH_BASE_URL"): - builder.oauth_base_url(os.environ["LARK_OAUTH_BASE_URL"]) - return builder.build() + params = {key: values[0] for key, values in parse_qs(callback.query).items()} + if params.get("state") != expected_state: + self._send_text(400, "OAuth state mismatch.") + result_queue.put({"error": "state_mismatch"}) + return + if "code" not in params: + self._send_text(400, "OAuth callback missing code.") + params.setdefault("error", "missing_code") + result_queue.put(params) + return -def test_live_tenant_token_exchange_smoke(): - client = _client() + self._send_text(200, "OAuth callback received. You can close this tab.") + result_queue.put(params) + + def _send_text(self, status: int, text: str): + data = text.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, fmt, *args): + return + + server = ThreadingHTTPServer((parsed.hostname, parsed.port), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield result_queue + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def _authorize_and_exchange_code(client: Client): + env = _require_env([ + "LARK_APP_ID", + "LARK_OAUTH_REDIRECT_URI", + "LARK_OAUTH_SCOPE", + ]) + if parse_bool(os.environ.get("LARK_OAUTH_PKCE_REQUIRED"), default=False): + pytest.fail("OAuth UAT E2E expects LARK_OAUTH_PKCE_REQUIRED=false") + + domains = _domains() + state = secrets.token_urlsafe(24) + auth_url = build_authorize_url( + oauth_base_url=domains.oauth_base_url, + app_id=env["LARK_APP_ID"], + redirect_uri=env["LARK_OAUTH_REDIRECT_URI"], + scope=env["LARK_OAUTH_SCOPE"], + state=state, + ) + + with _oauth_callback_server(env["LARK_OAUTH_REDIRECT_URI"], state) as callback_queue: + print("\nOpen this URL to authorize OAuth UAT E2E:\n{}".format(auth_url)) + webbrowser.open(auth_url) + try: + params = callback_queue.get(timeout=_oauth_timeout_seconds()) + except queue.Empty: + pytest.fail("OAuth callback timed out") + + if params.get("error"): + pytest.fail("OAuth callback failed: " + params["error"]) + return client.access_token.retrieve_by_authorization_code( + code=params["code"], + redirect_uri=env["LARK_OAUTH_REDIRECT_URI"], + scope=env["LARK_OAUTH_SCOPE"], + ) + + +async def _connect_ws_once(conn_url: str, listen_seconds: int): + kwargs = ws_client._ws_connect_kwargs() + kwargs.setdefault("open_timeout", 10) + kwargs.setdefault("close_timeout", 5) + async with websockets.connect(conn_url, **kwargs): + await asyncio.sleep(listen_seconds) + + +def _ws_listen_seconds() -> int: + seconds = int(os.environ.get("LARK_WS_LISTEN_SECONDS") or "30") + assert seconds > 0 + return seconds + + +def _assert_provider_received_aud(provider: ModeEnvProvider, expected_aud: str): + assert expected_aud in provider.auds + + +@pytest.mark.slow +def test_live_provider_takes_precedence_over_app_secret(): + client, provider = _build_client_with_app_secret_and_provider("zti") + + token = TokenManager.get_self_tenant_token(client.config) + + assert token + _assert_provider_received_aud(provider, extract_aud_from_url(_domains().oauth_base_url)) + + +@pytest.mark.slow +def test_live_app_secret_tenant_token_and_tat_message(): + client = _build_app_secret_client() token = TokenManager.get_self_tenant_token(client.config) + message_id = _send_tat_message(client, _case_id("SECRET-TAT")) assert token + assert message_id + +@pytest.mark.slow +@pytest.mark.parametrize("mode", ["zti", "gdpr"]) +def test_live_client_assertion_tenant_token_and_tat_message(mode): + client, provider = _build_client_assertion_client(mode) -def test_live_authorization_code_exchange_smoke(): - code = os.environ.get("LARK_OAUTH_CODE") - if not code: - pytest.skip("LARK_OAUTH_CODE is required") + token = TokenManager.get_self_tenant_token(client.config) + message_id = _send_tat_message(client, _case_id(mode.upper() + "-TAT")) + + assert token + assert message_id + _assert_provider_received_aud(provider, extract_aud_from_url(_domains().oauth_base_url)) - resp = _client().access_token.retrieve_by_authorization_code(code=code) - assert resp.access_token +@pytest.mark.slow +@pytest.mark.parametrize("mode", ["zti", "gdpr"]) +def test_live_client_assertion_oauth_uat_authorization_code_refresh_and_basic_batch(mode): + client, provider = _build_client_assertion_client(mode) + token = _authorize_and_exchange_code(client) + if not token.access_token: + pytest.fail("authorization code exchange did not return access_token") + _call_basic_batch_with_uat(client, token.access_token) -def test_live_refresh_token_smoke(): - refresh_token = os.environ.get("LARK_REFRESH_TOKEN") - if not refresh_token: - pytest.skip("LARK_REFRESH_TOKEN is required") + if not token.refresh_token: + pytest.fail("authorization code exchange did not return refresh_token; verify offline_access is enabled") + refreshed = client.access_token.refresh( + refresh_token=token.refresh_token, + scope=os.environ.get("LARK_OAUTH_SCOPE"), + ) + if not refreshed.access_token: + pytest.fail("refresh token exchange did not return access_token") + _call_basic_batch_with_uat(client, refreshed.access_token) + _assert_provider_received_aud(provider, extract_aud_from_url(_domains().oauth_base_url)) - resp = _client().access_token.refresh(refresh_token=refresh_token) - assert resp.access_token +@pytest.mark.slow +def test_live_app_secret_ws_real_connect(): + if not parse_bool(os.environ.get("LARK_WS_CONNECT_E2E"), default=False): + pytest.skip("set LARK_WS_CONNECT_E2E=true to run real WS connect") + env = _require_env(["LARK_APP_ID", "LARK_APP_SECRET"]) + domains = _domains() + client = ws_client.Client( + env["LARK_APP_ID"], + env["LARK_APP_SECRET"], + domain=domains.openapi_domain, + auto_reconnect=False, + ) + conn_url = client._get_conn_url() + asyncio.run(_connect_ws_once(conn_url, _ws_listen_seconds())) -def test_live_ws_bootstrap_smoke(): - if os.environ.get("LARK_WS_CLIENT_ASSERTION_E2E") != "1": - pytest.skip("set LARK_WS_CLIENT_ASSERTION_E2E=1 to run WS live smoke") - app_id = os.environ.get("LARK_APP_ID") - if not app_id or not os.environ.get("LARK_CLIENT_ASSERTION"): - pytest.skip("LARK_APP_ID and LARK_CLIENT_ASSERTION are required") +@pytest.mark.slow +@pytest.mark.parametrize("mode", ["zti", "gdpr"]) +def test_live_client_assertion_ws_real_connect(mode): + if not parse_bool(os.environ.get("LARK_WS_CONNECT_E2E"), default=False): + pytest.skip("set LARK_WS_CONNECT_E2E=true to run real WS connect") + env = _require_env(["LARK_APP_ID"]) + if mode == "zti": + _require_env(["LARK_ZTI_CLIENT_ASSERTION"]) + else: + _require_env(["LARK_GDPR_CLIENT_ASSERTION", "LARK_GDPR_PROXY_SERVICE", "LARK_GDPR_PROXY_PREFIX"]) + domains = _domains() + provider = ModeEnvProvider(mode) client = ws_client.Client( - app_id, + env["LARK_APP_ID"], "", - domain=os.environ.get("LARK_OPENAPI_DOMAIN", "https://open.feishu.cn"), - client_assertion_provider=EnvProvider(), + domain=domains.openapi_domain, + auto_reconnect=False, + client_assertion_provider=provider, ) conn_url = client._get_conn_url() - - assert conn_url.startswith(("ws://", "wss://")) + asyncio.run(_connect_ws_once(conn_url, _ws_listen_seconds())) + _assert_provider_received_aud(provider, extract_aud_from_url(domains.openapi_domain)) diff --git a/lark_oapi/core/tests/test_client_assertion_live_harness.py b/lark_oapi/core/tests/test_client_assertion_live_harness.py new file mode 100644 index 000000000..9777a39d8 --- /dev/null +++ b/lark_oapi/core/tests/test_client_assertion_live_harness.py @@ -0,0 +1,124 @@ +from pathlib import Path + +import pytest + +from lark_oapi.core.client_assertion import TargetInfo +from lark_oapi.core.tests.e2e.client_assertion_live_harness import ( + BOE_DOMAINS, + ONLINE_DOMAINS, + ModeEnvProvider, + build_host_override_getaddrinfo, + build_authorize_url, + decode_jwt_payload_unverified, + deploy_domains, + load_env_file, + parse_bool, +) + + +def test_parse_bool_accepts_common_values(): + assert parse_bool("true") is True + assert parse_bool("1") is True + assert parse_bool("yes") is True + assert parse_bool("false") is False + assert parse_bool("0") is False + assert parse_bool(None, default=True) is True + + +def test_deploy_domains_supports_online_and_boe(): + assert deploy_domains("online") == ONLINE_DOMAINS + assert deploy_domains("cn") == ONLINE_DOMAINS + assert deploy_domains("boe") == BOE_DOMAINS + + with pytest.raises(ValueError, match="LARK_DEPLOY_ENV"): + deploy_domains("lark") + + +def test_load_env_file_preserves_existing_env_and_handles_quotes(tmp_path: Path): + env_file = tmp_path / ".env.e2e" + env_file.write_text( + "\n".join( + [ + "# comment", + "export LARK_APP_ID=cli_file", + 'LARK_OAUTH_SCOPE="contact:user.basic_profile:readonly offline_access"', + "LARK_APP_SECRET=from_file # inline comment", + ] + ), + encoding="utf-8", + ) + env = {"LARK_APP_ID": "cli_existing"} + + load_env_file(env_file, env=env) + + assert env["LARK_APP_ID"] == "cli_existing" + assert env["LARK_OAUTH_SCOPE"] == "contact:user.basic_profile:readonly offline_access" + assert env["LARK_APP_SECRET"] == "from_file" + + +def test_mode_env_provider_returns_zti_without_target_info(): + env = {"LARK_ZTI_CLIENT_ASSERTION": "zti-token"} + provider = ModeEnvProvider("zti", env=env) + + token = provider.retrieve_token("accounts.feishu.cn") + + assert token.value == "zti-token" + assert token.target_info is None + assert provider.auds == ["accounts.feishu.cn"] + + +def test_mode_env_provider_returns_gdpr_with_target_info(): + env = { + "LARK_GDPR_CLIENT_ASSERTION": "gdpr-token", + "LARK_GDPR_PROXY_SERVICE": "gdpr-proxy.example.internal", + "LARK_GDPR_PROXY_PREFIX": "/proxy/example", + } + provider = ModeEnvProvider("gdpr", env=env) + + token = provider.retrieve_token("open.feishu.cn") + + assert token.value == "gdpr-token" + assert token.target_info == TargetInfo("gdpr-proxy.example.internal", "/proxy/example") + assert provider.auds == ["open.feishu.cn"] + + +def test_build_authorize_url_uses_local_redirect_without_pkce(): + url = build_authorize_url( + oauth_base_url="https://accounts.feishu.cn", + app_id="cli_xxx", + redirect_uri="http://127.0.0.1:8765/uat_e2e/callback", + scope="contact:user.basic_profile:readonly offline_access", + state="state-123", + ) + + assert url.startswith("https://accounts.feishu.cn/open-apis/authen/v1/authorize?") + assert "app_id=cli_xxx" in url + assert "redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fuat_e2e%2Fcallback" in url + assert "scope=contact%3Auser.basic_profile%3Areadonly+offline_access" in url + assert "state=state-123" in url + assert "code_challenge" not in url + + +def test_decode_jwt_payload_unverified_decodes_payload(): + token = "header.eyJleHAiOjEyMywiYXVkIjpbImFjY291bnRzLmZlaXNodS5jbiJdfQ.signature" + + payload = decode_jwt_payload_unverified(token) + + assert payload == {"exp": 123, "aud": ["accounts.feishu.cn"]} + + +def test_build_host_override_getaddrinfo_only_rewrites_target_host(): + calls = [] + + def fake_getaddrinfo(host, port, family, type_arg, proto_arg, flags_arg): + calls.append((host, port, family, type_arg, proto_arg, flags_arg)) + return [("result", host, family)] + + resolver = build_host_override_getaddrinfo( + fake_getaddrinfo, + "gdpr-proxy.example.internal", + "192.0.2.10", + ) + + assert resolver("gdpr-proxy.example.internal", 443, 0, 1, 2, 3) == [("result", "192.0.2.10", 2)] + assert resolver("open.feishu.cn", 443, 0, 1, 2, 3) == [("result", "open.feishu.cn", 0)] From 6f35dcbdc9c897ef20d33a965b7b63e3821df8d1 Mon Sep 17 00:00:00 2001 From: WangWeijia Date: Wed, 10 Jun 2026 16:24:29 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=E5=88=A0=E9=99=A4=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I7d3d9b14746d8088009b1856d20d34010ad10aa0 --- .gitignore | 1 - ...lient_assertion_keyless_python_tasks.zh.md | 243 ------------- ...lient_assertion_keyless_python_tests.zh.md | 282 --------------- ...registration_app_preset_python_tasks.zh.md | 231 ------------ ...registration_app_preset_python_tests.zh.md | 341 ------------------ 5 files changed, 1098 deletions(-) delete mode 100644 doc/client_assertion_keyless_python_tasks.zh.md delete mode 100644 doc/client_assertion_keyless_python_tests.zh.md delete mode 100644 doc/registration_app_preset_python_tasks.zh.md delete mode 100644 doc/registration_app_preset_python_tests.zh.md diff --git a/.gitignore b/.gitignore index e084aa9d0..25feb0a75 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ upload.sh /tests/ .env sensitive_info_result.txt -doc/ .env.e2e .env_e2e .env.e2e.example diff --git a/doc/client_assertion_keyless_python_tasks.zh.md b/doc/client_assertion_keyless_python_tasks.zh.md deleted file mode 100644 index 8f9f8cb14..000000000 --- a/doc/client_assertion_keyless_python_tasks.zh.md +++ /dev/null @@ -1,243 +0,0 @@ -# Python ClientAssertion 无密钥改造任务清单 - -> 后续 AGENT 执行时,请逐项完成并把 `- [ ]` 改成 `- [x]`,或在条目后追加完成标记。实现以 `oapi-sdk-go` 当前 `v3_main` 的 ClientAssertion 行为为准,GO 文档仅作辅助。 - -## 目标 - -为 Python SDK 增加 ClientAssertion 无密钥模式:调用方注入 `ClientAssertionProvider`,SDK 在需要应用侧凭证时向 provider 获取 `client_assertion`,再向 OAuth 服务换取 tenant access token 或用户 access token。SDK 不生成 JWT、不签名、不保存私钥。 - -## 当前 Python SDK 现状 - -| 模块 | 当前职责 | 改造关注点 | -| --- | --- | --- | -| `lark_oapi/client.py` | `ClientBuilder`、全局 `Config`、服务初始化 | 增加 provider、OAuth base URL builder;初始化 `client.access_token` | -| `lark_oapi/core/model/config.py` | 保存 `app_id`、`app_secret`、`domain`、`app_type`、cache 等配置 | 新增 provider 和 OAuth base URL 字段 | -| `lark_oapi/core/token/auth.py` | 请求前鉴权、选择 app/tenant/user token | 加入 ClientAssertion 模式 token type 决策 | -| `lark_oapi/core/token/manager.py` | 获取并缓存 app/tenant token | 增加 OAuth JWT bearer tenant token exchange;ClientAssertion 模式禁止 app token | -| `lark_oapi/core/http/transport.py` | 组装 URL/header/body 并发送 sync/async 请求 | 支持 absolute URL,供 OAuth/proxy endpoint 使用;Debug 日志输出前递归脱敏敏感凭证 | -| `lark_oapi/ws/client.py` | WebSocket endpoint bootstrap 和连接管理 | 支持 provider、TargetInfo 代理、自定义 header 覆盖规则 | - -## GO SDK 对齐约束 - -- [x] ✅ `app_id` 仍然必填;配置 provider 后 `app_secret` 可以为空。 -- [x] ✅ `ClientAssertionProvider.retrieve_token(aud)` 每次需要 assertion 时调用;SDK 不缓存 assertion。 -- [x] ✅ ClientAssertion 模式只支持自建应用;Python 中对应 `AppType.SELF`。`AppType.ISV` 直接拒绝。 -- [x] ✅ 普通 OpenAPI 请求在 ClientAssertion 模式下优先使用 tenant token;显式传入 user token 且接口支持 user token 时继续使用 user token。 -- [x] ✅ AppAccessToken-only API 在 ClientAssertion 模式下报 `7103`。 -- [x] ✅ tenant token 使用 `POST {oauth_base_url}/oauth/v3/token`,grant type 为 `urn:ietf:params:oauth:grant-type:jwt-bearer`。 -- [x] ✅ OAuth audience 使用 OAuth host;WS audience 使用 WS domain host。 -- [x] ✅ `TargetInfo` 只做朴素拼接:`target_service + target_prefix + api_path`;`target_service` 无 scheme 时补 `https://`。 -- [x] ✅ tenant token 缓存 TTL 按 `expires_in - 3min`;Python 当前 cache 接口接收绝对 Unix 过期时间,因此写入 `time.time() + max(expires_in - 180, 0)`。 -- [x] ✅ WS provider 获取失败时直接返回原始错误,不包装成 `7102`;空 token 仍返回 `7101`。 - -## 建议新增公共类型 - -文件:`lark_oapi/core/client_assertion.py` - -```python -from dataclasses import dataclass -from typing import Optional, Protocol - - -@dataclass -class TargetInfo: - target_service: str - target_prefix: str = "" - - -@dataclass -class ClientAssertionToken: - value: str - target_info: Optional[TargetInfo] = None - - -class ClientAssertionProvider(Protocol): - def retrieve_token(self, aud: str) -> ClientAssertionToken: - raise NotImplementedError -``` - -## 实现任务点 - -### 任务 1:常量、错误和 URL 工具 - -涉及文件: -- `lark_oapi/core/const.py` -- `lark_oapi/core/exception.py` -- `lark_oapi/core/client_assertion.py` -- `lark_oapi/core/__init__.py` - -- [x] ✅ 增加 OAuth 域名常量:`FEISHU_OAUTH_DOMAIN = "https://accounts.feishu.cn"`、`LARK_OAUTH_DOMAIN = "https://accounts.larksuite.com"`。 -- [x] ✅ 增加 OAuth token path:`OAUTH_TOKEN_URI = "/oauth/v3/token"`。 -- [x] ✅ 增加 grant/type 常量:`GRANT_TYPE_JWT_BEARER`、`CLIENT_ASSERTION_TYPE_JWT_BEARER`。 -- [x] ✅ 增加 header 常量:`X_TARGET_SERVICE = "X-Target-Service"`。 -- [x] ✅ 增加错误码常量:`7100` 到 `7104`,命名对齐 GO SDK。 -- [x] ✅ 新增 `TargetInfo`、`ClientAssertionToken`、`ClientAssertionProvider`。 -- [x] ✅ 新增 `ClientAssertionException(code, msg)`,用于 provider 为空、token 为空、模式不支持等本地错误。 -- [x] ✅ 新增 `AccessTokenException(status_code, code, error, error_description)`,用于 OAuth user token API 非 200 响应。 -- [x] ✅ 新增 `extract_aud_from_url(raw_url)`:无 scheme 时补 `https://`,返回 host,支持端口。 -- [x] ✅ 新增 `resolve_oauth_base_url(config)`:显式 `config.oauth_base_url` 优先,否则默认 OpenAPI host 映射 accounts host。 -- [x] ✅ 新增 `resolve_oauth_aud(config)`:从 OAuth base URL 解析 host。 -- [x] ✅ 新增 `build_proxy_url(target_info, api_path)`:无 scheme 时补 `https://`,然后朴素拼接。 -- [x] ✅ 在 `lark_oapi/core/__init__.py` 导出新增类型和工具。 - -验收方框: -- [x] ✅ 默认 Feishu/Lark audience 正确。 -- [x] ✅ 自定义 `oauth_base_url="http://127.0.0.1:18080"` 时 aud 为 `127.0.0.1:18080`。 -- [x] ✅ 自定义 OpenAPI domain 且未配置 OAuth base URL 时抛出清晰错误。 -- [x] ✅ proxy URL 拼接不额外修正斜杠,保持 GO 行为。 - -### 任务 2:ClientBuilder 和 Config 入口 - -涉及文件: -- `lark_oapi/core/model/config.py` -- `lark_oapi/client.py` - -- [x] ✅ `Config` 增加 `client_assertion_provider` 字段。 -- [x] ✅ `Config` 增加 `oauth_base_url` 字段。 -- [x] ✅ `ClientBuilder` 增加 `client_assertion_provider(provider)`。 -- [x] ✅ `ClientBuilder` 增加 `oauth_base_url(oauth_base_url: str)`。 -- [x] ✅ `Client` 增加 `access_token` 属性,命名遵循现有 lowercase service 风格。 -- [x] ✅ `ClientBuilder.build()` 初始化 `client.access_token` 服务。 -- [x] ✅ provider 和 `app_secret` 同时配置时,OAuth/token/WS 路径优先使用 provider。 - -验收方框: -- [x] ✅ `Client.builder().app_id("cli_example").client_assertion_provider(provider).build()` 成功。 -- [x] ✅ provider 存在时 `app_secret` 为空不会在 build 阶段失败。 -- [x] ✅ 未配置 provider 的 app_secret 模式保持现有行为。 - -### 任务 3:鉴权选择逻辑 - -涉及文件: -- `lark_oapi/core/token/auth.py` - -- [x] ✅ 在 `verify(config, request, option)` 开头加入 ClientAssertion 分支。 -- [x] ✅ provider 存在且 `config.app_id` 为空时报清晰错误:`app_id not found`。 -- [x] ✅ provider 存在且 `config.app_type == AppType.ISV` 时返回 `7100`。 -- [x] ✅ `option.user_access_token` 非空且接口支持 `AccessTokenType.USER` 时选择 user token,并且不调用 provider。 -- [x] ✅ 接口支持 `AccessTokenType.TENANT` 时调用 `TokenManager.get_self_tenant_token(config)`。 -- [x] ✅ tenant token 获取成功后写入 `option.tenant_access_token`,并将 `request.token_types` 改为 `{AccessTokenType.TENANT}`。 -- [x] ✅ 仅支持 `AccessTokenType.APP` 时返回 `7103`。 -- [x] ✅ provider 不存在时保持现有 app_secret 模式兼容。 - -验收方框: -- [x] ✅ tenant+app 混合 token types 在 provider 模式下选择 tenant。 -- [x] ✅ 显式 user token 在 provider 模式下不触发 tenant token exchange。 -- [x] ✅ app-only API 在 provider 模式下返回 `7103`。 -- [x] ✅ ISV + provider 返回 `7100`。 - -### 任务 4:tenant token 的 ClientAssertion exchange - -涉及文件: -- `lark_oapi/core/token/manager.py` -- `lark_oapi/core/token/__init__.py` - -- [x] ✅ `TokenManager.get_self_app_token(config)` 在 provider 存在时直接返回 `7100`,信息对齐 GO:ClientAssertion 模式不支持 AppAccessToken。 -- [x] ✅ `TokenManager.get_self_tenant_token(config)` 在 provider 存在时先按当前 Python tenant token cache key 读取缓存。 -- [x] ✅ cache miss 时解析 OAuth base URL 和 aud。 -- [x] ✅ cache miss 时调用 `config.client_assertion_provider.retrieve_token(aud)`。 -- [x] ✅ provider 抛错时包装为 `7102`,message 保留原始错误。 -- [x] ✅ token 为 `None` 或 `value` 为空时报 `7101`。 -- [x] ✅ 请求 `POST {oauth_base_url}/oauth/v3/token`。 -- [x] ✅ 请求 body 包含 `grant_type`、`client_assertion_type`、`client_assertion`、`client_id`。 -- [x] ✅ `TargetInfo` 存在时改走 proxy URL,并设置 `X-Target-Service` 为真实 OAuth aud。 -- [x] ✅ 响应无 `access_token` 时,错误 message 优先 `error_description`,其次 `error`,最后 `oauth token response missing access token`。 -- [x] ✅ 成功后缓存 tenant token,过期时间为 `time.time() + max(expires_in - 180, 0)`。 -- [x] ✅ cache key 先沿用现有 `self_tenant_token:{app_id}`;如产品明确要求隔离,再加 mode suffix。 - -验收方框: -- [x] ✅ 请求路径是 `/oauth/v3/token`。 -- [x] ✅ 请求体字段和 GO SDK 完全一致。 -- [x] ✅ provider 每次 cache miss 被调用;cache hit 不调用 provider。 -- [x] ✅ `expires_in < 180` 时不会写入过去时间导致异常。 - -### 任务 5:Transport 支持 absolute URL - -涉及文件: -- `lark_oapi/core/http/transport.py` - -- [x] ✅ `_build_url(domain, uri, paths)` 支持 `uri` 为 `http://` 或 `https://` 开头的完整 URL。 -- [x] ✅ absolute URL 只替换 path params,不再拼接 `domain`。 -- [x] ✅ 相对 URL 仍按现有逻辑拼接 `domain + uri`。 -- [x] ✅ OAuth token exchange 调用方直接解析 OAuth 响应,不走 `Client.request()` 的 `BaseResponse` 语义。 -- [x] ✅ 保持自定义 headers、User-Agent、Content-Type 行为不回退。 -- [x] ✅ Debug 日志在序列化前递归脱敏 `Authorization`、`client_assertion`、`ClientAssertion`、`client_secret`、`AppSecret`、`*token*` 等敏感字段。 -- [x] ✅ 脱敏只作用于日志副本,不修改真实请求 headers/body。 - -验收方框: -- [x] ✅ absolute OAuth URL 不被拼成 `https://open.feishu.cnhttps://accounts.example.com/oauth/v3/token`。 -- [x] ✅ 普通 OpenAPI 相对路径行为不变。 -- [x] ✅ 同步和异步 Transport Debug 日志均不会输出 ClientAssertion、AppSecret、Authorization bearer token 等原文。 - -### 任务 6:OAuth user AccessToken 服务 - -新增文件建议: -- `lark_oapi/core/access_token/__init__.py` -- `lark_oapi/core/access_token/client.py` -- `lark_oapi/core/access_token/model.py` - -修改文件: -- `lark_oapi/client.py` - -- [x] ✅ 新增 `client.access_token` 服务。 -- [x] ✅ 提供 `retrieve_by_authorization_code(code, redirect_uri=None, code_verifier=None, scope=None)`。 -- [x] ✅ 提供 `refresh(refresh_token, scope=None)`。 -- [x] ✅ provider 存在时 body 使用 `client_assertion_type` 和 `client_assertion`。 -- [x] ✅ provider 不存在且 `app_secret` 存在时 body 使用 `client_secret`。 -- [x] ✅ provider 和 `app_secret` 都为空时报 `7104`。 -- [x] ✅ 成功响应暴露 `access_token`、`token_type`、`expires_in`、`refresh_token`、`refresh_token_expires_in`、`scope`。 -- [x] ✅ 非 200 响应抛 `AccessTokenException`,保留 HTTP status code、`code`、`error`、`error_description`。 -- [x] ✅ `TargetInfo` 存在时走 proxy URL,并设置 `X-Target-Service` 为真实 OAuth aud。 - -验收方框: -- [x] ✅ authorization code 与 refresh token 的 body 字段分别正确。 -- [x] ✅ app_secret fallback 不包含 client assertion 字段。 -- [x] ✅ PKCE 字段 `code_verifier` 透传。 -- [x] ✅ OAuth error response 不被当成普通 OpenAPI `{code,msg}`。 - -### 任务 7:WebSocket ClientAssertion bootstrap - -涉及文件: -- `lark_oapi/ws/client.py` -- `lark_oapi/ws/exception.py` - -- [x] ✅ `Client.__init__` 增加 `client_assertion_provider=None`。 -- [x] ✅ `_get_conn_url()` 中 provider 和 `app_secret` 不能同时都为空。 -- [x] ✅ provider 存在时 aud 使用 `_domain` 的 host。 -- [x] ✅ provider 抛错时直接抛原始错误,保持 GO 细节。 -- [x] ✅ token 为空时报 `ClientException(7101, "client assertion token is empty")`。 -- [x] ✅ provider 模式 body 发送 `{"AppID": app_id, "AppSecret": "", "ClientAssertion": token.value}`,保留必填的 `AppSecret` 字段为空串。 -- [x] ✅ `TargetInfo` 存在时 URL 改为 proxy URL,并设置 `X-Target-Service` 为真实 aud。 -- [x] ✅ 用户 headers 先合并,SDK 注入的 `locale`、`User-Agent`、`X-Target-Service` 后覆盖。 -- [x] ✅ 非 200 响应如果 body 可解析出 `msg`,使用服务端 msg;否则使用 `system busy`。 -- [x] ✅ 缺少凭证时错误文案说明 `app_id` 必填,且 `app_secret` / `client_assertion_provider` 至少提供一个。 - -验收方框: -- [x] ✅ app_secret bootstrap 旧行为不变。 -- [x] ✅ provider bootstrap 传空串 `AppSecret`,不传真实密钥。 -- [x] ✅ 每次 `_get_conn_url()` 都调用 provider。 -- [x] ✅ 自定义 header 不丢失,冲突时 `X-Target-Service` 使用 SDK 注入值。 - -### 任务 8:样例和文档 - -新增文件建议: -- `samples/client_assertion/client_assertion_provider_sample.py` -- `samples/client_assertion/access_token_authorization_code_sample.py` -- `samples/ws/client_assertion_sample.py` - -修改文件: -- `README.md` -- `README.zh.md` - -- [x] ✅ 给出最小 provider 样例,从环境变量读取已生成的 assertion。 -- [x] ✅ 明确 SDK 不生成 JWT;生产环境建议 provider 对接 KMS、Vault 或内部签发服务。 -- [x] ✅ 写清 `oauth_base_url` 何时需要配置。 -- [x] ✅ 写清 ISV / app-only API 不支持。 -- [x] ✅ README 中 app_secret 模式说明不被破坏。 - -## 建议实现顺序 - -- [x] ✅ 先做任务 1 和任务 2,只暴露类型与配置入口。 -- [x] ✅ 再做任务 3 到任务 5,让普通 OpenAPI 的 tenant token 链路跑通。 -- [x] ✅ 然后做任务 6,补齐 OAuth user AccessToken API。 -- [x] ✅ 再做任务 7,补齐 WS。 -- [x] ✅ 最后做任务 8,并跑完整回归和本地 mock E2E。 diff --git a/doc/client_assertion_keyless_python_tests.zh.md b/doc/client_assertion_keyless_python_tests.zh.md deleted file mode 100644 index c843bc7ac..000000000 --- a/doc/client_assertion_keyless_python_tests.zh.md +++ /dev/null @@ -1,282 +0,0 @@ -# Python ClientAssertion 无密钥测试清单 - -> 后续 AGENT 执行时,请逐项完成并把 `- [ ]` 改成 `- [x]`,或在条目后追加完成标记。本文件只描述测试与 E2E,任务实现见 `doc/client_assertion_keyless_python_tasks.zh.md`。 - -## 测试文件规划 - -| 文件 | 覆盖内容 | -| --- | --- | -| `lark_oapi/core/tests/test_client_assertion_core.py` | provider 类型、错误码、OAuth aud/base URL、proxy URL | -| `lark_oapi/core/tests/test_client_assertion_auth.py` | token type 选择、app-only 拒绝、ISV 拒绝、manual user token 优先 | -| `lark_oapi/core/tests/test_client_assertion_token_manager.py` | tenant token OAuth exchange、缓存、provider 错误、空 token、OAuth 错误响应 | -| `lark_oapi/core/tests/test_client_assertion_access_token.py` | authorization code、refresh token、app_secret fallback、OAuth error、TargetInfo proxy | -| `lark_oapi/ws/tests/test_client_assertion.py` | WS bootstrap provider、proxy、headers、空 token、provider error 原样抛出 | -| `lark_oapi/core/tests/test_transport_absolute_url.py` | absolute URL 拼接与相对 URL 兼容 | -| `lark_oapi/core/tests/test_transport_log_redaction.py` | Transport Debug 日志敏感字段脱敏,覆盖同步和异步请求 | -| `lark_oapi/core/tests/e2e/test_client_assertion_keyless_local.py` | 本地 mock E2E | -| `lark_oapi/core/tests/e2e/test_client_assertion_keyless_live.py` | 真实环境 smoke E2E,默认跳过 | - -## 单元与集成测试用例 - -### Core / URL 工具 - -- [x] ✅ `test_resolve_oauth_base_url_default_feishu` - - 输入:`Config.domain = "https://open.feishu.cn"` - - 期望:OAuth base URL 为 `https://accounts.feishu.cn`,aud 为 `accounts.feishu.cn`。 - -- [x] ✅ `test_resolve_oauth_base_url_default_lark` - - 输入:`Config.domain = "https://open.larksuite.com"` - - 期望:OAuth base URL 为 `https://accounts.larksuite.com`,aud 为 `accounts.larksuite.com`。 - -- [x] ✅ `test_resolve_oauth_base_url_explicit_localhost` - - 输入:`Config.oauth_base_url = "http://127.0.0.1:18080"` - - 期望:OAuth base URL 保留 http scheme,aud 为 `127.0.0.1:18080`。 - -- [x] ✅ `test_resolve_oauth_base_url_requires_explicit_for_custom_domain` - - 输入:`Config.domain = "https://open.feishu-boe.cn"` - - 期望:未配置 `oauth_base_url` 时抛错。 - -- [x] ✅ `test_build_proxy_url_adds_https_when_scheme_missing` - - 输入:`TargetInfo(target_service="proxy.example.com", target_prefix="/proxy")` 和 `/oauth/v3/token` - - 期望:`https://proxy.example.com/proxy/oauth/v3/token`。 - -### 鉴权选择 - -- [x] ✅ `test_verify_client_assertion_prefers_tenant_over_app` - - request token types 为 `{AccessTokenType.APP, AccessTokenType.TENANT}`。 - - provider 存在。 - - 期望:选择 tenant,`option.tenant_access_token` 被写入。 - -- [x] ✅ `test_verify_client_assertion_manual_user_token_wins` - - request token types 为 `{AccessTokenType.TENANT, AccessTokenType.USER}`。 - - 传入 `option.user_access_token`。 - - 期望:不调用 provider,不请求 OAuth token endpoint。 - -- [x] ✅ `test_verify_client_assertion_rejects_app_only` - - request token types 为 `{AccessTokenType.APP}`。 - - provider 存在。 - - 期望:返回 `7103`。 - -- [x] ✅ `test_verify_client_assertion_rejects_isv` - - `config.app_type = AppType.ISV`。 - - provider 存在。 - - 期望:返回 `7100`。 - -- [x] ✅ `test_verify_app_secret_mode_still_requires_app_secret` - - provider 不存在,`app_secret` 为空。 - - 期望:保持现有 `NoAuthorizationException("app_id or app_secret not found")` 行为。 - -### Tenant token manager - -- [x] ✅ `test_get_self_tenant_token_by_client_assertion_requests_oauth_token` - - fake server 校验 `/oauth/v3/token` body。 - - 期望:body 包含 JWT bearer grant type、client assertion、client id;返回 tenant token 并写缓存。 - -- [x] ✅ `test_get_self_tenant_token_by_client_assertion_cache_hit_skips_provider` - - 第一次请求写入缓存,第二次请求同一 app。 - - 期望:第二次不调用 provider。 - -- [x] ✅ `test_get_self_tenant_token_by_client_assertion_without_cache_miss_calls_provider` - - 清空 cache 后请求两次。 - - 期望:每次 cache miss 都调用 provider。 - -- [x] ✅ `test_get_self_tenant_token_by_client_assertion_with_proxy` - - provider 返回 `TargetInfo(target_service=proxy, target_prefix="/proxy")`。 - - 期望:fake proxy 收到 `/proxy/oauth/v3/token` 和 `X-Target-Service: accounts.feishu.cn`。 - -- [x] ✅ `test_get_self_tenant_token_by_client_assertion_empty_token` - - provider 返回 `None` 或 `ClientAssertionToken(value="")`。 - - 期望:返回 `7101`。 - -- [x] ✅ `test_get_self_tenant_token_by_client_assertion_provider_error` - - provider 抛 `RuntimeError("boom")`。 - - 期望:返回 `7102`,message 包含 `boom`。 - -- [x] ✅ `test_get_self_tenant_token_by_client_assertion_oauth_error_message_priority` - - OAuth 响应无 `access_token`,包含 `error_description` 和 `error`。 - - 期望:错误 message 优先使用 `error_description`。 - -- [x] ✅ `test_get_self_app_token_blocked_in_client_assertion_mode` - - provider 存在时调用 `TokenManager.get_self_app_token(config)`。 - - 期望:返回 `7100`。 - -### Transport - -- [x] ✅ `test_build_url_keeps_absolute_http_url` - - 输入:`uri = "http://127.0.0.1:18080/oauth/v3/token"`。 - - 期望:不拼接 `conf.domain`。 - -- [x] ✅ `test_build_url_keeps_absolute_https_url` - - 输入:`uri = "https://accounts.feishu.cn/oauth/v3/token"`。 - - 期望:不拼接 `conf.domain`。 - -- [x] ✅ `test_build_url_relative_path_unchanged` - - 输入:`domain = "https://open.feishu.cn"`、`uri = "/open-apis/mock/v1/ping"`。 - - 期望:输出 `https://open.feishu.cn/open-apis/mock/v1/ping`。 - -- [x] ✅ `test_execute_redacts_sensitive_headers_and_body_from_debug_log` - - 输入:同步请求 headers/body 中包含 `Authorization`、`client_assertion`、`client_secret`、`AppSecret`、`refresh_token` 等敏感字段。 - - 期望:Debug 日志只输出 `***`,真实请求 headers/body 仍保留原值。 - -- [x] ✅ `test_aexecute_redacts_sensitive_headers_and_body_from_debug_log` - - 输入:异步请求 headers/body 中包含 user token、`client_assertion` 和 `client_secret`。 - - 期望:Debug 日志不包含原始凭证,真实请求 payload 不被脱敏副本污染。 - -### OAuth user AccessToken - -- [x] ✅ `test_access_token_authorization_code_with_client_assertion` - - 调用 `client.access_token.retrieve_by_authorization_code(code="code", redirect_uri="https://example.com/cb", code_verifier="verifier")`。 - - 期望:body 包含 `grant_type=authorization_code`、`client_assertion_type`、`client_assertion`、`client_id`、`code`、`redirect_uri`、`code_verifier`。 - -- [x] ✅ `test_access_token_refresh_with_client_assertion` - - 调用 `client.access_token.refresh(refresh_token="refresh-token")`。 - - 期望:body 包含 `grant_type=refresh_token`、`refresh_token` 和 client assertion 字段。 - -- [x] ✅ `test_access_token_authorization_code_with_app_secret_fallback` - - provider 为空,`app_secret` 存在。 - - 期望:body 包含 `client_secret`,不包含 client assertion 字段。 - -- [x] ✅ `test_access_token_refresh_with_app_secret_fallback` - - provider 为空,`app_secret` 存在。 - - 期望:body 包含 `client_secret` 和 `refresh_token`。 - -- [x] ✅ `test_access_token_rejects_missing_credentials` - - provider 和 `app_secret` 都为空。 - - 期望:返回 `7104`。 - -- [x] ✅ `test_access_token_returns_access_token_exception_for_non_200` - - OAuth endpoint 返回 HTTP 401,body 包含 `code`、`error`、`error_description`。 - - 期望:抛 `AccessTokenException`,保留所有字段。 - -- [x] ✅ `test_access_token_proxy_keeps_custom_headers` - - provider 返回 `TargetInfo`,调用时传自定义 headers。 - - 期望:proxy 收到自定义 header 和 SDK 注入的 `X-Target-Service`。 - -### WebSocket - -- [x] ✅ `test_ws_get_conn_url_with_app_secret_keeps_existing_behavior` - - 使用 `Client("app_id", "app_secret")`。 - - 期望:body 为 `{"AppID": "app_id", "AppSecret": "app_secret"}`。 - -- [x] ✅ `test_ws_get_conn_url_with_client_assertion` - - 使用 `Client("app_id", "", client_assertion_provider=provider)`。 - - 期望:body 为 `{"AppID": "app_id", "AppSecret": "", "ClientAssertion": "assertion"}`,保留必填的 `AppSecret` 字段为空串。 - -- [x] ✅ `test_ws_get_conn_url_with_client_assertion_proxy` - - provider 返回 `TargetInfo`。 - - 期望:请求 URL 为 proxy URL,header `X-Target-Service` 为 WS domain host。 - -- [x] ✅ `test_ws_get_conn_url_retrieves_token_each_time` - - provider 依次返回 `assertion-1`、`assertion-2`。 - - 期望:连续两次 `_get_conn_url()` 分别发送两个 assertion。 - -- [x] ✅ `test_ws_get_conn_url_empty_client_assertion_token` - - provider 返回空 token。 - - 期望:抛 `ClientException`,code 为 `7101`。 - -- [x] ✅ `test_ws_get_conn_url_missing_credentials_message` - - 使用 `Client("app_id", "")` 且不提供 provider。 - - 期望:错误文案为 `app_id is required and either app_secret or client_assertion_provider is required`。 - -- [x] ✅ `test_ws_provider_error_is_not_wrapped` - - provider 抛出 `RuntimeError("boom")`。 - - 期望:`_get_conn_url()` 抛出的就是原始 `RuntimeError`。 - -- [x] ✅ `test_ws_non_200_uses_server_msg_when_available` - - bootstrap 返回 HTTP 500,body 为 `{"code":20050,"msg":"target service unavailable"}`。 - - 期望:抛 `ServerException(500, "target service unavailable")`。 - -## 本地 mock E2E - -文件:`lark_oapi/core/tests/e2e/test_client_assertion_keyless_local.py` - -目标: -- [x] ✅ 覆盖 `provider -> OAuth token exchange -> 普通 OpenAPI 请求带 tenant token` 的完整链路。 -- [x] ✅ 覆盖 `client.access_token` authorization code / refresh token 的完整链路。 -- [x] ✅ 覆盖 WS bootstrap 的 provider、TargetInfo、headers 链路。 - -本地 server 需要提供: -- [x] ✅ `POST /oauth/v3/token`:校验 request body,返回 `{"access_token":"tenant-token","expires_in":7200}` 或用户 token 响应。 -- [x] ✅ `GET /open-apis/mock/v1/ping`:校验 `Authorization: Bearer tenant-token`,返回 `{"code":0,"msg":"ok"}`。 -- [x] ✅ `POST /callback/ws/endpoint`:校验 `ClientAssertion` body,返回 WS endpoint JSON。 - -本地 E2E 关键断言: -- [x] ✅ provider 收到的 OAuth aud 是 `127.0.0.1:`。 -- [x] ✅ OAuth exchange body 使用 JWT bearer grant type。 -- [x] ✅ 普通 OpenAPI 请求最终带 tenant token。 -- [x] ✅ 第二次普通请求命中 tenant token cache,不再次调用 provider。 -- [x] ✅ WS bootstrap 使用 domain host 作为 aud,且 body 中 `AppSecret` 为空串。 - -推荐命令: - -```bash -python -m pytest lark_oapi/core/tests/e2e/test_client_assertion_keyless_local.py -v -``` - -## 真实环境 E2E - -文件:`lark_oapi/core/tests/e2e/test_client_assertion_keyless_live.py` - -状态:真实环境 smoke 用例已实现;本机未设置 `LARK_CLIENT_ASSERTION_E2E=1` 和真实凭证,完整回归中按设计跳过。 - -默认跳过条件: -- [x] ✅ 未设置 `LARK_CLIENT_ASSERTION_E2E=1` 时跳过。 -- [x] ✅ 缺少必要环境变量时跳过。 - -环境变量: -- `LARK_APP_ID`:应用 app id。 -- `LARK_CLIENT_ASSERTION`:外部系统已签发好的 assertion。SDK 测试只消费它,不生成它。 -- `LARK_OPENAPI_DOMAIN`:可选,默认 `https://open.feishu.cn`。 -- `LARK_OAUTH_BASE_URL`:自定义域或 BOE 环境必填,默认按 domain 映射。 -- `LARK_OAUTH_CODE`:可选,一次性 authorization code,用于 user token authorization code E2E。 -- `LARK_REFRESH_TOKEN`:可选,用于 refresh token E2E。 - -真实 E2E 分组: -- [x] ✅ tenant token exchange smoke:provider 从 `LARK_CLIENT_ASSERTION` 返回 assertion,断言拿到非空 tenant token。 -- [x] ✅ authorization code exchange smoke:仅当 `LARK_OAUTH_CODE` 存在时运行,断言 `access_token` 非空。 -- [x] ✅ refresh token smoke:仅当 `LARK_REFRESH_TOKEN` 存在时运行,断言 `access_token` 非空。 -- [x] ✅ WS bootstrap smoke:仅当 `LARK_WS_CLIENT_ASSERTION_E2E=1` 存在时运行,只调用 `_get_conn_url()`,不进入长期 `start()` 阻塞循环。 - -推荐命令: - -```bash -LARK_CLIENT_ASSERTION_E2E=1 \ -LARK_APP_ID=cli_example \ -LARK_CLIENT_ASSERTION=example_assertion \ -python -m pytest lark_oapi/core/tests/e2e/test_client_assertion_keyless_live.py -v -``` - -## 回归命令 - -新增测试定向运行: - -```bash -python -m pytest \ - lark_oapi/core/tests/test_client_assertion_core.py \ - lark_oapi/core/tests/test_client_assertion_auth.py \ - lark_oapi/core/tests/test_client_assertion_token_manager.py \ - lark_oapi/core/tests/test_client_assertion_access_token.py \ - lark_oapi/core/tests/test_transport_absolute_url.py \ - lark_oapi/core/tests/test_transport_log_redaction.py \ - lark_oapi/ws/tests/test_client_assertion.py -v -``` - -完整回归: - -```bash -python -m pytest lark_oapi/core/tests lark_oapi/ws/tests lark_oapi/channel/tests -v -``` - -## 验收清单 - -- [x] ✅ app_secret 模式现有测试全部通过。 -- [x] ✅ provider 模式允许 app_secret 为空。 -- [x] ✅ SDK 不生成、不解析、不签名 JWT。 -- [x] ✅ tenant token OAuth exchange 请求体与 GO SDK 一致。 -- [x] ✅ OAuth audience 与 WS audience 规则分别正确。 -- [x] ✅ TargetInfo proxy URL 和 `X-Target-Service` 与 GO SDK 一致。 -- [x] ✅ app-only API 在 provider 模式下返回 `7103`。 -- [x] ✅ ISV 应用在 provider 模式下返回 `7100`。 -- [x] ✅ WS provider error 原样抛出,空 token 返回 `7101`。 -- [x] ✅ 本地 mock E2E 覆盖 OpenAPI、AccessToken、WS 三条链路。 -- [x] ✅ 真实环境 E2E 默认跳过,只有显式环境变量开启时才运行。 diff --git a/doc/registration_app_preset_python_tasks.zh.md b/doc/registration_app_preset_python_tasks.zh.md deleted file mode 100644 index bf52bc452..000000000 --- a/doc/registration_app_preset_python_tasks.zh.md +++ /dev/null @@ -1,231 +0,0 @@ -# Python 一键创建应用 app_preset 实现任务点 - -## 背景 - -Node SDK commit `984bb8d80aa98c5d873ec094287330a377689161` 为 `registerApp` 新增了 `appPreset`,用于在扫码创建应用页面预填应用头像、名称和描述。Python SDK 需要实现同等能力,但使用 Python 风格参数名 `app_preset`。 - -该能力只影响二维码 URL,不影响 `/oauth/v1/app/registration` 的 `begin` / `poll` 请求体。SDK 接收原始参数值,由 SDK 负责 URL Encode;Web 创建页负责展示、`{user}` 替换、图片处理、用户编辑以及最终提交。 - -## 设计原则 - -- 对齐 Node 行为,Python API 使用 snake_case。 -- `app_preset` 是创建页初始化预填值,不是最终创建结果的强约束。 -- 用户传原始值,例如 `{user}的应用`,不要要求用户预先 URL Encode。 -- 保留已有二维码参数:`from=sdk`、`tp=sdk`、`source=python-sdk[/source]`。 -- 只做 SDK 侧轻量校验,不探测图片 URL、不校验图片格式、不校验名称和描述长度。 - -## 公开 API - -同步接口新增参数: - -```python -def register_app( - on_qr_code, - on_status_change=None, - source=None, - cancel_event=None, - domain="https://accounts.feishu.cn", - lark_domain="https://accounts.larksuite.com", - app_preset=None, -): -``` - -异步接口新增参数: - -```python -async def aregister_app( - on_qr_code, - on_status_change=None, - source=None, - domain="https://accounts.feishu.cn", - lark_domain="https://accounts.larksuite.com", - app_preset=None, -): -``` - -`app_preset` 使用 dict: - -```python -{ - "avatar": "https://example.com/a.png", - "name": "{user}的应用", - "desc": "由业务平台自动生成", -} -``` - -多个头像: - -```python -{ - "avatar": [ - "https://example.com/a.png", - "https://example.com/b.webp", - "https://example.com/c.gif", - ], -} -``` - -## 任务 1:扩展 flow 构造参数 - -修改文件:`lark_oapi/scene/registration/__init__.py` - -- `_RegistrationFlow.__init__` 新增 `app_preset=None` 参数。 -- 保存为 `self._app_preset`。 -- `_SyncFlow.__init__` 接收并透传 `app_preset`。 -- `_AsyncFlow` 继续复用 `_RegistrationFlow.__init__`。 -- `register_app` 创建 `_SyncFlow` 时传入 `app_preset`。 -- `aregister_app` 创建 `_AsyncFlow` 时传入 `app_preset`。 - -建议结构: - -```python -class _RegistrationFlow: - def __init__(self, on_qr_code, on_status_change, source, domain, lark_domain, app_preset=None): - self._on_qr_code = on_qr_code - self._on_status_change = on_status_change - self._source = source - self._base_url = domain - self._lark_url = lark_domain - self._app_preset = app_preset -``` - -## 任务 2:新增 app_preset URL 参数追加逻辑 - -修改文件:`lark_oapi/scene/registration/__init__.py` - -新增常量: - -```python -_AVATAR_MAX_COUNT = 6 -``` - -新增私有方法: - -```python -def _apply_app_preset(self, params): - if not self._app_preset: - return - - avatar = self._app_preset.get("avatar") - name = self._app_preset.get("name") - desc = self._app_preset.get("desc") - - if avatar is not None: - avatars = avatar if isinstance(avatar, list) else [avatar] - if len(avatars) == 0: - raise ValueError("app_preset.avatar must contain at least 1 URL") - if len(avatars) > _AVATAR_MAX_COUNT: - raise ValueError( - f"app_preset.avatar supports at most {_AVATAR_MAX_COUNT} URLs, got {len(avatars)}" - ) - for index, url in enumerate(avatars): - if not isinstance(url, str) or url == "": - raise ValueError(f"app_preset.avatar[{index}] must be a non-empty string") - params["avatar"] = avatars - - if name is not None: - params["name"] = name - - if desc is not None: - params["desc"] = desc -``` - -如果需要更严格的 Python 运行时类型防御,可以额外校验 `self._app_preset` 必须是 dict、`name` / `desc` 必须是 str。但为了贴近 Node,最小实现只要求 `avatar` 行为一致。 - -## 任务 3:接入 _build_qr_url - -修改文件:`lark_oapi/scene/registration/__init__.py` - -在 `_build_qr_url` 设置完已有参数后调用 `_apply_app_preset(params)`: - -```python -def _build_qr_url(self, uri): - parsed = urlparse(uri) - params = parse_qs(parsed.query) - params["from"] = "sdk" - params["tp"] = "sdk" - params["source"] = f"{_SDK_NAME}/{self._source}" if self._source else _SDK_NAME - self._apply_app_preset(params) - return urlunparse(parsed._replace(query=urlencode(params, doseq=True))) -``` - -保留 `doseq=True`。这是多头像生成重复 query 参数的关键: - -```text -avatar=https%3A%2F%2Fexample.com%2Fa.png&avatar=https%3A%2F%2Fexample.com%2Fb.webp -``` - -## 任务 4:补充代码注释 - -修改文件:`lark_oapi/scene/registration/__init__.py` - -在 `_apply_app_preset` 或 `_build_qr_url` 附近增加简短注释,表达 Node 注释中的关键原因: - -```python -# app_preset values are pre-fill values for the app-creation page. -# urlencode handles URL encoding; callers should pass raw values. -``` - -不要把注释写成“最终应用信息”,因为最终值以用户在 Web 页面提交为准。 - -## 任务 5:更新 README - -修改文件: - -- `README.md` -- `README.zh.md` - -在一键创建应用参数表增加: - -- `app_preset` -- `app_preset.avatar` -- `app_preset.name` -- `app_preset.desc` - -英文描述要包含: - -- all fields are optional -- users can still edit them on the app-creation page -- SDK handles URL encoding automatically - -中文描述要包含: - -- 所有字段选填 -- 用户扫码后仍可在页面修改 -- SDK 自动 URL Encode,调用方传原始值 - -## 任务 6:补充使用示例 - -可选修改文件: - -- `README.md` -- `README.zh.md` -- 或新增 `samples/registration/app_preset_sample.py` - -示例应展示原始值传入: - -```python -register_app( - on_qr_code=lambda info: print(info["url"]), - app_preset={ - "avatar": [ - "https://example.com/a.png", - "https://example.com/b.webp", - ], - "name": "{user}的应用", - "desc": "由业务平台自动生成", - }, -) -``` - -不要在示例中手动调用 `quote` 或预先传 `%7Buser%7D...`。 - -## 非目标 - -- 不改 `/oauth/v1/app/registration` 的 begin/poll 请求参数。 -- 不上传头像文件。 -- 不下载或探测头像 URL。 -- 不校验图片格式、跨域、重定向、过期链接。 -- 不校验 `name` / `desc` 的中英文长度。 -- 不在 SDK 内替换 `{user}`。 - diff --git a/doc/registration_app_preset_python_tests.zh.md b/doc/registration_app_preset_python_tests.zh.md deleted file mode 100644 index 8e6d5cad9..000000000 --- a/doc/registration_app_preset_python_tests.zh.md +++ /dev/null @@ -1,341 +0,0 @@ -# Python 一键创建应用 app_preset 测试用例 - -## 单元测试范围 - -建议新增文件: - -- `lark_oapi/scene/registration/tests/test_app_preset.py` - -测试重点是二维码 URL 构造。同步和异步流程都复用 `_RegistrationFlow._build_qr_url`,所以大部分用例可直接测试内部 flow,端到端用例再覆盖 `register_app` / `aregister_app` 的参数透传。 - -## 测试辅助函数 - -建议在测试文件中定义: - -```python -from urllib.parse import parse_qs, urlparse - -from lark_oapi.scene.registration import _RegistrationFlow - - -def build_url(app_preset=None, source=None, raw_url="https://accounts.feishu.cn/page/launcher?ticket=abc"): - flow = _RegistrationFlow( - on_qr_code=lambda info: None, - on_status_change=None, - source=source, - domain="https://accounts.feishu.cn", - lark_domain="https://accounts.larksuite.com", - app_preset=app_preset, - ) - return flow._build_qr_url(raw_url) - - -def parse_query(url): - return parse_qs(urlparse(url).query) -``` - -如果不希望测试直接导入私有类,可改为通过 mock `_post` 的方式跑 `register_app`,但直接测 `_build_qr_url` 更聚焦、速度更快。 - -## 单元测试用例 - -### 1. 不传 app_preset 时不追加新参数 - -```python -def test_build_qr_url_omits_app_preset_params_when_not_provided(): - url = build_url() - query = parse_query(url) - - assert "avatar" not in query - assert "name" not in query - assert "desc" not in query - assert query["from"] == ["sdk"] - assert query["tp"] == ["sdk"] - assert query["source"] == ["python-sdk"] - assert query["ticket"] == ["abc"] -``` - -### 2. source 不受 app_preset 影响 - -```python -def test_build_qr_url_keeps_source_with_app_preset(): - url = build_url(app_preset={"name": "X"}, source="lark-cli") - query = parse_query(url) - - assert query["source"] == ["python-sdk/lark-cli"] - assert query["name"] == ["X"] -``` - -### 3. 支持单个头像字符串 - -```python -def test_build_qr_url_accepts_single_avatar_string(): - url = build_url(app_preset={"avatar": "https://example.com/a.png"}) - query = parse_query(url) - - assert query["avatar"] == ["https://example.com/a.png"] -``` - -### 4. 支持多个头像且保持顺序 - -```python -def test_build_qr_url_accepts_avatar_list_and_preserves_order(): - avatars = [ - "https://example.com/a.png", - "https://example.com/b.webp", - "https://example.com/c.gif", - ] - - url = build_url(app_preset={"avatar": avatars}) - query = parse_query(url) - - assert query["avatar"] == avatars -``` - -### 5. 恰好 6 个头像通过 - -```python -def test_build_qr_url_accepts_exactly_six_avatars(): - avatars = [f"https://example.com/{index}.png" for index in range(6)] - - url = build_url(app_preset={"avatar": avatars}) - query = parse_query(url) - - assert query["avatar"] == avatars -``` - -### 6. 超过 6 个头像报错 - -```python -import pytest - - -def test_build_qr_url_rejects_more_than_six_avatars(): - avatars = [f"https://example.com/{index}.png" for index in range(7)] - - with pytest.raises(ValueError, match=r"at most 6 URLs, got 7"): - build_url(app_preset={"avatar": avatars}) -``` - -### 7. 空头像数组报错 - -```python -def test_build_qr_url_rejects_empty_avatar_list(): - with pytest.raises(ValueError, match=r"at least 1 URL"): - build_url(app_preset={"avatar": []}) -``` - -### 8. 空头像字符串报错 - -```python -def test_build_qr_url_rejects_empty_avatar_string(): - with pytest.raises(ValueError, match=r"avatar\[0\].*non-empty string"): - build_url(app_preset={"avatar": ""}) -``` - -### 9. 头像数组中的空字符串报错且包含索引 - -```python -def test_build_qr_url_rejects_empty_avatar_list_item_with_index(): - with pytest.raises(ValueError, match=r"avatar\[1\].*non-empty string"): - build_url(app_preset={"avatar": ["https://example.com/a.png", ""]}) -``` - -### 10. name 支持原始值并由 SDK 编码 - -```python -def test_build_qr_url_url_encodes_name_with_user_placeholder(): - url = build_url(app_preset={"name": "{user}的应用"}) - query = parse_query(url) - - assert query["name"] == ["{user}的应用"] - assert "name=%7Buser%7D%E7%9A%84%E5%BA%94%E7%94%A8" in url -``` - -### 11. desc 支持原始值并由 SDK 编码 - -```python -def test_build_qr_url_url_encodes_desc(): - url = build_url(app_preset={"desc": "由业务平台自动生成"}) - query = parse_query(url) - - assert query["desc"] == ["由业务平台自动生成"] - assert "%E7%94%B1%E4%B8%9A%E5%8A%A1%E5%B9%B3%E5%8F%B0" in url -``` - -### 12. avatar、name、desc 可同时存在 - -```python -def test_build_qr_url_emits_all_app_preset_fields(): - url = build_url( - app_preset={ - "avatar": ["https://example.com/a.png", "https://example.com/b.png"], - "name": "MyApp", - "desc": "demo", - } - ) - query = parse_query(url) - - assert query["avatar"] == ["https://example.com/a.png", "https://example.com/b.png"] - assert query["name"] == ["MyApp"] - assert query["desc"] == ["demo"] -``` - -## 同步端到端测试用例 - -目标:验证 `register_app(..., app_preset=...)` 能把参数透传到二维码回调。 - -建议使用 monkeypatch 替换 `_SyncFlow._post` 和 `time.sleep`,避免真实网络和等待。 - -```python -import time -from urllib.parse import parse_qs, urlparse - -import pytest - -from lark_oapi.scene import registration - - -def test_register_app_sync_passes_app_preset_to_qr_url(monkeypatch): - responses = [ - {"supported_auth_methods": ["client_secret"]}, - { - "device_code": "dev-1", - "verification_uri_complete": "https://accounts.feishu.cn/page/launcher", - "interval": 1, - "expires_in": 60, - }, - { - "client_id": "cli_a", - "client_secret": "sec_a", - "user_info": {"open_id": "ou_x", "tenant_brand": "feishu"}, - }, - ] - - def fake_post(self, data): - return responses.pop(0) - - monkeypatch.setattr(registration._SyncFlow, "_post", fake_post) - monkeypatch.setattr(time, "sleep", lambda seconds: None) - - captured = {} - - result = registration.register_app( - on_qr_code=lambda info: captured.update(info), - app_preset={ - "avatar": ["https://example.com/a.png", "https://example.com/b.webp"], - "name": "{user}的应用", - "desc": "由业务平台自动生成", - }, - ) - - query = parse_qs(urlparse(captured["url"]).query) - assert query["avatar"] == ["https://example.com/a.png", "https://example.com/b.webp"] - assert query["name"] == ["{user}的应用"] - assert query["desc"] == ["由业务平台自动生成"] - assert result["client_id"] == "cli_a" - assert result["client_secret"] == "sec_a" -``` - -## 异步端到端测试用例 - -目标:验证 `aregister_app(..., app_preset=...)` 与同步接口行为一致。 - -```python -from urllib.parse import parse_qs, urlparse - -import pytest - -from lark_oapi.scene import registration - - -@pytest.mark.asyncio -async def test_register_app_async_passes_app_preset_to_qr_url(monkeypatch): - responses = [ - {"supported_auth_methods": ["client_secret"]}, - { - "device_code": "dev-1", - "verification_uri_complete": "https://accounts.feishu.cn/page/launcher", - "interval": 1, - "expires_in": 60, - }, - { - "client_id": "cli_a", - "client_secret": "sec_a", - "user_info": {"open_id": "ou_x", "tenant_brand": "feishu"}, - }, - ] - - async def fake_post(self, data): - return responses.pop(0) - - async def fake_sleep(seconds): - return None - - monkeypatch.setattr(registration._AsyncFlow, "_post", fake_post) - monkeypatch.setattr(registration.asyncio, "sleep", fake_sleep) - - captured = {} - - result = await registration.aregister_app( - on_qr_code=lambda info: captured.update(info), - app_preset={ - "avatar": "https://example.com/a.png", - "name": "{user}的应用", - "desc": "由业务平台自动生成", - }, - ) - - query = parse_qs(urlparse(captured["url"]).query) - assert query["avatar"] == ["https://example.com/a.png"] - assert query["name"] == ["{user}的应用"] - assert query["desc"] == ["由业务平台自动生成"] - assert result["client_id"] == "cli_a" - assert result["client_secret"] == "sec_a" -``` - -## 真实环境端到端验证 - -真实环境 E2E 不建议默认进入 CI,因为需要扫码人工确认。建议作为手动样例或 gated 测试。 - -步骤: - -1. 调用 `register_app`,传入 `app_preset`。 -2. 在 `on_qr_code` 中打印 URL 或生成二维码。 -3. 打开 URL,确认创建页中头像候选、名称、描述已预填。 -4. 确认用户仍可修改这些字段。 -5. 提交创建,确认 SDK 返回 `client_id` 和 `client_secret`。 - -手动脚本示例: - -```python -import lark_oapi as lark - - -def on_qr_code(info): - print("Open this URL or render it as QR code:") - print(info["url"]) - - -result = lark.register_app( - on_qr_code=on_qr_code, - app_preset={ - "avatar": [ - "https://example.com/a.png", - "https://example.com/b.webp", - ], - "name": "{user}的应用", - "desc": "由业务平台自动生成", - }, -) - -print(result["client_id"]) -``` - -验收点: - -- URL 中包含 `avatar`、`name`、`desc`。 -- URL 中 `{user}` 被百分号编码,页面展示时由 Web 端替换。 -- 多头像按传入顺序展示,第一个默认选中。 -- Web 页面允许用户修改预填值。 -- 创建成功后返回应用凭证。 - From 9224262c758c2054f6d0a42b336b3eabb4e9b0d2 Mon Sep 17 00:00:00 2001 From: WangWeijia Date: Wed, 10 Jun 2026 16:41:03 +0800 Subject: [PATCH 6/6] Avoid logging sensitive transport payloads Change-Id: I035e711c97f82170d6f39d337a97e270f7e9c65c --- lark_oapi/core/http/transport.py | 91 +++---------------- .../tests/test_transport_log_redaction.py | 44 +++++++-- 2 files changed, 49 insertions(+), 86 deletions(-) diff --git a/lark_oapi/core/http/transport.py b/lark_oapi/core/http/transport.py index a9f72796f..47fd4e3d3 100644 --- a/lark_oapi/core/http/transport.py +++ b/lark_oapi/core/http/transport.py @@ -1,6 +1,6 @@ import json import urllib.parse -from typing import Any, Optional +from typing import Optional import httpx import requests @@ -13,10 +13,6 @@ from lark_oapi.core.utils.user_agent import build_user_agent -_REDACTED = "***" -_SENSITIVE_KEYWORDS = ("authorization", "token", "secret", "assertion", "password", "ticket") - - class Transport(object): @staticmethod @@ -43,10 +39,12 @@ def execute(conf: Config, req: BaseRequest, option: Optional[RequestOption] = No timeout=conf.timeout, ) - logger.debug(f"{str(req.http_method.name)} {_redact_url(url)} {response.status_code}, " - f"headers: {_marshal_log_value(headers)}, " - f"params: {_marshal_log_value(req.queries)}, " - f"body: {_marshal_log_body(data)}") + logger.debug( + f"{str(req.http_method.name)} request completed with status {response.status_code}, " + f"headers_count: {len(headers)}, " + f"params_count: {len(req.queries)}, " + f"body_present: {data is not None}" + ) resp = RawResponse() resp.status_code = response.status_code @@ -89,10 +87,10 @@ async def aexecute(conf: Config, req: BaseRequest, option: Optional[RequestOptio ) logger.debug( - f"{str(req.http_method.name)} {_redact_url(url)} {response.status_code}" - f"{f', headers: {_marshal_log_value(headers)}' if headers else ''}" - f"{f', params: {_marshal_log_value(req.queries)}' if req.queries else ''}" - f"{f', body: {_marshal_log_value(_merge_dicts(json_, files, data))}' if json_ or files or data else ''}" + f"{str(req.http_method.name)} request completed with status {response.status_code}, " + f"headers_count: {len(headers)}, " + f"params_count: {len(req.queries)}, " + f"body_present: {json_ is not None or files is not None or data is not None}" ) resp = RawResponse() @@ -143,70 +141,3 @@ def _build_header(request: BaseRequest, option: RequestOption, conf: Optional[Co headers[AUTHORIZATION] = f"Bearer {option.user_access_token}" return headers - - -def _is_sensitive_key(key: Any) -> bool: - key = str(key).lower() - return any(keyword in key for keyword in _SENSITIVE_KEYWORDS) - - -def _redact_sensitive(value: Any) -> Any: - if isinstance(value, dict): - return { - key: _REDACTED if _is_sensitive_key(key) else _redact_sensitive(item) - for key, item in value.items() - } - if isinstance(value, tuple): - if len(value) == 2 and not isinstance(value[0], (dict, list, tuple)) and _is_sensitive_key(value[0]): - return value[0], _REDACTED - return tuple(_redact_sensitive(item) for item in value) - if isinstance(value, list): - if len(value) == 2 and not isinstance(value[0], (dict, list, tuple)) and _is_sensitive_key(value[0]): - return [value[0], _REDACTED] - return [_redact_sensitive(item) for item in value] - return value - - -def _marshal_log_value(value: Any) -> Optional[str]: - return JSON.marshal(_redact_sensitive(value)) - - -def _marshal_log_body(data: Any) -> str: - if data is None: - return "None" - if isinstance(data, MultipartEncoder): - return _REDACTED - if isinstance(data, bytes): - try: - return _marshal_log_value(json.loads(str(data, UTF_8))) - except Exception: - return _REDACTED - value = _marshal_log_value(data) - return value if value is not None else "None" - - -def _redact_url(url: str) -> str: - parsed = urllib.parse.urlsplit(url) - if not parsed.query: - return url - - query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) - redacted_query = [ - (key, _REDACTED if _is_sensitive_key(key) else value) - for key, value in query - ] - return urllib.parse.urlunsplit(( - parsed.scheme, - parsed.netloc, - parsed.path, - urllib.parse.urlencode(redacted_query), - parsed.fragment, - )) - - -def _merge_dicts(*dicts): - res = {} - for d in dicts: - if d is not None: - res.update(d) - return res diff --git a/lark_oapi/core/tests/test_transport_log_redaction.py b/lark_oapi/core/tests/test_transport_log_redaction.py index 0c578a486..0ec9cc814 100644 --- a/lark_oapi/core/tests/test_transport_log_redaction.py +++ b/lark_oapi/core/tests/test_transport_log_redaction.py @@ -17,7 +17,7 @@ def _request(body): return req -def test_execute_redacts_sensitive_headers_and_body_from_debug_log(monkeypatch): +def test_execute_omits_sensitive_headers_queries_and_body_from_debug_log(monkeypatch): captured = {} debug_logs = [] body = { @@ -29,6 +29,7 @@ def test_execute_redacts_sensitive_headers_and_body_from_debug_log(monkeypatch): def fake_request(method, url, *, headers=None, params=None, data=None, timeout=None): captured["headers"] = dict(headers) + captured["params"] = list(params) captured["data"] = data return SimpleNamespace(status_code=200, headers={}, content=b"{}") @@ -37,6 +38,7 @@ def fake_request(method, url, *, headers=None, params=None, data=None, timeout=N conf = Config() req = _request(body) + req.add_query("access_token", "query-token-secret") option = RequestOption() option.tenant_access_token = "tenant-token-secret" option.headers = {"X-Api-Token": "header-token-secret"} @@ -50,20 +52,34 @@ def fake_request(method, url, *, headers=None, params=None, data=None, timeout=N "refresh-secret", "app-secret", "ws-assertion", + "query-token-secret", "tenant-token-secret", "header-token-secret", ): assert secret not in log_output - assert '"Authorization": "***"' in log_output - assert '"client_assertion": "***"' in log_output + for key in ( + "Authorization", + "X-Api-Token", + "access_token", + "client_assertion", + "client_secret", + "refresh_token", + "AppSecret", + "ClientAssertion", + ): + assert key not in log_output + assert "headers_count:" in log_output + assert "params_count: 1" in log_output + assert "body_present: True" in log_output assert captured["headers"]["Authorization"] == "Bearer tenant-token-secret" assert captured["headers"]["X-Api-Token"] == "header-token-secret" + assert captured["params"] == [("access_token", "query-token-secret")] assert JSON.unmarshal(captured["data"].decode("utf-8"), dict)["client_assertion"] == "assertion-secret" @pytest.mark.asyncio -async def test_aexecute_redacts_sensitive_headers_and_body_from_debug_log(monkeypatch): +async def test_aexecute_omits_sensitive_headers_queries_and_body_from_debug_log(monkeypatch): captured = {} debug_logs = [] body = {"client_assertion": "async-assertion-secret", "client_secret": "async-client-secret"} @@ -78,6 +94,7 @@ async def __aexit__(self, exc_type, exc, tb): async def request(self, method, url, *, headers=None, params=None, json=None, data=None, files=None, timeout=None): captured["headers"] = dict(headers) + captured["params"] = list(params) captured["json"] = json return SimpleNamespace(status_code=200, headers={}, content=b"{}") @@ -87,17 +104,32 @@ async def request(self, method, url, *, headers=None, params=None, json=None, da conf = Config() req = _request(body) req.token_types = {AccessTokenType.USER} + req.add_query("refresh_token", "async-query-refresh-secret") option = RequestOption() option.user_access_token = "user-token-secret" + option.headers = {"X-Password": "async-header-password"} await transport.Transport.aexecute(conf, req, option) log_output = "\n".join(debug_logs) assert "async-assertion-secret" not in log_output assert "async-client-secret" not in log_output + assert "async-query-refresh-secret" not in log_output assert "user-token-secret" not in log_output - assert '"Authorization": "***"' in log_output - assert '"client_secret": "***"' in log_output + assert "async-header-password" not in log_output + for key in ( + "Authorization", + "X-Password", + "refresh_token", + "client_assertion", + "client_secret", + ): + assert key not in log_output + assert "headers_count:" in log_output + assert "params_count: 1" in log_output + assert "body_present: True" in log_output assert captured["headers"]["Authorization"] == "Bearer user-token-secret" + assert captured["headers"]["X-Password"] == "async-header-password" + assert captured["params"] == [("refresh_token", "async-query-refresh-secret")] assert captured["json"]["client_assertion"] == "async-assertion-secret"