Skip to content

feat(plugin-auth): an OAuth consent binds the consenting session's active organization, carried as organization_id - #24218

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-24166-oauth-consent-organization
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-24166-oauth-consent-organization

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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

The contract review of record is the lane seat's (TIER_DEFAULT).


Generated by Claude Code

…zation, carried as organization_id

WIP: provider options (postLogin.consentReferenceId, customAccessTokenClaims),
verifyMcpAccessToken returns organizationId, reference_id descriptions.

Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH
Co-authored-by: Claude <noreply@anthropic.com>
…l authorize, consent, token and refresh flow

Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH
Co-authored-by: Claude <noreply@anthropic.com>
…-translate them in zh-CN, ja-JP and es-ES

en rows regenerated with pnpm i18n:extract; the three translated values are
hand-written translations of the new description.

Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH
Co-authored-by: Claude <noreply@anthropic.com>
…organization pin

Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 17, 2026
@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/plugin-auth, touching 11 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/auth-schema-config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx (via AuthManager (symbol, a top-level class), verifyMcpAccessToken (symbol, a method of class AuthManager))
  • content/docs/kernel/services-checklist.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authorization.mdx (via sys_oauth_access_token (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), sys_oauth_refresh_token (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/auth-schema-config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 33 pages)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 18 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a19b2a7a51d15b506cc916798bd5ac8788723288 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b18aa84d838230117772a2538cda6b3a66eaadf0 — the merge of head 1e4146df8ad2ed24f2d011b3b03842c2840a7dbe into base a19b2a7a51d15b506cc916798bd5ac8788723288, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b18aa84d838230117772a2538cda6b3a66eaadf0 && git checkout b18aa84d838230117772a2538cda6b3a66eaadf0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a19b2a7a51d15b506cc916798bd5ac8788723288 1e4146df8ad2ed24f2d011b3b03842c2840a7dbe && git checkout -B drift-repro a19b2a7a51d15b506cc916798bd5ac8788723288 && git merge --no-ff 1e4146df8ad2ed24f2d011b3b03842c2840a7dbe

node scripts/docs-audit/affected-docs.mjs --json a19b2a7a51d15b506cc916798bd5ac8788723288

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a19b2a7a51d15b506cc916798bd5ac8788723288 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ract does

The changeset and verifyMcpAccessToken's docblock now say the caller vets the
claim by the rule that vets a session's active organization
(vetOrganizationClaim), not against a current membership.

Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 1e4146df8ad2ed24f2d011b3b03842c2840a7dbe
Local-runs: none

Owed by the Clause-②: yes (widening) declaration of claim 6238919385 alone. The diff touches no packages/spec/src/** file and no governed path. Read at 2026-10-17T20:26Z:

① Derived judgments

  • AuthManager.verifyMcpAccessToken's result gains organizationId?: string: a widening, and correct. plugin-auth/src/index.ts re-exports ./auth-manager.js whole, so the method's type is published. The member is optional, so every caller compiles unchanged. It matches IAuthService.verifyMcpAccessToken's result as spec(contracts): the verified MCP access token result carries organizationId, the organization an OAuth consent bound (slice 1 of #24137, ruled A at 6232069920) #24165 widened it (contract review PASS 6237222227). The dev measured the built dist/index.d.ts / .d.mts BASE a237d7f496 vs head: exactly one declaration line is added. platform-objects' built declarations are byte-identical.
  • The issued MCP access token gains the claim organization_id. The name is the contract's. It is not in the provider's RESERVED_ACCESS_TOKEN_CLAIMS set (dist/introspect-*.mjs), so it is not stripped at issuance.
    • Its only producer is customAccessTokenClaims({ referenceId }), and the reference comes only from postLogin.consentReferenceId.
    • The provider calls that hook with { user, session, scopes } and nothing else (dist/authorize-*.mjs), so no request parameter reaches it. This is pinned: organization-looking authorize parameters change nothing recorded.
    • A consent with no active organization binds none, and its tokens carry no claim. No default value exists.
  • Existing consents re-confirm once. The provider's consent lookup filters on referenceId whenever the hook answers one, so a null-reference consent does not satisfy an authorize made in an organization. This is pinned: a re-prompt once, none after, and an old refresh family that stays unbound. There is no dual path and no fallback, as the ruling requires.
  • The verifier refuses a present-but-malformed organization_id. This narrows the verifier's acceptance for a shape this server never mints. The one path that can produce it is an administrator-written sys_oauth_resource.custom_claims, which the provider ranks above customAccessTokenClaims. This is a runtime behaviour of an existing fail-closed method, not a published accept set. Reading the claim as absent would be consumer-side leniency that masks the misconfiguration. Pinned with a control (the string case is accepted). Correct.
  • reference_id on sys_oauth_consent, sys_oauth_refresh_token and sys_oauth_access_token now names the bound organization.
  • No new config key or environment variable. postLogin and customAccessTokenClaims are options of the provider instance. shouldRedirect: () => false adds no redirect: the provider reads postLogin.page only when shouldRedirect answers true.

② Semver level

@objectstack/plugin-auth minor (a published type widening and a new token claim) and @objectstack/platform-objects patch (description prose and the generated bundle rows). Both match the diff. Clause-②: yes (widening) is at a line start in the PR body and in the changeset.

③ Boundary flags

Implemented-by: claude/issue-24166-oauth-consent-organization
Reviewed-by: session_01Nk17Z26X5DB59iQztDBADH

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-17T20:27Z · head 1e4146df8a. It covers round 0 (81b7b99c2c, read by the seat at 6239991736 on #24166) and patch round 1. Contract review PASS 6240503112 on this head. It lands through the merge queue once every check on this head is green.

  • Shape: a draft PR to main. Its first line is Fixes #24166, and that is the only closing keyword in the body. Clause-②: yes (widening) is at a line start in the body and in the changeset. Card and PR assignee: marchtian. Not governed: check-governed-merges reads 0 of 12 paths, and +579 / −19 ≤ 3000.
  • Premises, measured by the dev and re-read by the seat in the published provider dist: the hooks see the session only; the consent lookup filters on the reference, so an unbound consent re-prompts once; no redirect is added; and organization_id is not a reserved claim.
  • The change, read by the seat:
    • postLogin.consentReferenceId returns the session's activeOrganizationId, or nothing.
    • customAccessTokenClaims emits it as organization_id only when a reference exists.
    • verifyMcpAccessToken:
      • returns it as organizationId;
      • refuses a present-but-malformed claim;
      • has its own return type extended to match IAuthService's;
      • keeps the client_credentials refusal and the JWKS cache untouched.
    • The three reference_id descriptions and their four locales name the bound organization. sys_oauth_application is unchanged.
  • The ruling's items, each pinned on a real authorization server:
    • the claim and both reference_id rows equal the session's organization;
    • a refresh keeps the consent's organization after the session switches;
    • no active organization gives no claim;
    • authorize parameters cannot choose the organization;
    • an unbound consent re-prompts exactly once, and an old refresh family stays unbound.
    • Ablated: with the hook disabled, 4 of 5 went red, and the no-organization case stayed green as predicted. Restored with blob equal to HEAD.
  • Patch round 1: the changeset's and the docblock's vetting sentence now say what the IAuthService contract says: the claim is vetted by the rule that vets a session's active organization (vetOrganizationClaim). No added line restates a membership guarantee.
  • Prose faces, each sentence checked against the diff:
  • Gates on this head: at this read, 29 checks succeeded, 8 were skipped by design (the roster set, plus Auto Label and Check PR Size after the body edit), and 2 Test Core shards were running. There were no failures. Locally: 69 derived families, 69 exit 0, and --ran reports 0 NOT-MEASURED.
  • Accepted deviations:
    • the hand-translated locale leaves (AGENTS.md's *.generated.ts row);
    • auth-schema-config.ts (a text this change made false);
    • the malformed-claim refusal (judged in the contract review);
    • origin/main past 1e4146df8 by b7f29ddfe (cli only); the merge group re-runs the gates.
  • Out-of-scope findings: carried to finding(mcp/oauth): an OAuth-token MCP agent acting for an ordinary user is denied every object operation — the D10 delegator is resolved with no active organization, so the user's organization-scoped grants do not count #24137 at 6240513333: a resource row's customClaims precedence, and vetOrganizationClaim under single.
  • Serial note: plugin-auth: the MCP access-token verifier holds the JWKS on a 5-minute TTL alone, with no write seam, so a removed signing key keeps verifying and a rotated-in key is refused until it lapses #24186 (p2, security, the verifier's JWKS cache in the same method) waits on this PR's merge, and the seat dispatches it from the merged main.

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

2 participants