diff --git a/.changeset/30918-client-authorized-clients.md b/.changeset/30918-client-authorized-clients.md new file mode 100644 index 00000000000..267894d9036 --- /dev/null +++ b/.changeset/30918-client-authorized-clients.md @@ -0,0 +1,10 @@ +--- +'@objectstack/client': patch +--- + +feat(client): `client.oauth.authorizedClients.list()` and `.listForUser(userId)` read the AI assistants a person has authorized and when each was last used + +Clause-②: yes (widening) +Metadata-protocol: none + +`list()` calls `GET /api/v1/auth/authorized-clients` with no parameter: the caller's own grants. `listForUser(userId)` calls `GET /api/v1/auth/admin/authorized-clients?userId=...` and is answered for a platform administrator only. Both unwrap the `{ success, data }` envelope to the new exported `AuthorizedClientsList` type, `{ clients: AuthorizedClient[]; truncated: boolean }`. `AuthorizedClient.lastUsedAt` is an hour start, `null` when no use has been recorded. A refusal throws, as every other method does. Nothing existing changes. diff --git a/.changeset/30918-platform-objects-consent-last-used.md b/.changeset/30918-platform-objects-consent-last-used.md new file mode 100644 index 00000000000..cf6a59c8739 --- /dev/null +++ b/.changeset/30918-platform-objects-consent-last-used.md @@ -0,0 +1,10 @@ +--- +'@objectstack/platform-objects': patch +--- + +feat(platform-objects): `sys_oauth_consent` gains `last_used_at`, the start of the most recent hour in which the client made a verified MCP call under the consent + +Clause-②: yes (widening) +Metadata-protocol: none + +A nullable, read-only datetime on the better-auth-managed consent table, owned by ObjectStack. The OAuth provider neither reads nor writes it. Its one writer is plugin-auth, server-side, after it verifies an MCP access token minted under the consent. The object stays off the data API (`apiEnabled: false`), so no client reaches the column. A withdrawn or revoked consent takes the stamp with it. Schema sync adds the column, and existing rows read `null`. diff --git a/.changeset/30918-plugin-auth-authorized-clients.md b/.changeset/30918-plugin-auth-authorized-clients.md new file mode 100644 index 00000000000..d162e749d00 --- /dev/null +++ b/.changeset/30918-plugin-auth-authorized-clients.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-auth': patch +--- + +feat(plugin-auth): a person can read the AI assistants they have authorized and when each was last used, and a platform administrator can read another person's + +Clause-②: yes (widening) +Metadata-protocol: none + +Two new reads, both ObjectStack mounts ahead of the better-auth catch-all and both in `AUTH_ROUTE_LEDGER`: + +- `GET /api/v1/auth/authorized-clients` answers the signed-in person's own OAuth consents (Claude Desktop, an IDE agent, any MCP client). It is keyed by the session and nothing else: it takes no query parameter, and one that is sent (`?userId=` included) is refused `400 VALIDATION_ERROR` rather than ignored. +- `GET /api/v1/auth/admin/authorized-clients?userId=ID` answers another person's, for a platform administrator only. It is gated exactly as the admin session doors are (ADR-0068 D2, a held session refused per ADR-0069 D3). The gate runs before the query is read, so anyone else (an organization owner or admin included) gets one `403 PERMISSION_DENIED` whether or not the person exists. It lists every organization the person granted in. A missing, empty or repeated `userId` is `400`, and an id naming no user is `404 RESOURCE_NOT_FOUND`. It is read only. + +Each row carries exactly `id` (the consent id, the key `POST /oauth2/delete-consent` takes), `clientId`, `name`, `scopes`, `organizationId`, `authorizedAt` and `lastUsedAt`. Nothing in it identifies or reconstructs a token. The answer is newest first, at most 200 rows, with `truncated: true` when the person holds more, and it is sent with `Cache-Control: no-store`. + +`lastUsedAt` is the start of the most recent clock hour in which the client made a verified MCP call under that consent, or `null` when none has been recorded. The server writes it after `verifyMcpAccessToken` admits a token, and only on the consent that admitted it: the consent for the token's client, person and organization, created no later than the token was issued, the rule the verifier's own consent read applies. So a revoked consent, or a token of an earlier grant, stamps nothing. The write is off the request path, at most once per consent per hour in each process (several processes may each write the same hour start), from the verified token and the server clock alone. A store failure never fails the MCP call, and each distinct failure is logged once per process at `error`; a consent withdrawn between the stamp's read and its write is not a failure. It lands in the new `sys_oauth_consent.last_used_at` column, so a deployment picks it up at its next schema sync. Until then the reads answer `500` and the stamp logs that error once per process. diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index ff22ac4cded..f99bd45af52 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **55 of the 261 sites are spelled that way**. A +forwarding shim cannot, and **55 of the 262 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **261** | +| 175 write call sites | quoted in the merged changeset | **262** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **184 of 261** decidable, **77** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 144 decidably elevated, 0 decidably not, 109 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **185 of 262** decidable, **77** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 145 decidably elevated, 0 decidably not, 109 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **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. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -144 of 261 (55%) as decidably elevated, with 109 more whose elevation is a +145 of 262 (55%) as decidably elevated, with 109 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 261`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 262`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **29 further sites** have an options argument this @@ -233,28 +233,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **261** | -| …whose object name is statically decidable | 184 | +| write call sites on the application surface | **262** | +| …whose object name is statically decidable | 185 | | …whose object name is chosen at run time | 77 | -| …against an object with tenancy ENABLED | 178 | +| …against an object with tenancy ENABLED | 179 | | …against an object that declares tenancy off | 6 | -| threading a tenant context | 198 | +| threading a tenant context | 199 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 55 | | …of those, against a decidably tenancy-enabled object | 29 | -| threading a decidably ELEVATED (`isSystem`) context | 144 | +| threading a decidably ELEVATED (`isSystem`) context | 145 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 109 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 199 | +| receiver carried a readable engine type | 200 | | receiver erased, placed by the object NAME | 52 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 116 | +| object name spelled inline | 117 | | object name spelled through a `const` | 68 | | object name is an `object: string` parameter | 7 | | object name is some other run-time expression | 70 | @@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-11-05 at `e40e04854`. +Measured on 2026-11-06 at `2ca181937`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 871 | -| engine-shaped types recognised | 105 | -| declared objects in the registry | 127 | -| same-named calls subtracted as non-engine | 229 | +| tracked non-test sources scanned | 1034 | +| engine-shaped types recognised | 111 | +| declared objects in the registry | 130 | +| same-named calls subtracted as non-engine | 232 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index d05ca1acbbb..407d97175b2 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 261 | -| Object name statically decidable | 184 | +| Write call sites | 262 | +| Object name statically decidable | 185 | | Object name chosen at run time | 77 | -| Against a tenancy-enabled object | 178 | +| Against a tenancy-enabled object | 179 | | Against an object declaring tenancy off | 6 | -| Threading a tenant context | 198 | +| Threading a tenant context | 199 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 55 | | …and decidably tenancy-enabled | 29 | -| Threading a decidably elevated context | 144 | +| Threading a decidably elevated context | 145 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 109 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-11-05 at `e40e04854`. +Measured on 2026-11-06 at `2ca181937`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 871 | -| engine-shaped types recognised | 105 | -| declared objects in the registry | 127 | -| same-named calls subtracted as non-engine | 229 | +| tracked non-test sources scanned | 1034 | +| engine-shaped types recognised | 111 | +| declared objects in the registry | 130 | +| same-named calls subtracted as non-engine | 232 | ## Every site @@ -153,6 +153,7 @@ Measured on 2026-11-05 at `e40e04854`. | `packages/plugins/plugin-auth/src/forced-reset-revocation.ts` | `update` | `SystemObjectName.API_KEY` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/forced-reset-revocation.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/idp-locale-claim.ts` | `update` | `sys_user` | enabled | elevated | 1 | +| `packages/plugins/plugin-auth/src/mcp-client-last-use.ts` | `update` | `sys_oauth_consent` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/member-role-canonical.ts` | `update` | `MEMBER_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/membership-backfill-ledger.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/membership-ended-session.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 2 | diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index f2ce29cc1ee..1e1bbafb314 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1230,6 +1230,45 @@ export interface OAuthConsentResult { url: string; } +/** + * One AI assistant (OAuth client) a person has authorized: a consent they + * granted on the consent page, as `oauth.authorizedClients.list()` and + * `oauth.authorizedClients.listForUser()` answer it. + * + * Exactly these members. Nothing here identifies or reconstructs a token: no + * access-token, refresh-token or authorization-code value or id, no client + * secret, no session. + */ +export interface AuthorizedClient { + /** The consent's own id — the key `POST /oauth2/delete-consent` takes to withdraw it. */ + id: string; + /** The OAuth client the grant is for. */ + clientId: string; + /** The client's display name; `null` when it declares none. */ + name: string | null; + /** What the person granted. */ + scopes: string[]; + /** The organization the grant is held in; `null` when none was active when it was granted. */ + organizationId: string | null; + /** When the grant was made (ISO 8601); `null` when the row carries no readable time. */ + authorizedAt: string | null; + /** + * The START of the most recent clock hour in which the client made a + * verified MCP call under this grant (ISO 8601) — the server records it at + * hour granularity, so it means "used during the hour starting at", never a + * precise moment. `null` when no use has been recorded. + */ + lastUsedAt: string | null; +} + +/** What both `oauth.authorizedClients` reads answer. */ +export interface AuthorizedClientsList { + /** Newest grant first. */ + clients: AuthorizedClient[]; + /** `true` when the person holds more grants than the server answers in one read (200). */ + truncated: boolean; +} + /** * The user object better-auth puts on the wire from the `auth.*` routes that * echo one — `changePassword`, `verifyEmail` (on a change-email verification), @@ -4951,6 +4990,45 @@ export class ObjectStackClient { }); return res.json(); }, + + /** + * The AI assistants (OAuth clients) a person has authorized, and when each + * was last used. ObjectStack routes under the auth route, answered in the + * `{ success, data }` envelope this unwraps. + */ + authorizedClients: { + /** + * The signed-in person's own authorized assistants. + * GET /api/v1/auth/authorized-clients + * + * Keyed by the session and nothing else: the route takes no parameter, + * and refuses one (`400`) rather than ignoring it, so this method sends + * none. Withdraw a grant with `POST /oauth2/delete-consent` and the row's + * `id`. + */ + list: async (): Promise => { + const route = this.getRoute('auth'); + const res = await this.fetch(`${this.baseUrl}${route}/authorized-clients`); + return this.unwrapResponse(res); + }, + + /** + * Another person's authorized assistants, in every organization they + * granted in (each row names its `organizationId`). + * GET /api/v1/auth/admin/authorized-clients?userId=... + * + * Platform administrators only, gated as the admin session doors are: + * anyone else is refused `403` whoever `userId` names. A `userId` that + * names no user answers `404`. Read only. + */ + listForUser: async (userId: string): Promise => { + const route = this.getRoute('auth'); + const res = await this.fetch( + `${this.baseUrl}${route}/admin/authorized-clients?userId=${encodeURIComponent(userId)}`, + ); + return this.unwrapResponse(res); + }, + }, }; /** diff --git a/packages/client/src/oauth-authorized-clients.test.ts b/packages/client/src/oauth-authorized-clients.test.ts new file mode 100644 index 00000000000..c10db7f4ae0 --- /dev/null +++ b/packages/client/src/oauth-authorized-clients.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30918] `oauth.authorizedClients` — the URL each method builds and the + * answer it hands back. The server half (who may read whose grants) is pinned + * in plugin-auth's `authorized-clients-read.real-plugin.test.ts`; this file + * pins only what the SDK puts on the wire and what it returns. + */ + +import { describe, it, expect, expectTypeOf, vi } from 'vitest'; +import { ObjectStackClient, type AuthorizedClientsList } from './index'; + +const BASE = 'http://localhost:3000'; + +const LIST: AuthorizedClientsList = { + clients: [ + { + id: 'consent_1', + clientId: 'client_1', + name: 'Desk Assistant', + scopes: ['openid', 'mcp:read'], + organizationId: 'org_1', + authorizedAt: '2026-11-01T09:12:00.000Z', + lastUsedAt: '2026-11-06T10:00:00.000Z', + }, + ], + truncated: false, +}; + +function clientAnswering(body: unknown, status = 200) { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }), + ); + const client = new ObjectStackClient({ baseUrl: BASE, fetch: fetchMock as never }); + return { client, fetchMock }; +} + +describe('#30918 oauth.authorizedClients', () => { + it('list() reads the caller\'s own grants with NO parameter and unwraps the envelope', async () => { + const { client, fetchMock } = clientAnswering({ success: true, data: LIST }); + const answer = await client.oauth.authorizedClients.list(); + expect(answer).toEqual(LIST); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit | undefined]; + // The route refuses any parameter, so the method sends none. + expect(url).toBe(`${BASE}/api/v1/auth/authorized-clients`); + expect(init?.method ?? 'GET').toBe('GET'); + }); + + it('listForUser(id) names the person in the one parameter the admin route takes, encoded', async () => { + const { client, fetchMock } = clientAnswering({ success: true, data: LIST }); + const answer = await client.oauth.authorizedClients.listForUser('user a&b'); + expect(answer).toEqual(LIST); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit | undefined]; + expect(url).toBe(`${BASE}/api/v1/auth/admin/authorized-clients?userId=user%20a%26b`); + expect(init?.method ?? 'GET').toBe('GET'); + }); + + it('a refusal reaches the caller as a throw, not as an empty list', async () => { + const { client } = clientAnswering( + { success: false, error: { code: 'PERMISSION_DENIED', message: 'Platform administrator access required.' } }, + 403, + ); + await expect(client.oauth.authorizedClients.listForUser('user_2')).rejects.toThrow(); + }); + + it('both methods are typed with the answer they unwrap', () => { + const client = new ObjectStackClient({ baseUrl: BASE, fetch: vi.fn() as never }); + expectTypeOf(client.oauth.authorizedClients.list).returns.toEqualTypeOf>(); + expectTypeOf(client.oauth.authorizedClients.listForUser).returns.toEqualTypeOf>(); + }); +}); diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index d6c4a4fde83..772653ccf62 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -1873,6 +1873,10 @@ export const enObjects: NonNullable = { }, updated_at: { label: "Updated At" + }, + last_used_at: { + label: "Last Used At", + help: "Start of the most recent hour in which the client made a verified MCP call under this consent; empty when none has been recorded" } } }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 362cb7c7ff0..cb8c1ad9371 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -1873,6 +1873,10 @@ export const esESObjects: NonNullable = { }, updated_at: { label: "Actualizado el" + }, + last_used_at: { + label: "Último uso", + help: "Inicio de la hora más reciente en la que el cliente hizo una llamada MCP verificada con este consentimiento; vacío si no se ha registrado ninguna" } } }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 39fb7fec74d..45faf4c89da 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -1873,6 +1873,10 @@ export const jaJPObjects: NonNullable = { }, updated_at: { label: "更新日時" + }, + last_used_at: { + label: "最終使用日時", + help: "この同意のもとでクライアントが検証済みの MCP 呼び出しを最後に行った時間帯の開始時刻。記録がない場合は空です" } } }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index ff5ab7c0cb6..b3086c28eab 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -1873,6 +1873,10 @@ export const zhCNObjects: NonNullable = { }, updated_at: { label: "更新时间" + }, + last_used_at: { + label: "最近使用时间", + help: "客户端在此同意下最近一次经验证的 MCP 调用所在小时的开始时间;尚无记录时为空" } } }, diff --git a/packages/platform-objects/src/identity/sys-oauth-consent.object.ts b/packages/platform-objects/src/identity/sys-oauth-consent.object.ts index 21c115a29c4..3f9d208553a 100644 --- a/packages/platform-objects/src/identity/sys-oauth-consent.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-consent.object.ts @@ -110,6 +110,24 @@ export const SysOauthConsent = ObjectSchema.create({ defaultValue: 'NOW()', readonly: true, }), + + // [#30918] ObjectStack's own column on this better-auth table: the + // oauth-provider neither reads nor writes it (plugin-auth's + // `oauth-provider-schema-parity.test.ts` pins that it is not one of the + // provider's columns). Its one writer is plugin-auth, server-side, after + // it has verified an MCP access token minted under this consent + // (`mcp-client-last-use.ts`), and the value is the start of that hour, so + // the column moves at most once an hour per consent. Nothing a client + // sends reaches it: the object is not on the data API, the provider's + // consent doors do not carry it, and the value is the server's clock. + // A withdrawn or revoked consent takes its stamp with it. + last_used_at: Field.datetime({ + label: 'Last Used At', + required: false, + readonly: true, + description: + 'Start of the most recent hour in which the client made a verified MCP call under this consent; empty when none has been recorded', + }), }, indexes: [ diff --git a/packages/plugins/plugin-auth/src/auth-manager-mcp-access-token.ts b/packages/plugins/plugin-auth/src/auth-manager-mcp-access-token.ts index 0fbb4c5e791..0ab9e4cdc4e 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-mcp-access-token.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-mcp-access-token.ts @@ -26,8 +26,10 @@ * keeps both methods — the published `dist/*.d.ts` lists each one — and each * body is one literal of exactly the {@link McpAccessTokenHost} members that * method reads, plus a `.call`. `verifyMcpAccessToken` is no longer `async`: - * it returns the moved function's own promise, so a caller awaits the same - * promise the body settles, with no extra wrapper. + * it returns the moved function's own promise with one `.then` on it, which + * hands a verified principal (and the verified token, for its `iat`) to the + * coarse last-use stamp (`mcp-client-last-use.ts`, #30918) and resolves to + * the same value. * * A method goes as a closure that calls it on the manager at call time, so a * test double or spy set on the manager is still the one a body reaches — diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 5aa5e1656b9..a53070b9060 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Auth, BetterAuthOptions } from 'better-auth'; -import type { JSONWebKeySet } from 'jose'; +import { decodeJwt, type JSONWebKeySet } from 'jose'; import type { SCIMIdentityState, SCIMTransactionContext } from '@better-auth/scim'; // better-auth value imports (betterAuth + plugins) are deferred via dynamic // import() in getOrCreateAuth() / buildPluginList() so that disabled plugins @@ -188,6 +188,9 @@ import { verifyMcpAccessTokenClaims, type McpAccessTokenHost, } from './auth-manager-mcp-access-token.js'; +// [#30918] The coarse last-use stamp a verified MCP call writes on its consent. +import { McpClientLastUseStamper, mcpClientLastUseStoreFor } from './mcp-client-last-use.js'; +import { runAfterResponse } from './run-after-response.js'; // [objectstack-ai/cloud#3606, ruling A] `/oauth2/revoke` for an MCP access // token: the answer `handleRequest` gives after the provider's, through // `answerOAuthTokenRevocation` below. @@ -4523,6 +4526,14 @@ export class AuthManager { * caller vets it on every request by the rule that vets a session's active * organization (`vetOrganizationClaim`, `@objectstack/core`), as the * `IAuthService` contract states. + * + * [#30918] A token that verifies also schedules, AFTER the verdict and off + * the request's path, the coarse last-use stamp on the consent it was minted + * under (`mcp-client-last-use.ts`): at most one write per consent per hour + * in each process, from this verified token and the server's clock, and + * only on the consent that admitted it (the consent read above, by the same + * rule). The stamp reads and writes nothing the verdict depends on, and its + * failure is never this method's failure. */ verifyMcpAccessToken( token: string, @@ -4536,7 +4547,66 @@ export class AuthManager { rereadKeyringForUnknownKey: (...a) => this.rereadKeyringForUnknownKey(...a), getAuthContext: (...a) => this.getAuthContext(...a), }; - return verifyMcpAccessToken.call(host, token); + // [#30918] A token that verified is a verified MCP call: its consent's + // coarse last-use stamp is settled after the response, never awaited here, + // and from the verified token only (`mcp-client-last-use.ts`). The + // verifier has just admitted the token by its consent (#30879, + // `mcpConsentAdmits`), so a revoked consent refuses the call and stamps + // nothing; the stamp picks its consent by the same rule. + return verifyMcpAccessToken.call(host, token).then((principal) => { + if (principal) this.scheduleMcpClientLastUse(principal, token); + return principal; + }); + } + + /** [#30918] The per-manager stamper, built on first use. */ + private mcpClientLastUse?: McpClientLastUseStamper; + + /** + * [#30918] Settle the last-use stamp of the consent `principal`'s token was + * minted under, after the response (`run-after-response.ts`). At most one + * write per consent per hour in each process, from the verified principal + * and the server's clock alone; each distinct failure is reported once per + * process at `error` + * (`error` when the host logger carries it, else `warn`, called through the + * property — see {@link audienceLogError}; `console.error` when the manager + * was given no logger, so the report is never silent). + */ + private scheduleMcpClientLastUse( + principal: { userId: string; clientId?: string; organizationId?: string }, + token: string, + ): void { + if (!principal.clientId) return; + // The token has just verified, so its payload is the one the signature + // covers: its `iat` is what the verifier admitted the consent against. + let issuedAt: number | undefined; + try { + const iat = decodeJwt(token).iat; + issuedAt = typeof iat === 'number' ? iat : undefined; + } catch { + issuedAt = undefined; + } + const stamper = (this.mcpClientLastUse ??= new McpClientLastUseStamper( + mcpClientLastUseStoreFor(() => this.getDataEngine()), + { + onNewFailure: (error) => { + const message = + '[AuthManager] The last-use time of authorized AI assistants could not be recorded: ' + + 'the authorized-clients reads keep answering the last stamp that landed as if it were current. ' + + 'Check that schema sync has added sys_oauth_consent.last_used_at and that the data engine is reachable; ' + + 'each consent is retried once an hour, and each distinct failure is reported once per process.'; + const meta = { error: error instanceof Error ? error.message : String(error) }; + if (this.config.logger) this.audienceLogError(message, meta); + else console.error(message, meta); + }, + }, + )); + runAfterResponse( + () => stamper.record({ ...principal, ...(issuedAt !== undefined ? { issuedAt } : {}) }), + () => { + // `record` never rejects; its own failure report is above. + }, + ); } /** diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 222700f251e..283ea0e6677 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -3874,6 +3874,28 @@ export class AuthPlugin implements Plugin { } }); + // ──────────────────────────────────────────────────────────────────── + // [#30918] admin: the AI assistants (OAuth clients) another person has + // authorized, per organization, and when each was last used. Read only. + // The shared gate first, so a caller outside a platform administrator's + // reach gets one refusal whoever `userId` names and whether or not that + // person exists; then the closed query set { userId } (authorized-clients-read.ts). + rawApp.get(`${basePath}/admin/authorized-clients`, async (c: any) => { + try { + const actor = await gateAdmin(c); + if (actor instanceof Response) return actor; + const { authorizedClientsDepsFor, runAdminAuthorizedClientsRead } = await import('./authorized-clients-read.js'); + const answer = await runAdminAuthorizedClientsRead(authorizedClientsDepsFor(this.authManager!), actor, { + queries: (c.req.queries() ?? {}) as Record, + }); + return c.json(answer.body, answer.status as any, answer.headers); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + ctx.logger.error('[AuthPlugin] admin/authorized-clients failed', err); + return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: 'The authorized clients could not be read.' } }, 500); + } + }); + // ──────────────────────────────────────────────────────────────────── // #2766 V1 — admin direct user management. `sys_user` CRUD is suppressed // (managedBy better-auth), and until now the only add-a-teammate path was @@ -4692,6 +4714,28 @@ export class AuthPlugin implements Plugin { } }); + // ──────────────────────────────────────────────────────────────────── + // [#30918] The caller's own authorized AI assistants (OAuth consents), + // with each one's display name, scopes, organization, grant time and last + // verified MCP call. Keyed by the session's subject and nothing else: the + // query set is closed and empty, so `?userId=` is refused, never ignored. + // Outside `/admin/` because it admits every signed-in person; another + // person's grants are the admin door above. See authorized-clients-read.ts. + rawApp.get(`${basePath}/authorized-clients`, async (c: any) => { + try { + const { authorizedClientsDepsFor, runOwnAuthorizedClientsRead } = await import('./authorized-clients-read.js'); + const answer = await runOwnAuthorizedClientsRead(authorizedClientsDepsFor(this.authManager!), { + headers: c.req.raw.headers, + queries: (c.req.queries() ?? {}) as Record, + }); + return c.json(answer.body, answer.status as any, answer.headers); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + ctx.logger.error('[AuthPlugin] authorized-clients failed', err); + return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: 'The authorized clients could not be read.' } }, 500); + } + }); + // ──────────────────────────────────────────────────────────────────── // Self-service resend of the email-verification link. SHADOWS better-auth's // native `/send-verification-email` (registered before the catch-all below). diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts index c9dd78849f0..8534019c9da 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts @@ -220,11 +220,20 @@ describe('auth route ledger hygiene', () => { // ruling of 2026-08-23). const own = AUTH_ROUTE_LEDGER.filter((e) => e.source === 'objectstack').map((e) => e.route).sort(); expect(own).toEqual([ + // [#30918] Both authorized-clients reads, on this pin's own terms: + // `auth-plugin.ts` mounts each itself (a `rawApp.get` on the + // `${basePath}/admin/authorized-clients` and + // `${basePath}/authorized-clients` templates, ahead of the catch-all), + // and the `live.has(route)` loop below holds them to the same proof — + // better-auth's own consent read is `/oauth2/get-consents`, a different + // path. + 'GET /api/v1/auth/admin/authorized-clients', // [#30398] On this pin's own terms: `auth-plugin.ts` mounts it itself (a // `rawApp.get` on the `${basePath}/admin/client-address` template, ahead // of the catch-all), and the `live.has(route)` loop below holds it to the // same proof — better-auth serves no address read. 'GET /api/v1/auth/admin/client-address', + 'GET /api/v1/auth/authorized-clients', 'GET /api/v1/auth/bootstrap-status', 'GET /api/v1/auth/config', // On this pin's own terms: `auth-plugin.ts` mounts it itself (a diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index 5cdd2ad576a..bd918d59c72 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts @@ -188,6 +188,15 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [ // statement — the method exists, this row declares it, and the URL now // resolves to an enumerated route. { route: 'POST /api/v1/auth/set-initial-password', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.setInitialPassword' }, + // #30918 — the AI assistants (OAuth consents) a person has authorized, and + // when each was last used: one ObjectStack read for the person, one for a + // platform administrator, both in the SDK (`oauth.authorizedClients`). The + // tracker id stays HERE and never in the `note` string, per the #16678 note + // below. Neither needs the provider's plugin to answer: `sys_oauth_consent` + // is registered whether or not it is enabled, so both are unconditional + // mounts and carry no `requires`. + { route: 'GET /api/v1/auth/admin/authorized-clients', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'oauth.authorizedClients.listForUser', note: 'another person\'s authorized AI assistants, ?userId=ID: platform-admin gated exactly as the admin session doors are (ADR-0068 D2, a held session refused per ADR-0069 D3), the gate run before the query is read, so a caller outside that reach is refused once whether or not the person exists; an organization owner or admin is not a platform admin and is refused. Every organization the person granted in, each row naming its organizationId. Read only. Closed query set {userId}: missing, empty or repeated is 400, an id naming no user is 404' }, + { route: 'GET /api/v1/auth/authorized-clients', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'oauth.authorizedClients.list', note: 'the caller\'s own authorized AI assistants (OAuth consents), keyed by the session\'s subject and nothing else: the query set is closed and empty, so a userId or any other parameter is refused 400 rather than ignored. Each row: consent id (the key /oauth2/delete-consent takes), clientId, name, scopes, organizationId, authorizedAt, lastUsedAt (start of the most recent hour with a verified MCP call, null when none is recorded); no token, code, secret or session value. Newest first, at most 200 rows with truncated saying when there are more; Cache-Control: no-store' }, // ───────────────────────────────────────────────────────────────────── // #10534 — the remaining ObjectStack raw-app mounts, ledgered. // diff --git a/packages/plugins/plugin-auth/src/authorized-clients-read.real-plugin.test.ts b/packages/plugins/plugin-auth/src/authorized-clients-read.real-plugin.test.ts new file mode 100644 index 00000000000..cd76f361636 --- /dev/null +++ b/packages/plugins/plugin-auth/src/authorized-clients-read.real-plugin.test.ts @@ -0,0 +1,549 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30918] The two authorized-clients reads, on the real plugin: a real + * `AuthManager` (real better-auth, real `customSession`, real sign-ups and + * sessions), the real `AuthPlugin` route registration on a Hono app, and the + * consents written through better-auth's own storage adapter, so the read + * decodes exactly what the OAuth provider stores. The engine is the shared + * in-memory one `sys-session-revoke.test.ts` runs on (`createMemoryEngine`). + * + * What is pinned, by the access rules on the card: + * + * - the own read answers the session subject's grants and nobody else's, and + * a parameter that names another person is REFUSED, not ignored; + * - the admin read admits a platform administrator, per organization, and + * refuses everyone else (a member, an organization owner, an organization + * admin) with one byte-identical answer whether or not the named person + * exists; anonymous is `401` on both doors; + * - the answer carries exactly the declared members and no token, code, + * secret or session value of any row; + * - a person with more grants than the cap is answered `truncated: true`. + * + * The coarse last-use stamp is pinned on a real SQL engine in + * `mcp-client-last-use.real-engine.test.ts`. + */ + +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; +import { Hono } from 'hono'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import type { PluginContext } from '@objectstack/core'; +import { AuthManager } from './auth-manager'; +import { AuthPlugin } from './auth-plugin'; +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; +import { inviteForAudienceGate } from './audience-gate-test-support'; +import { memoizeScrypt } from './__tests__/scrypt-memo.testkit'; +import { + ADMIN_AUTHORIZED_CLIENTS_PATH, + ADMIN_AUTHORIZED_CLIENTS_USER_NOT_FOUND_MESSAGE, + AUTHORIZED_CLIENTS_PATH, + AUTHORIZED_CLIENTS_READ_CAP, +} from './authorized-clients-read'; + +const SECRET = 'test-secret-at-least-32-chars-long-30918'; +const PASSWORD = 'S3cure!Passw0rd-authorized-clients'; +const ORIGIN = 'http://localhost:3000'; +const BASE = '/api/v1/auth'; +const PS_ADMIN = 'ps_admin_full_access'; + +const ADMIN_EMAIL = 'padmin@authorized-clients.example.com'; +const ALICE_EMAIL = 'alice@authorized-clients.example.com'; +const BOB_EMAIL = 'bob@authorized-clients.example.com'; +const OWNER_EMAIL = 'olivia.owner@authorized-clients.example.com'; +const ORG_ADMIN_EMAIL = 'oscar.orgadmin@authorized-clients.example.com'; + +const ORG_ACME = 'org_acme_30918'; +const ORG_BETA = 'org_beta_30918'; + +/** Values that must never appear in any answer: one per secret-bearing column the fixture writes. */ +const CLIENT_SECRET = 'client-secret-MUST-NOT-LEAK-30918'; +const ACCESS_TOKEN = 'access-token-MUST-NOT-LEAK-30918'; +const REFRESH_TOKEN = 'refresh-token-MUST-NOT-LEAK-30918'; + +/** The exact members of one answered row, and of the answer itself. */ +const ROW_KEYS = ['authorizedAt', 'clientId', 'id', 'lastUsedAt', 'name', 'organizationId', 'scopes']; +const DATA_KEYS = ['clients', 'truncated']; + +const ENV_KEYS = ['OS_MCP_SERVER_ENABLED', 'OS_OIDC_PROVIDER_ENABLED'] as const; +const savedEnv: Record = {}; + +const mockCtx = (): PluginContext => + ({ + registerService: vi.fn(), + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + }) as any; + +const makeManager = (engine: any) => new AuthManager({ secret: SECRET, baseUrl: ORIGIN, dataEngine: engine } as any); + +const direct = (manager: AuthManager, path: string, body: unknown) => + manager.handleRequest( + new Request(`${ORIGIN}${BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + ); + +const cookieFrom = (response: Response): string => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + +const userRows = (engine: any) => (engine.tables.get('sys_user') ?? []) as any[]; + +const userIdFor = (engine: any, email: string): string => { + const row = userRows(engine).find((r) => r.email === email); + if (!row) throw new Error(`no sys_user row for ${email}`); + return String(row.id); +}; + +/** The Default Organization, staged when the engine holds none. */ +const defaultOrganizationIdOf = async (engine: any): Promise => { + const existing = (engine.tables.get('sys_organization') ?? []).find((r: any) => r.slug === 'default'); + if (existing) return String(existing.id); + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }); + return 'org_default'; +}; + +/** ADR-0068 D2 standing, granted the way the platform grants it: the admin_full_access set. */ +const grantPlatformAdmin = async (engine: any, userId: string) => { + if (!(engine.tables.get('sys_permission_set') ?? []).some((r: any) => r.id === PS_ADMIN)) { + await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); + } + await engine.insert('sys_user_permission_set', { + user_id: userId, + permission_set_id: PS_ADMIN, + permission_set: 'admin_full_access', + organization_id: await defaultOrganizationIdOf(engine), + }); +}; + +/** The five sign-ups every case starts from, made once per file. */ +let signedUpTables: Promise> | undefined; +const signedUpOnce = (): Promise> => + (signedUpTables ??= (async () => { + const template = createMemoryEngine(); + const manager = makeManager(template); + for (const [email, name] of [ + [ADMIN_EMAIL, 'Platform Admin'], + [ALICE_EMAIL, 'Alice'], + [BOB_EMAIL, 'Bob'], + [OWNER_EMAIL, 'Olivia Owner'], + [ORG_ADMIN_EMAIL, 'Oscar OrgAdmin'], + ]) { + await inviteForAudienceGate(manager, email); + const res = await direct(manager, '/sign-up/email', { email, password: PASSWORD, name }); + expect(res.status, `sign-up ${email}: ${await res.clone().text()}`).toBe(200); + } + return template.tables as Map; + })()); + +const arrange = async () => { + const engine = createMemoryEngine(); + for (const [name, rows] of await signedUpOnce()) engine.tables.set(name, structuredClone(rows)); + const manager = makeManager(engine); + const ids = { + admin: userIdFor(engine, ADMIN_EMAIL), + alice: userIdFor(engine, ALICE_EMAIL), + bob: userIdFor(engine, BOB_EMAIL), + owner: userIdFor(engine, OWNER_EMAIL), + orgAdmin: userIdFor(engine, ORG_ADMIN_EMAIL), + }; + await grantPlatformAdmin(engine, ids.admin); + + // Bob is a member of two organizations; Olivia owns the first and Oscar + // administers it. Neither holds platform standing. + for (const [id, slug] of [ + [ORG_ACME, 'acme-30918'], + [ORG_BETA, 'beta-30918'], + ]) { + await engine.insert('sys_organization', { id, name: slug, slug }); + } + for (const [userId, organizationId, role] of [ + [ids.bob, ORG_ACME, 'member'], + [ids.bob, ORG_BETA, 'member'], + [ids.owner, ORG_ACME, 'owner'], + [ids.orgAdmin, ORG_ACME, 'admin'], + ]) { + await engine.insert('sys_member', { + organization_id: organizationId, + user_id: userId, + role, + created_at: new Date(), + }); + } + + const app = new Hono(); + const ctx = mockCtx(); + const plugin = new AuthPlugin({ secret: SECRET }); + await plugin.init(ctx); + (plugin as any).authManager = manager; + (plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); + + const signIn = async (email: string): Promise => { + const res = await direct(manager, '/sign-in/email', { email, password: PASSWORD }); + expect(res.status, `sign-in ${email}: ${await res.clone().text()}`).toBe(200); + return cookieFrom(res); + }; + + const sessionUser = async (cookie: string): Promise => { + const res = await manager.handleRequest(new Request(`${ORIGIN}${BASE}/get-session`, { headers: { cookie } })); + return ((await res.json().catch(() => null)) as any)?.user ?? null; + }; + + /** One OAuth client, with a secret the answers must never carry. */ + const registerClient = async (clientId: string, name: string | null) => { + await engine.insert('sys_oauth_application', { + id: `app_${clientId}`, + client_id: clientId, + name, + client_secret: CLIENT_SECRET, + redirect_uris: JSON.stringify(['http://localhost:56789/callback']), + }); + }; + + /** + * A consent, written by better-auth's own storage adapter (the write the + * provider's consent door makes), plus the token rows a real grant carries, + * so an answer that leaked one would be caught. + */ + const grant = async ( + userId: string, + clientId: string, + opts: { referenceId?: string; scopes?: string[]; createdAt: Date }, + ): Promise => { + const authCtx = await manager.getAuthContext(); + const row = await authCtx.adapter.create({ + model: 'oauthConsent', + data: { + clientId, + userId, + scopes: opts.scopes ?? ['openid', 'data:read'], + ...(opts.referenceId ? { referenceId: opts.referenceId } : {}), + createdAt: opts.createdAt, + updatedAt: opts.createdAt, + }, + }); + await engine.insert('sys_oauth_access_token', { + token: `${ACCESS_TOKEN}-${row.id}`, + client_id: clientId, + user_id: userId, + scopes: JSON.stringify(opts.scopes ?? ['openid', 'data:read']), + }); + await engine.insert('sys_oauth_refresh_token', { + token: `${REFRESH_TOKEN}-${row.id}`, + client_id: clientId, + user_id: userId, + scopes: JSON.stringify(opts.scopes ?? ['openid', 'data:read']), + }); + return String(row.id); + }; + + const get = (path: string, cookie?: string) => + app.request(`${ORIGIN}${BASE}${path}`, { + method: 'GET', + headers: { origin: ORIGIN, ...(cookie ? { cookie } : {}) }, + }); + + /** No token, secret or session value of any row appears in `text`. */ + const expectNoSecretMaterial = (text: string) => { + expect(text).not.toContain(CLIENT_SECRET); + expect(text).not.toContain(ACCESS_TOKEN); + expect(text).not.toContain(REFRESH_TOKEN); + for (const session of (engine.tables.get('sys_session') ?? []) as any[]) { + expect(text.includes(String(session.token)), 'a session token appeared in an answer').toBe(false); + } + }; + + return { engine, manager, ids, signIn, sessionUser, registerClient, grant, get, expectNoSecretMaterial }; +}; + +type Rig = Awaited>; + +/** Bob's three grants: one per organization he granted in, and one with none active. */ +const grantBob = async (rig: Rig) => { + await rig.registerClient('client_desk', 'Desk Assistant'); + await rig.registerClient('client_ide', 'IDE Agent'); + await rig.registerClient('client_unnamed', null); + const acme = await rig.grant(rig.ids.bob, 'client_desk', { + referenceId: ORG_ACME, + scopes: ['openid', 'data:read'], + createdAt: new Date('2026-11-01T09:00:00.000Z'), + }); + const beta = await rig.grant(rig.ids.bob, 'client_ide', { + referenceId: ORG_BETA, + scopes: ['openid', 'data:write'], + createdAt: new Date('2026-11-03T09:00:00.000Z'), + }); + const none = await rig.grant(rig.ids.bob, 'client_unnamed', { + scopes: ['openid'], + createdAt: new Date('2026-11-02T09:00:00.000Z'), + }); + return { acme, beta, none }; +}; + +const BOB_EXPECTED = (ids: { acme: string; beta: string; none: string }) => [ + { + id: ids.beta, + clientId: 'client_ide', + name: 'IDE Agent', + scopes: ['openid', 'data:write'], + organizationId: ORG_BETA, + authorizedAt: '2026-11-03T09:00:00.000Z', + lastUsedAt: null, + }, + { + id: ids.none, + clientId: 'client_unnamed', + name: null, + scopes: ['openid'], + organizationId: null, + authorizedAt: '2026-11-02T09:00:00.000Z', + lastUsedAt: null, + }, + { + id: ids.acme, + clientId: 'client_desk', + name: 'Desk Assistant', + scopes: ['openid', 'data:read'], + organizationId: ORG_ACME, + authorizedAt: '2026-11-01T09:00:00.000Z', + lastUsedAt: null, + }, +]; + +const readJson = async (res: Response) => { + const text = await res.text(); + return { status: res.status, text, body: JSON.parse(text) as any, cacheControl: res.headers.get('cache-control') }; +}; + +beforeEach(() => { + for (const k of ENV_KEYS) { + savedEnv[k] = process.env[k]; + delete process.env[k]; + } + // The provider's own models (`oauthConsent`) are what the fixture writes through. + process.env.OS_MCP_SERVER_ENABLED = 'true'; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + vi.restoreAllMocks(); + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } +}); + +const scrypt = memoizeScrypt(); +afterAll(scrypt.restore); + +describe('#30918 GET /authorized-clients — the caller\'s own grants', () => { + it('a member reads exactly their own grants, newest first, with the declared members and no secret material', async () => { + const rig = await arrange(); + const bobGrants = await grantBob(rig); + await rig.registerClient('client_alice', 'Alice Helper'); + const aliceGrant = await rig.grant(rig.ids.alice, 'client_alice', { + createdAt: new Date('2026-11-04T09:00:00.000Z'), + }); + + const bob = await rig.signIn(BOB_EMAIL); + const bobAnswer = await readJson(await rig.get(AUTHORIZED_CLIENTS_PATH, bob)); + expect(bobAnswer.status, bobAnswer.text).toBe(200); + expect(bobAnswer.cacheControl).toBe('no-store'); + expect(Object.keys(bobAnswer.body.data).sort()).toEqual(DATA_KEYS); + expect(bobAnswer.body).toEqual({ success: true, data: { clients: BOB_EXPECTED(bobGrants), truncated: false } }); + for (const row of bobAnswer.body.data.clients) expect(Object.keys(row).sort()).toEqual(ROW_KEYS); + rig.expectNoSecretMaterial(bobAnswer.text); + + const alice = await rig.signIn(ALICE_EMAIL); + const aliceAnswer = await readJson(await rig.get(AUTHORIZED_CLIENTS_PATH, alice)); + expect(aliceAnswer.status, aliceAnswer.text).toBe(200); + expect(aliceAnswer.body.data.clients.map((row: any) => row.id)).toEqual([aliceGrant]); + rig.expectNoSecretMaterial(aliceAnswer.text); + }); + + it('a member who names another person with ?userId= is REFUSED 400, never answered as if it were not sent', async () => { + const rig = await arrange(); + await grantBob(rig); + const alice = await rig.signIn(ALICE_EMAIL); + + const answer = await readJson(await rig.get(`${AUTHORIZED_CLIENTS_PATH}?userId=${rig.ids.bob}`, alice)); + expect(answer.status, answer.text).toBe(400); + expect(answer.body.success).toBe(false); + expect(answer.body.error.code).toBe('VALIDATION_ERROR'); + expect(answer.body.error.message).toContain('userId'); + expect(answer.text).not.toContain('client_desk'); + expect(answer.text).not.toContain(rig.ids.bob); + }); + + it('any other parameter is refused the same way — the query set is closed and empty', async () => { + const rig = await arrange(); + const bob = await rig.signIn(BOB_EMAIL); + for (const query of ['?user_id=x', '?limit=500', '?organizationId=org_acme_30918', '?subject=me']) { + const answer = await readJson(await rig.get(`${AUTHORIZED_CLIENTS_PATH}${query}`, bob)); + expect(answer.status, `${query}: ${answer.text}`).toBe(400); + expect(answer.body.error.code, query).toBe('VALIDATION_ERROR'); + } + }); + + it('anonymous is 401 UNAUTHENTICATED on both doors', async () => { + const rig = await arrange(); + await grantBob(rig); + for (const path of [AUTHORIZED_CLIENTS_PATH, `${ADMIN_AUTHORIZED_CLIENTS_PATH}?userId=${rig.ids.bob}`]) { + const answer = await readJson(await rig.get(path)); + expect(answer.status, `${path}: ${answer.text}`).toBe(401); + expect(answer.body.error.code, path).toBe('UNAUTHENTICATED'); + expect(answer.text).not.toContain('client_desk'); + } + }); + + it(`a person holding more than ${AUTHORIZED_CLIENTS_READ_CAP} grants is answered truncated, never a list that silently stops`, async () => { + const rig = await arrange(); + await rig.registerClient('client_many', 'Many'); + const start = Date.parse('2026-01-01T00:00:00.000Z'); + for (let i = 0; i <= AUTHORIZED_CLIENTS_READ_CAP; i++) { + await rig.grant(rig.ids.bob, 'client_many', { + referenceId: `org_${i}`, + createdAt: new Date(start + i * 60_000), + }); + } + const bob = await rig.signIn(BOB_EMAIL); + const answer = await readJson(await rig.get(AUTHORIZED_CLIENTS_PATH, bob)); + expect(answer.status, answer.text).toBe(200); + expect(answer.body.data.truncated).toBe(true); + expect(answer.body.data.clients).toHaveLength(AUTHORIZED_CLIENTS_READ_CAP); + // Newest first: the oldest grant is the one past the cap. + expect(answer.body.data.clients.map((row: any) => row.organizationId)).not.toContain('org_0'); + + const exact = await arrange(); + await exact.registerClient('client_few', 'Few'); + await exact.grant(exact.ids.bob, 'client_few', { createdAt: new Date(start) }); + const bobAgain = await exact.signIn(BOB_EMAIL); + const few = await readJson(await exact.get(AUTHORIZED_CLIENTS_PATH, bobAgain)); + expect(few.body.data).toMatchObject({ truncated: false }); + expect(few.body.data.clients).toHaveLength(1); + }); +}); + +describe('#30918 GET /admin/authorized-clients — another person\'s grants', () => { + it('a platform administrator reads the person\'s grants in every organization, each row naming its organization', async () => { + const rig = await arrange(); + const bobGrants = await grantBob(rig); + const admin = await rig.signIn(ADMIN_EMAIL); + expect((await rig.sessionUser(admin))?.isPlatformAdmin, 'precondition: platform-admin standing').toBe(true); + + const answer = await readJson(await rig.get(`${ADMIN_AUTHORIZED_CLIENTS_PATH}?userId=${rig.ids.bob}`, admin)); + expect(answer.status, answer.text).toBe(200); + expect(answer.cacheControl).toBe('no-store'); + expect(answer.body).toEqual({ success: true, data: { clients: BOB_EXPECTED(bobGrants), truncated: false } }); + expect(new Set(answer.body.data.clients.map((row: any) => row.organizationId))).toEqual( + new Set([ORG_ACME, ORG_BETA, null]), + ); + for (const row of answer.body.data.clients) expect(Object.keys(row).sort()).toEqual(ROW_KEYS); + rig.expectNoSecretMaterial(answer.text); + }); + + it('rule 7: a member, an organization OWNER and an organization ADMIN are refused with ONE answer, whether or not the person exists', async () => { + const rig = await arrange(); + await grantBob(rig); + const callers = { + member: await rig.signIn(ALICE_EMAIL), + orgOwner: await rig.signIn(OWNER_EMAIL), + orgAdmin: await rig.signIn(ORG_ADMIN_EMAIL), + // The person themself: their own door is the other one. + self: await rig.signIn(BOB_EMAIL), + }; + for (const [who, cookie] of Object.entries(callers)) { + expect((await rig.sessionUser(cookie))?.isPlatformAdmin, `precondition: ${who} holds no platform standing`).toBe(false); + const existing = await readJson(await rig.get(`${ADMIN_AUTHORIZED_CLIENTS_PATH}?userId=${rig.ids.bob}`, cookie)); + const missing = await readJson(await rig.get(`${ADMIN_AUTHORIZED_CLIENTS_PATH}?userId=usr_nobody_30918`, cookie)); + const unnamed = await readJson(await rig.get(ADMIN_AUTHORIZED_CLIENTS_PATH, cookie)); + expect(existing.status, `${who}: ${existing.text}`).toBe(403); + expect(existing.body.error.code, who).toBe('PERMISSION_DENIED'); + // Byte-identical: the refusal says nothing about whether the person exists. + expect(missing.status, who).toBe(existing.status); + expect(missing.text, who).toBe(existing.text); + expect(unnamed.text, who).toBe(existing.text); + expect(existing.text).not.toContain('client_desk'); + expect(existing.text).not.toContain(rig.ids.bob); + } + }); + + it('rule 7, outside their organizations: an organization admin or owner naming a person of ANOTHER organization gets the same one refusal', async () => { + const rig = await arrange(); + await grantBob(rig); + // Alice joins Beta only, and grants a client there: Olivia and Oscar hold + // Acme, so Alice is a person outside every organization they administer. + await rig.engine.insert('sys_member', { + organization_id: ORG_BETA, + user_id: rig.ids.alice, + role: 'member', + created_at: new Date(), + }); + await rig.registerClient('client_beta_only', 'Beta Assistant'); + await rig.grant(rig.ids.alice, 'client_beta_only', { + referenceId: ORG_BETA, + createdAt: new Date('2026-11-04T09:00:00.000Z'), + }); + const orgsOf = (userId: string) => + ((rig.engine.tables.get('sys_member') ?? []) as any[]) + .filter((row) => row.user_id === userId) + .map((row) => row.organization_id); + expect(orgsOf(rig.ids.alice), 'precondition: the target is in Beta only').toEqual([ORG_BETA]); + + for (const [who, email] of [ + ['orgAdmin', ORG_ADMIN_EMAIL], + ['orgOwner', OWNER_EMAIL], + ] as const) { + const cookie = await rig.signIn(email); + const callerId = (await rig.sessionUser(cookie))?.id; + expect(orgsOf(callerId), `precondition: ${who} is in Acme only`).toEqual([ORG_ACME]); + expect((await rig.sessionUser(cookie))?.isPlatformAdmin, `precondition: ${who} holds no platform standing`).toBe(false); + + const outside = await readJson(await rig.get(`${ADMIN_AUTHORIZED_CLIENTS_PATH}?userId=${rig.ids.alice}`, cookie)); + const inside = await readJson(await rig.get(`${ADMIN_AUTHORIZED_CLIENTS_PATH}?userId=${rig.ids.bob}`, cookie)); + expect(outside.status, `${who}: ${outside.text}`).toBe(403); + expect(outside.body.error.code, who).toBe('PERMISSION_DENIED'); + // The same bytes for a person inside and outside their organization: + // the gate reads no organization, so neither answer says which it is. + expect(outside.text, who).toBe(inside.text); + expect(outside.text).not.toContain('client_beta_only'); + expect(outside.text).not.toContain(rig.ids.alice); + } + }); + + it('a platform administrator naming nobody, a repeated or unknown parameter is 400; an id naming no user is 404', async () => { + const rig = await arrange(); + await grantBob(rig); + const admin = await rig.signIn(ADMIN_EMAIL); + + for (const query of ['', '?userId=', '?userId=%20', `?userId=${rig.ids.bob}&userId=${rig.ids.alice}`, `?userId=${rig.ids.bob}&limit=5`, '?user_id=x']) { + const answer = await readJson(await rig.get(`${ADMIN_AUTHORIZED_CLIENTS_PATH}${query}`, admin)); + expect(answer.status, `${query || '(none)'}: ${answer.text}`).toBe(400); + expect(answer.body.error.code, query).toBe('VALIDATION_ERROR'); + expect(answer.text).not.toContain('client_desk'); + } + + const missing = await readJson(await rig.get(`${ADMIN_AUTHORIZED_CLIENTS_PATH}?userId=usr_nobody_30918`, admin)); + expect(missing.status, missing.text).toBe(404); + expect(missing.body).toEqual({ + success: false, + error: { code: 'RESOURCE_NOT_FOUND', message: ADMIN_AUTHORIZED_CLIENTS_USER_NOT_FOUND_MESSAGE }, + }); + }); + + it('a person with no grants is answered an empty list, not a refusal', async () => { + const rig = await arrange(); + const admin = await rig.signIn(ADMIN_EMAIL); + const answer = await readJson(await rig.get(`${ADMIN_AUTHORIZED_CLIENTS_PATH}?userId=${rig.ids.alice}`, admin)); + expect(answer.status, answer.text).toBe(200); + expect(answer.body).toEqual({ success: true, data: { clients: [], truncated: false } }); + }); +}); diff --git a/packages/plugins/plugin-auth/src/authorized-clients-read.ts b/packages/plugins/plugin-auth/src/authorized-clients-read.ts new file mode 100644 index 00000000000..efb12855fe2 --- /dev/null +++ b/packages/plugins/plugin-auth/src/authorized-clients-read.ts @@ -0,0 +1,367 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30918] The AI assistants a person has authorized, and when each was last + * used: one read for the person themself, one for a platform administrator. + * + * GET /api/v1/auth/authorized-clients — the caller's own + * GET /api/v1/auth/admin/authorized-clients?userId=ID — another person's + * + * "An AI assistant the person authorized" is an OAuth consent: a + * `sys_oauth_consent` row the person's own decision on the consent page wrote + * (Claude Desktop, an IDE agent, any MCP client). The consent page tells the + * person they can withdraw it at any time; these reads are how they, and an + * administrator responsible for them, see what can act as them. Before them + * the only read was better-auth's own `GET /oauth2/get-consents`: unledgered, + * capped at the adapter's default 100 rows without saying so, keyed to the + * session and blind to a `userId` parameter (it answers the caller's own rows + * whatever the parameter says), with no name and no last use. + * + * ## The own read: keyed by the session's subject, and by nothing else + * + * The rows are the session user's. The query set is CLOSED and EMPTY, so a + * request that names anybody, `?userId=` or any other spelling, is refused + * `400 VALIDATION_ERROR` naming the parameter rather than answered as if it + * had not been sent: an ignored selector reads exactly like an honoured one, + * which is the failure mode `get-consents` has. A GET carries no body. + * + * ## The admin read: a separate door, and never a wider one + * + * Gated by the ADR-0068 D2 platform-admin judge every ObjectStack `/admin/*` + * mount calls (`gateAdmin` in `auth-plugin.ts`, the judge in + * `platform-admin-gate.ts`), with a held session refused per ADR-0069 D3. That + * is the platform's rule for one person acting on another's sessions: + * `sys-session/revoke` admits a platform administrator on another user's + * session and nobody else, and better-auth's `/admin/list-user-sessions` is + * refused to every ObjectStack platform administrator by design until a + * consumer re-mounts it on this same gate. An organization owner or admin + * who is not a platform administrator is refused here, as they are at every + * session door. The gate runs before the query is read, so a caller outside + * that reach gets one refusal, whoever the `userId` names and whether or not + * that person exists: `401` with no session, `403` otherwise. + * + * Per organization. A consent binds the organization the person had active + * when they granted it (`reference_id`), so every row says which organization + * it belongs to (`organizationId`, `null` for a grant made with none active). + * A platform administrator's standing is not bounded to an organization: it + * crosses the tenant wall on better-auth-managed objects (ADR-0095 D1), so + * the admin read lists the person's grants in every organization. + * + * Read only. Withdrawing a grant is the person's own door + * (`POST /oauth2/delete-consent`, keyed by the row's `id`); an administrator + * withdrawing another person's grant is not a door this module serves. + * + * ## The answer + * + * `200 { success: true, data: { clients, truncated } }`, `Cache-Control: + * no-store`, newest grant first. Each row carries exactly: + * + * - `id` — the consent's own id, the key the withdraw door takes; + * - `clientId`, `name` — the OAuth client and its display name (`null` when + * the client declares none); + * - `scopes` — what the person granted; + * - `organizationId` — the organization the grant is held in; + * - `authorizedAt` — when the grant was made (ISO 8601), `null` for a row + * that carries no readable grant time (a consent older than the provider + * that writes one); + * - `lastUsedAt` — the start of the most recent hour the client made a + * verified MCP call under this grant, or `null`: no call has been recorded + * (`mcp-client-last-use.ts`). + * + * Nothing that identifies or reconstructs a token: no access-token, refresh + * token or authorization-code value or id, no client secret, no session. The + * client row is read for its `client_id` and `name` columns only. + * + * At most {@link AUTHORIZED_CLIENTS_READ_CAP} rows. One more is read, so a + * person holding more grants than that is answered `truncated: true` rather + * than a list that silently stops. + * + * ## Refusals (ADR-0112: code AND status) + * + * - no session: `401 UNAUTHENTICATED`, before anything else is read; + * - a held session: `403` with the posture's own code (both doors); + * - the admin door, a caller who is not a platform administrator: + * `403 PERMISSION_DENIED`; + * - an unknown query parameter (any, on the own read): `400 VALIDATION_ERROR`; + * - the admin door, `userId` missing, empty or repeated: `400 VALIDATION_ERROR`; + * - the admin door, a `userId` that names no user: `404 RESOURCE_NOT_FOUND`. + */ + +import { repeatedQueryParamMessage, unknownQueryParamRefusalMessage } from '@objectstack/rest'; +import type { AuthSessionApi } from '@objectstack/spec/contracts'; +import { inProcessSessionReadInput } from '@objectstack/types'; +import type { PlatformAdminActor } from './platform-admin-gate.js'; +import { judgeHeldSession, PLATFORM_ADMIN_REFUSAL_MESSAGES } from './platform-admin-gate.js'; + +/** The own read, relative to the auth base path. */ +export const AUTHORIZED_CLIENTS_PATH = '/authorized-clients'; + +/** The admin read, relative to the auth base path. */ +export const ADMIN_AUTHORIZED_CLIENTS_PATH = '/admin/authorized-clients'; + +/** The own read's CLOSED query set: empty — the rows are the session's. */ +export const AUTHORIZED_CLIENTS_QUERY_PARAMS: readonly string[] = Object.freeze([]); + +/** The admin read's one parameter: the person whose grants are read. */ +export const ADMIN_AUTHORIZED_CLIENTS_USER_PARAM = 'userId'; + +/** The admin read's CLOSED query set. */ +export const ADMIN_AUTHORIZED_CLIENTS_QUERY_PARAMS: readonly string[] = Object.freeze([ + ADMIN_AUTHORIZED_CLIENTS_USER_PARAM, +]); + +/** The most rows either read answers. */ +export const AUTHORIZED_CLIENTS_READ_CAP = 200; + +/** The admin read's not-found message, for a `userId` that names no user. */ +export const ADMIN_AUTHORIZED_CLIENTS_USER_NOT_FOUND_MESSAGE = 'No user matches the supplied userId.'; + +/** One authorized client, as both reads answer it. */ +export interface AuthorizedClient { + /** The consent's id: the key `POST /oauth2/delete-consent` takes. */ + id: string; + clientId: string; + /** The client's display name, `null` when it declares none. */ + name: string | null; + scopes: string[]; + /** The organization the grant is held in, `null` when none was active at the grant. */ + organizationId: string | null; + /** When the grant was made, ISO 8601; `null` when the row carries no readable time. */ + authorizedAt: string | null; + /** Start of the most recent hour with a verified MCP call, ISO 8601; `null` = none recorded. */ + lastUsedAt: string | null; +} + +export interface AuthorizedClientsData { + clients: AuthorizedClient[]; + /** `true` when the person holds more than {@link AUTHORIZED_CLIENTS_READ_CAP} grants. */ + truncated: boolean; +} + +export interface AuthorizedClientsResult { + status: 200 | 400 | 401 | 403 | 404; + body: + | { success: true; data: AuthorizedClientsData } + | { success: false; error: { code: string; message: string } }; + headers: Record; +} + +/** What the in-process session read answers, as `AuthSessionApi` declares it. */ +type SessionRead = Awaited>>; + +/** A consent row, as the read decodes it. */ +export interface ConsentRecord { + id: string; + clientId: string; + referenceId: string | null; + scopes: string[]; + createdAt: Date | null; + /** The stored last-use stamp, `null` when none has been written. */ + lastUsedAt: Date | null; +} + +/** The seams the reads use. {@link authorizedClientsDepsFor} wires the real ones. */ +export interface AuthorizedClientsDeps { + /** The caller's session, `customSession`-decorated (`authGate`). */ + readCallerSession(headers: Headers): Promise; + /** `userId`'s consents, newest first, at most `limit`. */ + listConsents(userId: string, limit: number): Promise; + /** `client_id` → display name, for the ids given. */ + clientNames(clientIds: readonly string[]): Promise>; + /** Does a user with this id exist? */ + userExists(userId: string): Promise; +} + +/** The slice of `AuthManager` the real seams are built from. */ +export interface AuthorizedClientsManager { + getApi(): Promise; + getDataEngine(): unknown; +} + +const SYSTEM_READ = { context: { isSystem: true, positions: [], permissions: [] } } as const; + +const NO_STORE = Object.freeze({ 'Cache-Control': 'no-store' }); + +interface ReadEngine { + find(object: string, query: Record, options?: unknown): Promise; +} + +const asRows = (value: unknown): Array> => + Array.isArray(value) ? (value as Array>) : []; + +const toDate = (value: unknown): Date | null => { + if (value == null || value === '') return null; + const date = value instanceof Date ? value : new Date(value as string | number); + return Number.isNaN(date.getTime()) ? null : date; +}; + +/** + * A consent's granted scopes. The provider's storage adapter writes the list + * as JSON text (`supportsArrays: false` in `objectql-adapter.ts`, so + * better-auth's adapter factory serializes a `string[]` field), and a driver + * that keeps arrays hands the array back as is. The provider writes every + * consent with a scope list, so a row whose list does not decode is refused + * loudly rather than answered as a grant of nothing. + */ +function scopesOf(row: Record): string[] { + let value: unknown = row.scopes; + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch { + value = undefined; + } + } + if (Array.isArray(value) && value.every((scope) => typeof scope === 'string')) return value as string[]; + throw new Error(`The scopes of consent ${String(row.id)} could not be read.`); +} + +/** + * The real seams: narrow engine reads under a system context, named columns + * only, so no secret column is ever loaded. The consent read takes an + * explicit limit (better-auth's own `get-consents` takes its adapter's + * default of 100 and says nothing past it) and its own last-use column in the + * same read. `sys_oauth_consent` is registered by plugin-auth's manifest + * whether or not the provider is enabled, so the read never depends on the + * provider's model being loaded. + */ +export function authorizedClientsDepsFor(manager: AuthorizedClientsManager): AuthorizedClientsDeps { + const engine = (): ReadEngine => { + const found = manager.getDataEngine() as ReadEngine | undefined; + if (!found) throw new Error('The data engine is not available.'); + return found; + }; + return { + async readCallerSession(headers) { + const api = (await manager.getApi()) as { getSession(input: unknown): Promise }; + return api.getSession(inProcessSessionReadInput(headers)); + }, + async listConsents(userId, limit) { + const rows = asRows( + await engine().find( + 'sys_oauth_consent', + { + where: { user_id: userId }, + fields: ['id', 'client_id', 'reference_id', 'scopes', 'created_at', 'last_used_at'], + orderBy: [ + { field: 'created_at', order: 'desc' }, + { field: 'id', order: 'asc' }, + ], + limit, + }, + SYSTEM_READ, + ), + ); + return rows.map((row) => ({ + id: String(row.id), + clientId: String(row.client_id), + referenceId: typeof row.reference_id === 'string' && row.reference_id ? row.reference_id : null, + scopes: scopesOf(row), + createdAt: toDate(row.created_at), + lastUsedAt: toDate(row.last_used_at), + })); + }, + async clientNames(clientIds) { + if (clientIds.length === 0) return new Map(); + const rows = asRows( + await engine().find( + 'sys_oauth_application', + { where: { client_id: { $in: [...clientIds] } }, fields: ['client_id', 'name'], limit: clientIds.length }, + SYSTEM_READ, + ), + ); + return new Map( + rows.map((row) => [ + String(row.client_id), + typeof row.name === 'string' && row.name.trim() ? row.name : null, + ]), + ); + }, + async userExists(userId) { + const rows = asRows( + await engine().find('sys_user', { where: { id: userId }, fields: ['id'], limit: 1 }, SYSTEM_READ), + ); + return rows.length > 0; + }, + }; +} + +const refusal = ( + status: 400 | 401 | 403 | 404, + code: string, + message: string, +): AuthorizedClientsResult => ({ + status, + body: { success: false, error: { code, message } }, + headers: { ...NO_STORE }, +}); + +/** Read `userId`'s grants into the answer both doors serve. */ +async function readAuthorizedClients( + deps: AuthorizedClientsDeps, + userId: string, +): Promise { + const consents = await deps.listConsents(userId, AUTHORIZED_CLIENTS_READ_CAP + 1); + const truncated = consents.length > AUTHORIZED_CLIENTS_READ_CAP; + const shown = consents.slice(0, AUTHORIZED_CLIENTS_READ_CAP); + const names = await deps.clientNames([...new Set(shown.map((row) => row.clientId))]); + const clients: AuthorizedClient[] = shown.map((row) => ({ + id: row.id, + clientId: row.clientId, + name: names.get(row.clientId) ?? null, + scopes: row.scopes, + organizationId: row.referenceId, + authorizedAt: row.createdAt?.toISOString() ?? null, + lastUsedAt: row.lastUsedAt?.toISOString() ?? null, + })); + return { status: 200, body: { success: true, data: { clients, truncated } }, headers: { ...NO_STORE } }; +} + +/** `GET /authorized-clients` — the caller's own grants. See the module header. */ +export async function runOwnAuthorizedClientsRead( + deps: AuthorizedClientsDeps, + request: { headers: Headers; queries: Record }, +): Promise { + const session = await deps.readCallerSession(request.headers); + const user = session?.user; + const subject = user?.id == null ? '' : String(user.id); + if (!subject) return refusal(401, 'UNAUTHENTICATED', PLATFORM_ADMIN_REFUSAL_MESSAGES[401]); + + const held = judgeHeldSession(user); + if (held) return { status: held.status, body: held.body, headers: { ...NO_STORE } }; + + const unknown = unknownQueryParamRefusalMessage(request.queries, AUTHORIZED_CLIENTS_QUERY_PARAMS); + if (unknown !== undefined) return refusal(400, 'VALIDATION_ERROR', unknown); + + return readAuthorizedClients(deps, subject); +} + +/** + * `GET /admin/authorized-clients?userId=ID` — another person's grants, for a + * caller the mount's platform-admin gate has already admitted (`actor`). See + * the module header. + */ +export async function runAdminAuthorizedClientsRead( + deps: AuthorizedClientsDeps, + _actor: PlatformAdminActor, + request: { queries: Record }, +): Promise { + const unknown = unknownQueryParamRefusalMessage(request.queries, ADMIN_AUTHORIZED_CLIENTS_QUERY_PARAMS); + if (unknown !== undefined) return refusal(400, 'VALIDATION_ERROR', unknown); + const values = request.queries[ADMIN_AUTHORIZED_CLIENTS_USER_PARAM] ?? []; + if (values.length > 1) { + return refusal(400, 'VALIDATION_ERROR', repeatedQueryParamMessage(ADMIN_AUTHORIZED_CLIENTS_USER_PARAM, values.length)); + } + const userId = (values[0] ?? '').trim(); + if (!userId) { + return refusal( + 400, + 'VALIDATION_ERROR', + `The "${ADMIN_AUTHORIZED_CLIENTS_USER_PARAM}" query parameter is required: the id of the person whose authorized clients are read.`, + ); + } + if (!(await deps.userExists(userId))) { + return refusal(404, 'RESOURCE_NOT_FOUND', ADMIN_AUTHORIZED_CLIENTS_USER_NOT_FOUND_MESSAGE); + } + return readAuthorizedClients(deps, userId); +} diff --git a/packages/plugins/plugin-auth/src/mcp-client-last-use.real-engine.test.ts b/packages/plugins/plugin-auth/src/mcp-client-last-use.real-engine.test.ts new file mode 100644 index 00000000000..14e69dd93e8 --- /dev/null +++ b/packages/plugins/plugin-auth/src/mcp-client-last-use.real-engine.test.ts @@ -0,0 +1,548 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30918] The coarse last-use stamp of an authorized AI assistant, on a real + * engine: a real `AuthManager` over a real ObjectQL engine on a real SQL + * driver (better-sqlite3, the `sys_oauth_consent` table created by schema + * sync, so the `last_used_at` column is the one the platform object declares), + * tokens minted by the real jwt plugin and verified by the real + * `verifyMcpAccessToken`, consents written by better-auth's own adapter. + * + * What is pinned: + * + * - COARSE: however many verified calls arrive in one clock hour, the consent + * is written once, with the start of that hour; the next hour writes once + * more; a second process on the same store reads once and writes nothing; + * - the stamp lands on the consent the token was minted under, by its + * organization claim; + * - NOT CLIENT-CONTROLLABLE: the provider's own update-consent door strips a + * last-use member from its body, and a token that does not verify, or + * names no `azp` client, stamps nothing; + * - a failing store never fails the MCP call, and each distinct failure is + * reported once; a consent withdrawn between the stamp's read and its + * write is no failure, so it cannot spend the report a real outage needs. + * + * The access rules of the two reads are pinned on the real plugin in + * `authorized-clients-read.real-plugin.test.ts`. + */ + +import { describe, it, expect, afterEach, beforeEach, afterAll, vi } from 'vitest'; +import { SignJWT, generateKeyPair } from 'jose'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { AuthManager, OAUTH_ORGANIZATION_CLAIM } from './auth-manager.js'; +import { authIdentityObjects } from './manifest.js'; +import { authorizedClientsDepsFor, runAdminAuthorizedClientsRead } from './authorized-clients-read.js'; +import { inviteForAudienceGate } from './audience-gate-test-support.js'; +import { memoizeScrypt } from './__tests__/scrypt-memo.testkit.js'; + +const BASE = 'http://localhost:3000'; +const AUTH_BASE = '/api/v1/auth'; +const SECRET = 'test-secret-at-least-32-chars-long-30918'; +const SYS = { context: { isSystem: true } } as const; +const USER = 'usr_30918'; +const CLIENT = 'client_30918'; +const OTHER_CLIENT = 'client_30918_other'; +const ORG_A = 'org_a_30918'; +const ORG_B = 'org_b_30918'; + +const HOUR_10 = Date.parse('2026-11-06T10:00:00.000Z'); +const HOUR_11 = Date.parse('2026-11-06T11:00:00.000Z'); +const MINUTE = 60_000; + +const ENV_KEYS = ['OS_MCP_SERVER_ENABLED', 'OS_OIDC_PROVIDER_ENABLED'] as const; +const savedEnv: Record = {}; +const engines: ObjectQL[] = []; + +beforeEach(() => { + for (const k of ENV_KEYS) { + savedEnv[k] = process.env[k]; + delete process.env[k]; + } + process.env.OS_MCP_SERVER_ENABLED = 'true'; + // Only the clock is faked: the stamp runs after the response on a real + // `setImmediate`, and the SQL driver keeps its own timers. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(HOUR_10 + 17 * MINUTE); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +afterEach(async () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + while (engines.length) { + try { + await (engines.pop() as unknown as { destroy?(): Promise })?.destroy?.(); + } catch { + /* noop */ + } + } +}); + +const scrypt = memoizeScrypt(); +afterAll(scrypt.restore); + +/** Let every after-response task scheduled so far run to the end. */ +const drain = async () => { + for (let i = 0; i < 25; i++) await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setTimeout(resolve, 20)); + for (let i = 0; i < 25; i++) await new Promise((resolve) => setImmediate(resolve)); +}; + +/** The stamp's own consent read: the one query that selects `last_used_at` by client and user. */ +function isStampRead(query: unknown): boolean { + const q = query as { fields?: unknown; where?: Record } | undefined; + return Array.isArray(q?.fields) && q.fields.includes('last_used_at') && !!q.where && 'client_id' in q.where; +} + +async function rig() { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + for (const object of authIdentityObjects) engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + await engine.syncSchemas(); + vi.spyOn((engine as any).logger, 'info').mockImplementation(() => undefined); + + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const makeManager = () => + new AuthManager({ secret: SECRET, baseUrl: BASE, dataEngine: engine as never, logger } as never); + const manager = makeManager(); + const api: any = await manager.getApi(); + + // Every write of the consent table, and every read the STAMP makes of it + // (the one that selects `last_used_at`), counted at the engine. The + // verifier's own per-call consent read (#30879) goes through better-auth's + // adapter and never selects the stamp, so it is not counted here. + let consentWrites = 0; + let consentReads = 0; + const realUpdate = engine.update.bind(engine); + const realFind = engine.find.bind(engine); + vi.spyOn(engine, 'update').mockImplementation((async (object: string, ...rest: unknown[]) => { + if (object === 'sys_oauth_consent') consentWrites += 1; + return (realUpdate as any)(object, ...rest); + }) as never); + vi.spyOn(engine, 'find').mockImplementation((async (object: string, ...rest: unknown[]) => { + if (object === 'sys_oauth_consent' && isStampRead(rest[0])) consentReads += 1; + return (realFind as any)(object, ...rest); + }) as never); + + /** + * A token for this deployment's MCP resource, signed by the jwt plugin at + * the current (fake) time, with the `iat` the provider mints into every + * access token (the verifier's consent read compares it, #30879). + */ + const mint = async (claims: Record = {}): Promise => + (await api.signJWT({ + body: { + payload: { + iat: Math.floor(Date.now() / 1000), + sub: USER, + azp: CLIENT, + scope: 'data:read', + iss: manager.getAuthIssuer(), + aud: manager.getMcpResourceUrl(), + ...claims, + }, + }, + })).token; + + /** A consent, as the provider's consent door writes it: through better-auth's own adapter. */ + const consent = async (opts: { userId?: string; clientId?: string; referenceId?: string } = {}): Promise => { + const ctx = await manager.getAuthContext(); + const row = await ctx.adapter.create({ + model: 'oauthConsent', + data: { + clientId: opts.clientId ?? CLIENT, + userId: opts.userId ?? USER, + scopes: ['openid', 'data:read'], + ...(opts.referenceId ? { referenceId: opts.referenceId } : {}), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + return String(row.id); + }; + + /** The stored stamp, read straight from the table. */ + const stampOf = async (consentId: string): Promise => { + const rows = (await realFind('sys_oauth_consent', { where: { id: consentId }, fields: ['id', 'last_used_at'] }, SYS)) as any[]; + expect(rows, `consent ${consentId} is gone`).toHaveLength(1); + const value = rows[0].last_used_at; + return value == null || value === '' ? null : new Date(value).toISOString(); + }; + + /** The person withdraws a consent: the row is deleted, as `/oauth2/delete-consent` and `/oauth2/revoke` delete it. */ + const withdraw = async (consentId: string): Promise => { + const ctx = await manager.getAuthContext(); + await ctx.adapter.delete({ model: 'oauthConsent', where: [{ field: 'id', value: consentId }] }); + }; + + return { + engine, + manager, + withdraw, + makeManager, + api, + logger, + mint, + consent, + stampOf, + writes: () => consentWrites, + reads: () => consentReads, + }; +} + +const iso = (ms: number) => new Date(ms).toISOString(); + +describe('[#30918] the last-use stamp is coarse: one write per consent per hour', () => { + it('many verified calls in one hour write once, with the hour\'s start; the next hour writes once more; the read answers it', async () => { + const r = await rig(); + const id = await r.consent(); + await r.engine.insert('sys_user', { id: USER, email: 'mia@last-use.example.com', name: 'Mia' }, SYS); + + const token = await r.mint(); + for (let i = 0; i < 25; i++) { + expect(await r.manager.verifyMcpAccessToken(token)).toMatchObject({ userId: USER, clientId: CLIENT }); + } + await vi.waitFor(async () => expect(await r.stampOf(id)).toBe(iso(HOUR_10))); + await drain(); + expect(r.writes(), 'twenty-five calls in one hour').toBe(1); + expect(r.reads(), 'one read per hour per consent in this process').toBe(1); + + // Later in the same hour: still the same stamp, no I/O at all. + vi.setSystemTime(HOUR_10 + 59 * MINUTE); + const late = await r.mint(); + for (let i = 0; i < 5; i++) await r.manager.verifyMcpAccessToken(late); + await drain(); + expect(r.writes()).toBe(1); + expect(r.reads()).toBe(1); + expect(await r.stampOf(id)).toBe(iso(HOUR_10)); + + // The next hour: one more write, with that hour's start. + vi.setSystemTime(HOUR_11 + 2 * MINUTE); + const next = await r.mint(); + for (let i = 0; i < 3; i++) await r.manager.verifyMcpAccessToken(next); + await vi.waitFor(async () => expect(await r.stampOf(id)).toBe(iso(HOUR_11))); + await drain(); + expect(r.writes()).toBe(2); + + // And the read answers exactly that hour start. + const answer = await runAdminAuthorizedClientsRead(authorizedClientsDepsFor(r.manager), {} as never, { + queries: { userId: [USER] }, + }); + expect(answer.status).toBe(200); + expect((answer.body as any).data.clients).toEqual([ + expect.objectContaining({ id, clientId: CLIENT, lastUsedAt: iso(HOUR_11) }), + ]); + }, 60_000); + + it('a second process on the same store reads once and writes nothing within an hour already stamped', async () => { + const r = await rig(); + const id = await r.consent(); + const token = await r.mint(); + await r.manager.verifyMcpAccessToken(token); + await vi.waitFor(async () => expect(await r.stampOf(id)).toBe(iso(HOUR_10))); + await drain(); + const [writes, reads] = [r.writes(), r.reads()]; + + const second = r.makeManager(); + await second.getApi(); + for (let i = 0; i < 10; i++) expect(await second.verifyMcpAccessToken(token)).toMatchObject({ userId: USER }); + await drain(); + expect(r.writes(), 'the stored hour is current, so the second process writes nothing').toBe(writes); + expect(r.reads(), 'and it reads once for the hour, not once per call').toBe(reads + 1); + }, 60_000); + + it('the stamp lands on the consent the token was minted under, by its organization claim', async () => { + const r = await rig(); + const inA = await r.consent({ referenceId: ORG_A }); + const inB = await r.consent({ referenceId: ORG_B }); + const inNone = await r.consent(); + const otherClient = await r.consent({ clientId: OTHER_CLIENT, referenceId: ORG_A }); + + await r.manager.verifyMcpAccessToken(await r.mint({ [OAUTH_ORGANIZATION_CLAIM]: ORG_A })); + await vi.waitFor(async () => expect(await r.stampOf(inA)).toBe(iso(HOUR_10))); + await drain(); + expect(await r.stampOf(inB)).toBeNull(); + expect(await r.stampOf(inNone)).toBeNull(); + expect(await r.stampOf(otherClient)).toBeNull(); + + await r.manager.verifyMcpAccessToken(await r.mint()); + await vi.waitFor(async () => expect(await r.stampOf(inNone)).toBe(iso(HOUR_10))); + await drain(); + expect(await r.stampOf(inB)).toBeNull(); + expect(await r.stampOf(otherClient)).toBeNull(); + }, 60_000); +}); + +describe('[#30918] nothing a client sends reaches the stamp', () => { + it('a token that does not verify, or names no azp client, stamps nothing', async () => { + const r = await rig(); + const id = await r.consent(); + const control = await r.consent({ clientId: OTHER_CLIENT }); + + // Wrong audience, a key this server never published, already expired, and + // a client named only in `client_id` (the stamp reads `azp` alone). + const wrongAudience = await r.mint({ aud: `${BASE}/not-the-mcp-resource` }); + const { privateKey } = await generateKeyPair('EdDSA'); + const now = Math.floor(Date.now() / 1000); + const foreignKey = await new SignJWT({ scope: 'data:read', azp: CLIENT }) + .setProtectedHeader({ alg: 'EdDSA', kid: 'kid-never-published-30918' }) + .setIssuer(r.manager.getAuthIssuer()) + .setAudience(r.manager.getMcpResourceUrl()) + .setSubject(USER) + .setIssuedAt(now) + .setExpirationTime(now + 900) + .sign(privateKey); + const expired = await r.mint({ exp: now - 60 }); + const noAzp = await r.mint({ azp: undefined, client_id: CLIENT }); + + expect(await r.manager.verifyMcpAccessToken(wrongAudience)).toBeNull(); + expect(await r.manager.verifyMcpAccessToken(foreignKey)).toBeNull(); + expect(await r.manager.verifyMcpAccessToken(expired)).toBeNull(); + expect(await r.manager.verifyMcpAccessToken('not.a.token')).toBeNull(); + // #30879 keys the verifier's consent read by `azp`, so a token without one + // is refused before the stamp could see it. + expect(await r.manager.verifyMcpAccessToken(noAzp), 'a client_id-only token is refused').toBeNull(); + + // Positive control on the same process: a verified token for another + // client stamps, so the stamp path is live while the five above did not. + await r.manager.verifyMcpAccessToken(await r.mint({ azp: OTHER_CLIENT })); + await vi.waitFor(async () => expect(await r.stampOf(control)).toBe(iso(HOUR_10))); + await drain(); + expect(await r.stampOf(id)).toBeNull(); + }, 60_000); + + it('the provider\'s own update-consent door strips a last-use member from its body', async () => { + const r = await rig(); + const email = 'nora@last-use.example.com'; + const password = 'S3cure!Passw0rd-last-use'; + await inviteForAudienceGate(r.manager, email); + const signUp = await r.manager.handleRequest( + new Request(`${BASE}${AUTH_BASE}/sign-up/email`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password, name: 'Nora' }), + }), + ); + expect(signUp.status, await signUp.clone().text()).toBe(200); + const userId = String(((await signUp.clone().json()) as any).user.id); + const cookie = (signUp.headers.getSetCookie?.() ?? [signUp.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + + const ctx = await r.manager.getAuthContext(); + await ctx.adapter.create({ + model: 'oauthClient', + data: { clientId: CLIENT, name: 'Desk Assistant', scopes: ['openid', 'data:read'], redirectUris: ['http://localhost:56789/cb'] }, + }); + const id = await r.consent({ userId }); + + const res = await r.manager.handleRequest( + new Request(`${BASE}${AUTH_BASE}/oauth2/update-consent`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: BASE, cookie }, + body: JSON.stringify({ + id, + update: { scopes: ['openid'], lastUsedAt: '2001-01-01T00:00:00.000Z', last_used_at: '2001-01-01T00:00:00.000Z' }, + lastUsedAt: '2001-01-01T00:00:00.000Z', + last_used_at: '2001-01-01T00:00:00.000Z', + }), + }), + ); + expect(res.status, await res.clone().text()).toBe(200); + // The door ran (the scopes moved) and the stamp did not. + const rows = (await r.engine.find( + 'sys_oauth_consent', + { where: { id }, fields: ['id', 'scopes', 'last_used_at'] }, + SYS, + )) as any[]; + expect(JSON.parse(String(rows[0].scopes))).toEqual(['openid']); + expect(await r.stampOf(id)).toBeNull(); + + // The one writer: a verified call, with the server's hour. + await r.manager.verifyMcpAccessToken(await r.mint({ sub: userId })); + await vi.waitFor(async () => expect(await r.stampOf(id)).toBe(iso(HOUR_10))); + }, 60_000); +}); + +describe('[#30918] a failing store', () => { + it('never fails the MCP call, is reported once at error, and is retried next hour, not next call', async () => { + const r = await rig(); + await r.consent(); + let attempts = 0; + const realUpdate = (r.engine.update as any).getMockImplementation(); + vi.mocked(r.engine.update).mockImplementation((async (object: string, ...rest: unknown[]) => { + if (object === 'sys_oauth_consent') { + attempts += 1; + throw new Error('no such column: last_used_at'); + } + return realUpdate(object, ...rest); + }) as never); + + for (const at of [HOUR_10 + 17 * MINUTE, HOUR_10 + 40 * MINUTE, HOUR_11 + 5 * MINUTE, HOUR_11 + 50 * MINUTE]) { + vi.setSystemTime(at); + const token = await r.mint(); + for (let i = 0; i < 4; i++) { + expect(await r.manager.verifyMcpAccessToken(token), 'the verdict never waits on, or fails with, the stamp').toMatchObject({ + userId: USER, + clientId: CLIENT, + }); + } + await drain(); + } + expect(attempts, 'one attempt per hour, however many calls').toBe(2); + const reports = r.logger.error.mock.calls.filter(([message]) => String(message).includes('last-use time')); + expect(reports, 'the same failure is reported once per process').toHaveLength(1); + expect(String(reports[0][0])).toContain('sys_oauth_consent.last_used_at'); + expect(reports[0][1]).toEqual({ error: 'no such column: last_used_at' }); + }, 60_000); + + it('reports each DISTINCT failure once, so an earlier failure never silences a later, different one', async () => { + const r = await rig(); + await r.consent(); + const failures: Record = { + [HOUR_10]: 'SQLITE_BUSY: database is locked', + [HOUR_11]: 'no such column: last_used_at', + [HOUR_11 + 60 * MINUTE]: 'SQLITE_BUSY: database is locked', + }; + let attempts = 0; + const realUpdate = (r.engine.update as any).getMockImplementation(); + vi.mocked(r.engine.update).mockImplementation((async (object: string, ...rest: unknown[]) => { + if (object === 'sys_oauth_consent') { + attempts += 1; + throw new Error(failures[Math.floor(Date.now() / (60 * MINUTE)) * 60 * MINUTE]); + } + return realUpdate(object, ...rest); + }) as never); + + // Every call after the consent was granted (10:17), so the verifier admits each. + for (const at of [HOUR_10 + 20 * MINUTE, HOUR_11 + 5 * MINUTE, HOUR_11 + 65 * MINUTE]) { + vi.setSystemTime(at); + await r.manager.verifyMcpAccessToken(await r.mint()); + await drain(); + } + expect(attempts, 'one attempt in each of the three hours').toBe(3); + const reports = r.logger.error.mock.calls.filter(([message]) => String(message).includes('last-use time')); + expect( + reports.map(([, meta]) => meta), + 'the locked store, then the missing column; the locked store again is not reported twice', + ).toEqual([{ error: 'SQLITE_BUSY: database is locked' }, { error: 'no such column: last_used_at' }]); + }, 60_000); + + it('a consent withdrawn between the stamp\'s read and its write is no failure: nothing is reported, and a later outage still is', async () => { + const r = await rig(); + const withdrawn = await r.consent(); + // The race on the real engine: the stamp reads the consent, then the + // person withdraws it (the row is deleted) before the stamp's write lands. + let withdrawAfterNextRead = true; + const realFind = (r.engine.find as any).getMockImplementation(); + vi.mocked(r.engine.find).mockImplementation((async (object: string, ...rest: unknown[]) => { + const rows = await realFind(object, ...rest); + if (object === 'sys_oauth_consent' && withdrawAfterNextRead && isStampRead(rest[0])) { + withdrawAfterNextRead = false; + await r.engine.delete('sys_oauth_consent', { where: { id: withdrawn }, ...SYS } as never); + } + return rows; + }) as never); + const realUpdate = (r.engine.update as any).getMockImplementation(); + let updateErrors: string[] = []; + vi.mocked(r.engine.update).mockImplementation((async (object: string, ...rest: unknown[]) => { + try { + return await realUpdate(object, ...rest); + } catch (error) { + updateErrors.push(String((error as { code?: unknown }).code)); + throw error; + } + }) as never); + + await r.manager.verifyMcpAccessToken(await r.mint()); + await drain(); + expect(updateErrors, 'the write did land on a row that was gone').toEqual(['RECORD_NOT_FOUND']); + const reportsOf = () => r.logger.error.mock.calls.filter(([message]) => String(message).includes('last-use time')); + expect(reportsOf(), 'a withdrawn consent has nothing to stamp, so it is not reported').toHaveLength(0); + + // A later real outage, in the same process, is still reported. + await r.consent(); + updateErrors = []; + vi.mocked(r.engine.update).mockImplementation((async (object: string, ...rest: unknown[]) => { + if (object === 'sys_oauth_consent') throw new Error('no such column: last_used_at'); + return realUpdate(object, ...rest); + }) as never); + vi.setSystemTime(HOUR_11 + 5 * MINUTE); + await r.manager.verifyMcpAccessToken(await r.mint()); + await drain(); + expect(reportsOf().map(([, meta]) => meta)).toEqual([{ error: 'no such column: last_used_at' }]); + }, 60_000); +}); + +describe('[#30918 × #30879] the stamp lands only on the consent that admitted the call', () => { + it('a revoked consent stamps nothing; a consent granted again is not stamped by a token of the old grant', async () => { + const r = await rig(); + const first = await r.consent(); + const oldToken = await r.mint(); + expect(await r.manager.verifyMcpAccessToken(oldToken)).toMatchObject({ userId: USER, clientId: CLIENT }); + await vi.waitFor(async () => expect(await r.stampOf(first)).toBe(iso(HOUR_10))); + await drain(); + const writes = r.writes(); + + // Withdrawn: the verifier refuses the token, so nothing is stamped, in + // this hour or the next. + await r.withdraw(first); + vi.setSystemTime(HOUR_11 + 3 * MINUTE); + expect(await r.manager.verifyMcpAccessToken(oldToken), 'a revoked consent refuses its token').toBeNull(); + await drain(); + expect(r.writes(), 'a revoked consent stamps nothing').toBe(writes); + + // Granted again: the old token belongs to the old grant and is refused; + // the new consent stays unstamped until a token of ITS grant is used. + vi.setSystemTime(HOUR_11 + 4 * MINUTE); + const again = await r.consent(); + expect(await r.manager.verifyMcpAccessToken(oldToken), 'a token older than the consent is refused').toBeNull(); + await drain(); + expect(await r.stampOf(again)).toBeNull(); + expect(r.writes()).toBe(writes); + + vi.setSystemTime(HOUR_11 + 5 * MINUTE); + expect(await r.manager.verifyMcpAccessToken(await r.mint())).toMatchObject({ userId: USER }); + await vi.waitFor(async () => expect(await r.stampOf(again)).toBe(iso(HOUR_11))); + }, 60_000); + + it('a re-grant that lands between the verdict and the stamp is not stamped with the old token\'s use', async () => { + const r = await rig(); + const first = await r.consent(); + const oldToken = await r.mint(); + let regranted: string | undefined; + + // The race on the real engine: the verdict admits the old token under its + // consent; before the stamp (which runs after the response) reads, the + // person revokes and grants again, two minutes later. + let regrantBeforeStampRead = true; + const realFind = (r.engine.find as any).getMockImplementation(); + vi.mocked(r.engine.find).mockImplementation((async (object: string, ...rest: unknown[]) => { + if (object === 'sys_oauth_consent' && regrantBeforeStampRead && isStampRead(rest[0])) { + regrantBeforeStampRead = false; + await r.withdraw(first); + vi.setSystemTime(HOUR_10 + 19 * MINUTE); + regranted = await r.consent(); + } + return realFind(object, ...rest); + }) as never); + + expect(await r.manager.verifyMcpAccessToken(oldToken), 'the verdict admits the token under its consent').toMatchObject({ + userId: USER, + }); + await drain(); + expect(regranted, 'the re-grant ran inside the stamp').toBeDefined(); + expect(r.writes(), 'the new consent is newer than the token, so it is not stamped').toBe(0); + expect(await r.stampOf(regranted!)).toBeNull(); + }, 60_000); +}); diff --git a/packages/plugins/plugin-auth/src/mcp-client-last-use.ts b/packages/plugins/plugin-auth/src/mcp-client-last-use.ts new file mode 100644 index 00000000000..5410d59fb7a --- /dev/null +++ b/packages/plugins/plugin-auth/src/mcp-client-last-use.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30918] When an authorized AI assistant was last used: a coarse stamp the + * server writes on a verified MCP call, and nothing else ever writes. + * + * ## Where it lives + * + * `sys_oauth_consent.last_used_at`, an ObjectStack column on the + * better-auth-managed consent table. The consent is the right row for three + * reasons. It is the grant the person can see and withdraw, so the stamp is + * read beside it (`authorized-clients-read.ts`). It is held per organization + * (`reference_id`), so a client used in one organization never moves the + * stamp of its grant in another. And a withdrawn or revoked consent is a + * deleted row, so no usage record outlives the grant it describes. MCP access + * tokens are JWTs and are not stored, so there is no token row to stamp, and + * the refresh token moves only on a refresh. + * + * ## Who writes it, and when + * + * Only {@link McpClientLastUseStamper.record}, and only with the principal + * `AuthManager.verifyMcpAccessToken` has just verified: the token's subject, + * its `azp` client, its `organization_id` claim and its `iat`, every one read + * from a signature this server checked. The time is the server's clock. No + * request field, header or body reaches the stamp, and the consent doors + * better-auth serves (`/oauth2/consent`, `/oauth2/update-consent`) do not + * carry the column. A token with no `azp` names no client, so it stamps + * nothing. + * + * ## Only the consent that admitted the call + * + * The verifier admits a token only while the consent it was minted under + * stands (#30879, `mcp-token-revocation.ts` `mcpConsentAdmits`): the consent + * for (`azp`, `sub`, organization) exists and was created no later than the + * token's `iat`. The stamp picks its consent by that same rule + * ({@link consentForToken}), so it can only land on the consent that admitted + * the call. A revoked consent refuses the call, so it stamps nothing. A + * consent revoked and granted again belongs to the new grant: an old token is + * refused, and even when the re-grant lands between the verdict and the stamp + * (the stamp runs after the response), the new consent is newer than the old + * token and is not stamped with the old token's use. A consent deleted + * between the stamp's read and its write is a no-op (below). A server-only + * `skip_consent` client has no consent row, so it stamps nothing. + * + * ## How coarse, and why an hour + * + * The stored value is the START of the clock hour in which a verified call + * arrived, and a row is written only when its stored hour is older than the + * current one. So: + * + * - **at most one write per consent per hour in each process**, however + * many MCP calls the client makes in it. An assistant can make hundreds of tool calls a + * minute; a per-request write would turn every one into a write on an + * identity table. An hour is the granularity a person needs to answer + * "is this laptop's assistant still acting as me?" and the granularity + * an administrator can be shown without exposing a minute-by-minute + * activity trail; + * - **the answer says what it means**: "used during the hour starting at + * X", never a precision the server did not keep. + * + * Each process also remembers, per (client, user, organization), the hour it + * last settled, so within an hour a busy client costs no I/O at all. The first + * verified call of an hour costs one read (and one write when no other + * process has stamped that hour yet); with several processes, each reads at + * most once an hour per consent and the row is written once, or a few times + * with the same value when first calls race. The memory is bounded + * ({@link MCP_CLIENT_LAST_USE_MEMO_CAP} entries, oldest dropped first). + * + * ## Off the request path + * + * The MCP request does not wait for the stamp: the manager schedules it after + * the response (`run-after-response.ts`). A failed read or write loses that + * hour's stamp and nothing else. Each DISTINCT failure (by its message) is + * reported once per process at `error`, because the reads keep answering an + * older stamp as if it were current, and the hour is retried in the next + * hour, not on the next call, so a store outage never becomes a write or a + * log line per request. Keying the report on the message, not on "the first + * failure", is what keeps one benign failure from silencing a later real + * one (the missing column, an unreachable store). The benign one is also + * not a failure at all: a consent withdrawn between the read and the write + * leaves nothing to stamp, so the real store answers that write's + * `RECORD_NOT_FOUND` as done. + */ + +/** The stamp's granularity: one clock hour. */ +export const MCP_CLIENT_LAST_USE_GRANULARITY_MS = 60 * 60 * 1000; + +/** How many (client, user, organization) keys one process remembers. */ +export const MCP_CLIENT_LAST_USE_MEMO_CAP = 10_000; + +/** + * How many distinct failure messages one process reports. A bound, so a store + * whose error text varies per row cannot turn the report into a log line per + * consent; past it the stamper stays silent, as one consent's hour is. + */ +export const MCP_CLIENT_LAST_USE_FAILURE_REPORT_CAP = 16; + +/** The verified principal, as `verifyMcpAccessToken` returns it, plus the verified token's `iat`. */ +export interface VerifiedMcpPrincipal { + userId: string; + clientId?: string; + organizationId?: string; + /** The verified token's `iat`, in seconds. Without one, no consent admits the token, so nothing is stamped. */ + issuedAt?: number; +} + +/** One consent row, as the store reads it for the stamp. */ +export interface StampableConsent { + id: string; + /** The consent's organization, `null`/empty when it binds none. */ + referenceId: string | null; + /** When the consent was granted, `null` when the row does not say. */ + createdAt: Date | null; + /** The stored stamp, `null` when none has been written. */ + lastUsedAt: Date | null; +} + +/** The two store operations the stamp needs. {@link mcpClientLastUseStoreFor} wires the real ones. */ +export interface McpClientLastUseStore { + /** Every consent `userId` holds for `clientId`, across organizations. */ + consentsFor(clientId: string, userId: string): Promise; + /** Write `at` as the consent's stamp. A consent that is gone by now is a no-op. */ + stamp(consentId: string, at: Date): Promise; +} + +/** + * The consent a verified token was minted under, by the rule the verifier + * admits it by (`mcpConsentAdmits`, #30879): the consent bound to the token's + * organization when it carries one, otherwise the consent bound to none (the + * provider's own `findConsent` rule), and only when it was created no later + * than the token was issued, compared in whole seconds, the unit of `iat`. A + * newer consent is a later grant the token does not belong to. + */ +export function consentForToken( + consents: readonly StampableConsent[], + organizationId: string | undefined, + issuedAt: number | undefined, +): StampableConsent | undefined { + const consent = organizationId + ? consents.find((row) => row.referenceId === organizationId) + : consents.find((row) => !row.referenceId); + if (!consent || typeof issuedAt !== 'number' || !Number.isFinite(issuedAt) || !consent.createdAt) return undefined; + return Math.floor(consent.createdAt.getTime() / 1000) <= issuedAt ? consent : undefined; +} + +/** The start of the clock hour `nowMs` falls in. */ +export function lastUseHourOf(nowMs: number): number { + return Math.floor(nowMs / MCP_CLIENT_LAST_USE_GRANULARITY_MS) * MCP_CLIENT_LAST_USE_GRANULARITY_MS; +} + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +/** The engine slice the real store reads and writes through. */ +interface StampEngine { + find(object: string, query: Record, options?: unknown): Promise; + update(object: string, doc: Record, options?: unknown): Promise; +} + +const toDate = (value: unknown): Date | null => { + if (value == null || value === '') return null; + const date = value instanceof Date ? value : new Date(value as string | number); + return Number.isNaN(date.getTime()) ? null : date; +}; + +/** + * The real store: the data engine under a system context. A consent is a + * better-auth-managed row, and a system write passes the identity write guard + * by construction (ADR-0092 D2), so this is the platform's own server-side + * write, as the session tombstone is. + */ +export function mcpClientLastUseStoreFor(getEngine: () => unknown): McpClientLastUseStore { + const engine = (): StampEngine => { + const found = getEngine() as StampEngine | undefined; + if (!found) throw new Error('The data engine is not available.'); + return found; + }; + return { + async consentsFor(clientId, userId) { + const rows = await engine().find( + 'sys_oauth_consent', + { + where: { client_id: clientId, user_id: userId }, + fields: ['id', 'reference_id', 'created_at', 'last_used_at'], + // One consent per organization the person granted this client in. + limit: 1000, + }, + { context: SYSTEM_CTX }, + ); + return (Array.isArray(rows) ? rows : []).map((row: Record) => ({ + id: String(row.id), + referenceId: typeof row.reference_id === 'string' && row.reference_id ? row.reference_id : null, + createdAt: toDate(row.created_at), + lastUsedAt: toDate(row.last_used_at), + })); + }, + async stamp(consentId, at) { + try { + await engine().update('sys_oauth_consent', { id: consentId, last_used_at: at }, { context: SYSTEM_CTX }); + } catch (error) { + // The consent was read a moment ago and withdrawn since (the person, + // or a revoke, deleted the row): there is no grant left to stamp, so + // the engine's by-id 404 (`recordNotFoundError`, `@objectstack/core`) + // is the stamp's done, not its failure. + if ((error as { code?: unknown } | null)?.code === 'RECORD_NOT_FOUND') return; + throw error; + } + }, + }; +} + +/** + * Records verified MCP calls as coarse per-consent stamps. One instance per + * `AuthManager`, so the per-process memory is shared by every token it + * verifies. + */ +export class McpClientLastUseStamper { + /** (client, user, organization) → the hour start this process last settled. */ + private readonly settled = new Map(); + /** The failure messages this process has already reported. */ + private readonly reportedFailures = new Set(); + + constructor( + private readonly store: McpClientLastUseStore, + private readonly opts: { + now?: () => number; + /** Told about each DISTINCT failure (by message) once in this process. */ + onNewFailure?: (error: unknown) => void; + } = {}, + ) {} + + /** + * Stamp the consent `principal`'s token was minted under, unless this hour + * is already stamped. Resolves once the stamp is settled; never rejects. + */ + async record(principal: VerifiedMcpPrincipal): Promise { + const { userId, clientId, organizationId, issuedAt } = principal; + if (!userId || !clientId) return; + const hour = lastUseHourOf((this.opts.now ?? Date.now)()); + const key = JSON.stringify([clientId, userId, organizationId ?? null]); + if (this.settled.get(key) === hour) return; + // Claimed BEFORE the I/O, so concurrent calls of the same hour skip it, + // and kept on failure, so an outage is retried next hour, not next call. + this.settled.delete(key); + this.settled.set(key, hour); + if (this.settled.size > MCP_CLIENT_LAST_USE_MEMO_CAP) { + const oldest = this.settled.keys().next().value; + if (oldest !== undefined) this.settled.delete(oldest); + } + try { + const consent = consentForToken(await this.store.consentsFor(clientId, userId), organizationId, issuedAt); + if (!consent) return; + if (consent.lastUsedAt && consent.lastUsedAt.getTime() >= hour) return; + await this.store.stamp(consent.id, new Date(hour)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (this.reportedFailures.has(message)) return; + if (this.reportedFailures.size >= MCP_CLIENT_LAST_USE_FAILURE_REPORT_CAP) return; + this.reportedFailures.add(message); + try { + this.opts.onNewFailure?.(error); + } catch { + // A report is best-effort; the stamp never becomes the caller's failure. + } + } + } +} diff --git a/packages/plugins/plugin-auth/src/oauth-provider-schema-parity.test.ts b/packages/plugins/plugin-auth/src/oauth-provider-schema-parity.test.ts index 506cce99b40..d56c1d0ccf7 100644 --- a/packages/plugins/plugin-auth/src/oauth-provider-schema-parity.test.ts +++ b/packages/plugins/plugin-auth/src/oauth-provider-schema-parity.test.ts @@ -81,4 +81,21 @@ describe('oauth-provider plugin schema ↔ platform-objects parity', () => { ).toEqual([]); }); } + + // [#30918] The other direction, for the one column ObjectStack adds to a + // provider table. `sys_oauth_consent.last_used_at` is written by + // plugin-auth's last-use stamp alone (`mcp-client-last-use.ts`), so no + // provider field may resolve to it: if a better-auth bump ever declares a + // consent field that lands on this column, the provider's own consent + // doors (`/oauth2/consent`, `/oauth2/update-consent`) could write it, and + // the stamp would no longer be server-only. + it('no oauthConsent field resolves to the ObjectStack-owned last_used_at column', () => { + const consent = schema.oauthConsent; + expect(consent, 'the installed provider declares no oauthConsent model').toBeDefined(); + const columns = Object.entries(consent.fields).map(([key, field]) => field.fieldName ?? key); + expect(columns.length, 'the oauthConsent model resolved no columns, so this check would be vacuous').toBeGreaterThan(0); + expect(columns).not.toContain('last_used_at'); + expect(columns).not.toContain('lastUsedAt'); + expect(Object.keys((SysOauthConsent as any).fields)).toContain('last_used_at'); + }); }); diff --git a/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts index c8cf384bce5..1cb12accf68 100644 --- a/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts @@ -241,6 +241,11 @@ const ADMITTED = [ // mount carrying the ADR-0068 gate first; no body or query parameter is // read and no stored state moves, so it has no individual effect pin below. 'GET /api/v1/auth/admin/client-address', + // #30918 — another person's authorized AI assistants (OAuth consents) and + // when each was last used. A READ: an ObjectStack raw mount carrying the + // ADR-0068 gate first, then the closed `{ userId }` query; no stored state + // moves, so it has no individual effect pin below. + 'GET /api/v1/auth/admin/authorized-clients', 'POST /api/v1/auth/admin/unlock-user', 'POST /api/v1/auth/admin/import-users', 'POST /api/v1/auth/admin/oauth2/toggle-disabled', @@ -710,6 +715,10 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout // #28793. No body is read; the empty one is what the console's action // sends besides the `organizationId` it adds, which the handler ignores. 'POST /api/v1/auth/admin/list-platform-admins': { body: {} }, + // #30918. The one parameter the read takes: with none the handler answers + // `400 VALIDATION_ERROR` past the gate, which the `diedBeforeTheGate` + // predicate below reads as a void reading. + 'GET /api/v1/auth/admin/authorized-clients': { query: `?userId=${targetUserId}` }, 'POST /api/v1/auth/admin/oauth2/toggle-disabled': { body: { client_id: 'standing-probe-client', disabled: false } }, 'POST /api/v1/auth/admin/sso/register': { body: { diff --git a/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts b/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts index 9bd4194e091..074213edb2f 100644 --- a/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts @@ -283,6 +283,12 @@ function expectationsFor(targetUserId: string): Record bucket: 'objectstack-gate', note: 'a read: the gate runs first and the handler reads no body and no query parameter; the admin gets 200 { clientAddress, trustProxy }', }, + // ── #30918 — another person's authorized AI assistants (a read) ───────── + 'GET /api/v1/auth/admin/authorized-clients': { + bucket: 'objectstack-gate', + query: `?userId=${targetUserId}`, + note: 'a read: the gate runs before the query is read, so anon and member are refused whoever userId names; the admin gets 200 { clients, truncated } for the target', + }, // ── #9653: the /admin/sso/* bridges, gated ahead of their delegation ──── // // The ADR-0068 gate runs BEFORE the bridge re-dispatches into better-auth,