Repository navigation
Commit 99e9d29
feat(plugin-auth): an OAuth consent binds the consenting session's active organization, carried as organization_id (#24218)
Fixes #24166
Clause-②: yes (widening)
Slice 2 of #24137 (ruling `6232069920`, letter A, the maintainer's
「同意」). #24137 keeps the runtime read and its pins; nothing in this PR
reads the claim at the MCP door, and nothing in `packages/runtime` or
`packages/core` changes. Upstream slice #24165 landed the contract
member (`b1845ed637`).
## What changed
- **`packages/plugins/plugin-auth/src/auth-manager.ts`, the
`oauthProvider({ … })` options.** Two seams the installed provider
declares:
- `postLogin.consentReferenceId` answers the consenting session's
`activeOrganizationId` (better-auth's field on the session of record),
or `undefined` when it has none. It reads the session the provider hands
it and nothing else. `postLogin` is one declared object, so its other
two members ride along: `shouldRedirect: () => false` (no extra
selection page; the organization is the one the session already has) and
`page` (read only when `shouldRedirect` answers true, which it never
does; it names the consent page).
- `customAccessTokenClaims` emits the consent's reference as
`organization_id`, and nothing when there is none.
- **`verifyMcpAccessToken`.** Its own return type gains
`organizationId?: string`, matching `IAuthService`'s, and it returns the
`organization_id` claim there. A token whose `organization_id` is
present but is not a non-empty string is refused (`null`), the method's
fail-closed posture for any token this server does not mint. The
`client_credentials` refusal and the JWKS cache region are untouched.
- **`packages/platform-objects`.** The `reference_id` description on
`sys_oauth_consent`, `sys_oauth_refresh_token` and
`sys_oauth_access_token` names the bound organization. The `en` bundle
rows were regenerated with `pnpm i18n:extract`. The three translated
values per locale (zh-CN, ja-JP, es-ES) were re-translated by hand,
which the AGENTS.md documentation-guardrails row allows for
translated-locale values; the generator then kept them.
`sys_oauth_application.reference_id` is unchanged: the provider writes
it only from `clientReference`, an option plugin-auth does not pass
(measured below).
- `auth-schema-config.ts`: the `oauthConsent` mapping's doc comment said
the column was "for client-supplied correlation", which this change
makes false. Comment only.
- One changeset: `@objectstack/plugin-auth` minor,
`@objectstack/platform-objects` patch.
## Premises, measured on the installed
`@better-auth/oauth-provider@1.7.3` (the lockfile pin)
Paths are under `node_modules/@better-auth/oauth-provider/dist/`.
1. **The seams exist and compose.**
- Declared: `oauth-1Ud-hvZY.d.mts:1540-1574` (`postLogin { page;
consentReferenceId({ user, session, scopes }); shouldRedirect }`),
`:1698-1704` (`customAccessTokenClaims({ user, referenceId, scopes,
resources, metadata })`).
- Consent row: the consent endpoint calls `consentReferenceId` and
creates the row with that `referenceId`
(`authorize-9whjxVLJ.mjs:67-118`).
- Code: `/oauth2/authorize` calls it again and puts it on the
authorization code (`:5646-5700`; `redirectWithAuthorizationCode` at
`:5706-5733` stores it).
- Mint: the code exchange passes `verificationValue.referenceId`
(`introspect-CbhhXT0E.mjs:2040`), the refresh row stores it
(`:1545-1556`), a refresh passes `refreshToken.referenceId` (`:2177`),
and `resolveAccessTokenClaims` hands it to `customAccessTokenClaims` on
both paths (`:239-262`).
- `organization_id` is not in `RESERVED_ACCESS_TOKEN_CLAIMS`
(`:189-202`), so it is not stripped. Read off the minted token below.
2. **The session's value, never the client's.** The hook's context is `{
user, session, scopes }` only; the authorize query, the consent body and
the client metadata never reach it. Pinned with a real organization the
user belongs to passed as `organization_id`, `organizationId`,
`reference_id` and `referenceId` on the authorize request: the consent
row, the refresh row and the token all carry the session's organization.
3. **Existing consents re-confirm once.** The provider's own seam:
`/oauth2/authorize` adds `referenceId` to the consent lookup whenever
the hook answers one (`authorize-9whjxVLJ.mjs:5660-5676`), so a consent
with a null reference no longer matches and the user is sent to the
consent page (`:5677-5680`). Nothing extra was wired. Pinned as a
differential: the same stored consent is honoured with no organization
in play (control), re-prompts once an organization is active, and is not
prompted a second time after re-confirming. An old refresh family keeps
minting tokens with no claim.
4. **A record, not an authorization.** This PR records and returns the
organization only. Notes for the runtime read are in the acceptance
notes.
## Clause-② measurement (built entry declarations, BASE `a237d7f496` vs
this branch)
- `@objectstack/plugin-auth` `dist/index.d.ts` and `dist/index.d.mts`:
the one declaration change is in `verifyMcpAccessToken(token: string)`:
its resolved object type gains `organizationId?: string;` beside
`userId`, `scopes` and `clientId?` (head line 3222, inside the
declaration at head lines 3218-3223; BASE lines 3208-3212). The rest of
the diff is doc-comment prose (the method's docblock, and the
`AUTH_OAUTH_CONSENT_SCHEMA` comment).
- `@objectstack/platform-objects` `dist/index.d.ts`, `index.d.mts`,
`plugin.d.ts`, `plugin.d.mts`: byte-identical (`diff` exit 0; md5
equal).
## Tests
- New real-flow pin
`src/auth-manager.oauth-consent-organization.test.ts`: the options a
real `AuthManager` hands `oauthProvider()`, booted in a real better-auth
server with the real provider and the real `organization` plugin, driven
DCR, sign-up, organization, authorize, consent, token and refresh. Five
cases:
- token `organization_id` equals the session's active organization;
consent row and refresh row `reference_id` equal it;
`verifyMcpAccessToken` returns `organizationId`;
- a refresh keeps the consent's organization after the session moves to
another one;
- no active organization: no reference, no claim, no `organizationId`
member;
- organization-looking authorize parameters change nothing recorded;
- re-confirm once, plus the old refresh family staying unbound.
- `src/auth-manager.mcp-oauth.test.ts`: three claim-shape pins on the
verifier (the claim is returned; no claim means no member; a present
non-string or empty claim is refused, with the string case as control).
- Ablation, once, through `scripts/ablation-replace.mjs` (the test
imports `./auth-manager` source, so no `dist` leg): `consentReferenceId`
replaced by `() => undefined`, anchor 1 to 0, blob `ab0876f238ad` to
`8422b44fd87d`. Result: 4 failed, 1 passed (the no-organization case,
which asserts absence and is expected to stay green). Restore: blob
equals HEAD (`ab0876f238ad`) and `git diff HEAD` empty.
- Measured on head `81b7b99c2` (the final commit), all under
`scripts/pm/os-verify-lock.sh` (shared-box seconds).
- `pnpm --filter @objectstack/plugin-auth typecheck`, then `pnpm
--filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`:
`check:test-typecheck: OK`; `Test Files 157 passed (157)`, `Tests 3084
passed | 10 skipped (3094)`; `VERDICT command-exit 0`.
- `pnpm --filter @objectstack/platform-objects typecheck`, then its
`vitest run --maxWorkers=2 --passWithNoTests`: `check:test-typecheck:
OK`; `Test Files 73 passed (73)`, `Tests 1136 passed (1136)`; `VERDICT
command-exit 0`.
- The new pin alone, verbose, at `1371aa7a3`: 5 of 5 passed; 1.29 s of
tests in a 16.3 s file run (the rest is module import).
- Local scope: the two packages' own tests and typecheck. Their import
sites owe nothing extra: `platform-objects`' built declarations are
byte-identical, and `plugin-auth`'s public face changes by one optional
member on one return type. `test:repo` is not run locally (CI's `Test
Core`); the `integration`-tier rule is `packages/cli`'s and does not
apply.
## Gates
- Derived on head `81b7b99c2` with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` (no paths; merge base
`8259ea03e`; 12 paths, +578 / -19): 69 commands, the same set the seat
derived (sorted set difference empty).
- All 69 run locally, each exit code captured before any pipe. 68
answered 0 on the first pass. `pnpm check:dual-build-cjs-loads` first
answered `PREREQUISITE NOT MET` (exit 3: packages outside this diff had
no `dist/`); after a full `turbo run build
--filter='!@objectstack/docs'` (75 of 76 tasks a cache replay) it
answered exit 0: `111 published require entry point(s) across 69
package(s) load`.
- Selected verdict lines: `check-i18n-bundles: OK (10 package(s) — all
bundles in sync, no undeclared authoring keys)`; `check-i18n-stale-fill:
OK (11 bundle set(s) — no new stale fills, 0 baselined)`;
`check-nul-bytes: OK`; `check-adr-0087-registration: this PR adds no
declared-breaking changeset (1 non-breaking changeset(s) seen)`.
- `node scripts/pm/dispatch-gates.mjs --ran`: `69 derived famil(ies)
accounted for — 69 run, 0 NOT-MEASURED`, exit 0.
- Everything else (the CI-only families, `Test Core`, `Build Core`,
`TypeScript Type Check`, dogfood) is CI's, read from this PR's check
runs.
## Patch round 1 (seat record `6239991736`)
- Two sentences now say what the `IAuthService.verifyMcpAccessToken`
contract says, and no more: the caller vets the organization on every
request by the rule that vets a session's active organization
(`vetOrganizationClaim`, `@objectstack/core`). They are the changeset's
last sentence under `AuthManager.verifyMcpAccessToken`, and the matching
sentence in `verifyMcpAccessToken`'s docblock (commit `099568c28`).
Neither restates a membership guarantee, because that rule reads no
membership under `single` or with no posture (see the acceptance notes).
Nothing else changed: the changeset keeps `Clause-②: yes (widening)`,
`@objectstack/plugin-auth` minor and `@objectstack/platform-objects`
patch.
- `origin/main` merged twice (`a0f3c8304`, `1e4146df8`), with no
generated file in either merge. The head is `1e4146df8`. `b7f29ddfe`
(cli only) landed afterwards and is not merged.
- Re-measured on `1e4146df8` through the verify lock:
- `pnpm --filter @objectstack/plugin-auth typecheck`:
`check:test-typecheck: OK`.
- `vitest run --maxWorkers=2`: `Test Files 157 passed (157)`, `Tests
3084 passed | 10 skipped (3094)`.
- The pin file, verbose: 5 of 5 passed.
- `platform-objects` `vitest run`: `Test Files 74 passed (74)`, `Tests
1139 passed (1139)`.
- Every run ended `VERDICT command-exit 0`.
- Gates: `dispatch-gates --commands --repo objectstack-ai/objectstack`
on `1e4146df8` (12 paths, +579 / -19) derives the same 69 commands. All
69 answered exit 0. `--ran` reports `69 run, 0 NOT-MEASURED`.
- No ablation, since the change is prose only.
## Acceptance notes
- **Resource custom claims outrank this claim.** The provider's
precedence is extension claims, then per-issuance claims, then
`customAccessTokenClaims`, then a resource row's `customClaims`
(`introspect-CbhhXT0E.mjs:229-262`). A
`sys_oauth_resource.custom_claims` value carrying `organization_id` on
the MCP resource row would therefore replace the consent-bound
organization on every MCP token. Not filed: a configuration the runtime
would honour, not one it drops. Carrier: #24137 (the runtime read),
noted.
- **Membership is re-checked only under a wall.** `vetOrganizationClaim`
(`packages/core/src/security/resolve-authz-context.ts:762-770`) keeps a
claim with no membership read under `single` or with no posture. That is
the session arm as ruled, and #24137's runtime read reuses it. Under
`single`, a former member's OAuth token keeps resolving in the bound
organization, exactly as a REST session's claim does. Read-only
inference. Carrier: #24137, noted.
- **The consent page URL now carries the session row id.** With
`postLogin` configured, the provider adds `ba_pl` (the consenting
session's `sys_session.id`, not its token) to the signed consent-page
query (`authorize-9whjxVLJ.mjs:5735`, `:5753`), so
`postLogin.shouldRedirect` is skipped on the way back. Observation.
Carrier: none.
- **A consent made with no organization updates an organization-bound
row's scopes.** When the hook answers no reference, the provider's
consent lookup has no reference filter and can match an
organization-bound consent for the same client and user, whose scopes
and resources it then overwrites; its reference is kept
(`authorize-9whjxVLJ.mjs:71-109`). It changes no binding. Provider
semantics, observation. Carrier: none.
- **The console's consent page does not name the organization being
bound.** The ruling's option A lists it as optional. Carrier: none.
- **Helper duplication.** The new pin carries its own copies of the
harness helpers in `auth-manager.mcp-oauth-resource.test.ts`, which are
module-local there. Sharing them would refactor that file. Carrier:
none.
- **Main merged once before opening** (`8259ea03e`; the incoming commits
touch `rest`, `service-settings` and dogfood, none of this diff's
packages), then rebuilt and re-measured.
The contract review of record is the lane seat's (`TIER_DEFAULT`).
- **Seat's append:** the dev's two notes for the runtime read (a
resource row's `customClaims` outranks `customAccessTokenClaims`;
`vetOrganizationClaim` reads no membership under `single`) are carried
to #24137 by the seat.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent a8adf66 commit 99e9d29
12 files changed
Lines changed: 579 additions & 19 deletions
File tree
- .changeset
- packages
- platform-objects/src
- apps/translations
- identity
- plugins/plugin-auth/src
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1587 | 1587 | | |
1588 | 1588 | | |
1589 | 1589 | | |
1590 | | - | |
| 1590 | + | |
1591 | 1591 | | |
1592 | 1592 | | |
1593 | 1593 | | |
| |||
1647 | 1647 | | |
1648 | 1648 | | |
1649 | 1649 | | |
1650 | | - | |
| 1650 | + | |
1651 | 1651 | | |
1652 | 1652 | | |
1653 | 1653 | | |
| |||
1715 | 1715 | | |
1716 | 1716 | | |
1717 | 1717 | | |
1718 | | - | |
| 1718 | + | |
1719 | 1719 | | |
1720 | 1720 | | |
1721 | 1721 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1587 | 1587 | | |
1588 | 1588 | | |
1589 | 1589 | | |
1590 | | - | |
| 1590 | + | |
1591 | 1591 | | |
1592 | 1592 | | |
1593 | 1593 | | |
| |||
1647 | 1647 | | |
1648 | 1648 | | |
1649 | 1649 | | |
1650 | | - | |
| 1650 | + | |
1651 | 1651 | | |
1652 | 1652 | | |
1653 | 1653 | | |
| |||
1715 | 1715 | | |
1716 | 1716 | | |
1717 | 1717 | | |
1718 | | - | |
| 1718 | + | |
1719 | 1719 | | |
1720 | 1720 | | |
1721 | 1721 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1587 | 1587 | | |
1588 | 1588 | | |
1589 | 1589 | | |
1590 | | - | |
| 1590 | + | |
1591 | 1591 | | |
1592 | 1592 | | |
1593 | 1593 | | |
| |||
1647 | 1647 | | |
1648 | 1648 | | |
1649 | 1649 | | |
1650 | | - | |
| 1650 | + | |
1651 | 1651 | | |
1652 | 1652 | | |
1653 | 1653 | | |
| |||
1715 | 1715 | | |
1716 | 1716 | | |
1717 | 1717 | | |
1718 | | - | |
| 1718 | + | |
1719 | 1719 | | |
1720 | 1720 | | |
1721 | 1721 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1587 | 1587 | | |
1588 | 1588 | | |
1589 | 1589 | | |
1590 | | - | |
| 1590 | + | |
1591 | 1591 | | |
1592 | 1592 | | |
1593 | 1593 | | |
| |||
1647 | 1647 | | |
1648 | 1648 | | |
1649 | 1649 | | |
1650 | | - | |
| 1650 | + | |
1651 | 1651 | | |
1652 | 1652 | | |
1653 | 1653 | | |
| |||
1715 | 1715 | | |
1716 | 1716 | | |
1717 | 1717 | | |
1718 | | - | |
| 1718 | + | |
1719 | 1719 | | |
1720 | 1720 | | |
1721 | 1721 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | | - | |
| 97 | + | |
98 | 98 | | |
99 | 99 | | |
100 | 100 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
66 | | - | |
| 66 | + | |
| 67 | + | |
67 | 68 | | |
68 | 69 | | |
69 | 70 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
87 | 87 | | |
88 | 88 | | |
89 | 89 | | |
90 | | - | |
| 90 | + | |
91 | 91 | | |
92 | 92 | | |
93 | 93 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
443 | 443 | | |
444 | 444 | | |
445 | 445 | | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
446 | 482 | | |
447 | 483 | | |
448 | 484 | | |
| |||
0 commit comments