Skip to content

Commit 829cb77

Browse files
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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
feat(plugin-auth): a JIT sign-in reads the IdP `locale` claim into the user's language preference, so a hosted environment follows the cloud account language
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
When an external IdP sign-in creates or refreshes a user just-in-time (the cloud as IdP on a hosted environment through `oidcProviders`, any generic-OAuth or social provider, or a `@better-auth/sso` OIDC provider), the standard OIDC `locale` claim now becomes `sys_user.locale`, the column the console reads as the signed-in user's language and messaging reads per recipient.
11+
12+
- **First creation:** the claim sets the preference.
13+
- **A later sign-in:** the claim updates it only while the user has not picked a language in the environment itself. A local choice wins and is never overwritten; clearing it hands the column back to the IdP.
14+
- **An unknown or unsupported claim:** nothing is stored (an IdP-written value is cleared), so the deployment default applies. The claim is closed against the locales the i18n service reports (`i18n.supportedLocales` when the app declares them), matched exact, then case-insensitive, then by base language (`en-US` → `en`, `ja` → `ja-JP`), and the supported spelling is stored. The underscore spelling (`zh_CN`) reads as the tag it stands for.
15+
- **No claim:** nothing changes.
16+
17+
What the IdP last wrote is recorded per user in `sys_verification` (identifier `idp-locale:` plus the user id), so an IdP-written value can be told apart from a local choice; no schema changes. Applying the claim never fails a sign-in: a store that refuses the write is reported at `warn` and the user keeps the language they had.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.
132132

133133
The same holds twice over for the context. An options argument spelled as a
134134
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
135-
forwarding shim cannot, and **51 of the 236 sites are spelled that way**. A
135+
forwarding shim cannot, and **51 of the 237 sites are spelled that way**. A
136136
context resolved from an inline literal or a local `const` can be tested for
137137
`isSystem`; one arriving from a helper call cannot.
138138

@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
197197

198198
| carried figure | where it survives | this census |
199199
| :--- | :--- | ---: |
200-
| 175 write call sites | quoted in the merged changeset | **236** |
200+
| 175 write call sites | quoted in the merged changeset | **237** |
201201
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
202-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **160 of 236** decidable, **76** undecidable |
203-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 134 decidably elevated, 0 decidably not, 94 undecidable |
202+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **161 of 237** decidable, **76** undecidable |
203+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 135 decidably elevated, 0 decidably not, 94 undecidable |
204204
| 141 and 132, two independent re-derivations | the card that filed this work | — |
205205

206206
**The differences are not reconciled, and deliberately so.** The old census's
@@ -217,11 +217,11 @@ would report a smaller number and would not say so.
217217

218218
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
219219
figure has no surviving corroboration anywhere in the tree.** This census reads
220-
134 of 236 (57%) as decidably elevated, with 94 more whose elevation is a
220+
135 of 237 (57%) as decidably elevated, with 94 more whose elevation is a
221221
run-time fact — so the claim is neither confirmed nor refuted, and the honest
222222
answer is that a static reading cannot settle it.
223223

224-
⇒ **Cite `2 / 236`, and say what it is**: the sites whose options argument was
224+
⇒ **Cite `2 / 237`, and say what it is**: the sites whose options argument was
225225
READ and holds no tenant context, against a decidably tenancy-enabled object.
226226
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
227227
without tenant context" — **29 further sites** have an options argument this
@@ -233,28 +233,28 @@ cannot read, and they are neither in nor out.
233233

234234
| what | count |
235235
| :--- | ---: |
236-
| write call sites on the application surface | **236** |
237-
| …whose object name is statically decidable | 160 |
236+
| write call sites on the application surface | **237** |
237+
| …whose object name is statically decidable | 161 |
238238
| …whose object name is chosen at run time | 76 |
239-
| …against an object with tenancy ENABLED | 159 |
239+
| …against an object with tenancy ENABLED | 160 |
240240
| …against an object that declares tenancy off | 1 |
241-
| threading a tenant context | 177 |
241+
| threading a tenant context | 178 |
242242
| PROVABLY carrying none (options read, no context key) | **8** |
243243
| …of those, against a decidably tenancy-enabled object | **2** |
244244
| options argument UNREADABLE — may or may not carry one | 51 |
245245
| …of those, against a decidably tenancy-enabled object | 29 |
246-
| threading a decidably ELEVATED (`isSystem`) context | 134 |
246+
| threading a decidably ELEVATED (`isSystem`) context | 135 |
247247
| threading a context that is decidably NOT elevated | 0 |
248248
| threading a context whose elevation is a run-time fact | 94 |
249249

250250
| how the instrument reached the site | count |
251251
| :--- | ---: |
252-
| receiver carried a readable engine type | 195 |
252+
| receiver carried a readable engine type | 196 |
253253
| receiver erased, placed by the object NAME | 31 |
254254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256256

257-
| object name spelled inline | 109 |
257+
| object name spelled inline | 110 |
258258
| object name spelled through a `const` | 51 |
259259
| object name is an `object: string` parameter | 7 |
260260
| object name is some other run-time expression | 69 |
@@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
307307
their values are not compared. The reasoning, and the measurement behind it,
308308
are in `scripts/check-tenant-audit-census.mjs`.
309309

310-
Measured on 2026-10-22 at `78b1ed16a`.
310+
Measured on 2026-10-22 at `4d4e2390c`.
311311

312312
| corpus scale (not enforced) | count |
313313
| :--- | ---: |
314-
| tracked non-test sources scanned | 700 |
315-
| engine-shaped types recognised | 86 |
314+
| tracked non-test sources scanned | 706 |
315+
| engine-shaped types recognised | 87 |
316316
| declared objects in the registry | 117 |
317-
| same-named calls subtracted as non-engine | 169 |
317+
| same-named calls subtracted as non-engine | 171 |
318318

319319
{/* END GENERATED: tenant-audit-census */}

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 236 |
37-
| Object name statically decidable | 160 |
36+
| Write call sites | 237 |
37+
| Object name statically decidable | 161 |
3838
| Object name chosen at run time | 76 |
39-
| Against a tenancy-enabled object | 159 |
39+
| Against a tenancy-enabled object | 160 |
4040
| Against an object declaring tenancy off | 1 |
41-
| Threading a tenant context | 177 |
41+
| Threading a tenant context | 178 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
4444
| Options argument unreadable | 51 |
4545
| …and decidably tenancy-enabled | 29 |
46-
| Threading a decidably elevated context | 134 |
46+
| Threading a decidably elevated context | 135 |
4747
| Threading a decidably non-elevated context | 0 |
4848
| Threading a context of undecidable elevation | 94 |
4949

@@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-22 at `78b1ed16a`.
93+
Measured on 2026-10-22 at `4d4e2390c`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 700 |
98-
| engine-shaped types recognised | 86 |
97+
| tracked non-test sources scanned | 706 |
98+
| engine-shaped types recognised | 87 |
9999
| declared objects in the registry | 117 |
100-
| same-named calls subtracted as non-engine | 169 |
100+
| same-named calls subtracted as non-engine | 171 |
101101

102102
## Every site
103103

@@ -145,6 +145,7 @@ Measured on 2026-10-22 at `78b1ed16a`.
145145
| `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `update` | `sys_member` | enabled | elevated | 1 |
146146
| `packages/plugins/plugin-auth/src/forced-reset-revocation.ts` | `update` | `SystemObjectName.API_KEY` | undecidable | elevated | 1 |
147147
| `packages/plugins/plugin-auth/src/forced-reset-revocation.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 1 |
148+
| `packages/plugins/plugin-auth/src/idp-locale-claim.ts` | `update` | `sys_user` | enabled | elevated | 1 |
148149
| `packages/plugins/plugin-auth/src/member-role-canonical.ts` | `update` | `MEMBER_OBJECT` | undecidable | elevated | 1 |
149150
| `packages/plugins/plugin-auth/src/membership-backfill-ledger.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 |
150151
| `packages/plugins/plugin-auth/src/membership-ended-session.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 2 |

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
type LinkingAuthContext,
4040
refuseImplicitAccountLink,
4141
} from './implicit-account-linking.js';
42+
import { IdpLocaleSync } from './idp-locale-claim.js';
4243
import type { IDataEngine } from '@objectstack/core';
4344
import {
4445
API_KEY_DEFAULT_EXPIRY_DAYS,
@@ -1885,12 +1886,18 @@ export class AuthManager {
18851886
// implicit link is written — see `implicit-account-linking.ts`. The
18861887
// audience gate judges only `create-user`, so the two never answer
18871888
// the same call.
1888-
validateUserInfo: async (data: any, ctx: any) =>
1889-
(await refuseImplicitAccountLink(data, ctx, {
1889+
//
1890+
// It is also where better-auth hands over the IdP's raw profile, so
1891+
// the profile's `locale` claim is STAGED here, refusing nothing; the
1892+
// session the sign-in mints settles it (`idp-locale-claim.ts`).
1893+
validateUserInfo: async (data: any, ctx: any) => {
1894+
this.idpLocale.stage(data, ctx);
1895+
return (await refuseImplicitAccountLink(data, ctx, {
18901896
requireLocalEmailVerified: this.implicitLinkRequiresLocalEmailVerified(),
18911897
resolveContext: () => this.linkingContext(),
18921898
logInfo: (m, meta) => this.config.logger?.info?.(m, meta),
1893-
})) ?? this.validateAudienceAdmission(data, ctx),
1899+
})) ?? this.validateAudienceAdmission(data, ctx);
1900+
},
18941901
},
18951902
account: {
18961903
...AUTH_ACCOUNT_CONFIG,
@@ -5050,6 +5057,25 @@ export class AuthManager {
50505057
}
50515058
}
50525059

5060+
/**
5061+
* An external sign-in's `locale` claim → `sys_user.locale`, staged by the
5062+
* `validateUserInfo` seam and settled on the session the sign-in mints.
5063+
* The rule and its record: `idp-locale-claim.ts`.
5064+
*/
5065+
private readonly idpLocale = new IdpLocaleSync({
5066+
getEngine: () => this.getDataEngine() as never,
5067+
getSupportedLocales: () => {
5068+
try {
5069+
const locales = this.config.getI18n?.()?.getLocales?.();
5070+
return Array.isArray(locales) ? locales : undefined;
5071+
} catch {
5072+
return undefined;
5073+
}
5074+
},
5075+
resolveAuth: () => this.linkingContext(),
5076+
warn: (message, meta) => this.config.logger?.warn?.(message, meta),
5077+
});
5078+
50535079
/**
50545080
* [ADR-0093 D7] The users created in each in-flight better-auth request,
50555081
* keyed by that request's endpoint context — the object better-auth hands
@@ -8448,6 +8474,7 @@ export class AuthManager {
84488474
const userDeleteAfter = async (user: any, ctx: any) => {
84498475
const result = hostUserDeleteAfter ? await hostUserDeleteAfter(user, ctx) : undefined;
84508476
await clearUserUnlinks(user, ctx);
8477+
await this.idpLocale.forget(user, ctx);
84518478
return result;
84528479
};
84538480

@@ -8589,6 +8616,9 @@ export class AuthManager {
85898616
this.config.getAuditSink?.(),
85908617
loginEventFor(session, typeof ctx?.path === 'string' ? ctx.path : undefined),
85918618
);
8619+
// An external sign-in's staged `locale` claim lands on the user the
8620+
// session names, new or returning (`idp-locale-claim.ts`). Never throws.
8621+
await this.idpLocale.settle(session, ctx);
85928622
return result;
85938623
};
85948624
// [#23772] A minted principal session is NEVER renewed. better-auth's

0 commit comments

Comments
 (0)