Repository navigation
Commit 829cb77
feat(plugin-auth): a JIT sign-in reads the IdP locale claim into the user language preference (#25303)
<!-- claude-projects-attribution: {"github_login":"os-sales"} -->
_Requested by **Sales** · [project
thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu6spbcJWyNTVVesWZafpRKR)_
Fixes #25261
Clause-②: no
Metadata-protocol: none
Claim of record: `6351359330` on the card (card thread,
`domain:services` seat 1). Branch `claude/project-thread-6usz2l` is the
harness-pinned branch for this session; it names no issue, so the
Duplicate Fix Guard's branch-name hint will not match, and the claim is
the identity record.
## What changes
When an external IdP sign-in creates or refreshes a user just-in-time,
the standard OIDC `locale` claim (OIDC Core 5.1, a BCP-47 tag) now
becomes `sys_user.locale`: the column the console reads as the signed-in
user's UI language (objectui `useUserLocale`, `LocaleSwitcher`,
`ProfilePage`) and service-messaging reads per recipient. On a hosted
environment the cloud is the IdP (`oidcProviders`, provider
`objectstack-cloud`), so the environment follows the cloud account
language. The cloud half (the control plane's OIDC provider emits
`locale`) is objectstack-ai/cloud#3159.
The rule (`packages/plugins/plugin-auth/src/idp-locale-claim.ts`, module
header):
| sign-in | what happens to `sys_user.locale` |
|:--|:--|
| first creation, supported claim | set to the claim, in the supported
spelling |
| later sign-in, the column still holds what the IdP last wrote (or is
unset) | follows the claim |
| later sign-in, the user picked a language in the environment | kept: a
local choice wins |
| unknown or unsupported claim | left unset (an IdP-written value is
cleared), so the deployment default applies |
| no `locale` claim | unchanged |
**Closed against the supported list.** The claim is matched against the
locales the i18n service reports (`getLocales()`, which the app's
declared `i18n.supportedLocales` narrows) with the platform's own
matcher, `resolveLocale` from `@objectstack/core` (exact,
case-insensitive, base language, variant), and the SUPPORTED spelling is
stored (`zh-cn` stores `zh-CN`, `en-US` stores `en` when `en` is what is
supported). The shape check is service-messaging's
`normalizeRecipientLocale`, the one reader of a value at rest in the
column, so nothing the IdP sends can store a value the readers discard.
The underscore spelling OIDC Core 5.1 notes (`zh_CN`) reads as the tag
it stands for. With no i18n service to ask, nothing is applied (said
once at `warn`): judging without the list would be judging anyway.
**Telling a local choice from an IdP-written value.** The column alone
cannot say who wrote it, so the value the IdP path last wrote is
recorded beside it: one `sys_verification` row per user (identifier
`idp-locale:` plus the user id), written through better-auth's DATABASE
adapter, the implicit-linking unlink record's idiom in the same package
and for the same reason (a host `secondaryStorage` cache must never be
its only home). The IdP owns the column while it is unset or still holds
exactly the recorded value. Every partial failure degrades toward "a
local choice wins" (the column is written before the record). A deleted
user's record is cleared in the composed `user.delete.after`. No schema
change, no new column, no new authorable key.
**Where it runs.** better-auth hands the raw IdP profile to the
`validateUserInfo` seam on every action of an external sign-in:
`source.oauth.profile` for generic-OAuth and social providers,
`source.sso.profile` for `@better-auth/sso` OIDC providers (measured in
the installed better-auth 1.7.12: `api/routes/callback.mjs`,
`oauth2/link-account.mjs`, `db/internal-adapter.mjs`, and
`@better-auth/sso` `dist/index.mjs`). That seam may still refuse and
runs before the user row exists, so it only STAGES the claim, keyed by
the request's endpoint context (a `WeakMap`, the `usersCreatedInRequest`
pattern). The session the sign-in mints, new user or returning, settles
it in the composed `session.create.after`. A refused sign-in mints no
session and the staged claim dies with its request. Applying the claim
never fails a sign-in: a store that refuses the write is reported at
`warn` (a functional degradation, the user visibly sees the default
language) and the user keeps the language they had.
`auth-manager.ts` gains only the three call sites and the instance; the
logic is in the new file, so the in-flight move-only split of that file
(#25224) and this change merge in either order.
## Pins
`packages/plugins/plugin-auth/src/idp-locale-claim.test.ts`, 22 cases:
- **Pure (13):** the claim off an `oauth` and an `sso-oidc` source, a
present-but-unusable value, no claim for SAML / password / SCIM sources;
the supported spelling (exact, case, base, variant), the underscore
spelling, unsupported and malformed claims; the decision (creation,
following, a local choice wins three ways, an unsupported claim clears
only an IdP-written value, a locally cleared column goes back to the
IdP, no-op); the deleted-user half.
- **End of the chain (9):** a real better-auth pipeline over the
in-memory engine (the `implicit-account-linking.test.ts` harness shape),
real OAuth round trips through the platform IdP (`/sign-in/social` then
`/callback/objectstack-cloud`, token and userinfo stubbed), reading what
landed in `sys_user` and `sys_verification`. The three acceptance cases
of the card:
- a sign-in with `locale: "zh-CN"` creates a user whose
`sys_user.locale` is `zh-CN`;
- a later sign-in with `locale: "en"` after the user picked `ja-JP`
locally keeps `ja-JP`;
- an unsupported `locale` (`fr-FR`) leaves the column unset;
and following (`zh-CN` then `en-US` stores `en`), an unsupported claim
clearing an IdP-written value but never a local one, no claim changing
nothing, a pre-existing local value kept, a user store that refuses the
write (sign-in still lands, `warn`), and no i18n service (nothing
written, one `warn`).
One-time proofs (not left as tests), each in
`scripts/ablation-replace.mjs` (anchor must hit once, blob must move,
restore proven: blob equal to HEAD and `git diff HEAD` empty). The
subject is imported from source, so there is no `dist/` leg.
| leg | what is undone | file reading |
|:--|:--|:--|
| 1 | the `settle` call in `session.create.after` | 5 failed / 17
passed: acceptance 1, following, no claim, store refusal, no i18n |
| 2 | the local-choice guard in `decideIdpLocaleWrite` | 4 failed / 18
passed: acceptance 2, the pure local-choice cell,
unsupported-never-clears-local, pre-existing local value |
| 3 | the supported-list closing in `resolveIdpLocale` (raw tag stored)
| 5 failed / 17 passed: acceptance 3, the two pure closing cells,
following, unsupported-clears |
## Tests and gates
Read on head `611c99ab` (after merging `main` at `f47f7f22`). Every test
run went through `scripts/pm/os-verify-lock.sh`; each exit code was
captured before any pipe.
- **Build:** `pnpm build`: `VERDICT command-exit 0`, `Tasks 76
successful, 76 total`.
- **The package:** `pnpm --filter @objectstack/plugin-auth test`: `Test
Files 186 passed (186)`, `Tests 3515 passed | 10 skipped (3525)`. `pnpm
--filter @objectstack/plugin-auth typecheck` (src, examples and the test
layer): exit 0.
- **The derived gate families:** `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` derived 109 commands on
`611c99ab`; all 109 exit 0 (`check:type-check-debt` under the lock;
`check-plugin-teardown-shape --self-test` after fetching its pinned
fixture commit; `check:dual-build-cjs-loads` after the full build).
`--ran`: `109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN`. The four
roster gates whose roster sits under a directory this diff touches
(`check-changeset-fixed`, `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`): exit 0.
- **Not run locally, declared to CI:** the type-check lanes and the
path-scheduled CI jobs `dispatch-gates` names. No dogfood battery is
owed (no `!` in the title, no `(narrowing)` arm).
**Derived artefacts this diff moves** (mechanical, each regenerated by
its own tool):
- `scripts/engine-double-contract.pinned.json`: the new test file's
engine double (`node scripts/check-engine-double-contract.mjs --write`).
- `content/docs/permissions/tenant-audit-census.mdx` and
`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`: the new
`sys_user` write is one more application-surface write call site (236 to
237; decidable 160 to 161; decidably elevated 134 to 135; it carries an
`isSystem` context). The generated regions come from `node
scripts/tenant-audit-census.mjs --write`; the hand-written figures the
gate holds to the census (six places on the page) were moved by hand to
the census's new figures, and `check-tenant-audit-census` is green.
These three paths sit outside the claim's declared file surface; they
are the gates' own ledgers for the declared change, not a widening.
## Acceptance notes
- Social providers are OAuth sign-ins too, so a Google sign-in that
carries `locale` sets the preference the same way; SAML sign-ins carry
no standard locale claim and are not read.
- A user who picks the very language the IdP asserts is
indistinguishable from one who never picked, and keeps following the
IdP; clearing the column locally hands it back to the IdP. Both follow
from "the IdP owns the column while it holds what the IdP last wrote".
- The alternative to the `sys_verification` record is a provenance
column on `sys_user` (a `platform-objects` change in another lane); the
record keeps the change inside `plugin-auth` with no schema change.
Either way the behaviour above is the same.
---
_Generated by [Claude
Code](https://claude.ai/code/session_019PQiYTjCGrkiAcRc8e5mrU)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_019PQiYTjCGrkiAcRc8e5mrU
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 09e07c2 commit 829cb77
7 files changed
Lines changed: 828 additions & 29 deletions
File tree
- .changeset
- content/docs/permissions
- docs/audits
- packages/plugins/plugin-auth/src
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
132 | 132 | | |
133 | 133 | | |
134 | 134 | | |
135 | | - | |
| 135 | + | |
136 | 136 | | |
137 | 137 | | |
138 | 138 | | |
| |||
197 | 197 | | |
198 | 198 | | |
199 | 199 | | |
200 | | - | |
| 200 | + | |
201 | 201 | | |
202 | | - | |
203 | | - | |
| 202 | + | |
| 203 | + | |
204 | 204 | | |
205 | 205 | | |
206 | 206 | | |
| |||
217 | 217 | | |
218 | 218 | | |
219 | 219 | | |
220 | | - | |
| 220 | + | |
221 | 221 | | |
222 | 222 | | |
223 | 223 | | |
224 | | - | |
| 224 | + | |
225 | 225 | | |
226 | 226 | | |
227 | 227 | | |
| |||
233 | 233 | | |
234 | 234 | | |
235 | 235 | | |
236 | | - | |
237 | | - | |
| 236 | + | |
| 237 | + | |
238 | 238 | | |
239 | | - | |
| 239 | + | |
240 | 240 | | |
241 | | - | |
| 241 | + | |
242 | 242 | | |
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
246 | | - | |
| 246 | + | |
247 | 247 | | |
248 | 248 | | |
249 | 249 | | |
250 | 250 | | |
251 | 251 | | |
252 | | - | |
| 252 | + | |
253 | 253 | | |
254 | 254 | | |
255 | 255 | | |
256 | 256 | | |
257 | | - | |
| 257 | + | |
258 | 258 | | |
259 | 259 | | |
260 | 260 | | |
| |||
307 | 307 | | |
308 | 308 | | |
309 | 309 | | |
310 | | - | |
| 310 | + | |
311 | 311 | | |
312 | 312 | | |
313 | 313 | | |
314 | | - | |
315 | | - | |
| 314 | + | |
| 315 | + | |
316 | 316 | | |
317 | | - | |
| 317 | + | |
318 | 318 | | |
319 | 319 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
37 | | - | |
| 36 | + | |
| 37 | + | |
38 | 38 | | |
39 | | - | |
| 39 | + | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | | - | |
| 46 | + | |
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | | - | |
98 | | - | |
| 97 | + | |
| 98 | + | |
99 | 99 | | |
100 | | - | |
| 100 | + | |
101 | 101 | | |
102 | 102 | | |
103 | 103 | | |
| |||
145 | 145 | | |
146 | 146 | | |
147 | 147 | | |
| 148 | + | |
148 | 149 | | |
149 | 150 | | |
150 | 151 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
| 42 | + | |
42 | 43 | | |
43 | 44 | | |
44 | 45 | | |
| |||
1885 | 1886 | | |
1886 | 1887 | | |
1887 | 1888 | | |
1888 | | - | |
1889 | | - | |
| 1889 | + | |
| 1890 | + | |
| 1891 | + | |
| 1892 | + | |
| 1893 | + | |
| 1894 | + | |
| 1895 | + | |
1890 | 1896 | | |
1891 | 1897 | | |
1892 | 1898 | | |
1893 | | - | |
| 1899 | + | |
| 1900 | + | |
1894 | 1901 | | |
1895 | 1902 | | |
1896 | 1903 | | |
| |||
5050 | 5057 | | |
5051 | 5058 | | |
5052 | 5059 | | |
| 5060 | + | |
| 5061 | + | |
| 5062 | + | |
| 5063 | + | |
| 5064 | + | |
| 5065 | + | |
| 5066 | + | |
| 5067 | + | |
| 5068 | + | |
| 5069 | + | |
| 5070 | + | |
| 5071 | + | |
| 5072 | + | |
| 5073 | + | |
| 5074 | + | |
| 5075 | + | |
| 5076 | + | |
| 5077 | + | |
| 5078 | + | |
5053 | 5079 | | |
5054 | 5080 | | |
5055 | 5081 | | |
| |||
8448 | 8474 | | |
8449 | 8475 | | |
8450 | 8476 | | |
| 8477 | + | |
8451 | 8478 | | |
8452 | 8479 | | |
8453 | 8480 | | |
| |||
8589 | 8616 | | |
8590 | 8617 | | |
8591 | 8618 | | |
| 8619 | + | |
| 8620 | + | |
| 8621 | + | |
8592 | 8622 | | |
8593 | 8623 | | |
8594 | 8624 | | |
| |||
0 commit comments