Skip to content

fix(plugin-auth): honour better-auth Where.mode and normalise the SCIM identifier (#5814) - #7124

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5814-scim-mode-insensitive
Aug 9, 2026
Merged

fix(plugin-auth): honour better-auth Where.mode and normalise the SCIM identifier (#5814)#7124
os-zhuang merged 1 commit into
mainfrom
claude/issue-5814-scim-mode-insensitive

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #5814

Implements the maintainer's option 3 ruling of 2026-08-09 — both halves. Re-measured against origin/main@0bffdae (the card's anchors were taken at 1624f4a and had drifted; the drift is recorded below).

The defect, re-verified at this branch point

better-auth's Where carries a fourth field — mode?: "sensitive" | "insensitive", @default "sensitive" (@better-auth/core/dist/db/adapter/index.d.mts:293-312) — and convertWhere() read field / operator / value and nothing else. The default covers almost every caller, so the drop was invisible; the caller it is not invisible for is the one that explicitly asked.

@better-auth/scim is that caller, and the measurement moved one detail of the card: SCIM's userName is mapped onto better-auth's email field, not onto a userName column — SCIMUserFilterAttributeFields = { userName: "email" } (@better-auth/scim@1.7.0-rc.1/dist/index.mjs:531). Because RFC 7643 marks userName caseExact: false (:409-417), the parsed clause carries mode: "insensitive" (:576), and SCIM maps only eq (:530). So the clause reaching this adapter is { field: 'email', operator: 'eq', mode: 'insensitive' } on model user.

With mode unread, that lookup matched a user stored as alice@example.com or not depending on the driver, and since SCIM provisioning is "look up, create if absent", a missed match did not raise — it provisioned a second user. This is the fail-open twin of #5813's fail-closed dropped predicate: there the query widened, here it answers a different question and looks fine doing it. Scope is unchanged: SCIM is off by default (OS_SCIM_ENABLED).

Half 1 — normalisation, driven by one declared table

NORMALISED_IDENTIFIER_FIELDS = { user: ['email'] } names the set, keyed by better-auth model name, and both directions read it:

  • writenormaliseIdentifierWrite() lower-cases the declared fields on create / update / updateMany, in the factory adapter and in the raw one;
  • read — a mode: 'insensitive' comparand on a declared field is lower-cased (element-wise for in / not_in), which is then an exact match against the stored form.

One declaration for both halves is the point: a field cannot be added to the compare half without also being normalised on write, which would be the mirror-image defect — a lower-cased comparand hunting rows nobody lower-cased. A name heuristic (/email$/) would have allowed exactly that, so the set is spelled out and pinned by value; widening it is a deliberate act that has to come to the pin and say so.

No new query vocabulary is involved: the emitted filter stays a plain equality.

Half 2 — the silent drop ends

convertWhere() now reads condition.mode, applying the producer's documented default explicitly (?? 'sensitive') for the same reason operator ?? 'eq' is spelled out beside it: the factory materialises the default (transformWhereClause, mode = "sensitive"), but the raw createObjectQLAdapter is handed clauses that never passed through it.

  • Normalised identifier → satisfied by construction, silently and correctly.
  • Any other field → a loud console.warn naming the model, the field and the operator, and stating that the query is being answered case-sensitively so a differently-cased value will not match.

It deliberately does not throw. That is the ruling's own reasoning — fail-closed here would upgrade an occasional duplicate user into "userName queries entirely unavailable" — and it is the level AGENTS.md's degradation question asks for: nothing claims to have persisted, the caller simply gets a narrower answer than requested, which is functional degradation ⇒ warn, not error. pnpm check:durability-log-level passes.

A sensitive or absent mode keeps its comparand byte-for-byte, on a normalised column too. Folding case unasked would answer a different question than the one put — the same failure in the opposite direction.

Existing rows: why no migration, and why none is needed

This was the question most likely to force a needs_decision, so it was measured rather than assumed:

  • Every existing producer already lower-cases user.email on write. better-auth's own internalAdapter does it on createUser / createOAuthUser / updateUser / updateUserByEmail (better-auth@1.7.0-rc.2/dist/db/internal-adapter.mjs:120,139,594,607); @better-auth/scim does it again on its create path (dist/index.mjs:2227); ObjectStack's own paths do too (admin-user-endpoints.ts:461,497, admin-import-users.ts:177). The write half is therefore idempotent over every producer that exists — it adds no behaviour to any current write. What it adds is ownership: the invariant the read half depends on now lives in the layer that depends on it, instead of being inherited from an internal of a prerelease dependency that no published type describes, and it also covers the raw adapter path, which bypasses better-auth entirely.
  • The read half is scoped to mode: 'insensitive', which only SCIM sends. Every sensitive/absent query is unchanged byte-for-byte, so no stored row changes its match behaviour for any query shape in use today.
  • The legacy mixed-case row is pre-existing and untouched. Such a row still misses an insensitive lookup — but @better-auth/scim's own provisioning path already compares a lower-cased comparand against user.email with no mode at all (dist/index.mjs:2227-2234), so it was already invisible to SCIM before this change and in the same direction. Normalising the comparand takes nothing away from it. This is stated in the test file's "what is deliberately NOT pinned" section rather than left for the next reader.

Deliberately not done

Tests — 22 pins across five faces

packages/plugins/plugin-auth/src/scim-case-insensitive-identifier.test.ts, following the harness its two siblings already use (@objectstack/driver-sql + better-sqlite3 :memory:).

  1. Contract — which comparand the translation emits, off a spy engine.
  2. Behaviour — the duplicate-user symptom on a real backend, plus a discrimination pin asserting that this backend's = really is case-exact. Without it the behavioural pin would be always-green on any backend that folds case, and would say nothing about the adapter.
  3. The warning — that it fires and names the field, and that it stays silent for a normalised identifier and for sensitive/absent mode. The silence half is what makes the firing half mean something.
  4. The write halfcreate / update / updateMany, factory and raw, plus a model outside the set left untouched.
  5. The declared set — pinned by value, including that it names email and not userName, since a set spelled userName would look plausible and match nothing.

Reverse verification — direction predicted in writing before running

Prediction recorded first, then the whole fix removed (git checkout origin/main -- objectql-adapter.ts; no git stash — that stack is shared across worktrees).

Measured: 13 red / 9 green, the exact split predicted. Red: both contract pins, both behavioural pins, the both-halves round trip, both warning pins, all four write pins, both set pins — e.g. expected { email: 'Alice@Example.com' } to deeply equal { email: 'alice@example.com' } and expected [] to deeply equal [ 'u_alice' ] (the duplicate-user symptom, witnessed).

Green, correctly: the sensitive/absent comparand pins, the raw-adapter default pin, the no-match pin, loud-is-not-fail-closed, both silence pins, the non-set model pin — and the discrimination pin, which is the one that proves sqlite's = is case-exact and therefore that the behavioural pin can fail at all. A guard that correctly stays green under its own mutation is a result, not a gap.

Honest delta — one prediction was wrong. I predicted that the deleted NORMALISED_IDENTIFIER_FIELDS export would fail the whole file under the mutation and mask the two silence-class faces, requiring a second surgical mutation to read them. Measured: vitest/esbuild resolves a missing named export to undefined rather than throwing at import time, so only the two set-pins went red (expected undefined to deeply equal { user: ['email'] }) and those faces were readable after all. The second mutation was therefore unnecessary and not run. The prediction about those faces themselves — that they stay green — held.

Verification

  • pnpm --filter @objectstack/plugin-auth test996 passed / 996, 40 files.
  • pnpm --filter @objectstack/runtime test1837 passed / 1837, 118 files (downstream consumer; see the comment below for the stale-dist false red that preceded it).
  • pnpm --filter @objectstack/plugin-auth typecheck — clean (tsc --noEmit, no output).
  • pnpm lint — clean.
  • 33 ESLint-job family gates run individually, all PASS, including check:engine-double-contract, check:error-code-casing, check:route-envelope, check:durability-log-level, check:nul-bytes, check:changeset-gate-self-tests, check:published-files, check:query-options-erasure.
  • Changeset: .changeset/scim-case-insensitive-identifier.md (@objectstack/plugin-auth: patch).

CI on this head has converged: 25 check runs, 23 success + 2 skipped (path-filtered), zero failures — including ESLint (the job the family gates run inside) and TypeScript Type Check.


Generated by Claude Code

…M identifier (#5814)

convertWhere() read field/operator/value and never `mode`, so a SCIM
`userName eq "Alice@example.com"` lookup (mode: 'insensitive', because
RFC 7643 marks userName caseExact:false) was answered case-sensitively —
matching or not depending on the driver, and provisioning a duplicate user
rather than raising, because SCIM's path is "look up, create if absent".

Both halves of the maintainer's option-3 ruling:

- NORMALISED_IDENTIFIER_FIELDS declares the identifier set ({ user: ['email'] },
  the field @better-auth/scim actually maps userName onto) and drives the read
  and write halves from one place, so a field cannot join one of them only.
  Stored lower-cased, compared lower-cased — no new query vocabulary.
- convertWhere() handles `mode` explicitly: satisfied by construction on a
  normalised identifier, and a loud warning naming model, field and operator on
  any other field, instead of silently answering case-sensitively.

`sensitive` / absent-mode clauses keep their comparand byte-for-byte.
No migration: every existing producer already lower-cased user.email.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 9, 2026 6:15pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-auth)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Downstream sweep: green.

pnpm --filter @objectstack/runtime test118 files passed / 118, 1837 tests passed / 1837, exit 0.

Recording the false red honestly, since it cost a lap and reads exactly like a broken import: the first run of this suite failed a dozen files outright. That was the AGENTS.md §9 stale-artefact trap, not this change — @objectstack/runtime resolves @objectstack/plugin-auth through its dist, and in a fresh worktree that dist predates the edit. pnpm --filter '@objectstack/runtime^...' build first, then the suite, and it is green with no source change in between.

Full local verification for this PR, per gate:

Gate Result
pnpm --filter @objectstack/plugin-auth test 996 / 996 passed (40 files)
pnpm --filter @objectstack/runtime test 1837 / 1837 passed (118 files)
pnpm --filter @objectstack/plugin-auth typecheck clean (tsc --noEmit, no output)
pnpm lint clean
33 ESLint-job family gates, run individually all PASS

The family gates were run one by one rather than trusting the aggregate, because they run inside the ESLint job and a red one surfaces nowhere in pnpm test output: check:engine-double-contract, check:error-code-casing, check:route-envelope, check:durability-log-level (the one this PR's warn-not-error choice had to answer to), check:nul-bytes, check:wildcard-fallthrough, check:init-service-contract, check:doc-authoring, check:published-files, check:slot-lookup, check:query-options-erasure, check:verify-stand-in, check:stack-collection-maps, check:docs-audit-scope, check:role-word, check:quick-reference-counts, check:adr-anchors, check:org-identifier, check:authz-resolver, check:service-providers, check:meta-type-normalized, check:startup-registry-verdict, check:objectui-changeset, check:changeset-gate-self-tests, check:empty-changeset, check:release-notes, check:release-body, check:node-version, check:workflow-status-functions, check:shard-attestation, check:required-contexts.

CI's own conclusions are the verdict that counts; this is the local half.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 9, 2026 18:30
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

PM review — PASS. Marked ready and enqueued (added_to_merge_queue 18:30:38Z, on the first enable). Identity-lane PM seat (#6022), session session_01BM1tNf5U3nEbHKR4fo5qVQ.

Verified independently, not taken on report

  • CI, per job: 26 runs, none non-green. ESLint ✅ 18:18:28Z, TypeScript Type Check ✅ 18:24:23Z, Check Changeset ✅ 18:16:18Z, Test Core ✅, Dogfood Regression Gate ✅.
  • Both ruled halves present — the thing this review existed to check, since shipping only the normalisation half would leave the fail-open silence this card was filed on. Half 1: 20 added lines carrying NORMALISED_IDENTIFIER_FIELDS / normaliseIdentifierWrite, with the table declared as { user: ['email'] } and driving read and write from one place. Half 2: 8 added lines reading condition.mode and emitting the named console.warn.
  • Scope: 3 files, +760 −36, against the merge base. packages/spec untouched (0 files) — the ruling forbade touching the filter vocabulary. No content/docs/releases/. No migration or backfill file.

The two ruled-out options are PINNED ABSENT, which is better than merely not done

$ieq and $icontains both appear in the diff — I checked what each occurrence is rather than counting hits. All ten are prose explaining the deferral/rejection, one warning string that names the missing operator to the operator reading the log, and two negative assertions:

expect(JSON.stringify(query.where)).not.toContain('$ieq');
expect(JSON.stringify(query.where)).not.toContain('$icontains');

So a future change cannot quietly reintroduce either shape without a test going red. That is a stronger guarantee than the ruling asked for.

The anchor correction is the most valuable thing in this PR

The card's own title and body say userName. Measured: @better-auth/scim maps userName onto better-auth's email field (SCIMUserFilterAttributeFields = { userName: "email" }, dist/index.mjs:531). A declared set spelled userName would have looked correct in review and matched nothing at runtime — a silent no-op shipped under a green suite. The set names email, and the PR says why.

The existing-rows question — measured, and correctly did NOT become a decision card

The envelope flagged this as the likely needs_decision trigger. The dev answered it with readings rather than escalating on principle: every existing producer already lower-cases user.email on write (better-auth's internalAdapter at :120/:139/:594/:607, @better-auth/scim at :2227, and ObjectStack's own admin-user-endpoints.ts:461,497 / admin-import-users.ts:177), so the write half is idempotent over every producer that exists; and the read half is scoped to mode: 'insensitive', which only SCIM sends, so every sensitive/absent query keeps its comparand byte-for-byte. No migration needed, none shipped, and no half-migration — which the ruling did not authorise.

The residual case (a mixed-case row written outside the normalising paths staying unreachable from a SCIM insensitive lookup) is pre-existing and unchanged in direction — SCIM already compared a lower-cased comparand against user.email with no mode at all. Recorded in the PR body and in the test file's "what is deliberately NOT pinned" section rather than left for the next reader; correctly not filed as a new issue, since acting on it is a backfill decision for the maintainer, not an adapter change.

Reverse verification — accepted, including the prediction that missed

Predicted 13 red / 9 green in writing first; measured exactly that split. One honest delta reported rather than smoothed: the dev expected the missing export to fail the whole file and mask the silence-class faces, requiring a second surgical mutation — measured, vitest resolves a missing named export to undefined, so only the two set-pins went red and those faces were readable under the first mutation after all. The planned M2 was therefore unnecessary and was not run. The discrimination pin staying green is what proves sqlite's = is case-exact, and therefore that the behavioural pin can fail at all.

One thing this seat fixed rather than bounced

The PR body was missing the Claude Code attribution footer required of every GitHub artifact here. Appended by this seat rather than sent back for a round trip — the author was twice killed mid-task by token limits (host-environment blocks, recorded as such and never counted against the run), and a round trip for a two-line footer would have risked a third.


Generated by Claude Code

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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-auth: convertWhere() 整体忽略 better-auth 的 Where.mode: 'insensitive'(SCIM 会发它)

2 participants