Skip to content

fix(plugin-auth): a SCIM bearer write carrying a Cookie and no Origin is no longer refused 403 MISSING_OR_NULL_ORIGIN - #33243

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-33237-scim-bearer-cookie-origin
Nov 14, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-33237-scim-bearer-cookie-origin

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #33237

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:AuthManager.handleRequest 在把请求交给 better-auth 之前,对带 Authorization: Bearer 的 /scim/v2/* 请求去掉 Cookie 头;其余路由一律不动。
  • 为什么改:better-auth 的来源检查只要请求带任何 Cookie 就要求 Origin,于是带 Cookie 罐(如 Cloudflare __cf_bm、粘性会话 Cookie)的身份提供方(Okta 已实测)做 SCIM 开通与停用全部被拒 403,离职员工的访问无法由目录撤销。SCIM 只认 bearer,Cookie 在该路径上不带任何权限。
  • 风险与回滚:风险面只有「/scim/v2/* 且带 bearer」这一种请求;无 bearer 的 SCIM 请求与所有浏览器路由仍走原来的来源检查。回滚即 revert 本 PR 的那一个 if 块。
  • 先看:packages/plugins/plugin-auth/src/auth-manager-request-routing.ts(新增的 if 块,判定条件三项是风险点);packages/plugins/plugin-auth/src/scim-bearer-cookie-origin.test.ts(四个用例与 boot() 里把来源检查打开的那几行)。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/scim-bearer-cookie-origin.test.ts,预期 4 个用例全绿。
  • 需要你做的:无,席位落地。

What changes

packages/plugins/plugin-auth/src/auth-manager-request-routing.ts, handleRequest: right after the vendor client-address rewrite, a request whose better-auth endpoint path is under /scim/v2 (isScimProtocolPath, the predicate the SCIM request scope already keys on) AND that carries Authorization: Bearer followed by a credential AND that carries a Cookie is rebuilt without the Cookie header before better-auth sees it. This is the route that relaxes no check: no global or per-path origin-check exemption is added, and skipOriginCheck / disableCSRFCheck are not touched.

Why it is safe, measured on better-auth 1.7.12 and @better-auth/scim 1.7.12 as installed:

  • validateOrigin (dist/api/middlewares/origin-check.mjs) reads useCookies = headers.has("cookie") off the request handleRequest hands it; with a cookie and no Origin or Referer it throws MISSING_OR_NULL_ORIGIN. That is the whole defect.
  • Every /scim/v2/* mutation endpoint is use: [authMiddleware], and that middleware (createSCIMConnectionMiddleware) authenticates from Authorization: Bearer alone: no bearer is 401 SCIM bearer token is required. The platform's verifyBearerToken (auth-manager-plugin-list.ts) reads only input.token.
  • SCIM writes are attributed to the verified connection (authSystemWriteContext returns actor: svc:scim:CONNECTION_ID first and never consults the session resolver when a SCIM connection is in scope), so stripping the cookie changes no attribution either.
  • A SCIM request with NO bearer keeps its cookie and still meets the origin check (pinned below).

The landing is the expected file; no producer elsewhere was needed. auth-manager.ts, auth-plugin.ts and the better-auth pin are untouched.

.changeset/33237-scim-bearer-cookie-origin.md: @objectstack/plugin-auth patch.

Tests

New file packages/plugins/plugin-auth/src/scim-bearer-cookie-origin.test.ts (real AuthManager, real ObjectQL on better-sqlite3, SCIM on, a minted connection credential: the scim-transaction-scope.test.ts harness shape). Four cases:

  1. SCIM bearer POST /Users with Cookie: __cf_bm=...; better-auth.session_token= and no Origin: 201 and the sys_user row exists.
  2. SCIM bearer PATCH /Users/{id} active:false with the same Cookie and no Origin: 200, resource active: false.
  3. SCIM POST /Users with the Cookie and NO bearer: still 403, code MISSING_OR_NULL_ORIGIN, no sys_user row written.
  4. CONTROL: a real session cookie (from sign-up) on POST /sign-out with no Origin: still 403, code MISSING_OR_NULL_ORIGIN.

How production semantics are ensured: two things would mask the bug under vitest, and the harness turns off both. handleRequest synthesizes a same-origin Origin outside production: every case runs with vi.stubEnv('NODE_ENV', 'production') and asserts it reads back. And better-auth reads NODE_ENV once at module load (@better-auth/core/env), so under vitest it defaults skipOriginCheck to true (create-context.mjs: disableOriginCheck unset means isTest()): boot() sets the production value false on the live $context and asserts skipCSRFCheck is false. Before that line was added, cases 3 and 4 were red on the unfixed tree (the control POST answered 200), which is how the masking was found.

Red on the unfixed tree (commit e9076fa, test only): cases 1 and 2 fail with {"message":"Missing or null Origin","code":"MISSING_OR_NULL_ORIGIN"}: expected 403 to be 201 (and to be 200); cases 3 and 4 pass.

Ablation (one-time, on the fix commit c8abb27, through scripts/ablation-replace.mjs): the anchor headers.delete('cookie'); replaced by a comment, anchor 1 to 0, blob 944e5a37b0bd to efaf0d39e2b3; cases 1 and 2 red again with 403, cases 3 and 4 green; restored, blob after restore 944e5a37b0bd equals HEAD and git diff HEAD empty. The test imports ./auth-manager.js relatively, so the subject resolves to src/, no dist/ step.

Acceptance notes

  • Behaviour change beyond the card's three cases, stated so it is not a surprise: a /scim/v2/* bearer request that carries a Cookie AND an untrusted Origin used to be refused 403 INVALID_ORIGIN; it now reaches the SCIM bearer check like any bearer client. Same reasoning: the origin check guards cookie authority, and there is none on this path.
  • The vendor's origin check is skipped wholesale under a test NODE_ENV, so any plugin-auth test that means to observe it must put skipOriginCheck = false on the context as boot() here does. Observation only; no card.
  • The cloud hosted proxy strip (cloud#4498) can retire once a release carrying this reaches cloud's pin; that is cloud's and the maintainer's call on cloud#4491.

Verification

All readings at head c8abb27 (worktree on origin/main 6c5d31f).

plugin-auth tests and typecheck: 270/270 files, 5118 passed, typecheck exit 0
  • pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 (whole package): Test Files 270 passed (270), Tests 5118 passed | 10 skipped (5128), lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck: exit 0; check:test-typecheck: OK ... 10 file(s) / 72 error(s) / 23 pinned signature(s) held (the new file adds none).
lint: exit 0
  • pnpm lint (repo-wide, run on the dispatch's request): lock VERDICT command-exit 0.
  • eslint --no-inline-config on the two changed TS files: exit 0.
derived gate families: 65 exit 0, 1 NOT MEASURED
  • node scripts/pm/dispatch-gates.mjs --commands: 66 derived families, each run locally; --ran reconciliation: 66 derived famil(ies) accounted for — 65 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).
  • pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (it needs every package's dist/; this worktree built only plugin-auth's closure): NOT MEASURED locally, CI runs it.
  • node scripts/check-nul-bytes.mjs: exit 0.
  • Not run locally, owned by CI: test:repo, the dogfood battery (no ! in the title, no narrowing arm), the UI lanes.

Overlap check before push: open drafts #33227 and #33221 do not touch auth-manager-request-routing.ts; no open PR from #32115 was found.


Generated by Claude Code

…re the origin check

A SCIM bearer write carrying any Cookie and no Origin was refused 403
MISSING_OR_NULL_ORIGIN by better-auth's validateOrigin. SCIM authenticates
by the bearer alone, so the Cookie carries no authority there.

Claude-Session: https://claude.ai/code/session_017xRBcSZ51q2s3nMfakhdJ3
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 1 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx (via handleRequest (symbol, a top-level function))
  • content/docs/permissions/authentication.mdx (via handleRequest (symbol, a top-level function))
What this run could not see
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 295ce127e0ff5167def12353ddf37c9ca31f74e0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3e67de58753aadf0120fb46576bf91eaf1a618f4 — the merge of head c8abb2726467e0fdb378f4035273e641fbf03fb3 into base 295ce127e0ff5167def12353ddf37c9ca31f74e0, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3e67de58753aadf0120fb46576bf91eaf1a618f4 && git checkout 3e67de58753aadf0120fb46576bf91eaf1a618f4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 295ce127e0ff5167def12353ddf37c9ca31f74e0 c8abb2726467e0fdb378f4035273e641fbf03fb3 && git checkout -B drift-repro 295ce127e0ff5167def12353ddf37c9ca31f74e0 && git merge --no-ff c8abb2726467e0fdb378f4035273e641fbf03fb3

node scripts/docs-audit/affected-docs.mjs --json 295ce127e0ff5167def12353ddf37c9ca31f74e0

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 295ce127e0ff5167def12353ddf37c9ca31f74e0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet objectstack-fleet Bot added the needs:contract-review Marker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides label Nov 14, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

review#2 · os-project-manager · session_01FEkQ5nhu4dRbKVQKDB4CDB

Contract-review-claim: session_01FEkQ5nhu4dRbKVQKDB4CDB · head c8abb27 · 2026-11-14T15:08Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

review#2 · os-project-manager · session_01FEkQ5nhu4dRbKVQKDB4CDB

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c8abb2726467e0fdb378f4035273e641fbf03fb3
Local-runs: none
Scope: none (no reviewFace hits; the face is the needs:contract-review label alone) + packages/plugins/plugin-auth/src/auth-manager-request-routing.ts, packages/plugins/plugin-auth/src/scim-bearer-cookie-origin.test.ts

Inputs read: card #33237 body and all five comments (triage 6875420457, claim 6877865244, downstream note 6878485932, dev report 6878554536, seat note 6878588507), the PR body, file list and net diff at this head, and the check-runs on this head. Live-tree code was read on origin/main at d4fbb4dbb6. The vendor packages (better-auth and @better-auth/scim) are not installed in the read-only tree, so statements about vendor behaviour rest on the in-tree pins and the green check-runs.

① Derived judgments

  • Accept-set, widened, right. handleRequest now serves a /scim/v2/* request that carries a bearer and a Cookie with no Origin, where it was refused 403 before. This is the card's Expected and its first acceptance line, pinned by cases 1 and 2 of the new test, green on this head.
  • Accept-set, widened beyond the card's three cases, right. A /scim/v2/* bearer request carrying a Cookie and an Origin the deployment does not trust now also reaches the SCIM bearer check. The card's Expected says a bearer-authenticated SCIM request is not subject to the browser origin/CSRF check, so this is inside the contract, and the dev disclosed it. The rebuilt request carries no cookie, so it has no more authority than the same request sent without one. No test pins this arm (nit 2).
  • Accept-set, unchanged, right. A /scim/v2/* request with a Cookie and no bearer keeps its Cookie and is still refused (case 3). A cookie-authenticated non-SCIM POST with no Origin is still refused (case 4, the positive control). Paths outside /scim/v2 are untouched: the predicate is isScimProtocolPath, the one the SCIM request scope already keys on.
  • Fail direction, right. Where the predicate and the vendor router could disagree on a path spelling, the predicate answers "not SCIM", the Cookie stays and the previous refusal stands; where the bearer test matches but the vendor reads no SCIM credential, the vendor's own 401 answers (pinned on main by scim-refusal-audit.test.ts).
  • Mount layer, read, unchanged. The one-account-per-request step hands on a request whose bearer names no session, so the fix takes effect in a deployment too; the other mount steps do not read the Cookie.
  • Public surface, none moved. No export, schema, route or error code added or removed; skipOriginCheck, disableCSRFCheck, the trusted-origins list and the better-auth pin are untouched.
  • Check-runs. Every gate-carrying lane on this head concluded success (Test Core 1/8 to 8/8, both type-check lanes, all lint lanes, Check Changeset, PR-body guards, Governed Surface Queue Guard, Dogfood Regression Gate).

② Semver level

.changeset/33237-scim-bearer-cookie-origin.md declares @objectstack/plugin-auth patch, matching a defect fix that admits requests the contract already meant to admit, with no API or schema change. Clause-②: no on the PR body, changeset and claim is right: no published schema or governed path, and no narrowing. Check Changeset is green.

③ Boundary flags

  • open_questions is empty and security_findings is none; agreed on both — the change removes an ambient credential from one request class and grants none.
  • Out-of-scope note 1 (the vendor skips its origin check under a test NODE_ENV): answered; the harness sets the production value and asserts it, and cases 3 and 4 being green shows the check is live. No card needed.
  • Out-of-scope note 2 (the untrusted-Origin arm): answered in ① as inside the card's Expected.
  • Out-of-scope note 3 (the cloud proxy strip can retire): cloud's call on cloud#4491, not this PR's.
  • File surface held: the claim's forbidden files are not in the diff.
  • Docs Drift Check named two pages through handleRequest; neither states anything about the origin check on SCIM, so nothing there is made false.

④ Design judgment

auth-manager-request-routing.ts, handleRequest: holds. The strip runs after the client address is established and before the dev-only Origin synthesis and the vendor call; nothing later reads the Cookie on a SCIM path, and the refusal-audit arm still reads authorization from the rebuilt request. The comment's claims check out against the tree (the verifier reads only the token; writes are attributed to the verified connection; a no-bearer SCIM request is refused anyway), except the wording in nit 1. Four attempts to break it (path spelling divergence, authorization-value divergence, a session cookie beside a bearer, the one-account rule at the mount and in the before-hook) all leave the rebuilt request with no more authority than one sent with no Cookie.

scim-bearer-cookie-origin.test.ts: holds. The four cases map onto the card's three acceptance lines (POST and PATCH separate), and the harness defeats both ways the cases could pass vacuously.

Findings:

  • nit 1 (auth-manager-request-routing.ts, the new comment): "Dropping the Cookie relaxes no check" reads stricter than the code: for a bearer-carrying SCIM request the origin check no longer applies at all, which is the card's Expected. Accepted deviation or follow-up, since a fix moves the head.
  • nit 2 (scim-bearer-cookie-origin.test.ts): the untrusted-Origin arm has no pinning case; a follow-up case would keep it from regressing unnoticed.
  • nit 3 (scim-bearer-cookie-origin.test.ts): the harness drives a manager with no bound route table, so the mount middlewares are not on the path; read and found to hand the request on. Coverage only.

No must-fix and no should-fix.

Implemented-by: claude/issue-33237-scim-bearer-cookie-origin
Reviewed-by: session_01FEkQ5nhu4dRbKVQKDB4CDB

VERDICT: PASS

@objectstack-fleet objectstack-fleet Bot removed the needs:contract-review Marker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides label Nov 14, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · sunhaolin · session_017xRBcSZ51q2s3nMfakhdJ3

席位核对 · 2026-11-14T15:19Z · ACCEPT

Head c8abb272. Contract review PASS 6879542741 (review#2, CONTRACT_REVIEW_TIER, same head, no must-fix or should-fix). Dev report 6878554536.

State change: ready, and auto-merge armed (--land).

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 14, 2026 15:20
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 14, 2026
Merged via the queue into main with commit a2be78d Nov 14, 2026
60 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-33237-scim-bearer-cookie-origin branch November 14, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-auth: a SCIM bearer write that carries any Cookie and no Origin is refused 403 MISSING_OR_NULL_ORIGIN

2 participants