Skip to content

[审计 2026-11-10] 代码问题、价值排序与执行计划 #2

Open
0 of 1 issue completed
Open

Description

@HsukqiLee

范围与执行约束

2026-11-10 默认分支代码审计。以下报告含基线、证据、复现边界、价值排序和详细计划。首批事项经过独立复核后按 sub-issue 实施,其他事项保留研究计划。协作检查只覆盖已推送工作,实施前再次核对。

FastCAS 源码审计报告

审计日期:2026-11-10。仓库:Q:/FullStack/fastrd/fast-cas;远端:https://github.com/FastR-D/FastCAS.git。

1. 结论与基线

首要发现:核心服务的 ID token scope 限制器使 sid 注入回调不可达。 internal/core/client.go:53–55 返回一个结果恒为 nil 的函数;锁定版 zitadel/oidc 将此结果作为是否调用 SetUserinfoFromRequest 的门槛。普通授权码登录和刷新生成的 ID token 因此缺少本应由 internal/core/tokens.go:341–350 写入的 sid。三语言 SDK 都读取该 claim,单会话退出通知也使用同一个会话标识。

现有 PR #1 处理另一个真实问题,未修复上述回调问题。 它修改 SDK 的应用内路径校验,与建议的核心服务修复没有文件重叠。建议保留 PR #1 的范围,另行提交核心修复与协议回归。

项目 审计时观测
本地 HEAD / main / origin/main 7e9e8c5a56ddefda2bc34f01cb130eee490fd5ed
远端 main(git ls-remote --heads origin) 7e9e8c5a56ddefda2bc34f01cb130eee490fd5ed
PR #1 head / 远端分支 460044ae3cce9bd2d66623800566624c645c4fab / codex/reject-control-character-paths
PR #1 base SHA 7e9e8c5a56ddefda2bc34f01cb130eee490fd5ed
工作树 审计前、验证后 git status --porcelain=v1 均为空;暂存区与工作树 diff 均为空
本地工具链 Go go1.27.0 windows/amd64;项目声明 Go 1.26.0、toolchain 1.26.8(go.mod:3–5)
开放 issue / PR issue 查询返回 [];开放 PR 仅 #1

本文仓库证据行号均相对上述本地 HEAD;依赖行号单独标明版本。仅新增本报告,未修改 fast-cas 的源码、测试、依赖清单或 Git 状态。

2. 架构与实际阅读范围

与本次问题相关的链路为:chi HTTP 服务 → zitadel/oidc Provider → core.Store 的授权、token、UserInfo 回调 → PostgreSQL;三语言 SDK 消费已验证的 ID token 和 UserInfo;退出通知经 outbox 签名并投递到应用。

阅读范围如下,范围外内容不作为已完成审计的依据:

  • 完整阅读:internal/core/client.go、tokens.go、authorization.go、logout.go、logout_test.go;internal/testutil/store.go;go.mod、README.md、docs/verification.md、Makefile、.github/workflows/core-sdk.yml。
  • 定向阅读:internal/httpapi/server.go:1–220、server_test.go:1–200、go_sdk_test.go:65–174;internal/core/events.go:40–99。
  • SDK:Go client.go:230–257,296–337;TypeScript browser.ts 全文、server.ts:33–44,102–133;Python client.py:146–163,169–192。
  • 依赖:实际下载并阅读 github.com/zitadel/oidc/v3@v3.51.3/pkg/op/token.go 的 token 响应与 ID token 生成逻辑、client.go:20–84、storage.go:120–159,没有用缓存中的其他版本代替锁定版本。
  • 远端协作:完整读取 PR 修复 SDK 应用内路径的控制字符校验缺口 #1 diff、正文、文件列表、commit、merge 状态及 checks;通过 git ls-remote 核对分支。
  • 搜索覆盖:仓库 Go 源码中的 RestrictAdditionalIdTokenScopes|SetUserinfoFromRequest|IDTokenUserinfoClaimsAssertion;SDK 与核心中的 sid|SessionID|session_id。后者仅用于定位,未宣称已完整阅读所有命中文件。

依赖身份:go.mod:10 锁定 v3.51.3;go.sum:57–58 分别记录:

github.com/zitadel/oidc/v3 v3.51.3 h1:jkEQ2k60amW6vwvzMwrGO7mw7vGxNp6BSsEH0AH6Gas=
github.com/zitadel/oidc/v3 v3.51.3/go.mod h1:KQmYte+zOePAeVwR3LM153dxWBC9orWZAIa4w9r1ZhU=

go mod download -json github.com/zitadel/oidc/v3@v3.51.3 返回同样的校验和。依赖证据目录为 D:/Caches/go/pkg/mod/github.com/zitadel/oidc/v3@v3.51.3/。

3. F1:ID token 缺失会话关联标识(P1,高可信)

3.1 根因与证据链

缺陷类别是回调所需的输入前提被上游策略清空。应遵守的规则是:限制可发布的身份资料时,仍须保留请求上下文回调所需的合法 scope,保证会话元数据随签名 token 输出。

环节 证据与含义
服务实际接入 Provider internal/httpapi/server.go:44–54 将 store 交给 op.NewOpenIDProvider,开启授权码所需配置及 refresh grant。
中心会话进入授权请求 internal/core/authorization.go:104–107 设置 Subject、SessionID = session.ID、认证时间及完成状态。
授权 scope 已有边界 authorization.go:53–59 要求 openid,并逐项验证请求 scope 在客户端登记范围内。
scope 被清空 internal/core/client.go:53–55 为 return func([]string) []string { return nil }。外层返回值是非 nil 函数;内层返回 nil slice。
依赖调用限制器 依赖 pkg/op/token.go:216 执行 client.RestrictAdditionalIdTokenScopes()(request.GetScopes())。
回调存在长度门槛 依赖 pkg/op/token.go:232–254 对普通请求仅在 len(scopes) > 0 时调用 UserInfo 回调,247–248 才调用可选接口 SetUserinfoFromRequest。
sid 注入点被绕过 internal/core/tokens.go:341–350 在 *Authorization / *Token 请求上追加 sid,但此函数无法越过上述门槛。
刷新保留原始会话 tokens.go:59–68 将授权或旧 Token 的 SessionID 复制到新 Token;209–231 从 refresh payload 恢复 Token。会话数据仍存在,丢失发生在 ID token claim 输出链路。

调用关系可压缩为:

Authorization.SessionID / Token.SessionID 已存在
  → CreateIDToken
  → RestrictAdditionalIdTokenScopes()(request.GetScopes()) == nil
  → len(scopes) == 0
  → 跳过 SetUserinfoFromRequest
  → 未执行 AppendClaims("sid", ...)
  → 签名 ID token 没有 sid

依赖 pkg/op/token.go:211 先生成基础身份 claims,因此该问题不要求登录失败才能出现。pkg/op/token.go:30–43 显示 token 响应通过相同的 CreateIDToken 路径构建。

3.2 用户影响与边界

  • Go SDK sdk/go/client.go:322–333 将 ID token 的 sid 写入 Identity.SessionID;TypeScript sdk/typescript/src/server.ts:123–127 写入 sessionId;Python sdk/python/src/fastcas/client.py:184–189 写入 sid。缺失时分别得到空字符串、undefined、None,常规登录仍可能成功。
  • internal/core/logout.go:71–95 的单会话退出按中心 sid 撤销会话及衍生 token,并将同一 sid 入队;internal/core/events.go:65–75 将其放入签名 logout token。应用若依赖登录时保存的 sid 匹配本地会话,会缺少对应关联键。
  • 中心侧会话和 token 撤销逻辑仍存在:tokens.go:94–103 检查中心会话存活,logout.go:86–92 执行撤销。不能据此报告“中心退出完全失效”。本次没有运行应用接收端,无法断言每个具体项目会保留会话还是扩大退出范围;确定的问题是 ID token 与 SDK 登录结果丢失关联标识。
  • Access token 有独立路径:client.go:56–58 保留 access scopes,tokens.go:394–399 写入 access 私有 claims。因此不能用 access token 含 sid 证明 ID token 正确,也不宜让 SDK 改为从 access token 猜测身份会话。
  • tokens.go:353–365 的 UserInfo endpoint 仅调用 userInfo,没有补写 sid。SDK 再取 UserInfo 并不能修复该丢失。
  • 依赖 pkg/op/token.go:232–240 的 token exchange 分支另有专用回调;本文不把它归入普通授权码/刷新链路的同一确定结论。设备授权的会话语义也需单独按原协议验收。

3.3 现有测试为何没有拦截

internal/httpapi/server_test.go:167–183 验证签名并声明 SID 字段,但断言仅检查 issuer、nonce、subject、audience,没有检查 SID;187–191 仅检查刷新成功,没有验证刷新后 ID token 的会话标识。

internal/httpapi/go_sdk_test.go:81–87 检查姓名和邮箱,没有检查 Identity.SessionID。internal/core/logout_test.go:232–263 验证中心撤销、其他中心会话保留及 outbox SID,但未先经授权码换取 ID token,故不能覆盖本缺陷。测试缺口属于 F1 的漏检原因,修复时应一并补齐。

4. F2:SDK 应用内路径接受 ASCII 控制字符(P1,已有 PR #1)

基线的 TS 浏览器 sdk/typescript/src/browser.ts:2–9、TS 服务端 server.ts:38–41、Go sdk/go/client.go:240–245、Python sdk/python/src/fastcas/client.py:146–150 只覆盖反斜杠及 CR/LF 等情况,未拒绝 TAB 等其余 ASCII 控制字符。

本次实际执行以下离线 Node 探针,未访问该域名:

const p = "/\t/example.invalid";
const accepted = p.startsWith("/") && !p.startsWith("//") && !/[\\\r\n]/.test(p);
console.log({ accepted, resolved: new URL(p, "https://app.example").href });
// accepted: true; resolved: "https://example.invalid/"

这证明旧校验的“应用内相对地址”前提可被 URL 规范化破坏。具体利用依赖应用如何使用路径;本次未证明生产重定向或令牌泄漏。

PR #1 已将上述校验扩展到 U+0000–U+001F、U+007F,并添加跨 SDK 回归,继续保留反斜杠和 // 限制。该问题沿用现有 PR 处理,避免重复实现。

5. PR #1 与冲突检查

PR:#1 修复 SDK 应用内路径的控制字符校验缺口。审计时状态为 OPEN、isDraft=false、MERGEABLE、mergeStateStatus=CLEAN。正文中历史上的 draft 表述不代表当前状态。

gh pr diff 1 与 git diff --name-status main...origin/codex/reject-control-character-paths 一致,涉及七个文件:

sdk/go/client.go
sdk/go/client_test.go                         (新增)
sdk/python/src/fastcas/client.py
sdk/python/tests/test_store.py
sdk/typescript/src/browser.ts
sdk/typescript/src/server.ts
sdk/typescript/test/browser.test.mjs
  • 当前文本冲突: GitHub 报告 PR 修复 SDK 应用内路径的控制字符校验缺口 #1 与当前 main 可合并。
  • 与 F1 最小计划的交集: 建议修改 internal/core/client.go、internal/httpapi/server_test.go,必要时扩展已有 internal/httpapi/go_sdk_test.go,均不在上述列表中。注意 internal/core/client.go 与 sdk/go/client.go 是不同文件。
  • 语义与验收: 两项修复分别改变 token claims 链路和路径输入边界,没有发现相互覆盖的逻辑;PR 修复 SDK 应用内路径的控制字符校验缺口 #1 的成功检查不能证明 sid 正确。审计时 gh pr checks 1 返回 10 项成功,包括两次触发的 core-sdk、Go、Python 3.10/3.13、TS 检查;这些是远端历史结果。
  • 协作顺序: F2 由 PR 修复 SDK 应用内路径的控制字符校验缺口 #1 承接;F1 使用独立小改动,避免把路径校验再写一次。若选择在 PR 修复 SDK 应用内路径的控制字符校验缺口 #1 合并后实施,先更新基线并重跑核心协议测试。开始实施前重新查询 PR、远端 head 和文件列表,以上冲突结论是当前观测,不是未来保证。

6. 最小实施计划

F1:修复生产者的 scope 策略并补强真实协议断言

  1. 在 internal/core/client.go:53–55 将内层函数调整为保留传入 scopes,与相邻 access token 限制器的形式一致:return func(v []string) []string { return v }。添加简短原因注释,说明清空列表会禁用请求回调并丢失会话 claims。
  2. 保持 IDTokenUserinfoClaimsAssertion() == false。依赖 pkg/op/token.go:221–229,269–282 在同时生成 access token 时仍会移除 profile/email/address/phone 等 UserInfo scopes,保留 openid 以进入请求回调。不得把外层函数改成 return nil:依赖会直接调用它。
  3. 在现有 internal/httpapi/server_test.go 协议测试中断言授权码签发的 ID token 含非空 sid,且等于真实中心浏览器会话 ID;验证刷新后签名 ID token 的 sid 与首次相同。复用 cookie jar、Store 和换码辅助函数,不另造签发链路。
  4. 在同一协议测试文件增加/扩展 scope 场景:最小 openid、普通 openid profile email,以及请求 offline_access 后的刷新。校验基础 claims 和资料发布边界;不能通过开启全部 UserInfo claims 来令测试变绿。
  5. 若加入 SDK 契约断言,扩展已有 internal/httpapi/go_sdk_test.go 检查 Identity.SessionID 与该浏览器中心会话一致。TS/Python 现有真实 Provider 契约一并回归;本次源码证据显示它们已读取 ID token claim,尚无改 SDK 生产代码的必要。

这是最小修复,因为 callback 与会话数据生产、存储、读取路径已经存在,错误位于调用前提。单改 tokens.go 的追加语句无法让被跳过的函数执行。无需新增配置、scope 名称、DTO 字段、依赖或迁移,也无需改 token 生命周期或退出策略。

最小设计复核:修复的是所有普通 ID token 请求共用的策略前提;不给调用者新增选择;sid 从已有会话确定;没有新增抽象或补偿读取;验收覆盖授权码与刷新两类请求,不绑定某个应用名称。生产结构调整限于现有策略返回值,未改变数据结构。实施前后均应核对净改动:生产代码预计单处替换加注释,测试增长仅服务真实协议断言。

F2:沿用 PR #1

完成维护者审阅并合并现有 PR,按其现有用例验证所有 ASCII 控制字符被拒绝、合法 Unicode/query/hash/百分号编码与默认路径保持兼容。无需为 F1 再触碰它的七个文件。

7. 验证记录与验收条件

本次实际执行

命令/检查 结果
git status --short、git rev-parse HEAD、git remote -v、git branch -avv、git log --oneline -10 确认干净基线及本地分支身份
git ls-remote --heads origin 远端有 main 与 PR #1 分支;SHA 如第 1 节
gh pr view 1、gh pr diff 1、gh pr checks 1、开放 issue/PR 查询 已核对状态、七文件 diff、10 项成功检查;无开放 issue
go mod download -json github.com/zitadel/oidc/v3@v3.51.3 成功;锁定版本源码与校验和可核对,下载发生在 Go 缓存
go test -mod=readonly ./internal/httpapi -run '^TestOIDCHTTPLoginRefreshAndReplay$' -count=1 -v 编译成功,但目标测试 SKIP:requires dedicated PostgreSQL: FASTCAS_TEST_DATABASE_URL。包输出 PASS 不代表该协议测试通过
第 4 节 Node URL 探针 旧校验接受 TAB 路径,解析结果为站外 URL
git status --porcelain=v1、git diff --exit-code、git diff --cached --exit-code 均干净,无仓库改动

F1 当前结论来自锁定依赖和业务源码的确定性调用路径;本机未完成带数据库的 HTTP 复现,也未验证修复后结果。没有启动生产服务或执行全工作区构建。

实施验收(全部满足方可关闭 F1)

  • 在独立 PostgreSQL 上,新增协议断言在基线失败,失败点为 ID token sid 缺失;修复后同一入口通过。不得只直接调用 SetUserinfoFromRequest 测试函数本身。
  • 首次授权码换码的签名 ID token:sid 非空且等于中心会话 ID;最小 openid 与常用资料 scopes 场景都成立。
  • 合法刷新返回的签名 ID token 保留同一 sid;原有 PKCE、nonce、issuer/audience、授权码单次消费及 refresh replay 防护保持通过。
  • 常规同时签发 access token 的响应继续保持原资料发布策略,姓名/邮箱由 UserInfo 端点获取;ID token 不因本修复额外携带这些资料。
  • SDK 登录结果得到同一 sid;将两个独立浏览器会话的登录结果与单会话 logout 通知关联,确认只匹配目标 CAS 来源会话,另一会话及项目本地登录来源保持不受影响。全局退出仍按 subject 处理。该项需应用接收端契约,中心 outbox 单测不能代替。
  • PR 修复 SDK 应用内路径的控制字符校验缺口 #1 合入后的组合基线上,路径控制字符回归与核心协议/SDK 契约均通过,所有要求真实环境的用例显示 pass,不能以 skip 验收。

建议验证命令(在 fast-cas 目录;先配置独立 FASTCAS_TEST_DATABASE_URL,CI 对齐 Go 1.26.8):

go test -mod=readonly ./internal/httpapi -run '^TestOIDCHTTPLoginRefreshAndReplay$' -count=1 -v
go test -mod=readonly -race ./internal/core ./internal/httpapi -run 'Test(OIDCHTTPLoginRefreshAndReplay|LogoutSessionQueuesSIDAndPreservesOtherSessions|GoSDKLoginAndLinkContract)$' -count=1 -v
go test -mod=readonly -race ./...
go vet -mod=readonly ./...

三语言 SDK、真实 Provider 契约及防 skip 门禁沿用 .github/workflows/core-sdk.yml:49–73;Go SDK 是独立模块,依该文件在 sdk/go 下执行测试。CI 门禁原命令为 go test -json -race -count=1 ./... | python3 scripts/check_contract_results.py --core。跨项目单会话关联需在具备全部项目和依赖的环境执行并补足相应断言;make verify-contracts 是现有全工作区入口(Makefile:12–13、docs/verification.md:3–9),不能假定它已经包含本次新增的精确会话验收。

8. 后续工作顺序

  1. 独立复核 F1 的依赖门槛与业务 claim 注入证据,准备带数据库的红→绿协议用例。
  2. 由现有 PR 修复 SDK 应用内路径的控制字符校验缺口 #1 处理 F2,F1 单独实施,提交前重查远端并记录新基线 SHA。
  3. 以真实 ID token、SDK 登录结果和单会话通知的同一 sid 为闭环验收依据,再跑现有核心与集成门禁。

本次交付为源码审计及实施方案;未创建 issue、提交、推送或 PR。

独立复核

FastCAS 首批项独立复核:ID token sid 与会话退出

复核日期:2026-11-10。复核对象:AUDIT-fast-cas.md 的 F1、F2 与最小实施计划。
源码基线:fast-cas main / origin/main,7e9e8c5a56ddefda2bc34f01cb130eee490fd5ed。
本次只新增本根报告,没有修改 FastCAS 源码、测试、依赖或 Git 状态;没有创建 issue、提交或推送。

1. 判定

批准 F1 的根因判断和最小生产修复方向;批准前提是按本文修订验收。 当前没有可验收的实现补丁,故不能把 F1 标为已修复。

F1 的源码证据闭环成立:

  1. internal/core/client.go:53–55 的 RestrictAdditionalIdTokenScopes 返回一个函数,而该函数对任意输入都返回 nil。
  2. 锁定依赖 github.com/zitadel/oidc/v3@v3.51.3 的 pkg/op/token.go:216 先执行该限制器;pkg/op/token.go:241–254 仅在过滤结果 len(scopes) > 0 时调用 SetUserinfoFromScopes,并进一步调用可选的 SetUserinfoFromRequest。
  3. internal/core/tokens.go:341–350 的 SetUserinfoFromRequest 对 *Authorization 和 *Token 追加 sid;internal/core/authorization.go:104–106 已将浏览器 session ID 写入授权请求,tokens.go:59–67 也把它从授权请求/旧 Token 复制到新 Token。
  4. 因而普通授权码和 refresh 请求的会话值可以存在于服务端对象中,但当前限制器把回调的进入条件清空,ID token 不会执行 sid 追加。

建议的单点修复仍然是把 client.go:54 改为保留输入 scopes,例如 return func(v []string) []string { return v },并保持 IDTokenUserinfoClaimsAssertion() == false。依赖在有 access token 时仍会移除 profile/email/address/phone(token.go:221–230、269–282),所以该修复不会把资料 scope 无条件扩张进 ID token;它只恢复请求级回调的执行前提。

2. F1 计划的必要修订

2.1 测试必须证明真实 token 链路

现有 internal/httpapi/server_test.go:159–197 已完成授权码换 token、签名解析、PKCE/nonce 等检查,但 SID 结构体字段没有断言。它也只验证 refresh 成功和 replay 拒绝,没有解析 refresh 返回的 ID token。实施时应:

  • 在授权码换码响应中断言签名 ID token 的 sid 非空,并等于该次真实浏览器 session 的 ID;
  • 在 refresh 响应中再次验签并断言 sid 与首次相同;
  • 保留授权码单次消费、refresh replay、PKCE、issuer、audience、subject、nonce 断言;
  • 覆盖 openid 和 openid profile email,以及包含 offline_access 的 refresh 场景,确保测试不是靠开启全部 UserInfo claims 变绿;
  • 断言资料发布边界仍成立:ID token 不因本修复额外携带 profile/email,UserInfo 仍按既有路径提供资料。

只直接调用 SetUserinfoFromRequest 不能作为 F1 通过,因为它绕过了本缺陷所在的 Provider scope 门槛。

2.2 “双会话退出”必须是端到端契约

单测 internal/core/logout_test.go:232–263 能证明中心按指定 sid 撤销目标 session、保留另一中心 session,并排队相应 SID;它不能单独证明浏览器登录产生的 ID token 和 SDK 登录结果携带了同一个 sid,也不能证明接收应用只退出目标本地会话。

因此验收应创建同一身份的两个独立浏览器 session:

  1. 分别完成授权码登录,取得两个可验签 ID token;两个 sid 均非空且互不相同,并分别等于对应中心 session ID。
  2. 对其中一个 sid 执行单会话退出,验证中心目标 session、其派生 token 和对应应用侧会话被撤销。
  3. 验证另一个中心 session、另一应用侧会话及项目本地登录来源仍存活;全局退出仍按 subject 语义单独验收。
  4. SDK Go/TypeScript/Python 的登录结果中的 session 字段分别等于同一 ID token 的 sid,不能用 access token 推测替代。

如果没有真实应用接收端,至少应把接收端契约测试作为待完成项,不把 outbox 生成测试冒充双会话退出验收。

2.3 数据库环境与 skip 门禁

本地集成 fixture 在 internal/testutil/store.go:30–31 没有 FASTCAS_TEST_DATABASE_URL 时直接 t.Skip。因此执行结果为 SKIP 时不得标记 F1 通过。实施验收必须使用独立 PostgreSQL,并在 CI/脚本中阻止关键协议测试被 skip;不能只以包级 PASS 作为结论。

3. 最小 design gate 五问

  1. 是否结构性修复? 是。修正 Provider 回调的共同输入前提,所有普通 ID token 请求都会恢复请求级 claims;不是只给某个 SDK 或 logout 路径打补丁。
  2. 是否增加调用方负担? 否。无需新增配置、scope 名称、DTO、迁移或 SDK fallback;现有 client 接口继续决定允许的 scope。
  3. 行为是否由已有事实确定? 是。sid 来自已保存的 Authorization.SessionID / refresh Token session 数据;不要从 access token 或客户端输入猜测。
  4. 复杂度是否为必要最小? 是,但测试需同时保留 ID token 资料边界。单点改变限制器即可让既有回调执行,无需改 token 生命周期或退出策略。
  5. 是否过拟合某一调用或应用? 否。生产修复面向 zitadel Provider 的所有普通授权码/refresh ID token 请求;验收使用授权码、refresh 和两个独立 session,而不是某一个项目名称或单一 UI。

4. F2 与 open PR #1 文件重叠核对

复核了 origin/codex/reject-control-character-paths(PR #1 head 460044ae3cce9bd2d66623800566624c645c4fab)相对 main 的文件列表。PR #1 仅涉及:

sdk/go/client.go
sdk/go/client_test.go
sdk/python/src/fastcas/client.py
sdk/python/tests/test_store.py
sdk/typescript/src/browser.ts
sdk/typescript/src/server.ts
sdk/typescript/test/browser.test.mjs

F1 最小计划涉及 internal/core/client.go、internal/httpapi/server_test.go,可选扩展 internal/httpapi/go_sdk_test.go。逐文件 diff 核对结果:与 PR #1 无文件重叠。其中 sdk/go/client.go 与 internal/core/client.go 是两个不同路径,不能因同名而判定冲突。F2 可继续由 PR #1 承接;F1 应保持独立,实施前仍需重新查询 PR head、base、状态和文件列表。

5. 已由当前源码证实的后续计划

以下不是 F1 的阻塞项,但值得单独排期。它们来自本次源码复核,不是对未读代码的推测。

5.1 Refresh 没有把旧 scopes 与当前 client 对照

internal/core/tokens.go:209–231 从 refresh token payload 反序列化 Token,保留其 Scopes;tokens.go:170–172 在轮换时只比较 refresh family 的 subject 和 client ID,没有读取当前 client scope 并逐项重验证。tokens.go:54 又将请求 scopes 写入新 token,CreateAccessAndRefreshTokens 的 refresh 分支沿用该请求对象。

因此,client scope 被收紧后,旧 refresh family 仍可能携带并继续生成原 scope,至少是一个已证实的策略一致性缺口。后续应明确产品契约:在 refresh 时重新读取当前 client,并拒绝或裁剪不再允许的 scopes;同时为“收紧 client 后旧 refresh”增加红→绿测试,验证 access token、ID token、UserInfo 和 refresh rotation 的边界。不要在 F1 中顺手改变该策略,避免把 claim 回调修复和 refresh 权限变更混在一个补丁内。

5.2 Link 事务内使用 Store 查询,且 Activate/Revoke 锁顺序相反

internal/core/links.go:67–100 的 PrepareLink 开启事务并锁定 link_intents;当 intent 已为 prepared/active 时在该事务内调用 s.Link(第 83 行)。ActivateLink 同样先在事务内 FOR UPDATE 锁 intent,再在第 114 行调用 s.Link。s.Link 在 links.go:132–135 通过 s.DB.QueryRow 重新从 pool 取连接,而不是使用当前 tx。若未来同一事务先写入/持有相关行再走该分支,这会造成事务可见性和连接等待问题;至少已确认事务内绕过 tx 重新取连接,应该改为 tx 内查询/返回,或在提交后再读取并配套明确语义。当前源码没有在 links.go 中调用 s.Identity,所以不把“links 内 s.Identity 自锁”写成已证实事实。

此外,锁顺序确实相反:

  • ActivateLink links.go:109 锁 link_intents,随后 links.go:120 更新 account_links;
  • RevokeLink links.go:152 先锁 account_links,随后 links.go:167 更新 link_intents。

并发 Activate/Revoke 同一 link 可能形成反向等待。后续应统一 intent→link 或 link→intent 的锁顺序,并增加并发死锁/超时回归,而不是只捕获某次 deadlock detected。

5.3 身份重新启用后,旧 approved device authorization 仍可领取

禁用身份时,internal/core/account_security.go:497–509 撤销 session、token 并排队 logout,但没有把 device_authorizations 的 approved 行改为 denied。ConfirmDevice 在 device.go:151–160 只允许对 active identity 做批准;然而 GetDeviceAuthorizatonState 在 device.go:92–102 对已是 approved 的记录只检查当前身份是否 active,身份暂时 disabled 时返回 ErrUnauthorized,没有消费或否决该记录。重新启用后,同一 approved 记录可继续进入 consumed 并返回 subject。

这使“禁用期间不能领用”不等于“禁用撤销了已批准设备授权”。后续应在禁用事务中撤销该身份所有 approved device authorizations,并增加序列测试:approve → disable → re-enable → poll,预期不得领取;同时确认既有 recovery/password/MFA reset 的 deny 行为保持一致。

6. 交付验收清单

  • 基线测试在独立 PostgreSQL 上因 ID token sid 缺失而红;最小修复后同一路径绿。
  • 授权码 ID token:签名有效,sid 非空且等于真实 session ID;openid 与资料 scope 均通过。
  • refresh ID token:签名有效,sid 与授权码 token 相同;refresh rotation、replay、PKCE/nonce/issuer/audience 继续通过。
  • ID token 资料边界未扩张,UserInfo 行为保持既有契约。
  • 两个独立 session 各自得到不同且正确的 sid;退出第一个只撤销第一个中心/应用会话,第二个保持有效。
  • 三语言 SDK 结果分别读到正确 sid;F1 通过不依赖 access token fallback。
  • 现有 PR 修复 SDK 应用内路径的控制字符校验缺口 #1 组合基线通过路径控制字符回归;F1 文件范围与其仍无重叠。
  • 关键测试没有因缺少数据库而 skip;最终记录实际环境、命令和结果。

本次结论是“F1 根因成立、最小方案批准并附验收修订;尚未实施”。

Activity

  1. HsukqiLee commented on Nov 10, 2026

    @HsukqiLee
    ContributorAuthor

    本地实施与最终独立评审记录

    基线:7e9e8c5a56ddefda2bc34f01cb130eee490fd5ed;本地分支:audit/2026-11-reviewed-fixes。

    源码已在隔离工作树完成首批实现与评审,尚未 commit/push/提交 PR。 因此以下是本地验证交付,不代表已合入、部署或远端 CI 通过;issues 保持开放。后续审计项仍按父报告计划排队。

    FastCAS issue 3 实施记录:ID token sid 与双会话退出

    最终状态:已补齐独立评审验收项,核心协议与生产修复通过最终独立复核,C1/C2解除。见 REVIEW-fast-cas-implementation.md。独立审阅者在隔离 PostgreSQL 中复跑 race;Windows Python 缺 authlib 失败与实施者 WSL 隔离契约通过分别保留,不混记结果。未 commit、未 push、未创建 PR、未关闭 issue。

    范围与设计门

    缺陷类别是:Provider 回调所需的请求上下文被上游 scope 策略清空,导致已有会话元数据生产者不可达。通用规则是:限制可发布的身份资料时,仍要保留合法请求 scope,使请求级会话 claims 回调能够执行。

    最小修复是让 RestrictAdditionalIdTokenScopes 原样保留传入 scope。调用方无需新增字段或选择,sid 继续由已有 Authorization.SessionID/refresh token 会话数据确定;没有增加 profile/email 范围、fallback、配置、DTO、迁移或依赖。IDTokenUserinfoClaimsAssertion 保持 false,资料发布边界由锁定的 zitadel/oidc 依赖继续处理。

    修改

    • internal/core/client.go
      • 将恒定返回 nil 的限制器改为返回传入 slice。
      • 加注释说明该输入是进入 SetUserinfoFromRequest、写入请求级会话 claim 的前提。
    • internal/httpapi/server_test.go
      • 授权码 ID token 验签后要求 sid 非空。
      • refresh ID token 重新验签,要求 sid 与授权码 ID token 相同。
      • 在包含 profile/email 请求 scope 的授权码场景中要求 ID token 不额外携带 name/email,守住资料发布边界。
      • 新增两个独立 cookie jar 的真实授权码登录场景;通过 /api/v1/me/sessions/{sid}/revoke,携带真实 Origin/CSRF,验证目标 session 被撤销、另一 session 仍活跃,两个 /api/v1/me 结果分别为未授权/成功。
      • 测试辅助函数只复用生产 HTTP 路径,不直接调用 SetUserinfoFromRequest。
    • internal/httpapi/go_sdk_test.go
      • 现有 Go SDK 真实 provider 集成要求登录结果保留非空 SessionID。
    • internal/httpapi/sdk_contract_test.go
      • 通过同一 HTTP provider 启动现有 TypeScript/Python SDK sid 契约,使用隔离依赖运行。
    • sdk/typescript/test/sid_contract.mjs、sdk/python/tests/sid_contract.py
      • 新增最小真实 provider 登录契约,分别断言 identity.sessionId/identity.sid。
    • sdk/typescript/test/contract.mjs、sdk/python/tests/contract.py
      • 现有契约补充 sid 断言;未触碰 PR1 所列路径校验文件。

    没有修改 PR1 涉及的 SDK 路径校验文件,也没有修改任何 SDK 生产代码或 tokens.go。

    Sweep

    按 structural-fix 要求执行了以下形态扫描:

    grep -R "RestrictAdditionalIdTokenScopes\|SetUserinfoFromRequest\|IDTokenUserinfoClaimsAssertion" . --include='*.go'
    grep -R "sid\|SessionID\|session_id\|LogoutSession\|logout" . --include='*.go'
    

    命中处置:

    • internal/core/client.go:53-55:确认是恒定 nil 的生产缺陷,已修复。
    • internal/core/client.go:56-60:access-token scope 限制器和 ID-token assertion 不是该缺陷,保留不变。
    • internal/core/tokens.go:341-350:已有 sid 注入回调,保留不变;本次恢复其 Provider 前置可达性。
    • internal/core/authorization.go:104-106、internal/core/tokens.go:59-68,209-231:已有会话 ID 生产/refresh 保留路径,保留不变。
    • internal/core/logout.go、internal/httpapi/accounts.go:已有单会话撤销路径,未添加第二套实现;测试通过真实 API 验证。
    • sdk/go/client.go、sdk/typescript/src/server.ts、sdk/python/src/fastcas/client.py:仅为确认现有 SDK 读取 sid,本次不改。

    未发现需要在 tokens.go、logout 实现或 SDK 中增加补偿读取的同类修复点。

    测试与环境

    首次未配置数据库时,关键 HTTP 测试明确显示 SKIP;该结果未作为通过。随后使用隔离 Docker PostgreSQL 16 容器,仅使用临时本地凭证 issue3_local_only、本地端口 55433 和测试数据库,测试后已删除容器;未读取或使用生产库凭证。

    通过:

    $env:FASTCAS_TEST_DATABASE_URL='postgres://fastcas_test:issue3_local_only@127.0.0.1:55433/fastcas_test?sslmode=disable'
    go test -mod=readonly ./internal/httpapi -run '^TestOIDCHTTP(LoginRefreshAndReplay|LogoutRevokesOnlyTheSelectedSession)$' -count=1 -v
      PASS TestOIDCHTTPLoginRefreshAndReplay
      PASS TestOIDCHTTPLogoutRevokesOnlyTheSelectedSession
    
    go test -mod=readonly -race ./internal/core ./internal/httpapi -run 'Test(OIDCHTTPLoginRefreshAndReplay|OIDCHTTPLogoutRevokesOnlyTheSelectedSession|LogoutSessionQueuesSIDAndPreservesOtherSessions|GoSDKLoginAndLinkContract)$' -count=1 -v
      PASS
    
    go vet -mod=readonly ./...
      PASS
    
    sdk/go: go test -mod=readonly -race ./...
      PASS
    

    失败或阻碍:

    • go test -mod=readonly ./... -count=1 在 Windows 上失败于既有 internal/core/TestOfflineSigningRotation:key files must not be readable by group or others。该测试要求 Unix 文件权限,失败与本次 diff 无关;同一命令中的其他包通过。
    • sdk/typescript: pnpm test 未能启动:工作树没有 node_modules/TypeScript compiler。
    • sdk/python: python -m pytest -q 未能收集:环境缺少 authlib,且工作树包未安装(fastcas 不在 import path)。

    以上阻碍均未改依赖、未安装生产环境组件、未将 skip 或未收集标为通过。

    独立评审验收补齐

    评审指出的 C1/C2 已逐项补齐:

    • 双 session 测试保存两个授权码响应的 access/refresh token。目标 SID logout 后,目标 access token 的 introspection 为 inactive,目标 refresh token 换取失败;另一 SID 的 access token 仍 active,另一 refresh token 换取成功。
    • logout 仍通过真实 /api/v1/me/sessions/{sid}/revoke,随后由真实 outbox delivery 投递本地 httptest backchannel receiver。receiver 验签 logout token,要求收到的 sid 等于目标 SID,并在两个本地会话映射中只撤销目标 SID。
    • Go、TypeScript、Python SDK 均从真实 provider 完成授权码登录并断言 SDK 登录结果的 sid;SDK 生产实现未改。

    本轮实际执行:

    go test -mod=readonly -race ./internal/core ./internal/httpapi -run 'Test(OIDCHTTPLoginRefreshAndReplay|OIDCHTTPLogoutRevokesOnlyTheSelectedSession|LogoutSessionQueuesSIDAndPreservesOtherSessions|GoSDKLoginAndLinkContract|TypeScriptSDKRealProviderSIDContract|PythonSDKRealProviderSIDContract)$' -count=1 -v
      PASS:核心 logout、授权码/refresh、双 session token 撤销与存活、backchannel SID 隔离、Go/TypeScript/Python sid 契约
    
    sdk/typescript: pnpm test
      PASS:build;4 tests passed,0 skipped
    
    WSL Ubuntu 临时 Python 依赖目录:
    PYTHONPATH=/tmp/fastcas-python-packages:/mnt/q/FullStack/fastrd/worktrees/fast-cas/sdk/python/src python3 -m pytest -q
      PASS:4 passed,1 skipped(既有可选测试;未将 skip 计为 pass)
    
    go vet -mod=readonly ./...
      PASS
    

    补充环境记录:

    • 无数据库直接运行新增 provider 测试时明确 SKIP;随后每次关键验收均使用独立 Docker PostgreSQL 16 并实际 PASS,未以包级 PASS 替代测试结果。
    • Windows Python pytest 的 4 个既有 SQLite 私有权限测试因 NTFS 无 Unix mode semantics 失败;未放宽生产权限检查,改在 WSL 隔离依赖目录完整通过。
    • 曾探测既有完整 TS/Python project contract:TS 旧契约在 research delegation fixture 缺失时返回 403,Python 旧契约在 Windows SQLite 私有权限检查失败;本次新增的最小 sid contracts 不依赖这些无关 fixture,并已在真实 provider 上 PASS。该探测结果不被记为通过。
    • TS 依赖安装于 worktree node_modules,Python 依赖安装于 D:/Temp/opencode/fastcas-python-venv 与 WSL /tmp/fastcas-python-packages;未修改锁文件、生产环境或生产凭证。Docker PostgreSQL 测试容器每轮结束后删除。

    净行数与完整 diff 自查

    源代码与测试改动(不含本记录文件)以 tracked git diff --numstat 加三个新增测试文件行数核对为 373 additions / 5 deletions,净 +368 行:生产代码净 +2 行,测试净 +366 行。新增行来自评审要求的 token 撤销/存活、backchannel receiver、三语言 sid contract runner;没有新增产品字段、配置、兼容路径或生产抽象。

    已执行:

    git diff --check
    git diff --stat
    git diff -- internal/core/client.go internal/httpapi/server_test.go
    git status --short
    git diff --numstat -- internal/core/client.go internal/httpapi/go_sdk_test.go internal/httpapi/server_test.go sdk/python/tests/contract.py sdk/typescript/test/contract.mjs
    

    完整 diff 自查结论:生产修改仍仅有 internal/core/client.go;其余修改均为 HTTP/SDK 测试文件与三个新增 sid contract 测试文件。没有 PR1 路径校验文件、SDK 生产文件、依赖清单、生产 compose 或凭证文件变更。当前 worktree 保留未提交修改,最终独立评审结果见上述报告。

    最终独立评审

    FastCAS 实施 diff 最终独立只读评审

    日期:2026-11-10
    对象:worktrees/fast-cas 相对 7e9e8c5 的全部未提交 diff、未跟踪测试及最新 IMPLEMENTATION-fast-cas.md。

    最终裁决

    生产修复与核心协议回归通过;原 C1/C2 代码验收阻断已解除。 RestrictAdditionalIdTokenScopes 原样保留请求 scope 的单点修复正确恢复 SetUserinfoFromRequest,且在锁定的 zitadel/oidc/v3.51.3 带 access token 路径中仍会移除 profile/email/address/phone scope,未发现额外资料 claims 泄露。

    Python SID 契约在本次 Windows 复跑中因环境缺少 authlib 失败;最新实施记录另有 WSL 隔离依赖目录的 4 passed, 1 skipped 结果。该失败是当前复核环境依赖限制,不是生产代码或测试断言失败;因此最终结论为代码批准,Python 契约需在已声明的 WSL/隔离依赖环境保留复验记录。任何 SKIP 均未计为通过。

    原阻断项复核

    C1:PostgreSQL 与关键 race 测试

    本次新建并使用临时 PostgreSQL 16 容器(本地端口 55433,测试后已删除),运行:

    go test -mod=readonly -race ./internal/core ./internal/httpapi -run 'Test(OIDCHTTPLoginRefreshAndReplay|OIDCHTTPLogoutRevokesOnlyTheSelectedSession|LogoutSessionQueuesSIDAndPreservesOtherSessions|GoSDKLoginAndLinkContract|TypeScriptSDKRealProviderSIDContract|PythonSDKRealProviderSIDContract)$' -count=1 -v
    

    结果:

    • TestLogoutSessionQueuesSIDAndPreservesOtherSessions:PASS
    • TestGoSDKLoginAndLinkContract:PASS
    • TestTypeScriptSDKRealProviderSIDContract:PASS
    • TestOIDCHTTPLoginRefreshAndReplay:PASS
    • TestOIDCHTTPLogoutRevokesOnlyTheSelectedSession:PASS
    • TestPythonSDKRealProviderSIDContract:当前 Windows Python FAIL,ModuleNotFoundError: authlib;不是 SKIP,也未伪报通过。

    同一轮实际启用了 -race;go vet -mod=readonly ./...:PASS。最新实施记录还报告了 WSL Python 隔离依赖目录的 4 passed, 1 skipped,其中既有可选测试的 skip 未被计作 pass。无数据库直接运行 provider 测试时会明确 SKIP;本次带 PG 的核心测试没有以包级 PASS 替代测试结果。

    C2:双会话 token、outbox 与 SDK 契约

    新增 TestOIDCHTTPLogoutRevokesOnlyTheSelectedSession 已逐项覆盖:

    • 两个独立 cookie jar 完成真实授权码登录并取得不同 sid;
    • 目标与另一 session 的 access/refresh token 在 logout 前均 active;
    • 真实 /api/v1/me/sessions/{sid}/revoke,携带 Origin/CSRF;
    • 目标 access token introspection 变为 inactive,目标 refresh token 兑换失败;
    • 另一 session 的 access token 仍 active,另一 refresh token 仍可兑换;
    • 真实 outbox delivery 投递本地 httptest receiver;receiver 验签 logout token,检查 sid;
    • receiver 的两个本地 session 映射只撤销目标 SID;
    • 两个 /api/v1/me 结果分别为未授权/成功。

    TestGoSDKLoginAndLinkContract 与 TestTypeScriptSDKRealProviderSIDContract 已在本次真实 provider 链路中通过 SID 断言;Python 契约的本次 Windows 子测试因缺少 authlib 失败,不能写成当前环境已通过。最新实施记录提供了 WSL 隔离依赖环境的 Python 结果;本次未改 SDK 生产代码。

    逐行代码结论

    • internal/core/client.go:53-57 只把恒定 nil 改为原样返回输入 scope;没有新增字段、fallback、配置、迁移、依赖或第二套 session 来源。
    • server_test.go 仍通过真实 Provider、授权码、refresh、HTTP logout 和 CSRF 链路;没有直接调用 SetUserinfoFromRequest 令测试变绿。
    • 授权码 ID token 断言 sid 非空、签名有效,name/email 不因请求 profile/email 扩张;refresh ID token 保持相同 sid;授权码与 refresh replay 仍拒绝。
    • introspect 辅助使用真实 /oauth/introspect,不查询数据库替代 token 状态判断。
    • outbox receiver 只使用测试私钥验签,且通过真实 DeliverEvent 触发;本地映射断言确保通知 SID 不是仅“收到任意 logout token”。
    • 三个 SDK 的新增契约均为测试文件;PR 修复 SDK 应用内路径的控制字符校验缺口 #1 所列 SDK 路径校验生产文件未被修改。

    PR/Issue 重叠

    只读 GitHub 查询:FastCAS PR #1 仍 OPEN,文件仅为 SDK 路径校验及其测试;本 diff 的生产文件为 internal/core/client.go,新增/修改测试在 internal/httpapi 与 SDK 测试目录,未发现文件重叠。开放 issue #2/#3;未执行任何远端写操作。

    实际验证与限制

    • go test ... -race:核心 HTTP、双 session、token 撤销/保活、outbox SID、Go/TS 契约通过;Python 当前 Windows 环境失败于缺少 authlib。
    • go vet -mod=readonly ./...:PASS。
    • git diff --check:通过,仅有 Windows autocrlf 提示。
    • 当前 worktree 只有预期生产文件、HTTP/SDK 测试修改和三个新增契约测试;无未声明源码文件。
    • Python 完整契约最终应在实施记录所述 WSL 隔离依赖环境复跑;其既有可选测试的 1 个 SKIP 必须继续单独记录。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions