Skip to content

Commit e5fd28c

Browse files
os-zhuangclaude
andauthored
fix(plugin-auth): honour better-auth Where.mode and normalise the SCIM identifier (#5814) (#7124)
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. Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent c5eef1d commit e5fd28c

3 files changed

Lines changed: 760 additions & 36 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): honour better-auth's `Where.mode`, and normalise the identifier SCIM matches on (#5814)
6+
7+
better-auth's `Where` carries a fourth field — `mode?: "sensitive" | "insensitive"`,
8+
`@default "sensitive"` — and `convertWhere()` in the ObjectQL adapter read `field` /
9+
`operator` / `value` and nothing else. The default covers almost every caller, so the
10+
drop was invisible; the caller it is not invisible for is the one that explicitly asked.
11+
12+
`@better-auth/scim` is that caller. SCIM's `userName` is case-insensitive by RFC 7643
13+
(`caseExact: false`), so a `filter=userName eq "Alice@example.com"` reaches this adapter
14+
as `{ field: 'email', operator: 'eq', mode: 'insensitive' }`. With `mode` unread, whether
15+
it matched a user stored as `alice@example.com` came down to how the driver under the
16+
auth path happens to compare strings — and because SCIM provisioning is "look up, create
17+
if absent", a missed match did not raise an error, it provisioned a **second user**.
18+
Only deployments that turned SCIM on (`OS_SCIM_ENABLED`, off by default) were exposed.
19+
20+
Both halves of the fix, per the maintainer's ruling on #5814:
21+
22+
- **Normalisation, not new vocabulary.** `sys_user.email` — the field SCIM's `userName`
23+
maps onto — is now stored lower-cased and compared lower-cased by this adapter. An
24+
insensitive lookup lower-cases its comparand, which is an *exact* match against the
25+
stored form, so nothing in the query vocabulary changes. The set is a declared table
26+
(`NORMALISED_IDENTIFIER_FIELDS`), not a name heuristic, and it drives the read and
27+
write halves from one place so a field cannot be added to one of them only.
28+
- **The silent drop ends.** `convertWhere()` handles `mode` explicitly. On a normalised
29+
identifier the request is satisfied by construction. On **any other** field, a
30+
`mode: 'insensitive'` clause now emits a loud warning naming the model, the field and
31+
the operator, and stating that the query is being answered case-sensitively — instead
32+
of answering a different question and looking fine doing it. It deliberately does not
33+
throw: refusing here would turn an occasional duplicate user into "`userName` queries
34+
entirely unavailable", which is the worse trade on an authentication path.
35+
36+
No migration ships and none is needed. Every existing write path already lower-cased
37+
`user.email` before reaching the adapter (better-auth's own `internalAdapter` does it on
38+
`createUser` / `createOAuthUser` / `updateUser` / `updateUserByEmail`, and SCIM's create
39+
path does it again), so the write half changes no existing behaviour — it moves the
40+
invariant the read half depends on into the layer that depends on it, instead of
41+
inheriting it from an internal of a prerelease dependency. Queries that do not set
42+
`mode`, or set it to `"sensitive"`, keep their comparand byte-for-byte: folding case
43+
unasked would be the same failure in the opposite direction.
44+
45+
Adding a case-insensitive equality operator (`$ieq`) was deferred until there is
46+
demonstrated pull for it, and downgrading `eq + insensitive` to `$icontains` was
47+
rejected — containment is not equality.

0 commit comments

Comments
 (0)