Skip to content

feat(sso): WIS-631 SSO v2 — three-secret state machine (F1/F2/bsid) + C2/N5 callback + dual gate - #37

Merged
bitzhjr merged 14 commits into
developfrom
fix/wis-621-sso-login-session
Jul 26, 2026
Merged

bitzhjr merged 14 commits into
developfrom
fix/wis-621-sso-login-session

Conversation

@bitzhjr

@bitzhjr bitzhjr commented Jul 24, 2026 •

Copy link
Copy Markdown

关联

  • Issue: WIS-631 Bug 2(控制台免密登录 new-api 白屏)— SSO v2 端到端改造
  • 仓库: new-api(QuantumNous/new-api fork)
  • 方案: 记星 R3 RFC v4 d8cdc28b + recon 路由矩阵 0853ed81;D1(公网 edge 保留 UserAuth 仅拒 C2/C5、legacy 分阶段)+ D2(SSO broker 只读 fail-closed、零 provisioning)锁定

背景

Bug 2 根因: new-api controller/sso.go 的 legacy POST /api/sso/login 用 sessions.Default(c) 但 upstream QuantumNous#6329 删了 gin sessions 注册 → panic → 白屏。SSO v2 用三秘密状态机(F1/F2/bsid)+ stateless token 替换 gin sessions,彻底关 login-CSRF。

改动摘要

N1 — /start 入口 + 双 gate + N3 boot 校验

  • GET /api/sso/wischoicer/start:WischoicerSsoEnabled gate + SsoRateLimit 先于 handler(429 不建 flow)。生成独立高熵 bsid(32B),种 state cookie(值=bsid、HttpOnly+Secure+SameSite=Lax+Path=/api/sso/wischoicer),建 F1 AuthFlow(Purpose=wischoicer_sso_start、Payload=HMAC(bsid)),302 到 ${WISCHOICER_SSO_AUTHORIZE_URL}?flow_token=F1(URL builder + no-store/no-referrer)。
  • 双 gate 独立互不推导: WischoicerSsoEnabled(env WISCHOICER_SSO_ENABLED)+ LegacySsoPatDisabled(env WISCHOICER_SSO_LEGACY_PAT_DISABLED),支持 pre/dual-track/cutover/fallback 分阶段。
  • N3 boot 校验: WischoicerSsoEnabled=true 强制 WISCHOICER_SSO_PUBLIC_ORIGIN 纯 origin(https+host、path∈{"","/"}、无 query/fragment/userinfo)+ WISCHOICER_SSO_AUTHORIZE_URL(https + 固定 path /bff/gateway/user/sso/authorize)+ SessionCookieSecure=true;不满足 → boot 拒启动(fail-closed)。wischoicerBoolEnv 仅接受空/true/false,其它非空值 → 报变量名 + 拒启动。

N2 — C2/C5 入站鉴权 middleware + 双槽 token holder

  • wischoicer_sso_auth.go: WischoicerSsoInternalAuth(C2 POST /api/sso/wischoicer/authorize 入站 sso-service-token)。
  • wischoicer_fee_read_auth.go: WischoicerFeeReadAuth(C5 fee-read-token)。
  • 镜像 wischoicer_billing_auth.go(Token B): SHA-256 定宽 + current/next 双槽位聚合 (matchCur|matchNext)==1(不短路,记星 R2 P1 闭合)。
  • config holders: WischoicerSsoServiceToken/Next + WischoicerFeeReadToken/Next(env WISCHOICER_SSO_SERVICE_TOKEN(_NEXT) / WISCHOICER_FEE_READ_TOKEN(_NEXT),current 空 → 路由不挂载 fail-closed)。CreateAuthFlowWithTx nil-tx guard。

C2 — POST /api/sso/wischoicer/authorize(mint F2)

  • 挂 WischoicerSsoInternalAuth + WischoicerSsoEnabled gate。收 {flow_token=F1, new_api_user_id}。在消费 F1 的同一事务里 mint F2(CreateAuthFlowWithTx,Purpose=wischoicer_sso_code、UserId=new_api_user_id、F1.payload 原样复制进 F2.payload 不解码/不重算,记星 edec1c4b),返回 callback_url。F1 一次性消费不可重放。

N5 — GET /api/sso/wischoicer/callback(consume F2 + bsid verify + session)

  • 在消费 F2 的事务内恒时校验 cookie(bsid) → HMAC ↔ F2.payload(subtle.ConstantTimeCompare)。action 始终返回 nil → 失败也提交烧码(cookie 缺失/格式错/hash 不符都先提交 F2 消费 + 统一失败 /login,记星 edec1c4b「失败也提交」防 bsid 爆破)。CreateLoginSession 在事务外(避免嵌套写 tx 死锁)。匹配 → session + refresh cookie + /dashboard;否则 → /login。一次性 clear state cookie(Path/Secure/HttpOnly/SameSite 与 /start set 对称)。

F1/F2 阶段类型隔离(记星 9c4b5fee P1)

  • F1=wischoicer_sso_start(C2 精确匹配消费);F2=wischoicer_sso_code(callback 精确匹配消费)。两 consumer 互拒对方 token + 不消费。

并发语义(张驰 B 决策)

  • 安全契约: 至多一次副作用 + loser fail-closed(不签 session)。SQLite 单写锁下 loser 可能拿 SQLITE_BUSY 而非 ErrAuthFlowConsumed——这是测试 artifact,不是安全差。
  • 生产 MySQL/PostgreSQL 行锁下 loser 天然 = ErrAuthFlowConsumed(阻塞等 winner 提交 → 看到 consumed)。
  • exact ErrAuthFlowConsumed 契约挂 model/wischoicer_sso_concurrent_integration_test.go(build tag integration,CI database:[mysql,postgres] 跑)。
  • SQLite 测试(controller/)按真实语义断言: 一次成功 + 一次非成功(busy/consumed 都接受)+ 仅一个 side effect。barrier: allReady WaitGroup + start channel。-count=100 -race 稳定绿。

环境变量

变量 说明
WISCHOICER_SSO_ENABLED true/false/空。启用 SSO v2 路由(boot 强制 origin/authorize-url/SessionCookieSecure 合法)
WISCHOICER_SSO_LEGACY_PAT_DISABLED true → legacy /api/sso/login 返 410(cutover 阶段)。与 Enabled 互不推导
WISCHOICER_SSO_PUBLIC_ORIGIN callback 公网纯 origin(https+host,无 path/query/fragment)
WISCHOICER_SSO_AUTHORIZE_URL C1→C3 固定跳转目标(https + /bff/gateway/user/sso/authorize)
WISCHOICER_SSO_SERVICE_TOKEN(_NEXT) user-service→new-api C2 入站 sso-service-token(双槽)
WISCHOICER_FEE_READ_TOKEN(_NEXT) user-service→new-api C5 入站 fee-read-token(双槽)

CI 证据

  • go test -race -count=100 ./controller/ -run Concurrent → 稳定绿
  • GitHub integration run 30202196022 六项全绿: mysql job 89793948883 (220s) + postgresql job 89793948912 (55s) → exact ErrAuthFlowConsumed 契约真跑
  • go vet / gofmt / diff-check 干净(pre-existing custom-event/email_test 告警非本次)

兼容性 / Breaking Change

  • 新增 SSO v2 路由(/api/sso/wischoicer/{start,callback,authorize}),仅在 WischoicerSsoEnabled=true 时挂载。默认 false(现有部署不受影响)。
  • legacy POST /api/sso/login 行为不变(LegacySsoPatDisabled=false 透传)。cutover 阶段 =true 返 410。
  • 不动 gateway AuthGatewayFilter / BFF injectAuthHeaders / 前端订单 body。

风险

  • SESSION_SECRET 无双槽轮换——SSO v2 的 HMAC(bsid) + AuthFlow token hash 都派生自它。轮换 SESSION_SECRET 会使所有在途 F1/F2 失效(用户需重新 /start)。
  • N4(edge allow/deny matrix + dashboard smoke + C5 + P2-3 gate 测试)未落本 PR——单独 PR。当前 SSO v2 路由仅 gated mount,无 edge 策略。

回滚

  1. 关 WISCHOICER_SSO_ENABLED=false(SSO v2 路由全部 unmount)。
  2. 排空 5 分钟 flow TTL(在途 F1/F2 自然过期,用户重新 /start 即可)。
  3. 全节点原子换值(不可滚动): WischoicerSsoEnabled 必须所有节点一致——半开半关会导致 /start 成功但 /callback 404(部分节点未挂载 callback 路由)。
  4. LegacySsoPatDisabled 可独立保持 false(恢复 legacy 透传)或 true(继续 410)。

Gate

R3(鉴权 / 安全边界)。技术 R2 已闭(记星 fd2b2d91 通过)。请 @jirui Zhao 拍 R3 合并。

🤖 Generated with Claude Code

赵吉瑞 and others added 14 commits July 25, 2026 00:46
/api/sso/login panicked at controller/sso.go (sessions.Default) because the
gin-contrib/sessions middleware was never registered anywhere in the app. Even
if it had been, it could not authenticate: middleware.UserAuth reads the
Authorization Bearer header (new-api dashboard JWT or PAT), not a
gin-contrib/sessions cookie — so the wallet/dashboard routes would still 401
and SSO could never actually log the browser in.

Reimplement SsoLogin against the existing login model: validate the PAT via
model.ValidateAccessToken (the token wischoicer-user mints through
POST /api/user/{id}/generate_access_token), then
service.CreateLoginSession + service.WriteRefreshCookie to issue a real
user_sessions row plus the httpOnly refresh cookie, then 302 to a same-origin
path. The SPA already bootstraps the Authorization access_token from that
refresh cookie on load (web/src/lib/auth-session.ts:bootstrapAuthentication ->
POST /api/user/auth/refresh), so UserAuth-protected routes authenticate.

Also harden the redirect sanitizer to reject the backslash authority form
(/\host, which several browsers normalize to //host), closing an open-redirect
gap in the 302 Location header.

Drops the now-unused gin-contrib/sessions import.

Tests in controller/sso_test.go cover: valid PAT mints a session + sets the
refresh cookie + 302 (the former panic path); invalid PAT returns 401 with no
session/cookie; redirect sanitizer neutralizes //host, https://host, and /\host.

Refs WIS-621

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address 记星 R3 blockers ① and ③. These are independent of the R3
credential decision (②), which waits on the Owner.

① open-redirect (P1): the prior sanitizer only inspected byte[1], so the
form value "%2F%09%2Fhost" decoded to "/<TAB>/host" and passed — browsers
strip TAB during URL parsing and treat it as the authority "//host".
Replace it with safeSameOriginRedirect: reject ASCII control bytes
(<0x20, 0x7f) and backslashes anywhere, require a single leading '/' and
no scheme/host, then normalize with path.Clean (query/fragment preserved).
Regression now covers HTAB/CR/LF, repeated leading slashes, backslash,
null/interior control bytes, absolute and scheme-relative URLs, and the
percent-encoded bypass through real form parsing.

③ Add Cache-Control: no-store to the SSO 302 (matching password login),
and add an end-to-end test proving the refresh cookie issued by SsoLogin
feeds POST /api/user/auth/refresh to mint an access_token that then
authenticates a real middleware.UserAuth-protected route back to the SSO
user — the closed loop the SPA bootstrap chain relies on.

Tests: controller/sso_test.go — 5 top-level tests, 17 redirect sub-cases,
all green; ./controller/ and ./service/ packages green.

Refs WIS-621

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SSO v2 foundation (RFC v4 §2 N2): inbound service-token middleware for the new
internal call points, mirroring wischoicer_billing_auth.go (Token B) — SHA-256
fixed-width + dual-slot constant-time compare, no short-circuit, 401
UNAUTHORIZED with {success,code,message} envelope, no token/slot leak.

- common/wischoicer_recharge_config.go: WischoicerSsoServiceToken/Next +
  WischoicerFeeReadToken/Next + WischoicerSsoInternalEnabled /
  WischoicerFeeReadEnabled, env WISCHOICER_SSO_SERVICE_TOKEN(_NEXT) +
  WISCHOICER_FEE_READ_TOKEN(_NEXT); current empty → route not mounted
  (fail-closed). Independent from Token A/B, never reused.
- middleware/wischoicer_sso_auth.go: WischoicerSsoInternalAuth (C2
  POST /api/sso/wischoicer/authorize), header X-Wischoicer-Sso-Service-Token.
- middleware/wischoicer_fee_read_auth.go: WischoicerFeeReadAuth (C5 fee-read),
  header X-Wischoicer-Fee-Read-Token. (C5 落点 = D3 待记星 round-4.)
- middleware/wischoicer_sso_auth_test.go: failure-envelope (401 + code) +
  current/next dual-slot pass, for both middleware.

Middleware + token holders only — no routes mounted yet (N1/N4 wire them).
Gates (WischoicerSSOEnabled/LegacySsoPatDisabled) + WISCHOICER_SSO_PUBLIC_ORIGIN
/ WISCHOICER_SSO_AUTHORIZE_URL boot validation (N3) land with N1 (start).

go test ./middleware/ ./common/ green; gofmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…boot validation

SSO v2 config foundation for /start (RFC v4 §2 N1 + §1.4 N3). New
common/wischoicer_sso_config.go:

- WischoicerSsoEnabled + LegacySsoPatDisabled: two INDEPENDENT gates (互不推导,
  RFC v4 §2 N5) — allow pre/dual/cutover/fallback phasing.
- WischoicerSsoEnabled=true requires WISCHOICER_SSO_PUBLIC_ORIGIN pure-origin
  (https+host, path ∈ {"","/"}, no query/fragment/userinfo) AND
  WISCHOICER_SSO_AUTHORIZE_URL (https + fixed /bff/gateway/user/sso/authorize);
  enabled-but-invalid → boot reject (fail-closed, main FatalLog).
- WischoicerSsoCallbackURL(code): URL builder = TrimRight(origin,"/") + fixed
  callback path + escape(code) — no user-input concat (RFC v4 §1.4).
- initWischoicerSsoConfig wired after initWischoicerRechargeConfig (common/init.go).

Tests (TDD green): origin/authorize-URL validation negatives (http/path/query/
fragment/userinfo/empty/wrong-path/trailing-slash); gate truth table (disabled
skips / enabled-valid passes / enabled-bad-origin rejects / enabled-bad-authorize
rejects / legacy-gate independent); callback URL builder. Full common suite green;
gofmt clean.

/start handler + route (consume WischoicerSsoEnabled/AuthorizeURL) land next —
this is the security config foundation only, no routes mounted. Does not touch
N2 middleware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wischoicerTokenMatchesAnySlot returned `matchCur == 1 || matchNext == 1`; Go's
|| short-circuits — objdump (记星 f726d64e) shows a conditional jump on
matchCur, so the result phase still carried a slot-dependent branch even though
both ConstantTimeCompare ran. Change to bit-OR `(matchCur | matchNext) == 1`:
both results (each 0/1) OR'd, no branch. The helper feeds Token B + SSO +
fee-read, so all three middleware inherit the fix.

- middleware/wischoicer_billing_auth.go: return (matchCur | matchNext) == 1.
- middleware/wischoicer_sso_auth_test.go: TestWischoicerTokenMatchesAnySlot_BitOrAggregation
  pins the aggregation (both-wrong / current / next / both).

P2 (fail-closed config tests: current空/next有 → Enabled=false + middleware-
direct-401; env unset idempotent; snapshotWischoicerConfig covers new globals)
batches with N4 conditional-mount tests, not this commit (per 张驰's sequencing).

go test -race ./middleware ./common green; go vet ./middleware clean; gofmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/sso/wischoicer/start (RFC v4 §2 N1), mounted only when
WischoicerSsoEnabled (boot-validated). The handler:

- creates an AuthFlow (purpose=wischoicer_sso, TTL=5min) via the existing
  model.CreateAuthFlow → flow_token (F1);
- sets a browser-binding state cookie (value=flow_token, HttpOnly+Secure+
  SameSite=Lax+Path=/+TTL) — Secure drops on plain HTTP (SSO needs HTTPS);
- 302 to ${WISCHOICER_SSO_AUTHORIZE_URL}?flow_token=F1 via url.URL builder
  (no string concat, §1.4).

Accepts no user-supplied redirect/origin/target — callback landing is fixed
(§1.4), closing open-redirect. Does not touch N2 middleware.

- model/auth_flow.go: AuthFlowPurposeWischoicerSSO = "wischoicer_sso".
- controller/wischoicer_sso.go: SsoStart.
- router/api-router.go: register GET /sso/wischoicer/start (SsoRateLimit + gate).
- controller/wischoicer_sso_test.go: 302 + flow_token non-empty + state cookie
  (value=flow_token, HttpOnly/Secure/SameSite=Lax/Path=/).

go test ./controller/ ./middleware/ ./common/ green; gofmt clean; vet clean on
changed files (pre-existing lock-copy/IPv6 warnings in untouched files, noted by
记星).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ no-referrer/no-store

N5 contract-independent pieces (callback 烧码建 session deferred — it consumes the
one-time code F2 whose storage/linkage is C2/N4 design, gated):

- controller/wischoicer_sso.go: state cookie Path narrowed from "/" to
  /api/sso/wischoicer (RFC §4 line 178) as shared const wischoicerSsoStateCookiePath
  — /start set + future callback read align on one symbol; 记星's Path review flips
  one line. /start 302 now sets Cache-Control: no-store + Referrer-Policy: no-referrer
  (don't cache the flow_token-bearing 302; don't leak flow_token via Referer to the
  authorize hop).
- controller/wischoicer_sso_test.go: assert Path == wischoicerSsoStateCookiePath +
  the two security headers.

Deferred (needs C2/N4 F2 contract): callback handler (verify state cookie ↔ code,
atomically consume code, CreateLoginSession + refresh cookie, redirect). Lands once
C2/N4 (issues the one-time code + binds the flow's user) is concrete, or next turn
with the exact F2 contract extracted from RFC §4.

go test -race ./controller/ ./middleware/ ./common/ green; gofmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P1-1 (承重, three-secret model — 记星 ff277017): /start previously wrote F1
itself into the state cookie + left F1.payload empty → "holding F1 = holding the
cookie", so callback couldn't bind by bsid. Fix the three independent secrets:
- generate high-entropy bsid (32B); state cookie now stores ONLY bsid (not F1);
  F1.payload = HMAC(SessionSecret-derived key, bsid) (context binding, not empty).
- C2 (N4) will carry the bsid_hash into F2; callback (N5) verifies cookie(bsid) ↔
  F2.payload in the F2-burn tx. A leaked/exfiltrated F1 alone cannot complete SSO.
- TestSsoStart now asserts cookie.Value ≠ F1 (== bsid) + F1.payload == HMAC(bsid).

P1-2 (boot fail-closed):
- wischoicerBoolEnv tightened: only empty/unset/"false" → false, "true" → true;
  any OTHER non-empty value → error reporting the var name (防 gate 拼写错误静默失效).
- WischoicerSsoEnabled=true && !SessionCookieSecure → reject boot (callback refresh
  cookie must be Secure; SessionCookieSecure set earlier in InitEnv).
- gate-truth-table test adds !Secure-reject + bad-bool-reject cases.

go test -race ./controller/ ./middleware/ ./common/ green; vet clean on changed
files (pre-existing lock-copy/IPv6 warnings in untouched files); gofmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
记星 edec1c4b F2 contract nailed. The coupled second half of the three-secret
flow (F1 from /start, F2 from C2, bsid from cookie):

C2 — POST /api/sso/wischoicer/authorize (WischoicerSsoInternalAuth + gate):
user-service broker calls with {flow_token=F1, new_api_user_id}. In the SAME tx
as consuming F1, mints F2 (one-time login code) with F1.payload copied VERBATIM
into F2.payload (no decode/recompute); returns callback_url = WischoicerSsoCallbackURL(F2).

N5 callback — GET /api/sso/wischoicer/callback?code=F2 (public, gate):
- in the consume-F2 tx: constant-time verify cookie(bsid) → HMAC ↔ F2.payload;
  the action returns nil REGARDLESS (失败也提交: cookie missing/format-error/hash-
  mismatch all commit the F2 burn first, then unified failure — each wrong probe
  burns a code, no bsid brute-force). CreateLoginSession is OUTSIDE the consume tx
  (avoids nested write-tx deadlock on sqlite); on system error F2 is already burned.
- bsid match → CreateLoginSession + refresh cookie → /dashboard; else → /login (unified).
- one-shot: clears the state cookie (symmetric Path/Secure/HttpOnly/SameSite with /start).
- F2 not replayable (one-time consume).

model: CreateAuthFlowWithTx(tx, input) — tx-scoped variant for C2's same-tx F2 mint.
router: register C2 (internal-auth) + callback (public), both WischoicerSsoEnabled-gated.

Tests (file-sqlite per-test to avoid :memory: per-connection empty-DB): C2 mints F2
with F1.payload verbatim; callback happy-path session; wrong-bsid burns F2 (unified
fail, no session); cookie-missing burns F2 (unified fail); F2 not replayable.
go test -race ./controller/ ./middleware/ ./common/ green; vet/gofmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P1 (承重, 记星 9c4b5fee): F1/F2 both used Purpose=wischoicer_sso → C2/callback
consumers ate each other's token (callback could consume F1, C2 could consume F2
+ re-mint). Fix: split into AuthFlowPurposeWischoicerSSOStart (F1, /start creates,
C2 consumes) + AuthFlowPurposeWischoicerSSOCode (F2, C2 mints, callback consumes).
C2 matches only Start; callback matches only Code. Regression: callback(F1) rejects
+ does NOT consume; C2(F2) rejects + does NOT consume.

P2-1: callback 302 now has Referrer-Policy: no-referrer (was only no-store).
P2-3 (Brooks-Lint): gofmt wischoicer_sso.go; cleaned dead placeholder + replaced
hand-written itoa with strconv.Itoa; CreateAuthFlowWithTx nil-tx guard.

NOT touched: CreateLoginSession-outside-tx design (passed 记星 edec1c4b); 77cc8b0-.

go test -race ./controller/ ./middleware/ ./common/ green; vet/gofmt clean. 7 callback
tests (C2 mint + happy + wrong-bsid-burns + cookie-missing-burns + not-replayable +
callback-rejects-F1 + C2-rejects-F2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
记星 9c4b5fee P2-2 三条 committed 回归(纯测试,不改实现):

1. ConcurrentDoubleCallback_ExactlyOneSession: 一 F2 并发双 callback → 恰一
   /dashboard + 一 /login + 库内恰一 session(DB CAS consumed_at IS NULL 原子消费;
   file-sqlite busy_timeout=5000 让第二写事务等待而非 locked-error;-race)。
2. CreateLoginSessionFails_F2ConsumedNoCookie: F2.UserId=999999(不存在)→ callback
   消费 F2(burn)→ CreateLoginSession 失败 → /login。F2 ConsumedAt≠nil(仍烧),
   无 refresh cookie。
3. ClearCookieSymmetric: callback 清 state cookie 的 Path/Secure/HttpOnly/SameSite/
   MaxAge 逐项与 /start set 对称(wischoicerSsoStateCookiePath 共用常量)。

Setup: PRAGMA busy_timeout=5000 for concurrent test support. sync import added.

go test -race ./controller/ ./middleware/ ./common/ green; vet/gofmt clean. 10 callback
tests total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eplay

记星 de34eafd P2×3 闭合(纯测试,零实现改动):

P2-1(最要紧): 并发用例加 ready/start barrier(两 goroutine parked 才 close 放行);
新增 model 层证明 TestConsumeAuthFlowWithAction_ConcurrentF2_ExactlyOneConsumes——直接
断言两次 ConsumeAuthFlowWithAction 恰一次 nil + 一次 ErrAuthFlowConsumed(不靠 HTTP
Location 反推,区分得出 lock 错 vs 真 consumed)。HTTP 层保留恰一 session 断言(带 barrier)。

P2-2: callback happy + wrong-bsid 两条都断言 Referrer-Policy: no-referrer。
P2-3: C2 重放同一 F1 → 首次 200、二次 400、恰一 F2 count=1、F1 consumed。

go test -race ./controller/ ./middleware/ ./common/ green; vet/gofmt clean. 11 callback tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ontract

张驰 B 决策:SQLite busy 是测试 artifact(生产行锁下 loser = consumed)。安全契约是
「至多一次副作用 + loser fail-closed」,不是「loser 必为 consumed」。不动 model 承重。

SQLite 测试 (controller/wischoicer_sso_callback_test.go):
- barrier 改对:allReady WaitGroup(2) + start channel;goroutine 先 Done() 再 <-start,
  主线程 Wait() 后 close(start)。上轮 close(ready) 不是真 barrier。
- 断言诚实:一次成功 + 一次非成功(busy/consumed 都接受)+ 仅一个 side effect。
  删掉 ErrAuthFlowConsumed 强断言。-count=100 稳定绿。

MySQL/PostgreSQL exact contract (model/wischoicer_sso_concurrent_integration_test.go,
  build tag integration):barrier 并发 → 断言恰一次 nil + 一次 ErrAuthFlowConsumed。
  CI database:[mysql,postgres] 跑。

Recon: 生产 DB = MySQL/PostgreSQL(非 SQLite)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bitzhjr bitzhjr changed the title fix(sso): rewrite SsoLogin to issue real dashboard session (WIS-621) feat(sso): WIS-631 SSO v2 — three-secret state machine (F1/F2/bsid) + C2/N5 callback + dual gate Jul 26, 2026
@bitzhjr
bitzhjr merged commit f8c1e47 into develop Jul 26, 2026
6 checks passed
@ApocalypseYun
ApocalypseYun deleted the fix/wis-621-sso-login-session branch August 15, 2026 09:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant