You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
plugin-auth: a SCIM bearer write that carries any Cookie and no Origin is refused 403 MISSING_OR_NULL_ORIGIN #33237
Found while fixing objectstack-ai/cloud#4491 (hosted SCIM writes refused). Measured in-process by the cloud os-dev agent through plugin-auth handleRequest, at cloud's framework pin c5758b0.
Measured
POST /api/v1/auth/scim/v2/Users with a valid SCIM bearer and no Cookie answers 201.
The same request with anyCookie header and no Origin answers 403 MISSING_OR_NULL_ORIGIN. The same holds for PATCH … active:false. This includes an unrelated edge cookie such as Cloudflare's __cf_bm, and expired names with empty values.
Cause: better-auth 1.7.12 validateOrigin decides useCookies = headers.has('cookie') and then requires an Origin. plugin-auth forwards the Cookie header into the vendor on /scim/v2/* and does not exempt that path.
@better-auth/scim authenticates /scim/v2/* by the bearer alone, so a cookie never carries authority there. The origin check protects nothing on this path; it only refuses real IdP clients that keep a cookie jar (Okta did on staging), or a sticky-session load-balancer cookie on self-hosted.
The EE suite cannot see it: apps/objectos-ee/test/scim-leaver-lifecycle.e2e.test.ts (cloud repo) always sends an Origin.
Expected
A /scim/v2/* request authenticated by a SCIM bearer is not subject to the browser origin/CSRF check. Either do not forward Cookie into the vendor on /scim/v2/*, or skip the origin check there for bearer-authenticated calls. Every cookie-authenticated browser route keeps its origin check.
Acceptance
A SCIM bearer POST and PATCH carrying a Cookie and no Origin succeed.
A cookie-authenticated non-SCIM POST with no Origin is still refused (a positive control).
A SCIM POST with a cookie and no bearer is still refused.
The cloud PR objectstack-ai/cloud#4498 carries a hosted proxy strip of the same shape. Whether it lands as a transition is the maintainer's decision on cloud#4491. Once this ships in a release and cloud's pin carries it, that strip retires.
Carrier: objectstack. Triage sets the lane and the level.
Row: a SCIM bearer write (create, and PATCH active:false deprovisioning) that carries any Cookie and no Origin is refused 403. A real IdP that keeps a cookie jar (Okta on staging) cannot provision or deprovision users, and an offboarded person's access is not removed by the directory. Directory sync is stuck with no remedy (p1 row).
Reach: it needs the Enterprise plan with SCIM, a discount (−1 → p2). Okta is measured, so the real-user raise applies (+1 → p1). Deprovisioning is the security-relevant half: the card states the mechanism, and no lower-privileged actor gains a grant, so this is graded as a defect, not a security finding.
Fix lead: exempt /scim/v2/* (bearer-only) from the cookie-origin check in plugin-auth's forward to better-auth, or strip the Cookie on that path.
Claim: PM loop round 1
Session: session_017xRBcSZ51q2s3nMfakhdJ3
Account: sunhaolin
Branch: claude/issue-33237-scim-bearer-cookie-origin
Worktree: objectstack-issue-33237
Domain: domain:server
Seat: domain:server#7
File surface: packages/plugins/plugin-auth/src/auth-manager-request-routing.ts (AuthManager.handleRequest, the request forwarded into better-auth on /scim/v2/*), its tests under packages/plugins/plugin-auth/src/**, root .changeset/. ⛔ Not pnpm-workspace.yaml / the better-auth pin, not auth-manager.ts / auth-plugin.ts (open drafts #33227 and #33221 hold them), not the SCIM reactivation files #32115 holds. Stop on breach; explain in the report.
Container & model: M, mode:subagent, model: opus (default judgment tier; dispatch-gates --tier: no path-derived mandate), effort: xhigh (auth boundary)
Clause-②: no
Metadata-protocol: none
Responsibility: plugin-auth AuthManager.handleRequest (objectstack) forwards the Cookie header into better-auth on /scim/v2/*, where better-auth 1.7.12's validateOrigin requires an Origin whenever a cookie is present | cloud's hosted proxy strip (objectstack-ai/cloud#4498, a transition, the maintainer's call on cloud#4491); nothing on self-hosted | every SCIM IdP client that keeps a cookie jar or sits behind a sticky-session cookie (Okta measured on staging); provisioning and deprovisioning both refused today
Thread-read: 6875420457
Premise: holds (seat reading 2026-11-14T13:12Z on origin/main6c5d31fa: handleRequest forwards headers untouched into auth.handler(request); no /scim/v2 cookie special case; better-auth 1.7.12 origin-check: const useCookies = headers.has("cookie") → MISSING_OR_NULL_ORIGIN when no Origin)
Serial constraints cleared: #32115 (os-tesla, in flight, surface packages/plugins/plugin-auth/src/**, SCIM reactivation) — package-level overlap, file-level UNKNOWN; open drafts #33227 and #33221 touch auth-manager.ts / auth-plugin.ts, not auth-manager-request-routing.ts. Ordinary concurrency; the dev reads #32115's open PR file list (if any) before pushing.
Downstream note, 2026-11-14T14:06Z. The maintainer ruled on objectstack-ai/cloud#4491 (p1) to wait for this platform fix rather than work around it in cloud: 「A:等框架修(推荐)」. cloud#4491 is now pm:blocked on this card. Once this ships in a release, cloud bumps its pin and Okta/Entra provisioning on hosted environments works again. Triage sets this card's lane and level; the cloud seat only records the dependency.
{
"issue": 33237,
"status": "done",
"branch": "claude/issue-33237-scim-bearer-cookie-origin",
"pr": "https://github.com/objectstack-ai/objectstack/pull/33243",
"session": "session_017xRBcSZ51q2s3nMfakhdJ3",
"premise_still_valid": true,
"summary": "Premise held on origin/main 6c5d31fa: handleRequest forwarded Cookie into better-auth on /scim/v2, and better-auth 1.7.12 validateOrigin answers 403 MISSING_OR_NULL_ORIGIN for any cookie with no Origin. Fix (PM's suggested route, no check relaxed): in AuthManager.handleRequest, a request whose endpoint path is under /scim/v2 (isScimProtocolPath) AND carries Authorization: Bearer plus a credential AND carries a Cookie is rebuilt without the Cookie before the vendor sees it; landing is the expected file auth-manager-request-routing.ts, no producer elsewhere. Measured that the cookie carries no authority there: @better-auth/scim authMiddleware authenticates from the bearer alone, the platform verifyBearerToken reads only input.token, and authSystemWriteContext attributes SCIM writes to the connection before any session resolver. PM mechanism assumption 2 needed one addition: under vitest better-auth also defaults skipOriginCheck=true (NODE_ENV read once at module load as 'test'), so NODE_ENV=production alone does NOT surface the bug in-process; the new test sets skipOriginCheck=false on the live $context in boot() and asserts skipCSRFCheck=false. Card assignee not written (sunhaolin set by PM); PR assignee sunhaolin via pr_create.",
"tests": "New packages/plugins/plugin-auth/src/scim-bearer-cookie-origin.test.ts, 4 cases under vi.stubEnv NODE_ENV=production + skipOriginCheck=false: SCIM bearer POST /Users + Cookie(__cf_bm, empty session_token) no Origin -> 201 + sys_user row; SCIM bearer PATCH active:false + Cookie no Origin -> 200 active:false; SCIM POST + Cookie NO bearer -> 403 code MISSING_OR_NULL_ORIGIN, no sys_user row; CONTROL real session cookie POST /sign-out no Origin -> 403 MISSING_OR_NULL_ORIGIN. Red on unfixed tree (e9076fa2a2): cases 1,2 fail '{\"message\":\"Missing or null Origin\",\"code\":\"MISSING_OR_NULL_ORIGIN\"}: expected 403 to be 201/200', cases 3,4 pass. Green at c8abb27264 together with scim-transaction-scope + scim-refusal-audit: 3 files, 33 passed. Whole plugin-auth package at c8abb27264: Test Files 270 passed (270), Tests 5118 passed | 10 skipped (5128), lock VERDICT command-exit 0; typecheck exit 0 (check:test-typecheck OK, 72 errors / 23 signatures held, unchanged). Ablation via scripts/ablation-replace.mjs on committed fix: anchor headers.delete('cookie') 1->0, blob 944e5a37b0bd->efaf0d39e2b3, cases 1,2 red (403), 3,4 green; restored blob 944e5a37b0bd == HEAD, git diff HEAD empty. Test imports src relatively (./auth-manager.js), no dist on the path, so no rebuild/dist preflight leg.",
"gates": "Local at c8abb27264: dispatch-gates --commands derived 66 families; all run; --ran reconciliation '66 derived famil(ies) accounted for, 65 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)'. 65 exit 0. pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (needs every package dist; only plugin-auth closure built) = NOT MEASURED locally. check-nul-bytes exit 0. pnpm lint (dispatch asked) lock VERDICT command-exit 0; eslint --no-inline-config on the two changed TS files exit 0. CI check-runs on PR head c8abb27264 at report time: all gate-carrying lanes in_progress (Lint, Type Check, Check Changeset, Governed Surface Queue Guard, PR-body guards, ...); opt-in/queue lanes skipped. Not run locally, CI-owned: test:repo, dogfood, UI lanes.",
"dogfood_battery": "n/a: no ! in the PR title and no (narrowing) arm",
"mcp_calls": "0",
"api_writes": "2: git push (branch, 3 pushes, not REST); relay pr_create (POST /repos/objectstack-ai/objectstack/dispatches -> fleet-write run 43603456865 -> POST /pulls + POST /issues/33243/assignees); post-stamped os-dev-report comment on #33237 (POST /repos/objectstack-ai/objectstack/dispatches via relay)",
"open_questions": [],
"security_findings": "none",
"out_of_scope_findings": [
"carrier: none · noted, not filed: better-auth skips the origin check wholesale under a test NODE_ENV (skipOriginCheck defaults to isTest() at module load), so any plugin-auth test meant to observe it must set skipOriginCheck=false on $context; PR Acceptance notes",
"carrier: none · noted, not filed: behaviour change beyond the card's three cases: a /scim/v2 bearer request with a Cookie AND an untrusted Origin was 403 INVALID_ORIGIN, now reaches the SCIM bearer check; PR Acceptance notes",
"carrier: cloud · noted, not filed: cloud#4498 hosted proxy strip can retire once a release with this reaches cloud's pin (maintainer's call on cloud#4491)"
]
}
The dev report (6878554536) is in, and PR #33243 is open as a draft at c8abb272, with Fixes #33237. The PR is labelled needs:contract-review. On the maintainer's 「契约复审等 review 座位」, the review seat runs that review; this seat does not.
For the reviewer:
Route: In AuthManager.handleRequest, a request is rebuilt without its Cookie header when all three hold: the path is under /scim/v2, it carries Authorization: Bearer, and it has a Cookie. No check is relaxed. The dev measured that the cookie carries no authority on this path: authMiddleware reads the bearer only, and authSystemWriteContext attributes the write to the SCIM connection.
Behaviour change beyond the card's three cases (judge it): A /scim/v2 bearer request with a Cookie and an untrusted Origin used to get 403 INVALID_ORIGIN. It now reaches the SCIM bearer check.
Test harness: Under vitest, better-auth defaults skipOriginCheck to true. The new test therefore sets skipOriginCheck=false on $context, and both negative cases stay refused: SCIM without a bearer → 403, and a browser session without an Origin → 403.
Ablation: Removing the strip turns cases 1 and 2 red. The blob was restored to match HEAD.
Local gates: 65 of 66 ran green. check:dual-build-cjs-loads was NOT MEASURED locally and is left to CI.
PR #33243 merged at a2be78d0 (2026-11-14T15:44Z), and Fixes #33237 closed this card. I checked on origin/main: the /scim/v2 bearer Cookie strip is in auth-manager-request-routing.ts, and scim-bearer-cookie-origin.test.ts is present.
Delivered: a /scim/v2/* request that carries a bearer and a Cookie, with no Origin, now reaches the SCIM bearer check instead of being refused 403 MISSING_OR_NULL_ORIGIN. A no-bearer SCIM request and a browser session with no Origin are still refused.
Left open, for whoever picks them up:
Review nit 2: the untrusted-Origin arm has no pin.
The cloud#4498 hosted proxy strip can retire once a release that carries this fix reaches cloud's pin. That call is the maintainer's, on cloud#4491.
Activity
objectstack-fleet commentedon Nov 14, 2026
triage#1 · hotlong · session_01Q4EjjMnyLqJdzuktEmRPXH
Path: none
Triage: first touch →
domain:server· p1 ·pm:queue· 2026-11-14T09:44Z⛔ Not a claim.
PATCH active:falsedeprovisioning) that carries any Cookie and no Origin is refused 403. A real IdP that keeps a cookie jar (Okta on staging) cannot provision or deprovision users, and an offboarded person's access is not removed by the directory. Directory sync is stuck with no remedy (p1 row)./scim/v2/*(bearer-only) from the cookie-origin check in plugin-auth's forward to better-auth, or strip the Cookie on that path.objectstack-fleet commentedon Nov 14, 2026
domain:server#7 · sunhaolin · session_017xRBcSZ51q2s3nMfakhdJ3
Claim: PM loop round 1
Session:
session_017xRBcSZ51q2s3nMfakhdJ3Account:
sunhaolinBranch:
claude/issue-33237-scim-bearer-cookie-originWorktree:
objectstack-issue-33237Domain:
domain:serverSeat:
domain:server#7File surface:
packages/plugins/plugin-auth/src/auth-manager-request-routing.ts(AuthManager.handleRequest, the request forwarded into better-auth on/scim/v2/*), its tests underpackages/plugins/plugin-auth/src/**, root.changeset/. ⛔ Notpnpm-workspace.yaml/ the better-auth pin, notauth-manager.ts/auth-plugin.ts(open drafts #33227 and #33221 hold them), not the SCIM reactivation files #32115 holds. Stop on breach; explain in the report.Container & model:
M,mode:subagent,model: opus (default judgment tier; dispatch-gates --tier: no path-derived mandate),effort: xhigh(auth boundary)Clause-②: no
Metadata-protocol: none
Responsibility:
plugin-auth AuthManager.handleRequest (objectstack) forwards the Cookie header into better-auth on /scim/v2/*, where better-auth 1.7.12's validateOrigin requires an Origin whenever a cookie is present|cloud's hosted proxy strip (objectstack-ai/cloud#4498, a transition, the maintainer's call on cloud#4491); nothing on self-hosted|every SCIM IdP client that keeps a cookie jar or sits behind a sticky-session cookie (Okta measured on staging); provisioning and deprovisioning both refused todayThread-read: 6875420457
Premise: holds (seat reading 2026-11-14T13:12Z on
origin/main6c5d31fa:handleRequestforwards headers untouched intoauth.handler(request); no/scim/v2cookie special case; better-auth 1.7.12origin-check:const useCookies = headers.has("cookie")→MISSING_OR_NULL_ORIGINwhen no Origin)Serial constraints cleared: #32115 (
os-tesla, in flight, surfacepackages/plugins/plugin-auth/src/**, SCIM reactivation) — package-level overlap, file-level UNKNOWN; open drafts #33227 and #33221 touchauth-manager.ts/auth-plugin.ts, notauth-manager-request-routing.ts. Ordinary concurrency; the dev reads #32115's open PR file list (if any) before pushing.Dispatched 2026-11-14T13:17Z.
objectstack-fleet commentedon Nov 14, 2026
repo:cloud#5 · hotlong · session_01Q4EjjPP1zuWBNq3nBXVei9
Downstream note, 2026-11-14T14:06Z. The maintainer ruled on objectstack-ai/cloud#4491 (p1) to wait for this platform fix rather than work around it in cloud: 「A:等框架修(推荐)」. cloud#4491 is now
pm:blockedon this card. Once this ships in a release, cloud bumps its pin and Okta/Entra provisioning on hosted environments works again. Triage sets this card's lane and level; the cloud seat only records the dependency.objectstack-fleet commentedon Nov 14, 2026
domain:server#7 · sunhaolin · session_017xRBcSZ51q2s3nMfakhdJ3
os-dev-report
{ "issue": 33237, "status": "done", "branch": "claude/issue-33237-scim-bearer-cookie-origin", "pr": "https://github.com/objectstack-ai/objectstack/pull/33243", "session": "session_017xRBcSZ51q2s3nMfakhdJ3", "premise_still_valid": true, "summary": "Premise held on origin/main 6c5d31fa: handleRequest forwarded Cookie into better-auth on /scim/v2, and better-auth 1.7.12 validateOrigin answers 403 MISSING_OR_NULL_ORIGIN for any cookie with no Origin. Fix (PM's suggested route, no check relaxed): in AuthManager.handleRequest, a request whose endpoint path is under /scim/v2 (isScimProtocolPath) AND carries Authorization: Bearer plus a credential AND carries a Cookie is rebuilt without the Cookie before the vendor sees it; landing is the expected file auth-manager-request-routing.ts, no producer elsewhere. Measured that the cookie carries no authority there: @better-auth/scim authMiddleware authenticates from the bearer alone, the platform verifyBearerToken reads only input.token, and authSystemWriteContext attributes SCIM writes to the connection before any session resolver. PM mechanism assumption 2 needed one addition: under vitest better-auth also defaults skipOriginCheck=true (NODE_ENV read once at module load as 'test'), so NODE_ENV=production alone does NOT surface the bug in-process; the new test sets skipOriginCheck=false on the live $context in boot() and asserts skipCSRFCheck=false. Card assignee not written (sunhaolin set by PM); PR assignee sunhaolin via pr_create.", "tests": "New packages/plugins/plugin-auth/src/scim-bearer-cookie-origin.test.ts, 4 cases under vi.stubEnv NODE_ENV=production + skipOriginCheck=false: SCIM bearer POST /Users + Cookie(__cf_bm, empty session_token) no Origin -> 201 + sys_user row; SCIM bearer PATCH active:false + Cookie no Origin -> 200 active:false; SCIM POST + Cookie NO bearer -> 403 code MISSING_OR_NULL_ORIGIN, no sys_user row; CONTROL real session cookie POST /sign-out no Origin -> 403 MISSING_OR_NULL_ORIGIN. Red on unfixed tree (e9076fa2a2): cases 1,2 fail '{\"message\":\"Missing or null Origin\",\"code\":\"MISSING_OR_NULL_ORIGIN\"}: expected 403 to be 201/200', cases 3,4 pass. Green at c8abb27264 together with scim-transaction-scope + scim-refusal-audit: 3 files, 33 passed. Whole plugin-auth package at c8abb27264: Test Files 270 passed (270), Tests 5118 passed | 10 skipped (5128), lock VERDICT command-exit 0; typecheck exit 0 (check:test-typecheck OK, 72 errors / 23 signatures held, unchanged). Ablation via scripts/ablation-replace.mjs on committed fix: anchor headers.delete('cookie') 1->0, blob 944e5a37b0bd->efaf0d39e2b3, cases 1,2 red (403), 3,4 green; restored blob 944e5a37b0bd == HEAD, git diff HEAD empty. Test imports src relatively (./auth-manager.js), no dist on the path, so no rebuild/dist preflight leg.", "gates": "Local at c8abb27264: dispatch-gates --commands derived 66 families; all run; --ran reconciliation '66 derived famil(ies) accounted for, 65 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)'. 65 exit 0. pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (needs every package dist; only plugin-auth closure built) = NOT MEASURED locally. check-nul-bytes exit 0. pnpm lint (dispatch asked) lock VERDICT command-exit 0; eslint --no-inline-config on the two changed TS files exit 0. CI check-runs on PR head c8abb27264 at report time: all gate-carrying lanes in_progress (Lint, Type Check, Check Changeset, Governed Surface Queue Guard, PR-body guards, ...); opt-in/queue lanes skipped. Not run locally, CI-owned: test:repo, dogfood, UI lanes.", "dogfood_battery": "n/a: no ! in the PR title and no (narrowing) arm", "mcp_calls": "0", "api_writes": "2: git push (branch, 3 pushes, not REST); relay pr_create (POST /repos/objectstack-ai/objectstack/dispatches -> fleet-write run 43603456865 -> POST /pulls + POST /issues/33243/assignees); post-stamped os-dev-report comment on #33237 (POST /repos/objectstack-ai/objectstack/dispatches via relay)", "open_questions": [], "security_findings": "none", "out_of_scope_findings": [ "carrier: none · noted, not filed: better-auth skips the origin check wholesale under a test NODE_ENV (skipOriginCheck defaults to isTest() at module load), so any plugin-auth test meant to observe it must set skipOriginCheck=false on $context; PR Acceptance notes", "carrier: none · noted, not filed: behaviour change beyond the card's three cases: a /scim/v2 bearer request with a Cookie AND an untrusted Origin was 403 INVALID_ORIGIN, now reaches the SCIM bearer check; PR Acceptance notes", "carrier: cloud · noted, not filed: cloud#4498 hosted proxy strip can retire once a release with this reaches cloud's pin (maintainer's call on cloud#4491)" ] }objectstack-fleet commentedon Nov 14, 2026
domain:server#7 · sunhaolin · session_017xRBcSZ51q2s3nMfakhdJ3
Seat note · 2026-11-14T14:13Z · ⛔ Not a claim
The dev report (6878554536) is in, and PR #33243 is open as a draft at
c8abb272, withFixes #33237. The PR is labelledneeds:contract-review. On the maintainer's 「契约复审等 review 座位」, the review seat runs that review; this seat does not.For the reviewer:
AuthManager.handleRequest, a request is rebuilt without itsCookieheader when all three hold: the path is under/scim/v2, it carriesAuthorization: Bearer, and it has aCookie. No check is relaxed. The dev measured that the cookie carries no authority on this path:authMiddlewarereads the bearer only, andauthSystemWriteContextattributes the write to the SCIM connection./scim/v2bearer request with a Cookie and an untrusted Origin used to get 403INVALID_ORIGIN. It now reaches the SCIM bearer check.skipOriginCheckto true. The new test therefore setsskipOriginCheck=falseon$context, and both negative cases stay refused: SCIM without a bearer → 403, and a browser session without an Origin → 403.check:dual-build-cjs-loadswas NOT MEASURED locally and is left to CI.objectstack-fleet commentedon Nov 14, 2026
domain:server#7 · sunhaolin · session_017xRBcSZ51q2s3nMfakhdJ3
Landed · 2026-11-14T15:45Z
PR #33243 merged at
a2be78d0(2026-11-14T15:44Z), andFixes #33237closed this card. I checked onorigin/main: the/scim/v2bearer Cookie strip is inauth-manager-request-routing.ts, andscim-bearer-cookie-origin.test.tsis present.Delivered: a
/scim/v2/*request that carries a bearer and a Cookie, with no Origin, now reaches the SCIM bearer check instead of being refused 403MISSING_OR_NULL_ORIGIN. A no-bearer SCIM request and a browser session with no Origin are still refused.Left open, for whoever picks them up:
State change:
pm:dispatchedremoved.