增加 PC 企业微信扫码登录(07-30-pc-wecom-qr-login)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-30 17:16:22 +08:00
父节点 45dc206a4c
当前提交 fb164eb690
共修改 21 个文件,包含 959 行新增和 22 行删除
+101 -1
查看文件
@@ -340,7 +340,8 @@ business role assignment -> separate administrator-audited operation
- Binding DB: unique `WecomIdentityBinding.employee_id` and - Binding DB: unique `WecomIdentityBinding.employee_id` and
`WecomIdentityBinding.external_user_id`. `WecomIdentityBinding.external_user_id`.
- Audit actions: `AUTH_WECOM_AUTO_BOUND` followed by - Audit actions: `AUTH_WECOM_AUTO_BOUND` followed by
`AUTH_WECOM_LOGIN_SUCCESS`; the latter records only `bindingSource`. `AUTH_WECOM_LOGIN_SUCCESS`; the latter records only `bindingSource` and the
fixed allowlisted `entrySource`.
### 3. Contracts ### 3. Contracts
@@ -411,6 +412,105 @@ OAuth userid -> existing binding OR exact userid/unique enterprise email match
-> transactional binding -> redacted audit -> direct WeCom session -> transactional binding -> redacted audit -> direct WeCom session
``` ```
## Scenario: Hosted PC WeCom Web Login
### 1. Scope / Trigger
- Trigger: adding or changing ordinary-browser WeCom login, the public WeCom
config response, OAuth state/callback handling or login-entry audit.
- Purpose: let an existing OA employee use the WeCom-hosted Web login page from
a normal PC browser without loading third-party scripts beside OA password or
email-code inputs.
### 2. Signatures
- Public config: `GET /api/auth/wecom/config` returns
`pcWebLoginEnabled: boolean` and `pcLoginUrl: string | null` separately from
the workbench `loginUrl`.
- OA entry: `GET /api/auth/wecom/login?returnTo=<internal-path>&entrySource=pc_qr`.
- Provider entry: `https://login.work.weixin.qq.com/wwlogin/sso/login` with
`login_type=CorpApp`, CorpID, AgentID, callback and signed state.
- Shared callback: `GET /api/auth/wecom/callback?code=<one-time-code>&state=<signed-state>`.
- Env gate: `WECOM_PC_WEB_LOGIN_ENABLED=false` by default.
- Audit source: `entrySource=pc_qr | wecom_workbench`; `mode` remains
`login | bind | onboard`.
### 3. Contracts
- The OA login page uses a normal redirect to the WeCom-hosted page. Do not add
`@wecom/jssdk`, a WeCom CDN script or a QR iframe to the credential page.
- `entrySource` is independent of `mode`, is validated against a fixed server
allowlist and never accepts `pc_qr` for bind/onboard. Missing source in an
in-flight legacy state means `wecom_workbench`; an unknown signed value fails.
- PC state remains a short-lived HMAC-signed payload and is correlated to the
initiating browser with a short-lived HttpOnly, Secure-in-production,
SameSite=Lax cookie. Validate and clear it before code exchange.
- The cookie is a CSRF/browser-correlation control, not server-side nonce
storage. Normal-browser replay fails after cookie clearing; deliberate replay
of both URL and old cookie is finally stopped by WeCom's one-time, five-minute
code contract and must not create a second OA session.
- Reuse existing userid mapping, fail-closed auto-binding, session limit,
device fingerprint, returnTo allowlist and structured mapping notices.
- Successful login audit contains only `bindingSource` and `entrySource`.
Code, state, nonce, cookies, full userid, Secret and raw provider payload stay
out of logs, audit and browser error details.
### 4. Validation & Error Matrix
- PC env gate off or base entry config incomplete -> `pcLoginUrl=null`; direct
PC entry is unavailable while workbench/password/email-code behavior remains.
- `pc_qr + bind/onboard` or unknown query source -> validation failure before
redirecting to WeCom.
- Tampered/expired state, unknown callback source, missing/mismatched/expired
correlation cookie or missing code -> fail before provider code exchange.
- Signed PC state after the PC gate is turned off -> clear the cookie and fail.
- Existing binding/unique auto-match -> shared session flow; mapping required,
conflict or inactive -> existing stable notice and no session.
- Replayed URL plus old cookie -> provider rejects the already-consumed code;
OA session and success audit counts remain unchanged.
- External/absolute/protocol-relative returnTo -> safe internal default.
### 5. Good/Base/Bad Cases
- Good: normal PC browser redirects to the hosted page, scans, returns with a
correlated callback and reaches the requested internal OA page.
- Base: WeCom workbench continues through `snsapi_base` with
`entrySource=wecom_workbench`; missing source remains compatible during a
rolling deployment.
- Bad: add `mode=qr`, load an SDK next to password fields, expose Secret in the
frontend, accept callback without browser correlation, or claim a signed
cookie is a server-consumed nonce.
### 6. Tests Required
- Route tests assert config gate, exact hosted host/path and parameters, signed
source/state, secure cookie attributes and PC bind/onboard rejection.
- Callback tests assert missing/mismatched/expired cookie, expired/tampered
state, missing code, unknown source and gate-off failures occur before fetch.
- Success/replay tests assert shared mapping and session behavior, sanitized
`entrySource`, cookie clearing and exactly one created session.
- Frontend E2E asserts PC-only button, safe returnTo/device fingerprint and no
duplicate button in a WeCom user agent; typecheck and production build pass.
- Run full backend tests, Prisma validation, external/readiness contracts,
sensitive-content scan, process/Trellis gates and `git diff --check`.
### 7. Wrong vs Correct
#### Wrong
```text
normal browser -> mode=qr -> third-party SDK on OA credential page
-> callback trusts signed state alone -> audit raw userid
```
#### Correct
```text
normal browser -> entrySource=pc_qr -> OA sets correlation cookie
-> WeCom-hosted login -> shared callback validates state + cookie
-> one-time code -> existing userid mapping -> sanitized session audit
```
## Scenario: WeCom Directory Export With Optional Phone ## Scenario: WeCom Directory Export With Optional Phone
### 1. Scope / Trigger ### 1. Scope / Trigger
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,143 @@
# PC 企业微信扫码登录设计
## 1. Design Goal
在普通 PC 浏览器提供企业微信托管 Web 登录入口,同时复用现有身份映射、会话签发和回调错误处理。首版不向 OA 登录页注入企微脚本,不改变工作台免登、绑定或 onboard 流程。
官方依据:
- 企业微信《开始开发》:新版 Web 登录支持移动端企微扫码和企微桌面端快速登录,并要求自建应用开启“企业微信授权登录”。
- <https://developer.work.weixin.qq.com/document/path/98151>
- 企业微信《Web登录组件》:托管页链接为 `https://login.work.weixin.qq.com/wwlogin/sso/login`,自建应用使用 `login_type=CorpApp`、CorpID、AgentID、URL 编码后的回调地址和 state。
- <https://developer.work.weixin.qq.com/document/path/98152>
- 企业微信《获取用户登录身份》:Web 登录 code 继续由 `/cgi-bin/auth/getuserinfo` 消费,code 一次有效且 5 分钟过期。
- <https://developer.work.weixin.qq.com/document/path/98176>
## 2. Current Architecture
```text
企微工作台 WebView
-> /api/auth/wecom/login
-> open.weixin.qq.com/connect/oauth2/authorize
-> /api/auth/wecom/callback
-> auth/getuserinfo
-> userid 映射/自动映射
-> OA session + 审计 + returnTo
```
现有 `mode` 只表达 `login / bind / onboard`。回调会把未知 mode 收敛为 `login`,因此不能用 `mode=qr` 承担入口区分。
## 3. Target Architecture
```text
普通 PC 浏览器
-> 登录页“企业微信扫码登录”
-> /api/auth/wecom/login?entrySource=pc_qr
-> 写入短期浏览器关联 cookie
-> login.work.weixin.qq.com/wwlogin/sso/login
-> /api/auth/wecom/callback?code=...&state=...
-> 校验签名 state + 过期时间 + entrySource + cookie nonce
-> 清除关联 cookie
-> 复用 auth/getuserinfo、userid 映射、session 与安全回跳
企微工作台 WebView
-> 保持现有入口和授权 URL
-> callback 缺少旧版 entrySource 时兼容解释为 wecom_workbench
```
## 4. Contracts
### 4.1 Entry configuration
`GET /api/auth/wecom/config` 在现有返回中增加:
- `pcWebLoginEnabled: boolean`
- `pcLoginUrl: string | null`
新增非敏感生产开关 `WECOM_PC_WEB_LOGIN_ENABLED`,默认关闭。只有现有企微 OAuth 配置完整且该开关打开时返回 PC 登录 URL。该开关允许独立回滚扫码入口,不影响工作台免登。
现有 `loginUrl` 继续只代表工作台入口,避免客户端误把两种企微授权地址混用。
### 4.2 Entry source
- 请求白名单:`wecom_workbench | pc_qr`。
- `/wecom/login` 未传值时默认为 `wecom_workbench`,保持既有调用兼容。
- state 中写入白名单化 `entrySource`。
- 回调兼容部署前已经发出的 state:缺少 `entrySource` 时按 `wecom_workbench` 处理;存在未知值时失败关闭,不静默降级。
- `mode` 不增加新值,绑定与 onboard 不允许使用 `pc_qr`。
### 4.3 Hosted Web login URL
仅服务端构造:
```text
https://login.work.weixin.qq.com/wwlogin/sso/login
?login_type=CorpApp
&appid=<CorpID>
&agentid=<AgentID>
&redirect_uri=<encoded callback>
&state=<encoded signed state>
```
CorpID、AgentID 和回调地址复用现有配置;Secret 不进入 URL 或前端。
### 4.4 Browser correlation and replay boundary
现有 state 是 `base64url(payload).HMAC-SHA256`,具备完整性和过期校验,但 nonce 没有消费状态。PC 扫码增加浏览器关联 cookie:
- HttpOnly、Secure(生产)、SameSite=Lax。
- path 限制为 `/api/auth/wecom`。
- 生命周期不长于 state 的 10 分钟。
- cookie 保存服务端签名的 nonce/入口上下文,或保存与 state nonce 可恒定时间比较的不可伪造值。
- callback 在调用企微 token/code 接口前校验;缺失、篡改或不匹配时失败关闭。
- 无论回调成功还是已进入终止性失败,都清除关联 cookie;正常浏览器第二次回调因 cookie 已清除而在 OA 侧先失败。
- 关联 cookie 是浏览器相关性/CSRF 控制,不是服务端一次性 nonce 存储。恶意客户端若同时保存并重放 URL 与旧 cookie,最终单次消费边界由企微 code 的“一次有效、5 分钟过期”合同保证;OA 不创建第二个会话。
工作台既有入口不强制增加该 cookie,避免扩大本任务兼容风险。若实施测试证明企微 WebView 可无差别支持,仍不顺手改变其合同。
### 4.5 Audit and privacy
成功登录继续使用 `AUTH_WECOM_LOGIN_SUCCESS`,extra 增加:
- `bindingSource`
- `entrySource`
日志和审计不得记录 code、state、nonce、cookie、Secret、完整 userid 或企微原始响应。失败日志仅记录固定错误类别、request id、mode 和 entrySource。
### 4.6 Frontend behavior
- 普通浏览器在“企业入口”区域展示“企业微信扫码登录”按钮。
- 点击按钮沿用当前安全 `returnTo` 与设备指纹拼接工具,跳转服务端 `pcLoginUrl`。
- 企业微信 WebView 不展示该按钮,继续显示“一键进入”。
- PC 入口不可用时不展示失效按钮;密码和邮箱验证码始终不受影响。
- 不加载第三方 SDK、script 或 iframe。
## 5. Error Matrix
| Condition | Behavior |
| --- | --- |
| PC 开关关闭或基础配置缺失 | `pcLoginUrl=null`,登录页保留既有方式 |
| 非法 `entrySource` | 400 或认证失败关闭,不重定向企微 |
| `pc_qr` 搭配 bind/onboard | 参数校验失败,不进入外部授权 |
| state 篡改/过期 | 401,不兑换 code |
| PC 关联 cookie 缺失/篡改/nonce 不匹配 | 401,不兑换 code |
| 正常浏览器重复回调 | cookie 已清除,OA 关联校验失败 |
| 恶意客户端重放 URL 与旧 cookie | 企微拒绝重复 code;OA 不创建第二个会话 |
| returnTo 外域或协议相对 URL | 回落安全默认页 |
| 未映射/冲突/停用 | 复用现有结构化提示并失败关闭 |
| 企微托管页/CDN 不可达 | OA 登录页不受影响,用户返回后可用密码/邮箱验证码 |
## 6. Compatibility and Rollout
- 无数据库迁移。
- 缺少 `entrySource` 的短期旧 state 仍可完成工作台回调。
- 先在企微管理后台人工只读核对授权登录与可信域名,再部署代码。
- 部署后先保持 PC 开关关闭完成既有登录回归;经生产配置批准后开启并做一名已绑定 active 员工的真实扫码验收。
- 回滚优先关闭 `WECOM_PC_WEB_LOGIN_ENABLED`;必要时回滚应用版本。两种方式都不影响已有登录入口。
## 7. Explicit Non-Decisions
- 不引入 `@wecom/jssdk`。
- 不在本任务开启 CSP;托管页方案避免新增登录页第三方脚本,但不把既有 `contentSecurityPolicy: false` 宣称为已治理。
- 不新增 nonce 数据表、Redis 或通用 OAuth 状态服务。
- 不把 PC 扫码用于 bind/onboard。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,98 @@
# PC 企业微信扫码登录实施计划
## 1. Preconditions
- [ ] 只读核对企微自建应用已开启“企业微信授权登录”。
- [ ] 只读核对 OA 公网域名已配置为 OAuth 可信域名或 Web 网页授权回调域名,且回调地址仍指向现有 `/api/auth/wecom/callback`。
- [x] 不在代码、日志、Trellis 或聊天中输出 CorpID、AgentID、Secret、真实 userid、code 或 state。
- [x] 用户批准本 PRD、设计和实施计划后,才运行 `task.py start` 并修改产品代码。
## 2. Backend Implementation
- [x] 在配置层增加默认关闭的 `WECOM_PC_WEB_LOGIN_ENABLED`,同步示例环境、部署检查和运行时 readiness 合同。
- [x] 扩展企微公开配置响应,分别返回工作台 `loginUrl` 与 PC `pcLoginUrl`,避免语义混用。
- [x] 在 `/wecom/login` 增加固定白名单 `entrySource`,拒绝 `pc_qr + bind/onboard`。
- [x] 增加企微托管 Web 登录 URL 构造函数,复用现有 CorpID、AgentID 和回调地址。
- [x] 为 `pc_qr` 写入短期 HttpOnly/SameSite=Lax 浏览器关联 cookie,并把白名单入口与 nonce 写入签名 state。
- [x] callback 在外部兑换前验证 state、entrySource、时效和 cookie nonce,并在终止路径清除 cookie;不把无状态 cookie 误报为服务端一次性 nonce 存储。
- [x] 成功审计增加脱敏 `entrySource`,保持既有 `bindingSource`、映射和会话合同。
## 3. Frontend Implementation
- [x] 扩展企微配置类型并在普通浏览器登录页展示“企业微信扫码登录”按钮。
- [x] 复用既有 returnTo 和设备指纹 URL 工具跳转服务端入口。
- [x] 企微 WebView 继续展示工作台一键进入;PC 开关关闭或配置缺失时保持密码/邮箱验证码入口可用。
- [x] 不安装、不加载 `@wecom/jssdk`,不加入第三方 script/iframe。
## 4. Automated Validation
### Backend focused tests
- [x] 配置 ready/disabled/misconfigured 与 `pcLoginUrl` 合同。
- [x] 托管页 URL 的 host/path、`CorpApp`、CorpID、AgentID、回调和签名 state。
- [x] `entrySource` 默认、白名单和非法组合。
- [x] state 篡改、过期、缺 code、非法 returnTo。
- [x] cookie 缺失、篡改、nonce 不匹配、回调后清除、正常浏览器重复回调,以及企微重复 code 不创建第二会话。
- [x] 成功、未映射、冲突、OA/企微停用以及审计脱敏。
- [x] 工作台、bind、onboard 回归。
建议命令:
```bash
cd server && npm test -- tests/unit/routes/wecomAuth.test.js tests/unit/routes/auth.login.test.js
cd server && npm run prisma:validate
```
### Frontend focused tests
- [x] 普通浏览器显示 PC 企微按钮并构造安全跳转。
- [x] 企微 WebView 不重复显示 PC 入口。
- [x] 配置关闭/失败时既有登录方式仍可用。
- [x] 既有错误提示不再要求用户先用密码绑定。
建议命令:
```bash
cd client && npm run typecheck
cd client && npm run build
```
### Project gates
```bash
node scripts/qa/check-process-contract.mjs --baseline --json
node scripts/qa/audit-docs.mjs --check-registry
node scripts/qa/check-trellis-governance.mjs
python3 ./.trellis/scripts/task.py validate 07-30-pc-wecom-qr-login
git diff --check
```
## 5. Review and Documentation
- [x] 认证改动完成后做只读安全/权限 review,不由 reviewer 修改代码。
- [x] 若实现形成稳定合同,更新 `.trellis/spec/hyb-oa/wecom-boundary.md`。
- [x] 仅在确有操作差异时更新现有管理员手册、部署 checklist/runbook;不新建平行文档。
- [ ] commit 使用中文并引用 `07-30-pc-wecom-qr-login`。
## 6. Production Gate
- [ ] 企微后台设置、生产环境配置、部署与真实扫码分别取得明确批准。
- [ ] 部署后公网 `/ready`、前端构建标识、现有工作台登录及既有密码/邮箱验证码冒烟通过。
- [ ] 开启 PC 开关后,由一名已映射 active 员工完成真实 PC 扫码或企微桌面端快速登录,并安全回跳原页面。
- [ ] 确认审计有 `entrySource=pc_qr` 且无敏感字段。
- [ ] 验证关闭 PC 开关后按钮消失、其他登录方式不受影响。
## 7. Interrupt and Rollback
- 真人报销链若出现阻断问题,本任务保持小步提交并可随时中断,优先处理真实业务阻断。
- 生产首选回滚:关闭 `WECOM_PC_WEB_LOGIN_ENABLED` 并重建/重启受控服务。
- 若后端回归异常,回滚本任务应用版本;不得删除既有企微绑定或会话数据。
## 8. Local Evidence (2026-07-30)
- 聚焦后端:`wecomAuth.test.js` 与 `check-wecom-runtime-ready.test.js` 共 37 项通过;补充重放断言后 `wecomAuth.test.js` 26 项通过。
- 完整后端:169 个测试文件、1091 项通过。
- Prisma schema validate 通过。
- 前端 typecheck、生产 build 通过;`wecom-entry.spec.ts` 2 项通过。
- 流程合同、文档审计(仅 22 个既有未注册文档 warning)、Trellis governance、task validate、敏感内容扫描和 `git diff --check` 通过。
- 未执行企微后台写入、生产环境配置、部署或真实扫码;对应 Production Gate 保持未勾选。
@@ -0,0 +1,87 @@
# PC 企业微信扫码登录
## Goal
让已经完成 OA 与企业微信 userid 映射的员工,在普通 PC 浏览器访问 OA 登录页时,可以使用企业微信完成扫码或桌面端快速登录,不必输入 OA 密码或邮箱验证码;同时保留现有企业微信工作台免登链路。
## Scope
- 普通 PC 浏览器登录页的企微托管 Web 登录入口。
- 服务端企微 Web 登录 URL、入口来源、短期浏览器关联 cookie、共享 callback 和登录审计。
- 默认关闭的独立生产开关、配置/readiness 合同、回滚和真实扫码验收。
- 现有工作台免登、绑定、onboard、密码和邮箱验证码登录的回归保护。
## Background and Confirmed Facts
- 当前普通浏览器登录页只提供账号密码和企业邮箱验证码;企业微信入口文案要求用户从企微工作台打开 OA。
- 当前后端已具备 `code -> userid -> OA 绑定/唯一自动映射 -> 会话签发` 全链路,并已覆盖未映射、映射冲突、账号停用和成功登录。
- 当前移动/工作台入口使用企业微信“网页授权登录”;PC 普通浏览器应使用企业微信“Web 登录”入口,二者不是同一个授权地址。
- 企业微信官方文档《Web登录组件》(2026-01-14)支持两种网站接入:使用 `@wecom/jssdk >= 2.3.2` 内嵌登录组件,或构造企微托管 Web 登录链接。
- 自建应用 Web 登录使用 `login_type=CorpApp`、企业 CorpID、应用 AgentID、回调地址和 state;成功后回调携带 `code` 与原样 state。
- 企业微信官方文档《获取用户登录身份》(2026-04-29)确认 Web 登录 code 仍由 `/cgi-bin/auth/getuserinfo` 消费,code 只能使用一次,5 分钟未使用自动过期;现有后端兑换接口可复用。
- 首版采用点击按钮后跳转企业微信托管 Web 登录页,不在 OA 登录页内嵌企微 JS-SDK 或第三方脚本。这样不扩大密码与邮箱验证码页面的脚本信任边界,并让企微 CDN 或组件异常不影响 OA 既有登录表单。
- 现有 state 是 HMAC 签名的短期 payload,不是 JWT;它包含过期时间和随机 nonce,但 nonce 当前没有持久化消费,实施时必须验证扫码入口的 CSRF 与重放边界,不能仅凭 nonce 字段宣称一次性防重放。
- 当前 `mode` 表示 `login / bind / onboard` 业务流程。PC 扫码入口不得引入 `mode=qr`;入口来源应使用独立、固定白名单字段,例如 `entrySource=pc_qr | wecom_workbench`。
## Requirements
### R1 普通 PC 浏览器入口
- 普通 PC 浏览器登录页展示清晰的“企业微信扫码登录”入口。
- 点击入口后跳转企业微信托管 Web 登录页;OA 登录页不加载 `@wecom/jssdk`、企微 CDN 脚本或内嵌二维码 iframe。
- 入口不得取代账号密码和邮箱验证码等应急方式。
- 企业微信内置 WebView 继续使用当前工作台一键进入,不重复展示 PC 扫码入口。
### R2 复用既有身份与会话链路
- Web 登录成功后的 code 复用现有 userid 获取、OA 绑定查找、唯一自动映射、会话签发、设备指纹和安全回跳逻辑。
- 已绑定且 active 的员工可直接登录;不要求先用 OA 用户名密码完成首次绑定。
- 未映射、映射冲突、OA/企微账号停用时继续失败关闭,并显示现有可理解提示。
- 不改变 `bind` 和 `onboard` 两条业务流程。
### R3 入口来源与审计
- `mode` 保持业务语义,不承担入口区分。
- `entrySource` 只接受服务端固定白名单值,未知值不得进入审计或影响授权地址选择。
- 成功登录审计记录入口来源和既有 binding source,但不记录 code、state、userid、Secret 或原始企微响应。
### R4 安全边界
- state 必须服务端签名、短时有效、校验回调来源上下文,并继续限制 returnTo,防止开放重定向。
- PC 扫码请求必须通过短期 HttpOnly、SameSite=Lax 同站关联 cookie 将随机 nonce 与发起浏览器绑定;回调校验后立即清除该 cookie,正常浏览器重复回调在 OA 侧失败关闭,不新增数据库 nonce 表。若恶意客户端同时重放回调 URL 与旧 cookie,则由企微一次性 code 阻止第二次兑换和会话创建。
- 实施前通过测试明确验证 state 篡改、过期、入口来源伪造、code 缺失、企微拒绝授权和重复回调行为。
- 前端不得接触应用 Secret;Web 登录 URL 由服务端按受控配置构造。
### R5 配置、上线与回滚
- 生产启用前只读核对现有自建应用是否已开启“企业微信授权登录”,以及 OA 域名是否满足 OAuth 可信域名或 Web 网页授权回调域名要求。
- 若需要修改企微后台、生产环境变量、部署或真实扫码验收,必须另行获得明确批准。
- 功能应具备独立开关或可安全退回现有登录页的配置边界;回滚不得影响工作台免登、密码登录和邮箱验证码登录。
## Acceptance Criteria
- [ ] 普通 PC 浏览器登录页出现明确的企业微信 Web 登录入口,企业微信内置 WebView 仍走原工作台入口。
- [ ] 已映射 active 员工使用手机企微扫码后进入原请求的安全 OA 页面,无需 OA 密码或首次绑定。
- [ ] 已登录企微桌面端时,可按企微官方组件能力完成快速登录或扫码登录。
- [ ] 未映射、冲突、OA 停用和企微停用账号均失败关闭,页面提示不引导用户先用密码绑定。
- [ ] 登录成功审计可区分 `pc_qr` 与 `wecom_workbench`,且不含 code、state、Secret、完整 userid 或原始 payload。
- [ ] state 篡改、过期、非法入口来源、缺 code、重复使用 code 和恶意 returnTo 的自动化负例通过。
- [ ] PC 扫码回调缺少、篡改或不匹配浏览器关联 cookie 时失败关闭且不调用企微 code 兑换接口;正常浏览器回调后 cookie 被清除,企微一次性 code 回归证明不会创建第二个会话。
- [ ] 现有工作台登录、绑定、onboard、账号密码和邮箱验证码回归通过。
- [ ] 本地后端/前端门禁通过;获得部署批准后,生产配置核验、公网构建标识、真实 PC 扫码和回滚检查通过,之后才通知用户可测试。
## Test Plan
- 后端单元测试覆盖 PC 配置和托管 URL、`entrySource` 白名单、state 和浏览器关联 cookie、安全回跳、成功/失败映射、审计脱敏及现有工作台/bind/onboard 回归。
- 前端测试与构建覆盖普通浏览器按钮、企微 WebView 入口隔离、配置关闭时降级,以及 returnTo/设备指纹传递。
- 执行 Prisma validate、前后端 typecheck/build、流程合同、文档审计、Trellis governance、task validate 和 `git diff --check`。
- 生产阶段另行批准后执行企微后台配置核验、公网 readiness/构建标识、现有登录回归、真实 PC 扫码、审计抽查和关闭开关回滚验证。
## Out of Scope
- 不建设新的 OA 账号体系,不要求逐人设置或分发初始密码。
- 不改变企微通讯录、userid 与 OA 账户的既有映射规则。
- 不扩展到微信个人号、第三方企业、外部联系人或多租户 SSO。
- 不在本任务重做找回密码、邮箱验证码或企业微信工作台页面体验。
- 不把 PC 扫码入口复用于绑定或新员工 onboard,除非后续任务单独批准。
- 不引入企微 JS-SDK、站内二维码 iframe,也不在本任务开启全站 CSP 改造;CSP 基线另行治理。
@@ -0,0 +1,26 @@
{
"id": "pc-wecom-qr-login",
"name": "pc-wecom-qr-login",
"title": "PC 企业微信扫码登录",
"description": "为普通 PC 浏览器增加企业微信 Web 扫码登录入口,复用现有 userid 映射、会话签发和审计链路。",
"status": "in_progress",
"dev_type": null,
"scope": "server,client,deploy,docs",
"package": "hyb-oa",
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-30",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+2
查看文件
@@ -79,6 +79,8 @@ export type WecomEntryConfig = {
corpId: string; corpId: string;
agentId: string; agentId: string;
loginUrl: string | null; loginUrl: string | null;
pcWebLoginEnabled: boolean;
pcLoginUrl: string | null;
bindEnabled: boolean; bindEnabled: boolean;
defaultReturnTo: string; defaultReturnTo: string;
status: "ready" | "disabled" | "misconfigured"; status: "ready" | "disabled" | "misconfigured";
+26 -4
查看文件
@@ -89,6 +89,9 @@ export function LoginPage() {
const wecomConfig = wecomConfigQuery.data; const wecomConfig = wecomConfigQuery.data;
const wecomEntry = wecomConfig?.entryEnabled && wecomConfig.loginUrl ? wecomConfig : null; const wecomEntry = wecomConfig?.entryEnabled && wecomConfig.loginUrl ? wecomConfig : null;
const pcWecomEntry = !isWecomContext && wecomConfig?.pcWebLoginEnabled && wecomConfig.pcLoginUrl
? wecomConfig
: null;
const wecomNotice = useMemo( const wecomNotice = useMemo(
() => resolveWecomBindNotice(searchParams.get("wecomBind")) ?? resolveWecomLoginNotice(searchParams.get("wecom")), () => resolveWecomBindNotice(searchParams.get("wecomBind")) ?? resolveWecomLoginNotice(searchParams.get("wecom")),
[searchParams], [searchParams],
@@ -96,7 +99,7 @@ export function LoginPage() {
const showWecomPrimary = isWecomContext; const showWecomPrimary = isWecomContext;
const showPasswordPanel = !showWecomPrimary || Boolean(wecomNotice) || passwordFallbackOpen || (showWecomPrimary && wecomConfig && !wecomEntry); const showPasswordPanel = !showWecomPrimary || Boolean(wecomNotice) || passwordFallbackOpen || (showWecomPrimary && wecomConfig && !wecomEntry);
const showBrowserWecomNotice = !showWecomPrimary && Boolean(wecomEntry); const showBrowserWecomNotice = !showWecomPrimary && Boolean(wecomEntry) && !pcWecomEntry;
useEffect(() => { useEffect(() => {
if (emailCodeCooldown <= 0) { if (emailCodeCooldown <= 0) {
@@ -142,6 +145,13 @@ export function LoginPage() {
window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo, getDeviceFingerprint())); window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo, getDeviceFingerprint()));
} }
function handlePcWecomLogin() {
if (!pcWecomEntry?.pcLoginUrl) {
return;
}
window.location.assign(buildWecomLoginUrl(pcWecomEntry.pcLoginUrl, redirectTo, getDeviceFingerprint()));
}
async function handleRequestEmailCode(event: React.FormEvent<HTMLFormElement>) { async function handleRequestEmailCode(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setEmailCodeError(""); setEmailCodeError("");
@@ -370,7 +380,9 @@ export function LoginPage() {
<div className="space-y-4"> <div className="space-y-4">
{!showWecomPrimary ? ( {!showWecomPrimary ? (
<div className="ux-command-note"> <div className="ux-command-note">
员工日常建议用企业邮箱验证码;固定办公电脑可使用账号密码。企业微信免登需要从企业微信工作台打开。 {pcWecomEntry
? "可使用企业微信扫码登录;企业邮箱验证码和账号密码作为备用方式。"
: "员工日常建议用企业邮箱验证码;固定办公电脑可使用账号密码。企业微信免登需要从企业微信工作台打开。"}
</div> </div>
) : null} ) : null}
@@ -432,7 +444,7 @@ export function LoginPage() {
: renderPasswordPanel(showWecomPrimary ? "login-password-secondary-panel" : "login-password-primary-panel") : renderPasswordPanel(showWecomPrimary ? "login-password-secondary-panel" : "login-password-primary-panel")
: null} : null}
{!showWecomPrimary && (showBrowserWecomNotice || oidcProvider) ? ( {!showWecomPrimary && (pcWecomEntry || showBrowserWecomNotice || oidcProvider) ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-3 text-xs text-slate-400"> <div className="flex items-center gap-3 text-xs text-slate-400">
<span className="h-px flex-1 bg-slate-100" /> <span className="h-px flex-1 bg-slate-100" />
@@ -440,7 +452,17 @@ export function LoginPage() {
<span className="h-px flex-1 bg-slate-100" /> <span className="h-px flex-1 bg-slate-100" />
</div> </div>
{showBrowserWecomNotice ? ( {pcWecomEntry ? (
<Button
className="w-full"
data-testid="wecom-entry-pc"
type="button"
variant="secondary"
onClick={handlePcWecomLogin}
>
企业微信扫码登录
</Button>
) : showBrowserWecomNotice ? (
<div <div
className="auth-side-entry flex gap-3 text-slate-600" className="auth-side-entry flex gap-3 text-slate-600"
data-testid="wecom-entry-browser-notice" data-testid="wecom-entry-browser-notice"
+16 -4
查看文件
@@ -8,6 +8,8 @@ function mockWecomConfigPayload() {
corpId: "wx-test-corp", corpId: "wx-test-corp",
agentId: "1000001", agentId: "1000001",
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile", loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
pcWebLoginEnabled: true,
pcLoginUrl: "/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
bindEnabled: true, bindEnabled: true,
defaultReturnTo: "/mobile", defaultReturnTo: "/mobile",
status: "ready", status: "ready",
@@ -53,14 +55,23 @@ async function mockWecomConfig(page: import("@playwright/test").Page) {
}); });
} }
test("E2E 企微入口:普通浏览器下只提示企业微信工作台打开", async ({ page }) => { test("E2E 企微入口:普通浏览器显示托管扫码入口并保留安全回跳", async ({ page }) => {
await mockAuthBase(page); await mockAuthBase(page);
await mockWecomConfig(page); await mockWecomConfig(page);
let loginEntryUrl = "";
await page.route("**/api/auth/wecom/login?**", async (route) => {
loginEntryUrl = route.request().url();
await route.fulfill({ status: 200, contentType: "text/plain", body: "mock pc wecom login entry" });
});
await page.goto("/login"); await page.goto("/login?redirect=%2Freimbursements");
await expect(page.getByTestId("login-password-primary-panel")).toBeVisible(); await expect(page.getByTestId("login-password-primary-panel")).toBeVisible();
await expect(page.getByTestId("wecom-entry-browser-notice")).toContainText("请从企业微信工作台打开 OA"); await expect(page.getByTestId("wecom-entry-pc")).toHaveText("企业微信扫码登录");
await expect(page.getByTestId("wecom-entry-secondary")).toHaveCount(0); await page.getByTestId("wecom-entry-pc").click();
await expect.poll(() => loginEntryUrl).toContain("entrySource=pc_qr");
expect(new URL(loginEntryUrl).searchParams.get("returnTo")).toBe("/reimbursements");
expect(new URL(loginEntryUrl).searchParams.get("deviceFingerprint")).toBeTruthy();
await expect(page.getByTestId("wecom-entry-browser-notice")).toHaveCount(0);
}); });
test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示映射缺失提示", async ({ page }) => { test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示映射缺失提示", async ({ page }) => {
@@ -83,6 +94,7 @@ test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示映
await page.goto("/login?redirect=%2Fportal&wecom=account_mapping_required"); await page.goto("/login?redirect=%2Fportal&wecom=account_mapping_required");
await expect(page.getByTestId("login-wecom-primary-panel")).toBeVisible(); await expect(page.getByTestId("login-wecom-primary-panel")).toBeVisible();
await expect(page.getByTestId("wecom-entry-primary")).toBeVisible(); await expect(page.getByTestId("wecom-entry-primary")).toBeVisible();
await expect(page.getByTestId("wecom-entry-pc")).toHaveCount(0);
await expect(page.getByTestId("login-wecom-notice")).toContainText("企业微信账号映射尚未完成"); await expect(page.getByTestId("login-wecom-notice")).toContainText("企业微信账号映射尚未完成");
await expect(page.getByTestId("login-wecom-notice")).not.toContainText("账号密码登录"); await expect(page.getByTestId("login-wecom-notice")).not.toContainText("账号密码登录");
await expect(page.getByTestId("login-password-secondary-panel")).toBeVisible(); await expect(page.getByTestId("login-password-secondary-panel")).toBeVisible();
+1
查看文件
@@ -74,6 +74,7 @@ OIDC_SCOPES=openid profile email
# ===== 企业微信入口 / OAuth(可选) ===== # ===== 企业微信入口 / OAuth(可选) =====
WECOM_ENABLED=false WECOM_ENABLED=false
WECOM_ENTRY_ENABLED=false WECOM_ENTRY_ENABLED=false
WECOM_PC_WEB_LOGIN_ENABLED=false
WECOM_CORP_ID= WECOM_CORP_ID=
WECOM_AGENT_ID= WECOM_AGENT_ID=
WECOM_AGENT_SECRET= WECOM_AGENT_SECRET=
+2 -1
查看文件
@@ -179,7 +179,8 @@
- [ ] 登录页已区分普通浏览器与企业微信 WebView 场景 - [ ] 登录页已区分普通浏览器与企业微信 WebView 场景
- [ ] 企业微信工作台入口 URL 已配置为 `https://oa.hyinda.com/api/auth/wecom/login?returnTo=%2Fmobile` - [ ] 企业微信工作台入口 URL 已配置为 `https://oa.hyinda.com/api/auth/wecom/login?returnTo=%2Fmobile`
- [ ] OAuth 回调地址已配置为 `https://oa.hyinda.com/api/auth/wecom/callback` - [ ] OAuth 回调地址已配置为 `https://oa.hyinda.com/api/auth/wecom/callback`
- [ ] `deploy/trialrun/.env` 已补齐 `WECOM_ENABLED / WECOM_ENTRY_ENABLED / WECOM_OAUTH_ENABLED / WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_AGENT_SECRET / WECOM_REDIRECT_URI / WECOM_ALLOWED_RETURN_HOSTS` - [ ] `deploy/trialrun/.env` 已补齐 `WECOM_ENABLED / WECOM_ENTRY_ENABLED / WECOM_OAUTH_ENABLED / WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_AGENT_SECRET / WECOM_REDIRECT_URI / WECOM_ALLOWED_RETURN_HOSTS`;PC 托管扫码经企微后台核验后再单独设置 `WECOM_PC_WEB_LOGIN_ENABLED=true`
- [ ] PC 托管扫码启用前,企微自建应用已开启“企业微信授权登录”,OA 域名已配置为 OAuth 可信域名或 Web 网页授权回调域名
- [ ] 至少 1 个管理员已在 `个人设置` 成功完成企微绑定 - [ ] 至少 1 个管理员已在 `个人设置` 成功完成企微绑定
- [ ] 已验证:已绑定员工从企业微信工作台进入后可直接落到 `/mobile` - [ ] 已验证:已绑定员工从企业微信工作台进入后可直接落到 `/mobile`
- [ ] 已验证:未绑定员工会回退到登录页并看到明确绑定提示 - [ ] 已验证:未绑定员工会回退到登录页并看到明确绑定提示
+2
查看文件
@@ -590,6 +590,7 @@ docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml
2. 在 `deploy/trialrun/.env` 中补齐: 2. 在 `deploy/trialrun/.env` 中补齐:
- `WECOM_ENABLED=true` - `WECOM_ENABLED=true`
- `WECOM_ENTRY_ENABLED=true` - `WECOM_ENTRY_ENABLED=true`
- `WECOM_PC_WEB_LOGIN_ENABLED=false`(默认关闭;确认企微授权登录和可信域名后再独立开启)
- `WECOM_CORP_ID` - `WECOM_CORP_ID`
- `WECOM_AGENT_ID` - `WECOM_AGENT_ID`
- `WECOM_AGENT_SECRET` - `WECOM_AGENT_SECRET`
@@ -597,6 +598,7 @@ docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml
- `WECOM_REDIRECT_URI=https://oa.example.com/api/auth/wecom/callback` - `WECOM_REDIRECT_URI=https://oa.example.com/api/auth/wecom/callback`
- `WECOM_ALLOWED_RETURN_HOSTS=oa.example.com` - `WECOM_ALLOWED_RETURN_HOSTS=oa.example.com`
3. 管理员先在个人设置页完成企微绑定,再拉试点用户验证免登。 3. 管理员先在个人设置页完成企微绑定,再拉试点用户验证免登。
4. 如需普通 PC 浏览器扫码登录,先在企微自建应用开启“企业微信授权登录”并核对 OAuth 可信域名或 Web 网页授权回调域名,再将 `WECOM_PC_WEB_LOGIN_ENABLED=true`;回滚时只需恢复为 `false`,不影响工作台免登、密码和邮箱验证码登录。
4. 企微消息只用于提醒与打开 OA,不新增第二套录入页面。 4. 企微消息只用于提醒与打开 OA,不新增第二套录入页面。
### C. 项目与合同最小导入 ### C. 项目与合同最小导入
+5 -3
查看文件
@@ -653,15 +653,17 @@ OPOLICY1 是正式启用前的制度收口层。它不新增一套业务流程
4. 在 `deploy/trialrun/.env` 中补齐并开启: 4. 在 `deploy/trialrun/.env` 中补齐并开启:
- `WECOM_ENABLED=true` - `WECOM_ENABLED=true`
- `WECOM_ENTRY_ENABLED=true` - `WECOM_ENTRY_ENABLED=true`
- `WECOM_PC_WEB_LOGIN_ENABLED=false`(普通 PC 浏览器扫码默认关闭)
- `WECOM_OAUTH_ENABLED=true` - `WECOM_OAUTH_ENABLED=true`
- `WECOM_CORP_ID=...` - `WECOM_CORP_ID=...`
- `WECOM_AGENT_ID=...` - `WECOM_AGENT_ID=...`
- `WECOM_AGENT_SECRET=...` - `WECOM_AGENT_SECRET=...`
- `WECOM_REDIRECT_URI=https://oa.hyinda.com/api/auth/wecom/callback` - `WECOM_REDIRECT_URI=https://oa.hyinda.com/api/auth/wecom/callback`
- `WECOM_ALLOWED_RETURN_HOSTS=oa.hyinda.com` - `WECOM_ALLOWED_RETURN_HOSTS=oa.hyinda.com`
5. 先 dry-run 核对通讯录 userid/企业邮箱与 OA active 账户的唯一映射,再 apply 建立 binding;无 binding 时,首次企微登录会用 userid 或企微企业邮箱精确匹配既有 active OA 账户并自动补绑定。员工不需要先输入 OA 密码。 5. 如需普通 PC 浏览器企业微信扫码登录,先在自建应用开启“企业微信授权登录”,并确认 OA 域名已配置为 OAuth 可信域名或 Web 网页授权回调域名,再单独设置 `WECOM_PC_WEB_LOGIN_ENABLED=true`。该入口跳转企微托管登录页,不在 OA 密码页加载企微脚本;关闭该开关即可独立回滚。
6. 若启用 OA 同步企业微信打卡数据,在企业微信打卡 API「可调用接口的应用」中授权 `海音达工作台`。OA 默认可复用 `WECOM_AGENT_SECRET`;只有单独创建考勤同步应用时才额外配置 `WECOM_CHECKIN_SECRET`。 6. 先 dry-run 核对通讯录 userid/企业邮箱与 OA active 账户的唯一映射,再 apply 建立 binding;无 binding 时,首次企微登录会用 userid 或企微企业邮箱精确匹配既有 active OA 账户并自动补绑定。员工不需要先输入 OA 密码。
7. HR/考勤员工日常审批后续配置企业微信原生审批模板作为主流程;WAPC2FLIVE 已自动生成请假/补卡 OA-POC 低风险样件并复跑只读 POC,WCBK1 已完成真实回调事件确认,WACP3R 已完成补偿拉取,WREAD1 已确认请假/补卡在企微中可读,WADM1 已完成自建留存/镜像模型设计,WARCH1 已完成最小请假/补卡历史留存,WARCH2 已完成正式只读历史查询。WARCH3/WCHECK1/WEXT1 后先做自动巡检、打卡摘要留存和外出/加班 dry-run;WOPS1 后告警默认发给 `HYD000006`(`wangshunli` 的员工号)且只报新增异常。同意单击即通过需继续纳入操作提示和审计设计。外出/加班正式模板人工测试仍需后续单独批准。报销、财务、采购、合同和项目类 OA 主办审批仍使用企业微信消息提醒并回到 OA 页面完成。 7. 若启用 OA 同步企业微信打卡数据,在企业微信打卡 API「可调用接口的应用」中授权 `海音达工作台`。OA 默认可复用 `WECOM_AGENT_SECRET`;只有单独创建考勤同步应用时才额外配置 `WECOM_CHECKIN_SECRET`。
8. HR/考勤员工日常审批后续配置企业微信原生审批模板作为主流程;WAPC2FLIVE 已自动生成请假/补卡 OA-POC 低风险样件并复跑只读 POC,WCBK1 已完成真实回调事件确认,WACP3R 已完成补偿拉取,WREAD1 已确认请假/补卡在企微中可读,WADM1 已完成自建留存/镜像模型设计,WARCH1 已完成最小请假/补卡历史留存,WARCH2 已完成正式只读历史查询。WARCH3/WCHECK1/WEXT1 后先做自动巡检、打卡摘要留存和外出/加班 dry-run;WOPS1 后告警默认发给 `HYD000006`(`wangshunli` 的员工号)且只报新增异常。同意单击即通过需继续纳入操作提示和审计设计。外出/加班正式模板人工测试仍需后续单独批准。报销、财务、采购、合同和项目类 OA 主办审批仍使用企业微信消息提醒并回到 OA 页面完成。
### 员工会看到的典型状态 ### 员工会看到的典型状态
- 映射唯一:首次从工作台进入即可自动补 binding 并直接登录,无需 OA 密码。 - 映射唯一:首次从工作台进入即可自动补 binding 并直接登录,无需 OA 密码。
+7
查看文件
@@ -68,9 +68,16 @@ MAX_UPLOAD_SIZE_BYTES="10485760"
# OIDC_SCOPES="openid profile email" # OIDC_SCOPES="openid profile email"
# ===== 企业微信(入口/打卡同步,可选)===== # ===== 企业微信(入口/打卡同步,可选)=====
# WECOM_ENABLED="false"
# WECOM_ENTRY_ENABLED="false"
# WECOM_PC_WEB_LOGIN_ENABLED="false"
# WECOM_OAUTH_ENABLED="false"
# WECOM_OAUTH_ONBOARDING_ENABLED="false"
# WECOM_CORP_ID="" # WECOM_CORP_ID=""
# WECOM_AGENT_ID="" # WECOM_AGENT_ID=""
# WECOM_AGENT_SECRET="" # WECOM_AGENT_SECRET=""
# WECOM_REDIRECT_URI="https://oa.example.com/api/auth/wecom/callback"
# WECOM_ALLOWED_RETURN_HOSTS="oa.example.com"
# WECOM_CHECKIN_SYNC_ENABLED="false" # WECOM_CHECKIN_SYNC_ENABLED="false"
# WECOM_CHECKIN_SECRET="" # WECOM_CHECKIN_SECRET=""
# WECOM_CONTACT_SYNC_ENABLED="false" # WECOM_CONTACT_SYNC_ENABLED="false"
+1
查看文件
@@ -253,6 +253,7 @@ function getConfig() {
employeeLifecycleEnabled: process.env.EMPLOYEE_LIFECYCLE_ENABLED === "true", employeeLifecycleEnabled: process.env.EMPLOYEE_LIFECYCLE_ENABLED === "true",
todoBoardEnabled: process.env.TODO_BOARD_ENABLED === "true", todoBoardEnabled: process.env.TODO_BOARD_ENABLED === "true",
wecomEntryEnabled: process.env.WECOM_ENTRY_ENABLED === "true", wecomEntryEnabled: process.env.WECOM_ENTRY_ENABLED === "true",
wecomPcWebLoginEnabled: process.env.WECOM_PC_WEB_LOGIN_ENABLED === "true",
projectDomainEnabled: process.env.PROJECT_DOMAIN_ENABLED === "true", projectDomainEnabled: process.env.PROJECT_DOMAIN_ENABLED === "true",
projectLinkingEnabled: process.env.PROJECT_LINKING_ENABLED === "true", projectLinkingEnabled: process.env.PROJECT_LINKING_ENABLED === "true",
projectCommunicationLogEnabled: process.env.PROJECT_COMMUNICATION_LOG_ENABLED === "true", projectCommunicationLogEnabled: process.env.PROJECT_COMMUNICATION_LOG_ENABLED === "true",
+115 -7
查看文件
@@ -19,13 +19,18 @@ const {
const BIND_COOKIE_NAME = "wecom_bind"; const BIND_COOKIE_NAME = "wecom_bind";
const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind"; const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind";
const PC_LOGIN_STATE_COOKIE_NAME = "wecom_pc_login_state";
const DEVICE_FINGERPRINT_COOKIE_NAME = "hyb_oa_device_fingerprint"; const DEVICE_FINGERPRINT_COOKIE_NAME = "hyb_oa_device_fingerprint";
const DATE_WINDOW_MS = 10 * 60 * 1000; const DATE_WINDOW_MS = 10 * 60 * 1000;
const DEFAULT_RETURN_TO = "/mobile"; const DEFAULT_RETURN_TO = "/mobile";
const PC_DEFAULT_RETURN_TO = "/portal";
const ENTRY_SOURCE_WECOM_WORKBENCH = "wecom_workbench";
const ENTRY_SOURCE_PC_QR = "pc_qr";
const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]); const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]);
const QUERY_SCHEMA = z.object({ const QUERY_SCHEMA = z.object({
returnTo: z.string().optional(), returnTo: z.string().optional(),
mode: z.enum(["login", "bind", "onboard"]).optional(), mode: z.enum(["login", "bind", "onboard"]).optional(),
entrySource: z.enum([ENTRY_SOURCE_WECOM_WORKBENCH, ENTRY_SOURCE_PC_QR]).optional(),
deviceFingerprint: z.string().trim().min(1).max(128).optional(), deviceFingerprint: z.string().trim().min(1).max(128).optional(),
}); });
const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional(); const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional();
@@ -70,6 +75,16 @@ function pendingLoginBindCookieOptions(config) {
}; };
} }
function pcLoginStateCookieOptions(config) {
return {
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
path: "/api/auth/wecom",
maxAge: DATE_WINDOW_MS / 1000,
};
}
function deviceFingerprintCookieOptions(config) { function deviceFingerprintCookieOptions(config) {
return { return {
httpOnly: false, httpOnly: false,
@@ -169,6 +184,7 @@ function resolveWecomEntryConfig(config) {
&& config.wecomRedirectUri, && config.wecomRedirectUri,
); );
const entryEnabled = Boolean(bindEnabled && config.wecomEntryEnabled); const entryEnabled = Boolean(bindEnabled && config.wecomEntryEnabled);
const pcWebLoginEnabled = Boolean(entryEnabled && config.wecomPcWebLoginEnabled);
const onboardingEnabled = Boolean(bindEnabled && config.wecomOauthOnboardingEnabled); const onboardingEnabled = Boolean(bindEnabled && config.wecomOauthOnboardingEnabled);
const hasDisabledReason = statusReasons.some((reason) => DISABLED_STATUS_REASONS.has(reason)); const hasDisabledReason = statusReasons.some((reason) => DISABLED_STATUS_REASONS.has(reason));
@@ -179,6 +195,10 @@ function resolveWecomEntryConfig(config) {
corpId: String(config.wecomCorpId ?? ""), corpId: String(config.wecomCorpId ?? ""),
agentId: String(config.wecomAgentId ?? ""), agentId: String(config.wecomAgentId ?? ""),
loginUrl: entryEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "login") : null, loginUrl: entryEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "login") : null,
pcWebLoginEnabled,
pcLoginUrl: pcWebLoginEnabled
? buildWecomLoginUrl(PC_DEFAULT_RETURN_TO, "login", ENTRY_SOURCE_PC_QR)
: null,
bindEnabled, bindEnabled,
onboardingEnabled, onboardingEnabled,
onboardingUrl: onboardingEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "onboard") : null, onboardingUrl: onboardingEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "onboard") : null,
@@ -212,6 +232,14 @@ function assertWecomEntryReady(config) {
return entryConfig; return entryConfig;
} }
function assertWecomPcWebLoginReady(config) {
const entryConfig = assertWecomEntryReady(config);
if (!entryConfig.pcWebLoginEnabled) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return entryConfig;
}
function assertWecomOnboardingReady(config) { function assertWecomOnboardingReady(config) {
assertWecomOauthReady(config); assertWecomOauthReady(config);
if (!config.wecomOauthOnboardingEnabled) { if (!config.wecomOauthOnboardingEnabled) {
@@ -219,11 +247,14 @@ function assertWecomOnboardingReady(config) {
} }
} }
function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login") { function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login", entrySource = ENTRY_SOURCE_WECOM_WORKBENCH) {
const params = new URLSearchParams({ returnTo }); const params = new URLSearchParams({ returnTo });
if (mode === "bind" || mode === "onboard") { if (mode === "bind" || mode === "onboard") {
params.set("mode", mode); params.set("mode", mode);
} }
if (entrySource === ENTRY_SOURCE_PC_QR) {
params.set("entrySource", ENTRY_SOURCE_PC_QR);
}
return `/api/auth/wecom/login?${params.toString()}`; return `/api/auth/wecom/login?${params.toString()}`;
} }
@@ -328,6 +359,48 @@ function buildWecomAuthorizeUrl(config, state, mode = "login") {
return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${encodeURIComponent(config.wecomCorpId)}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&agentid=${encodeURIComponent(config.wecomAgentId)}&state=${encodeURIComponent(state)}#wechat_redirect`; return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${encodeURIComponent(config.wecomCorpId)}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&agentid=${encodeURIComponent(config.wecomAgentId)}&state=${encodeURIComponent(state)}#wechat_redirect`;
} }
function buildWecomPcAuthorizeUrl(config, state) {
const url = new URL("https://login.work.weixin.qq.com/wwlogin/sso/login");
url.searchParams.set("login_type", "CorpApp");
url.searchParams.set("appid", config.wecomCorpId);
url.searchParams.set("agentid", config.wecomAgentId);
url.searchParams.set("redirect_uri", config.wecomRedirectUri);
url.searchParams.set("state", state);
return url.toString();
}
function safeEqualString(left, right) {
if (typeof left !== "string" || typeof right !== "string") return false;
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) return false;
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function resolveCallbackEntrySource(value) {
if (value == null) return ENTRY_SOURCE_WECOM_WORKBENCH;
if (value === ENTRY_SOURCE_WECOM_WORKBENCH || value === ENTRY_SOURCE_PC_QR) return value;
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
function validatePcLoginStateCookie({ request, reply, config, ctx }) {
const cookieToken = request.cookies?.[PC_LOGIN_STATE_COOKIE_NAME];
const verified = verifyToken(cookieToken, config.jwtSecret);
reply.clearCookie(PC_LOGIN_STATE_COOKIE_NAME, pcLoginStateCookieOptions(config));
assertWecomPcWebLoginReady(config);
if (!verified.ok) {
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
const payload = verified.payload ?? {};
const valid = payload.entrySource === ENTRY_SOURCE_PC_QR
&& typeof payload.exp === "number"
&& payload.exp > Date.now()
&& safeEqualString(payload.nonce, ctx.nonce);
if (!valid) {
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
}
function resolveOAuthDeviceFingerprint(value) { function resolveOAuthDeviceFingerprint(value) {
if (typeof value === "string" && value.trim()) { if (typeof value === "string" && value.trim()) {
return value.trim(); return value.trim();
@@ -398,6 +471,15 @@ async function wecomAuthRoutes(fastify) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() }); throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
} }
const mode = parsed.data.mode ?? "login"; const mode = parsed.data.mode ?? "login";
const entrySource = parsed.data.entrySource ?? ENTRY_SOURCE_WECOM_WORKBENCH;
if (entrySource === ENTRY_SOURCE_PC_QR && mode !== "login") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { entrySource: "unsupported_for_mode" },
});
}
if (mode === "bind") { if (mode === "bind") {
assertWecomOauthReady(fastify.config); assertWecomOauthReady(fastify.config);
const bindCookie = request.cookies?.[BIND_COOKIE_NAME]; const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
@@ -408,18 +490,28 @@ async function wecomAuthRoutes(fastify) {
} }
} else if (mode === "onboard") { } else if (mode === "onboard") {
assertWecomOnboardingReady(fastify.config); assertWecomOnboardingReady(fastify.config);
} else if (entrySource === ENTRY_SOURCE_PC_QR) {
assertWecomPcWebLoginReady(fastify.config);
} else { } else {
assertWecomEntryReady(fastify.config); assertWecomEntryReady(fastify.config);
} }
const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request); const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request);
const deviceFingerprint = resolveOAuthDeviceFingerprint(parsed.data.deviceFingerprint); const deviceFingerprint = resolveOAuthDeviceFingerprint(parsed.data.deviceFingerprint);
const exp = Date.now() + DATE_WINDOW_MS;
const nonce = crypto.randomUUID();
const state = signPayload({ const state = signPayload({
exp: Date.now() + DATE_WINDOW_MS, exp,
nonce: crypto.randomUUID(), nonce,
returnTo, returnTo,
mode, mode,
entrySource,
deviceFingerprint, deviceFingerprint,
}, fastify.config.jwtSecret); }, fastify.config.jwtSecret);
if (entrySource === ENTRY_SOURCE_PC_QR) {
const correlationToken = signPayload({ exp, nonce, entrySource }, fastify.config.jwtSecret);
reply.setCookie(PC_LOGIN_STATE_COOKIE_NAME, correlationToken, pcLoginStateCookieOptions(fastify.config));
return reply.redirect(buildWecomPcAuthorizeUrl(fastify.config, state));
}
return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state, mode)); return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state, mode));
}); });
@@ -464,14 +556,28 @@ async function wecomAuthRoutes(fastify) {
const stateToken = String(request.query?.state ?? "").trim(); const stateToken = String(request.query?.state ?? "").trim();
const code = String(request.query?.code ?? "").trim(); const code = String(request.query?.code ?? "").trim();
const verified = verifyToken(stateToken, fastify.config.jwtSecret); const verified = verifyToken(stateToken, fastify.config.jwtSecret);
if (!verified.ok || !code) { if (!verified.ok) {
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 }); throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
} }
const ctx = verified.payload ?? {}; const ctx = verified.payload ?? {};
const mode = ctx.mode === "bind" ? "bind" : ctx.mode === "onboard" ? "onboard" : "login";
const entrySource = resolveCallbackEntrySource(ctx.entrySource);
if (typeof ctx.exp !== "number" || ctx.exp <= Date.now()) { if (typeof ctx.exp !== "number" || ctx.exp <= Date.now()) {
if (entrySource === ENTRY_SOURCE_PC_QR) {
reply.clearCookie(PC_LOGIN_STATE_COOKIE_NAME, pcLoginStateCookieOptions(fastify.config));
}
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
if (entrySource === ENTRY_SOURCE_PC_QR) {
if (mode !== "login") {
reply.clearCookie(PC_LOGIN_STATE_COOKIE_NAME, pcLoginStateCookieOptions(fastify.config));
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
validatePcLoginStateCookie({ request, reply, config: fastify.config, ctx });
}
if (!code) {
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 }); throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
} }
const mode = ctx.mode === "bind" ? "bind" : ctx.mode === "onboard" ? "onboard" : "login";
const returnTo = sanitizeReturnTo(ctx.returnTo, fastify.config, request); const returnTo = sanitizeReturnTo(ctx.returnTo, fastify.config, request);
const profile = await exchangeWecomCode(fastify.config, code); const profile = await exchangeWecomCode(fastify.config, code);
if (!profile.externalUserId) { if (!profile.externalUserId) {
@@ -479,6 +585,7 @@ async function wecomAuthRoutes(fastify) {
{ {
requestId: request.id, requestId: request.id,
mode, mode,
entrySource,
hasUserId: false, hasUserId: false,
hasOpenId: Boolean(profile.externalOpenUserId), hasOpenId: Boolean(profile.externalOpenUserId),
hasDeviceId: Boolean(profile.deviceId), hasDeviceId: Boolean(profile.deviceId),
@@ -497,6 +604,7 @@ async function wecomAuthRoutes(fastify) {
wecom: "missing_user_id", wecom: "missing_user_id",
hasOpenId: Boolean(profile.externalOpenUserId), hasOpenId: Boolean(profile.externalOpenUserId),
mode, mode,
entrySource,
}, },
}); });
} }
@@ -645,7 +753,7 @@ async function wecomAuthRoutes(fastify) {
? "account_mapping_inactive" ? "account_mapping_inactive"
: "account_mapping_required"; : "account_mapping_required";
request.log.warn( request.log.warn(
{ requestId: request.id, errorCode: error.code, notice }, { requestId: request.id, errorCode: error.code, notice, entrySource },
"wecom automatic login binding failed closed", "wecom automatic login binding failed closed",
); );
reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config)); reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config));
@@ -664,7 +772,7 @@ async function wecomAuthRoutes(fastify) {
tokenVersion: loginEmployee.token_version, tokenVersion: loginEmployee.token_version,
deviceFingerprint, deviceFingerprint,
auditAction: "AUTH_WECOM_LOGIN_SUCCESS", auditAction: "AUTH_WECOM_LOGIN_SUCCESS",
extra: { bindingSource }, extra: { bindingSource, entrySource },
}); });
reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config)); reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config));
@@ -225,6 +225,10 @@ function buildEntryOauthCheck(config) {
evidence: { evidence: {
endpoint: "/api/auth/wecom/config", endpoint: "/api/auth/wecom/config",
loginUrl: missing.length ? null : "/api/auth/wecom/login?returnTo=%2Fmobile&mode=login", loginUrl: missing.length ? null : "/api/auth/wecom/login?returnTo=%2Fmobile&mode=login",
pcWebLoginEnabled: Boolean(config.wecomPcWebLoginEnabled),
pcLoginUrl: missing.length || !config.wecomPcWebLoginEnabled
? null
: "/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
bindUrl: missing.length ? null : "/api/auth/wecom/login?returnTo=%2Fmobile&mode=bind", bindUrl: missing.length ? null : "/api/auth/wecom/login?returnTo=%2Fmobile&mode=bind",
returnHosts, returnHosts,
enabled: Boolean(config.wecomEnabled && config.wecomEntryEnabled && config.wecomOauthEnabled), enabled: Boolean(config.wecomEnabled && config.wecomEntryEnabled && config.wecomOauthEnabled),
+6 -1
查看文件
@@ -38,7 +38,12 @@ function evaluateWecom(config) {
return { return {
state: 'ready', state: 'ready',
detail: '企业微信入口与免登配置齐全', detail: '企业微信入口与免登配置齐全',
checks: [`corpId=${config.wecomCorpId}`, `agentId=${config.wecomAgentId}`, `redirectUri=${config.wecomRedirectUri}`], checks: [
`corpId=${config.wecomCorpId}`,
`agentId=${config.wecomAgentId}`,
`redirectUri=${config.wecomRedirectUri}`,
`pcWebLogin=${config.wecomPcWebLoginEnabled ? 'on' : 'off'}`,
],
}; };
} }
+297 -1
查看文件
@@ -42,6 +42,7 @@ async function buildApp({ config = {}, prisma = buildPrisma(), fastifyOptions =
cookieSecure: true, cookieSecure: true,
wecomEnabled: true, wecomEnabled: true,
wecomEntryEnabled: true, wecomEntryEnabled: true,
wecomPcWebLoginEnabled: false,
wecomOauthEnabled: true, wecomOauthEnabled: true,
wecomOauthOnboardingEnabled: false, wecomOauthOnboardingEnabled: false,
wecomCorpId: "wx-test-corp", wecomCorpId: "wx-test-corp",
@@ -138,11 +139,83 @@ describe("wecom auth routes R27", () => {
status: "ready", status: "ready",
defaultReturnTo: "/mobile", defaultReturnTo: "/mobile",
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile", loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
pcWebLoginEnabled: false,
pcLoginUrl: null,
})); }));
await app.close(); await app.close();
}); });
it("returns an independently gated PC Web login entry", async () => {
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
entryEnabled: true,
pcWebLoginEnabled: true,
pcLoginUrl: "/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
}));
await app.close();
});
it("builds the hosted PC Web login URL and browser correlation cookie", async () => {
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const response = await app.inject({
method: "GET",
url: "/api/auth/wecom/login?entrySource=pc_qr&returnTo=%2Freimbursements&deviceFingerprint=pc-device-1",
});
expect(response.statusCode).toBe(302);
const location = new URL(response.headers.location);
expect(location.origin).toBe("https://login.work.weixin.qq.com");
expect(location.pathname).toBe("/wwlogin/sso/login");
expect(location.searchParams.get("login_type")).toBe("CorpApp");
expect(location.searchParams.get("appid")).toBe("wx-test-corp");
expect(location.searchParams.get("agentid")).toBe("1000001");
expect(location.searchParams.get("redirect_uri")).toBe("https://oa.hyinda.com/api/auth/wecom/callback");
const state = verifyToken(location.searchParams.get("state"), JWT_SECRET);
expect(state).toEqual(expect.objectContaining({
ok: true,
payload: expect.objectContaining({
returnTo: "/reimbursements",
mode: "login",
entrySource: "pc_qr",
deviceFingerprint: "pc-device-1",
}),
}));
const setCookie = String(response.headers["set-cookie"] ?? "");
expect(setCookie).toContain("wecom_pc_login_state=");
expect(setCookie).toContain("Path=/api/auth/wecom");
expect(setCookie).toContain("HttpOnly");
expect(setCookie).toContain("Secure");
expect(setCookie).toContain("SameSite=Lax");
await app.close();
});
it("rejects PC entry for bind/onboard and while its independent switch is off", async () => {
const disabledApp = await buildApp();
const disabled = await disabledApp.inject({
method: "GET",
url: "/api/auth/wecom/login?entrySource=pc_qr&returnTo=%2Fportal",
});
expect(disabled.statusCode).toBe(404);
await disabledApp.close();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const unsupported = await app.inject({
method: "GET",
url: "/api/auth/wecom/login?entrySource=pc_qr&mode=onboard&returnTo=%2Fportal",
});
expect(unsupported.statusCode).toBe(400);
expect(unsupported.json().error.details).toEqual({ entrySource: "unsupported_for_mode" });
await app.close();
});
it("keeps onboarding disabled by default", async () => { it("keeps onboarding disabled by default", async () => {
const app = await buildApp(); const app = await buildApp();
@@ -560,7 +633,7 @@ describe("wecom auth routes R27", () => {
expect(prisma.auditLog.create).toHaveBeenCalledWith({ expect(prisma.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ data: expect.objectContaining({
action: "AUTH_WECOM_LOGIN_SUCCESS", action: "AUTH_WECOM_LOGIN_SUCCESS",
extra: { bindingSource: "auto" }, extra: { bindingSource: "auto", entrySource: "wecom_workbench" },
}), }),
}); });
expect(authSession.create).toHaveBeenCalledWith({ expect(authSession.create).toHaveBeenCalledWith({
@@ -630,6 +703,229 @@ describe("wecom auth routes R27", () => {
await app.close(); await app.close();
}); });
it("fails closed before code exchange when the PC browser correlation cookie is missing", async () => {
global.fetch = vi.fn();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-missing-cookie",
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
deviceFingerprint: "pc-device-missing-cookie",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
});
expect(response.statusCode).toBe(401);
expect(response.json().error.code).toBe("AUTH_004");
expect(global.fetch).not.toHaveBeenCalled();
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
await app.close();
});
it("fails closed before code exchange when the PC browser nonce does not match", async () => {
global.fetch = vi.fn();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-state",
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
}, JWT_SECRET);
const cookie = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-cookie",
entrySource: "pc_qr",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
headers: { cookie: `wecom_pc_login_state=${cookie}` },
});
expect(response.statusCode).toBe(401);
expect(global.fetch).not.toHaveBeenCalled();
await app.close();
});
it("clears PC correlation state for expired state, missing code, or a disabled PC switch", async () => {
global.fetch = vi.fn();
const nonce = "pc-nonce-terminal-failures";
const cookie = signPayload({
exp: Date.now() + 60_000,
nonce,
entrySource: "pc_qr",
}, JWT_SECRET);
const cookieHeader = { cookie: `wecom_pc_login_state=${cookie}` };
const enabledApp = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const expiredState = signPayload({
exp: Date.now() - 1,
nonce,
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
}, JWT_SECRET);
const expired = await enabledApp.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(expiredState)}&code=test-code`,
headers: cookieHeader,
});
expect(expired.statusCode).toBe(401);
expect(String(expired.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
const validState = signPayload({
exp: Date.now() + 60_000,
nonce,
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
}, JWT_SECRET);
const missingCode = await enabledApp.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(validState)}`,
headers: cookieHeader,
});
expect(missingCode.statusCode).toBe(401);
expect(String(missingCode.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
await enabledApp.close();
const disabledApp = await buildApp();
const disabled = await disabledApp.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(validState)}&code=test-code`,
headers: cookieHeader,
});
expect(disabled.statusCode).toBe(404);
expect(String(disabled.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
await disabledApp.close();
expect(global.fetch).not.toHaveBeenCalled();
});
it("reuses the mapped login flow for PC Web login and records a sanitized entry source", async () => {
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "access-token" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, UserId: "wecom-pc-user", OpenId: "wecom-open-pc" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "access-token-replay" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 40029, errmsg: "invalid code" }),
});
const authSession = {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue({ id: "session-pc" }),
};
const prisma = buildPrisma({
authSession,
wecomIdentityBinding: {
findUnique: vi.fn().mockResolvedValue({
employee_id: EMPLOYEE_ID,
employee: {
id: EMPLOYEE_ID,
status: "active",
token_version: 0,
},
}),
},
});
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true }, prisma });
const nonce = "pc-nonce-success";
const state = signPayload({
exp: Date.now() + 60_000,
nonce,
returnTo: "/reimbursements",
mode: "login",
entrySource: "pc_qr",
deviceFingerprint: "pc-device-success",
}, JWT_SECRET);
const cookie = signPayload({
exp: Date.now() + 60_000,
nonce,
entrySource: "pc_qr",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
headers: { cookie: `wecom_pc_login_state=${cookie}` },
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe("https://oa.hyinda.com/reimbursements");
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
expect(authSession.create).toHaveBeenCalledWith({
data: expect.objectContaining({
employee_id: EMPLOYEE_ID,
device_fingerprint: "pc-device-success",
}),
});
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
action: "AUTH_WECOM_LOGIN_SUCCESS",
extra: { bindingSource: "existing", entrySource: "pc_qr" },
}),
}));
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("test-code");
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain(state);
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("wecom-pc-user");
const replay = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
headers: { cookie: `wecom_pc_login_state=${cookie}` },
});
expect(replay.statusCode).toBe(401);
expect(replay.json().error).toEqual(expect.objectContaining({
code: "AUTH_003",
details: { wecom: "userinfo_exchange_failed" },
}));
expect(authSession.create).toHaveBeenCalledTimes(1);
await app.close();
});
it("rejects a signed but unknown callback entry source before code exchange", async () => {
global.fetch = vi.fn();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-unknown-source",
returnTo: "/portal",
mode: "login",
entrySource: "unknown_source",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
});
expect(response.statusCode).toBe(401);
expect(global.fetch).not.toHaveBeenCalled();
await app.close();
});
it("returns structured diagnostics when wecom callback has only open id", async () => { it("returns structured diagnostics when wecom callback has only open id", async () => {
const logs = []; const logs = [];
mockWecomFetchWithPayload({ mockWecomFetchWithPayload({
@@ -1,6 +1,7 @@
const { const {
assertSendAllowed, assertSendAllowed,
buildApprovalConfigChecks, buildApprovalConfigChecks,
buildEntryOauthCheck,
buildSecretMatrix, buildSecretMatrix,
evaluateConclusion, evaluateConclusion,
executeRuntimeReady, executeRuntimeReady,
@@ -24,6 +25,7 @@ function baseConfig(overrides = {}) {
appTimezone: "Asia/Shanghai", appTimezone: "Asia/Shanghai",
wecomEnabled: true, wecomEnabled: true,
wecomEntryEnabled: true, wecomEntryEnabled: true,
wecomPcWebLoginEnabled: false,
wecomOauthEnabled: true, wecomOauthEnabled: true,
wecomCorpId: "corp-runtime", wecomCorpId: "corp-runtime",
wecomAgentId: "1000002", wecomAgentId: "1000002",
@@ -73,6 +75,22 @@ function wecomProfile(overrides = {}) {
} }
describe("check-wecom-runtime-ready", () => { describe("check-wecom-runtime-ready", () => {
it("reports the independently gated PC Web login entry without blocking workbench readiness", () => {
const disabled = buildEntryOauthCheck(baseConfig());
expect(disabled.status).toBe("OK");
expect(disabled.evidence).toEqual(expect.objectContaining({
enabled: true,
pcWebLoginEnabled: false,
pcLoginUrl: null,
}));
const enabled = buildEntryOauthCheck(baseConfig({ wecomPcWebLoginEnabled: true }));
expect(enabled.status).toBe("OK");
expect(enabled.evidence.pcLoginUrl).toBe(
"/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
);
});
it("parses runner options and enforces message send double confirmation", () => { it("parses runner options and enforces message send double confirmation", () => {
const options = parseArgs([ const options = parseArgs([
"--live", "--live",