Repository navigation
feat(plugin-auth): an OAuth consent binds the consenting session's active organization, carried as organization_id - #24218
Conversation
…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>
…uth-consent-organization
📓 Docs Drift CheckThis PR changes 2 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 18 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…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>
…uth-consent-organization
…uth-consent-organization
Contract reviewServed-tier: Owed by the
① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT ·
Generated by Claude Code |
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 inpackages/runtimeorpackages/corechanges. Upstream slice #24165 landed the contract member (b1845ed637).What changed
packages/plugins/plugin-auth/src/auth-manager.ts, theoauthProvider({ … })options. Two seams the installed provider declares:postLogin.consentReferenceIdanswers the consenting session'sactiveOrganizationId(better-auth's field on the session of record), orundefinedwhen it has none. It reads the session the provider hands it and nothing else.postLoginis 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) andpage(read only whenshouldRedirectanswers true, which it never does; it names the consent page).customAccessTokenClaimsemits the consent's reference asorganization_id, and nothing when there is none.verifyMcpAccessToken. Its own return type gainsorganizationId?: string, matchingIAuthService's, and it returns theorganization_idclaim there. A token whoseorganization_idis 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. Theclient_credentialsrefusal and the JWKS cache region are untouched.packages/platform-objects. Thereference_iddescription onsys_oauth_consent,sys_oauth_refresh_tokenandsys_oauth_access_tokennames the bound organization. Theenbundle rows were regenerated withpnpm 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_idis unchanged: the provider writes it only fromclientReference, an option plugin-auth does not pass (measured below).auth-schema-config.ts: theoauthConsentmapping's doc comment said the column was "for client-supplied correlation", which this change makes false. Comment only.@objectstack/plugin-authminor,@objectstack/platform-objectspatch.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/.oauth-1Ud-hvZY.d.mts:1540-1574(postLogin { page; consentReferenceId({ user, session, scopes }); shouldRedirect }),:1698-1704(customAccessTokenClaims({ user, referenceId, scopes, resources, metadata })).consentReferenceIdand creates the row with thatreferenceId(authorize-9whjxVLJ.mjs:67-118)./oauth2/authorizecalls it again and puts it on the authorization code (:5646-5700;redirectWithAuthorizationCodeat:5706-5733stores it).verificationValue.referenceId(introspect-CbhhXT0E.mjs:2040), the refresh row stores it (:1545-1556), a refresh passesrefreshToken.referenceId(:2177), andresolveAccessTokenClaimshands it tocustomAccessTokenClaimson both paths (:239-262).organization_idis not inRESERVED_ACCESS_TOKEN_CLAIMS(:189-202), so it is not stripped. Read off the minted token below.{ 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 asorganization_id,organizationId,reference_idandreferenceIdon the authorize request: the consent row, the refresh row and the token all carry the session's organization./oauth2/authorizeaddsreferenceIdto 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.Clause-② measurement (built entry declarations, BASE
a237d7f496vs this branch)@objectstack/plugin-authdist/index.d.tsanddist/index.d.mts: the one declaration change is inverifyMcpAccessToken(token: string): its resolved object type gainsorganizationId?: string;besideuserId,scopesandclientId?(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 theAUTH_OAUTH_CONSENT_SCHEMAcomment).@objectstack/platform-objectsdist/index.d.ts,index.d.mts,plugin.d.ts,plugin.d.mts: byte-identical (diffexit 0; md5 equal).Tests
src/auth-manager.oauth-consent-organization.test.ts: the options a realAuthManagerhandsoauthProvider(), booted in a real better-auth server with the real provider and the realorganizationplugin, driven DCR, sign-up, organization, authorize, consent, token and refresh. Five cases:organization_idequals the session's active organization; consent row and refresh rowreference_idequal it;verifyMcpAccessTokenreturnsorganizationId;organizationIdmember;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).scripts/ablation-replace.mjs(the test imports./auth-managersource, so nodistleg):consentReferenceIdreplaced by() => undefined, anchor 1 to 0, blobab0876f238adto8422b44fd87d. Result: 4 failed, 1 passed (the no-organization case, which asserts absence and is expected to stay green). Restore: blob equals HEAD (ab0876f238ad) andgit diff HEADempty.81b7b99c2(the final commit), all underscripts/pm/os-verify-lock.sh(shared-box seconds).pnpm --filter @objectstack/plugin-auth typecheck, thenpnpm --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 itsvitest run --maxWorkers=2 --passWithNoTests:check:test-typecheck: OK;Test Files 73 passed (73),Tests 1136 passed (1136);VERDICT command-exit 0.1371aa7a3: 5 of 5 passed; 1.29 s of tests in a 16.3 s file run (the rest is module import).platform-objects' built declarations are byte-identical, andplugin-auth's public face changes by one optional member on one return type.test:repois not run locally (CI'sTest Core); theintegration-tier rule ispackages/cli's and does not apply.Gates
81b7b99c2withnode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths; merge base8259ea03e; 12 paths, +578 / -19): 69 commands, the same set the seat derived (sorted set difference empty).pnpm check:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET(exit 3: packages outside this diff had nodist/); after a fullturbo 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.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.Test Core,Build Core,TypeScript Type Check, dogfood) is CI's, read from this PR's check runs.Patch round 1 (seat record
6239991736)IAuthService.verifyMcpAccessTokencontract 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 underAuthManager.verifyMcpAccessToken, and the matching sentence inverifyMcpAccessToken's docblock (commit099568c28). Neither restates a membership guarantee, because that rule reads no membership undersingleor with no posture (see the acceptance notes). Nothing else changed: the changeset keepsClause-②: yes (widening),@objectstack/plugin-authminor and@objectstack/platform-objectspatch.origin/mainmerged twice (a0f3c8304,1e4146df8), with no generated file in either merge. The head is1e4146df8.b7f29ddfe(cli only) landed afterwards and is not merged.1e4146df8through 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).platform-objectsvitest run:Test Files 74 passed (74),Tests 1139 passed (1139).VERDICT command-exit 0.dispatch-gates --commands --repo objectstack-ai/objectstackon1e4146df8(12 paths, +579 / -19) derives the same 69 commands. All 69 answered exit 0.--ranreports69 run, 0 NOT-MEASURED.Acceptance notes
customAccessTokenClaims, then a resource row'scustomClaims(introspect-CbhhXT0E.mjs:229-262). Asys_oauth_resource.custom_claimsvalue carryingorganization_idon 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: 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 (the runtime read), noted.vetOrganizationClaim(packages/core/src/security/resolve-authz-context.ts:762-770) keeps a claim with no membership read undersingleor with no posture. That is the session arm as ruled, and 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's runtime read reuses it. Undersingle, a former member's OAuth token keeps resolving in the bound organization, exactly as a REST session's claim does. Read-only inference. Carrier: 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, noted.postLoginconfigured, the provider addsba_pl(the consenting session'ssys_session.id, not its token) to the signed consent-page query (authorize-9whjxVLJ.mjs:5735,:5753), sopostLogin.shouldRedirectis skipped on the way back. Observation. Carrier: none.authorize-9whjxVLJ.mjs:71-109). It changes no binding. Provider semantics, observation. Carrier: none.auth-manager.mcp-oauth-resource.test.ts, which are module-local there. Sharing them would refactor that file. Carrier: none.8259ea03e; the incoming commits touchrest,service-settingsand dogfood, none of this diff's packages), then rebuilt and re-measured.The contract review of record is the lane seat's (
TIER_DEFAULT).customClaimsoutrankscustomAccessTokenClaims;vetOrganizationClaimreads no membership undersingle) are 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 by the seat.Generated by Claude Code