Skip to content

Commit 99e9d29

Browse files
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
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
feat(plugin-auth): an OAuth consent binds the consenting user's active organization, the access token carries it as `organization_id`, and `verifyMcpAccessToken` returns it as `organizationId`
7+
8+
Clause-②: yes (widening)
9+
10+
- **What changed.** When a user consents to an OAuth client, the embedded authorization server records the consenting session's active organization as the consent's `reference_id`. The value rides the authorization code onto the refresh family it starts (`sys_oauth_refresh_token.reference_id`), and every access token minted from either carries it as the `organization_id` claim. It is written through the installed `@better-auth/oauth-provider`'s own seams (`postLogin.consentReferenceId` and `customAccessTokenClaims`); no new configuration key or environment variable.
11+
- **The session's organization, never the request's.** The value is read from the consenting session only. Nothing in the authorize request, the consent body or the client's registration can choose it.
12+
- **No organization, no claim.** A user with no active organization at consent gets a consent with no reference, and tokens with no `organization_id`. There is no guessed or default organization; such a token's principal resolves with global grants only.
13+
- **A refresh keeps the consent's organization.** A refresh token mints access tokens in the organization its consent bound, even after the user switches the browser session to another organization. Authorizing again while another organization is active asks for a second consent, for that organization.
14+
- **Existing consents re-confirm once.** A consent stored before this change has no reference, so the next authorization made while the user has an active organization shows the consent page again, once. Until the user re-confirms, a refresh family started before this change keeps minting tokens with no `organization_id`, so they carry global grants only.
15+
- **`AuthManager.verifyMcpAccessToken`.** Its result type gains `organizationId?: string`, matching `IAuthService.verifyMcpAccessToken` in `@objectstack/spec/contracts`, and it returns the token's `organization_id` claim there. A token whose `organization_id` is present but is not a non-empty string is refused, as every other malformed token is: this server never mints one. The organization is a record, not an authorization. The caller vets it on every request by the rule that vets a session's active organization (`vetOrganizationClaim`, `@objectstack/core`).
16+
- **`@objectstack/platform-objects`.** The `reference_id` field description on `sys_oauth_consent`, `sys_oauth_refresh_token` and `sys_oauth_access_token` now names the bound organization, instead of "Caller-supplied correlation identifier". Prose only; no field, type or schema change.

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,7 +1587,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
15871587
},
15881588
reference_id: {
15891589
label: "Reference ID",
1590-
help: "Caller-supplied correlation identifier"
1590+
help: "Organization bound by the consent this token was issued under; empty when none was bound"
15911591
},
15921592
authorization_code_id: {
15931593
label: "Authorization Code ID",
@@ -1647,7 +1647,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
16471647
},
16481648
reference_id: {
16491649
label: "Reference ID",
1650-
help: "Caller-supplied correlation identifier"
1650+
help: "Organization bound by the consent that started this refresh family; empty when none was bound"
16511651
},
16521652
authorization_code_id: {
16531653
label: "Authorization Code ID",
@@ -1715,7 +1715,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
17151715
},
17161716
reference_id: {
17171717
label: "Reference ID",
1718-
help: "Caller-supplied correlation identifier"
1718+
help: "Organization this consent binds: the consenting user's active organization at consent; empty when none was active"
17191719
},
17201720
resources: {
17211721
label: "Resources",

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,7 +1587,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
15871587
},
15881588
reference_id: {
15891589
label: "ID de referencia",
1590-
help: "Identificador de correlación proporcionado por quien llama."
1590+
help: "Organización vinculada por el consentimiento con el que se emitió este token; vacío si no se vinculó ninguna."
15911591
},
15921592
authorization_code_id: {
15931593
label: "ID del código de autorización",
@@ -1647,7 +1647,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
16471647
},
16481648
reference_id: {
16491649
label: "ID de referencia",
1650-
help: "Identificador de correlación proporcionado por quien llama."
1650+
help: "Organización vinculada por el consentimiento que inició esta familia de tokens de actualización; vacío si no se vinculó ninguna."
16511651
},
16521652
authorization_code_id: {
16531653
label: "ID del código de autorización",
@@ -1715,7 +1715,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
17151715
},
17161716
reference_id: {
17171717
label: "ID de referencia",
1718-
help: "Identificador de correlación proporcionado por quien llama."
1718+
help: "Organización que vincula este consentimiento: la organización activa del usuario al consentir; vacío si no había ninguna."
17191719
},
17201720
resources: {
17211721
label: "Recursos",

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,7 +1587,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
15871587
},
15881588
reference_id: {
15891589
label: "参照 ID",
1590-
help: "呼び出し元が指定した相関識別子"
1590+
help: "このトークンの発行根拠となった同意が紐づけた組織。紐づけがない場合は空"
15911591
},
15921592
authorization_code_id: {
15931593
label: "認可コード ID",
@@ -1647,7 +1647,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
16471647
},
16481648
reference_id: {
16491649
label: "参照 ID",
1650-
help: "呼び出し元が指定した相関識別子"
1650+
help: "このリフレッシュトークン系列を開始した同意が紐づけた組織。紐づけがない場合は空"
16511651
},
16521652
authorization_code_id: {
16531653
label: "認可コード ID",
@@ -1715,7 +1715,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
17151715
},
17161716
reference_id: {
17171717
label: "参照 ID",
1718-
help: "呼び出し元が指定した相関識別子"
1718+
help: "この同意が紐づける組織。同意時のユーザーのアクティブな組織で、アクティブな組織がなかった場合は空"
17191719
},
17201720
resources: {
17211721
label: "リソース",

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,7 +1587,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
15871587
},
15881588
reference_id: {
15891589
label: "引用 ID",
1590-
help: "调用方提供的关联标识符"
1590+
help: "签发该令牌所依据的同意所绑定的组织;未绑定组织时为空"
15911591
},
15921592
authorization_code_id: {
15931593
label: "授权码 ID",
@@ -1647,7 +1647,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
16471647
},
16481648
reference_id: {
16491649
label: "引用 ID",
1650-
help: "调用方提供的关联标识符"
1650+
help: "开启该刷新令牌族的同意所绑定的组织;未绑定组织时为空"
16511651
},
16521652
authorization_code_id: {
16531653
label: "授权码 ID",
@@ -1715,7 +1715,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
17151715
},
17161716
reference_id: {
17171717
label: "引用 ID",
1718-
help: "调用方提供的关联标识符"
1718+
help: "该同意绑定的组织:同意时用户的当前活动组织;同意时无活动组织则为空"
17191719
},
17201720
resources: {
17211721
label: "资源",

‎packages/platform-objects/src/identity/sys-oauth-access-token.object.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export const SysOauthAccessToken = ObjectSchema.create({
9494
label: 'Reference ID',
9595
required: false,
9696
maxLength: 255,
97-
description: 'Caller-supplied correlation identifier',
97+
description: 'Organization bound by the consent this token was issued under; empty when none was bound',
9898
}),
9999

100100
authorization_code_id: Field.text({

‎packages/platform-objects/src/identity/sys-oauth-consent.object.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ export const SysOauthConsent = ObjectSchema.create({
6363
label: 'Reference ID',
6464
required: false,
6565
maxLength: 255,
66-
description: 'Caller-supplied correlation identifier',
66+
description:
67+
"Organization this consent binds: the consenting user's active organization at consent; empty when none was active",
6768
}),
6869

6970
resources: Field.textarea({

‎packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export const SysOauthRefreshToken = ObjectSchema.create({
8787
label: 'Reference ID',
8888
required: false,
8989
maxLength: 255,
90-
description: 'Caller-supplied correlation identifier',
90+
description: 'Organization bound by the consent that started this refresh family; empty when none was bound',
9191
}),
9292

9393
authorization_code_id: Field.text({

‎packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,42 @@ describe('verifyMcpAccessToken (local JWKS verification, fail-closed)', () => {
443443
});
444444
});
445445

446+
// ── the bound organization, by CLAIM SHAPE (#24166) ─────────────────────
447+
// What the real authorization server mints, and from where, is pinned in
448+
// auth-manager.oauth-consent-organization.test.ts against a running server.
449+
// These pin how the verifier READS the claim, including shapes that server
450+
// never mints.
451+
452+
it('returns the `organization_id` claim as `organizationId`', async () => {
453+
const token = await signToken({ sub: 'user-1', client_id: 'client-abc', organization_id: 'org-a' });
454+
expect(await manager().verifyMcpAccessToken(token)).toEqual({
455+
userId: 'user-1',
456+
scopes: ['data:read', 'data:write'],
457+
clientId: 'client-abc',
458+
organizationId: 'org-a',
459+
});
460+
});
461+
462+
it('a token with no `organization_id` claim resolves with no `organizationId` member at all', async () => {
463+
const res = await manager().verifyMcpAccessToken(await signToken());
464+
expect(res).not.toBeNull();
465+
expect(Object.keys(res!)).not.toContain('organizationId');
466+
});
467+
468+
it('refuses a token whose `organization_id` is present but is not a non-empty string', async () => {
469+
// Not read as "no organization": a claim this server never mints is doubt,
470+
// and the method is fail-closed on doubt. The string case beside it is the
471+
// control, so a method that refuses everything cannot satisfy this.
472+
for (const organization_id of [42, '', ['org-a'], { id: 'org-a' }, null]) {
473+
const token = await signToken({ organization_id });
474+
expect(
475+
await manager().verifyMcpAccessToken(token),
476+
`organization_id ${JSON.stringify(organization_id)} must be refused`,
477+
).toBeNull();
478+
}
479+
expect(await manager().verifyMcpAccessToken(await signToken({ organization_id: 'org-a' }))).not.toBeNull();
480+
});
481+
446482
it('rejects garbage / non-JWT input without touching the JWKS', async () => {
447483
const m = manager();
448484
expect(await m.verifyMcpAccessToken('')).toBeNull();

0 commit comments

Comments
 (0)