diff --git a/.changeset/mcp-oauth-authorization.md b/.changeset/mcp-oauth-authorization.md new file mode 100644 index 0000000000..10ef28e86b --- /dev/null +++ b/.changeset/mcp-oauth-authorization.md @@ -0,0 +1,56 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-auth': minor +'@objectstack/runtime': minor +'@objectstack/mcp': minor +--- + +feat(mcp): spec-compliant OAuth 2.1 authorization for `/api/v1/mcp` (#2698) + +Any OAuth-capable MCP client (claude.ai custom connectors, Claude Desktop, +Claude Code) can now connect to a deployment **self-serve**: no admin-minted +API key, no central registry — you sign in through the browser as yourself and +every tool call runs under your own permissions and row-level security. + +**Each deployment is its own authorization server**, backed by the embedded +better-auth instance (`@better-auth/oauth-provider`). Rationale for the design +decisions lives in #2698; the moving parts: + +- **Discovery**: `/.well-known/oauth-protected-resource` (RFC 9728, incl. the + path-inserted variant for `/api/v1/mcp`) and + `/.well-known/oauth-authorization-server` (RFC 8414, incl. the path-inserted + variant for the `/api/v1/auth` issuer) are served from the deployment origin. + 401s from `/api/v1/mcp` advertise the resource metadata via + `WWW-Authenticate`, so clients bootstrap the flow automatically. +- **Dynamic Client Registration (RFC 7591)** is enabled (unauthenticated, as + the MCP spec requires) whenever the MCP surface is on — every deployment is a + distinct AS, so clients cannot ship pre-registered IDs. Force it either way + with `OS_OIDC_DCR_ENABLED` or the new `plugins.dynamicClientRegistration` + auth-config field. The embedded AS itself now auto-enables when + `OS_MCP_SERVER_ENABLED=true` (explicit `OS_OIDC_PROVIDER_ENABLED=false` still + wins). +- **Authorization-code + PKCE** flow with RFC 8707 resource binding: access + tokens are minted with `aud=/api/v1/mcp` and verified locally + (signature/issuer/audience/expiry) against the deployment's own JWKS — + fail-closed parity with API keys: unknown/expired/wrong-audience tokens, + sub-less M2M tokens, or a presented-but-invalid bearer never fall back to an + ambient session, they 401. +- **Token → ExecutionContext**: a valid access token resolves to the same + principal-bound `ExecutionContext` as every other credential, single-sourced + through `resolveAuthzContext` — OAuth adds a second *provenance* for the + principal, not a second authz model. `ExecutionContext` gains an optional + `oauthScopes` field carrying the token's granted scopes. +- **Coarse scopes → tool families**, enforced at tool dispatch: `data:read` + (list/describe/query/get), `data:write` (create/update/delete), + `actions:execute` (list_actions/run_action). Constants live in + `@objectstack/spec/ai` (`MCP_OAUTH_SCOPES`). Tools outside the grant are not + registered — and therefore rejected — for that request. API-key and session + principals are unaffected (not scope-limited). +- **TLS required, localhost exempt** (OAuth 2.1): on a plain-HTTP non-loopback + origin the OAuth track stays dark (no metadata, no bearer acceptance) and the + endpoint remains API-key-only. Local clients reach intranet deployments; + claude.ai web connectors additionally need public HTTPS reachability. + +**API keys are unchanged** (dual-track): `x-api-key` / `Authorization: ApiKey` / +`Authorization: Bearer osk_…` keep working exactly as before for CI and +headless agents — covered by new regression tests. diff --git a/content/docs/ai/agents.mdx b/content/docs/ai/agents.mdx index 2437261c55..b7143889a0 100644 --- a/content/docs/ai/agents.mdx +++ b/content/docs/ai/agents.mdx @@ -42,6 +42,44 @@ the Builder, never silently re-routed. (For data query and source-mode authoring the **open edition** ships neither agent and uses `@objectstack/mcp` (BYO-AI) instead — see the callout in the [AI Overview](/docs/ai).) +## Connect your AI (BYO-AI over MCP) + +Every deployment with `OS_MCP_SERVER_ENABLED=true` serves MCP at +`/api/v1/mcp`, with two authentication tracks: + +- **OAuth 2.1 (interactive clients — recommended).** Each deployment is its + own spec-compliant authorization server: the endpoint publishes + `.well-known/oauth-protected-resource` / `.well-known/oauth-authorization-server` + discovery metadata, clients self-register via Dynamic Client Registration + (RFC 7591) and run an authorization-code + PKCE flow. Nothing is + pre-registered with Anthropic or any central service, so **self-hosted and + private deployments work out of the box**. You log in through the browser + as yourself; every tool call runs under your permissions and RLS. Consent + scopes (`data:read`, `data:write`, `actions:execute`) narrow which tool + families the client gets. +- **API key (headless).** Mint a key (`POST /api/v1/keys`, shown once) and + send it as `x-api-key` — for CI, scripts, and agents without a browser. + +Per client: + +| Client | How to connect | +|---|---| +| **Claude Code** | `claude mcp add --transport http objectstack https:///api/v1/mcp` — a browser login opens on first use. Headless alternative: add `--header "x-api-key: osk_..."`. | +| **Claude Desktop** | Settings → Connectors → *Add custom connector* → paste the MCP URL → sign in when prompted. | +| **claude.ai (web)** | Settings → Connectors → *Add custom connector* → paste the MCP URL. | +| **Other MCP clients** | Any client implementing MCP authorization discovers the flow automatically; header-based clients can send the API key instead. | + + +**Private / intranet deployments.** OAuth requires HTTPS (localhost is +exempt, per OAuth 2.1). **Local** clients — Claude Code and Claude Desktop — +run on your machine, so they can reach an intranet-only deployment (an +internal CA works). **claude.ai web connectors** connect from Anthropic's +servers, so they additionally need the MCP endpoint reachable from the public +internet — a network decision, not a platform switch. On a plain-HTTP +non-localhost deployment the OAuth track stays dark and the endpoint is +API-key-only, fail-closed. + + ## You extend the platform with **skills**, not agents `*.agent.ts` is **closed to third parties** — the `agent` metadata type is diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 76d21a73b6..fe025d012d 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -309,9 +309,29 @@ security apply to the agent exactly as they do to a person. The support desk you built in six steps is now a backend an agent can *run* — not just a database it can read. -See [Actions as Tools](/docs/ai/actions-as-tools) for the `run_action` bridge and -[the MCP reference](/docs/references/ai/mcp) for enabling the server and connecting -a client. +Connecting is self-serve. Start the app with the MCP surface on +(`OS_MCP_SERVER_ENABLED=true`), then add it to your client — the deployment is +its own OAuth 2.1 authorization server, so interactive clients just open a +browser login (you connect as yourself, no admin-minted credentials): + +```bash +claude mcp add --transport http support-desk https://your-deployment.example.com/api/v1/mcp +# first tool use opens a browser login — you're connected as yourself +``` + +For headless callers (CI, scripts), mint an API key instead +(`POST /api/v1/keys`, shown once) and pass it as a header: + +```bash +claude mcp add --transport http support-desk https://your-deployment.example.com/api/v1/mcp \ + --header "x-api-key: osk_..." +``` + +See [Connect your AI](/docs/ai/agents#connect-your-ai-byo-ai-over-mcp) for +per-client instructions (claude.ai / Claude Desktop / Claude Code and the +private-deployment reachability note), [Actions as Tools](/docs/ai/actions-as-tools) +for the `run_action` bridge, and [the MCP reference](/docs/references/ai/mcp) for +enabling the server. ## Recap — where each guardrail sat diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 1642554ad9..3af7d1a88c 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -314,7 +314,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | +| **types** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | | **namespaces** | `string[]` | optional | Filter by namespaces | | **packageId** | `string` | optional | Filter by owning package | | **search** | `string` | optional | Full-text search query | @@ -349,7 +349,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type | +| **type** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type | | **name** | `string` | ✅ | Item name (snake_case) | | **data** | `Record` | ✅ | Metadata payload | | **namespace** | `string` | optional | Optional namespace | diff --git a/content/docs/references/identity/eval-user.mdx b/content/docs/references/identity/eval-user.mdx index 8874d893f8..142f58d4a5 100644 --- a/content/docs/references/identity/eval-user.mdx +++ b/content/docs/references/identity/eval-user.mdx @@ -13,15 +13,15 @@ RLS, client UI gates) under the canonical variable name `current_user` (aliases `user`, `ctx.user`) with an **identical shape**. A predicate such as -`current_user.roles.exists(r, r == 'org_admin')` (or +`current_user.positions.exists(p, p == 'org_admin')` (or -`'org_admin' in current_user.roles`) therefore evaluates identically wherever +`'org_admin' in current_user.positions`) therefore evaluates identically wherever it is written. -`roles: string[]` is the **only canonical** role field. Singular `role` is NOT +`positions: string[]` is the **only canonical** membership field (renamed from -part of this contract — its legacy "overwritten to 'admin' on promotion" +`roles`, ADR-0090 D3). A singular field is NOT part of this contract — its legacy "overwritten to 'admin' on promotion" behavior is the footgun this eliminates. @@ -52,8 +52,8 @@ const result = EvalUser.parse(data); | **id** | `string` | ✅ | User ID | | **name** | `string` | optional | Display name | | **email** | `string` | optional | Email address | -| **roles** | `string[]` | ✅ | Canonical role names assigned to the user (scope-resolved) | -| **isPlatformAdmin** | `boolean` | optional | DERIVED alias of 'platform_admin' in roles. Deprecated. | +| **positions** | `string[]` | ✅ | Canonical position/identity names assigned to the user (scope-resolved) | +| **isPlatformAdmin** | `boolean` | optional | DERIVED alias of 'platform_admin' in positions. Deprecated. | | **organizationId** | `string \| null` | optional | Active organization ID (null = platform/unscoped) | diff --git a/content/docs/references/identity/index.mdx b/content/docs/references/identity/index.mdx index 20acb8642e..842e243b77 100644 --- a/content/docs/references/identity/index.mdx +++ b/content/docs/references/identity/index.mdx @@ -9,6 +9,6 @@ This section contains all protocol schemas for the identity layer of ObjectStack - + diff --git a/content/docs/references/identity/meta.json b/content/docs/references/identity/meta.json index 89019b65e1..f31c25b4c5 100644 --- a/content/docs/references/identity/meta.json +++ b/content/docs/references/identity/meta.json @@ -4,7 +4,7 @@ "eval-user", "identity", "organization", - "role", + "position", "scim" ] } \ No newline at end of file diff --git a/content/docs/references/identity/position.mdx b/content/docs/references/identity/position.mdx new file mode 100644 index 0000000000..e25def0720 --- /dev/null +++ b/content/docs/references/identity/position.mdx @@ -0,0 +1,90 @@ +--- +title: Position +description: Position protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Position Schema — the flat capability-distribution group (ADR-0090 D3). + +A position (岗位, "job role" in NetSuite/Workday terms) is a **named, + +assignable bundle of permission sets**: users hold positions + +(`sys_user_position`), positions bind permission sets + +(`sys_position_permission_set`), and a user's capability is the union of + +every set reached that way plus direct grants. + +Positions are deliberately **flat** — no `parent`, no hierarchy. The + +visibility hierarchy lives on the business-unit tree (`sys_business_unit`, + +ADR-0057 D2) and the manager chain (`sys_user.manager_id`); re-adding a + +second tree here is the mistake ADR-0057 D5 retired and ADR-0090 D3 + +finalizes. + +VOCABULARY (ADR-0090 D3): the word "role" is reserved-forbidden across the + +platform — capability = permission_set, distribution = position, + +hierarchy = business_unit. The sole exception is better-auth's internal + +`sys_member.role` (org-membership tier), projected as + +`org_membership_level`. + +**NAMING CONVENTION:** + +Position names MUST be lowercase snake_case to prevent security issues. + +@example Good position names + +- 'sales_manager' + +- 'ceo' + +- 'region_east_vp' + +- 'engineering_lead' + +@example Bad position names (will be rejected) + +- 'SalesManager' (camelCase) + +- 'CEO' (uppercase) + +- 'Region East VP' (spaces and uppercase) + + +**Source:** `packages/spec/src/identity/position.zod.ts` + + +## TypeScript Usage + +```typescript +import { Position } from '@objectstack/spec/identity'; +import type { Position } from '@objectstack/spec/identity'; + +// Validate data +const result = Position.parse(data); +``` + +--- + +## Position + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Unique position name (lowercase snake_case) | +| **label** | `string` | ✅ | Display label (e.g. VP of Sales) | +| **description** | `string` | optional | | + + +--- + diff --git a/content/docs/references/identity/role.mdx b/content/docs/references/identity/role.mdx deleted file mode 100644 index 30b4c75cf5..0000000000 --- a/content/docs/references/identity/role.mdx +++ /dev/null @@ -1,75 +0,0 @@ ---- -title: Role -description: Role protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -Role Schema (aka Business Unit / Org Unit) - -Defines the organizational hierarchy (Reporting Structure). - -COMPARISON: - -- Salesforce: "Role" (Hierarchy for visibility rollup) - -- Microsoft: "Business Unit" (Structural container for data) - -- Kubernetes/AWS: "Role" usually refers to Permissions (we use PermissionSet for that) - -ROLES IN OBJECTSTACK: - -Used primarily for "Reporting Structure" - Managers see subordinates' data. - -**NAMING CONVENTION:** - -Role names MUST be lowercase snake_case to prevent security issues. - -@example Good role names - -- 'sales_manager' - -- 'ceo' - -- 'region_east_vp' - -- 'engineering_lead' - -@example Bad role names (will be rejected) - -- 'SalesManager' (camelCase) - -- 'CEO' (uppercase) - -- 'Region East VP' (spaces and uppercase) - - -**Source:** `packages/spec/src/identity/role.zod.ts` - - -## TypeScript Usage - -```typescript -import { Role } from '@objectstack/spec/identity'; -import type { Role } from '@objectstack/spec/identity'; - -// Validate data -const result = Role.parse(data); -``` - ---- - -## Role - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique role name (lowercase snake_case) | -| **label** | `string` | ✅ | Display label (e.g. VP of Sales) | -| **parent** | `string` | optional | Parent Role ID (Reports To) | -| **description** | `string` | optional | | - - ---- - diff --git a/content/docs/references/kernel/execution-context.mdx b/content/docs/references/kernel/execution-context.mdx index 9e988379d2..e30cf50ad8 100644 --- a/content/docs/references/kernel/execution-context.mdx +++ b/content/docs/references/kernel/execution-context.mdx @@ -54,7 +54,10 @@ const result = ExecutionContext.parse(data); | **timezone** | `string` | optional | | | **locale** | `string` | optional | | | **currency** | `string` | optional | | -| **roles** | `string[]` | ✅ | | +| **positions** | `string[]` | ✅ | | +| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | +| **audience** | `Enum<'internal' \| 'external'>` | optional | | +| **onBehalfOf** | `Object` | optional | | | **permissions** | `string[]` | ✅ | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -62,6 +65,7 @@ const result = ExecutionContext.parse(data); | **rlsMembership** | `Record` | optional | | | **isSystem** | `boolean` | ✅ | | | **skipTriggers** | `boolean` | optional | | +| **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | | **traceId** | `string` | optional | | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index f73f024796..5b73062921 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -130,7 +130,7 @@ const result = MetadataBulkRegisterRequest.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **event** | `Enum<'metadata.registered' \| 'metadata.updated' \| 'metadata.unregistered' \| 'metadata.validated' \| 'metadata.deployed' \| 'metadata.overlay.applied' \| 'metadata.overlay.removed' \| 'metadata.imported' \| 'metadata.exported'>` | ✅ | Event type | -| **metadataType** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type | +| **metadataType** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type | | **name** | `string` | ✅ | Metadata item name | | **namespace** | `string` | optional | Namespace | | **packageId** | `string` | optional | Owning package ID | @@ -147,7 +147,7 @@ const result = MetadataBulkRegisterRequest.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | +| **types** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | | **namespaces** | `string[]` | optional | Filter by namespaces | | **packageId** | `string` | optional | Filter by owning package | | **search** | `string` | optional | Full-text search query | @@ -202,8 +202,7 @@ const result = MetadataBulkRegisterRequest.parse(data); * `doc` * `book` * `permission` -* `profile` -* `role` +* `position` * `agent` * `tool` * `skill` diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 8a39477531..92e2af7597 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -89,8 +89,7 @@ const result = FieldPermission.parse(data); | **label** | `string` | optional | Display label | | **packageId** | `string` | optional | [ADR-0086 D3] Owning package id for a package-shipped set (absent = env-authored) | | **managedBy** | `Enum<'package' \| 'platform' \| 'user'>` | optional | [ADR-0086 D3] Record provenance: package (upgrade-owned metadata) vs platform/user (env config) | -| **isProfile** | `boolean` | ✅ | Whether this is a user profile | -| **isDefault** | `boolean` | ✅ | [ADR-0056 D7] When true, this profile is the FALLBACK assigned to authenticated users who have no explicit grants — an app declares its default access posture here instead of relying on the built-in member_default. Foundation for SSO/JIT provisioning. | +| **isDefault** | `boolean` | ✅ | [ADR-0090 D5] Install-time suggestion to bind this set to the everyone position (admin confirms; never auto-bound) | | **objects** | `Record` | ✅ | Entity permissions | | **fields** | `Record` | optional | Field level security | | **systemPermissions** | `string[]` | optional | System level capabilities | diff --git a/content/docs/references/security/rls.mdx b/content/docs/references/security/rls.mdx index 5a37bc3f5f..5e8507889a 100644 --- a/content/docs/references/security/rls.mdx +++ b/content/docs/references/security/rls.mdx @@ -217,7 +217,7 @@ const result = RLSEvaluationResult.parse(data); | **operation** | `Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>` | ✅ | Database operation this policy applies to | | **using** | `string` | optional | Filter condition for SELECT/UPDATE/DELETE. One of the four compiler-supported forms: `field = current_user.`, `field = 'literal'`, `field IN (current_user.)`, or `1 = 1`. Optional for INSERT-only policies. | | **check** | `string` | optional | Validation condition for INSERT/UPDATE (defaults to USING clause if not specified - enforced at application level) | -| **roles** | `string[]` | optional | Roles this policy applies to (omit for all roles) | +| **positions** | `string[]` | optional | Positions this policy applies to (omit for all) | | **enabled** | `boolean` | ✅ | Whether this policy is active | | **priority** | `integer` | ✅ | Policy evaluation priority (higher = evaluated first) | | **tags** | `string[]` | optional | Policy categorization tags | diff --git a/content/docs/references/security/sharing.mdx b/content/docs/references/security/sharing.mdx index 8f616ef87a..3ad113f440 100644 --- a/content/docs/references/security/sharing.mdx +++ b/content/docs/references/security/sharing.mdx @@ -62,8 +62,8 @@ const result = OWDModel.parse(data); * `user` * `group` -* `role` -* `role_and_subordinates` +* `position` +* `unit_and_subordinates` * `guest` diff --git a/content/docs/references/system/auth-config.mdx b/content/docs/references/system/auth-config.mdx index e34f85d4dc..cafdc821ae 100644 --- a/content/docs/references/system/auth-config.mdx +++ b/content/docs/references/system/auth-config.mdx @@ -80,6 +80,7 @@ Advanced / low-level Better-Auth options | **passwordRejectBreached** | `boolean` | ✅ | Reject passwords found in the Have I Been Pwned breach corpus (enables better-auth's haveibeenpwned plugin) | | **magicLink** | `boolean` | ✅ | Enable Magic Link login | | **oidcProvider** | `boolean` | ✅ | Enable the OpenID Connect provider plugin (acts as an OIDC IdP) | +| **dynamicClientRegistration** | `boolean` | optional | Allow unauthenticated RFC 7591 Dynamic Client Registration (default: follows OS_MCP_SERVER_ENABLED) | | **deviceAuthorization** | `boolean` | ✅ | Enable RFC 8628 Device Authorization Grant (CLI / TV-style login) | | **admin** | `boolean` | ✅ | Enable platform admin operations (ban/unban, set-password, impersonate, set-role) | diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 6f911fe7e7..5d35c60630 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -474,6 +474,14 @@ export default class Serve extends Command { if (requires.includes('auth') && !requires.includes('email')) { requires.push('email'); } + // `OS_MCP_SERVER_ENABLED=true` says "serve MCP at /api/v1/mcp" — the + // dispatcher gates the route on the SAME env var, so honoring the flag + // without also loading the MCP plugin would 501 every request (#2698: + // flipping one switch must yield a connectable MCP endpoint). Explicit + // `requires: ['mcp']` in config works without the env var too. + if (process.env.OS_MCP_SERVER_ENABLED === 'true' && !requires.includes('mcp')) { + requires.push('mcp'); + } // Default capability slate — every preset except `minimal` gets the // foundational services (queue + job + cache + settings + email + // storage). Opt out with `objectstack serve --preset minimal`. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 60398b1607..5d3f9f7323 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -271,7 +271,58 @@ Please provide: ## Using with AI Clients -### Claude Desktop +### Connecting to a running deployment (remote HTTP) + +A running ObjectStack deployment (with `OS_MCP_SERVER_ENABLED=true`) serves +MCP over Streamable HTTP at `/api/v1/mcp`. Two authentication tracks: + +**OAuth 2.1 — the human-client track (recommended).** Each deployment is its +own spec-compliant authorization server (backed by the embedded better-auth +instance): it serves `.well-known/oauth-protected-resource` and +`.well-known/oauth-authorization-server` discovery metadata, supports Dynamic +Client Registration (RFC 7591) and the authorization-code + PKCE flow. Any +OAuth-capable MCP client connects self-serve — no admin-minted credentials, +no central registry; you log in through the browser as yourself and every +tool call runs under **your** permissions and row-level security. + +```bash +# Claude Code +claude mcp add --transport http objectstack https://your-deployment.example.com/api/v1/mcp +# then approve the browser login on first use + +# claude.ai — Settings → Connectors → Add custom connector → paste the MCP URL +# (requires the deployment to be reachable from the public internet over HTTPS) + +# Claude Desktop — Settings → Connectors → Add custom connector +``` + +TLS is required for OAuth (localhost is exempt, per OAuth 2.1). Local clients +(Claude Code / Desktop) can reach intranet deployments; claude.ai web +connectors additionally need the endpoint publicly reachable. Coarse scopes +(`data:read`, `data:write`, `actions:execute`) narrow the exposed tool +families at consent time; permissions/RLS still bind every call to the +logged-in user. + +**API key — the headless track (CI, scripts, background agents).** Mint a key +(`POST /api/v1/keys`, shown once) and send it as a header — no browser +involved, unchanged from before: + +```json +{ + "mcpServers": { + "objectstack": { + "type": "http", + "url": "https://your-deployment.example.com/api/v1/mcp", + "headers": { "x-api-key": "osk_..." } + } + } +} +``` + +(`Authorization: ApiKey ` and `Authorization: Bearer ` +are also accepted.) + +### Claude Desktop (local stdio server) Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: diff --git a/packages/mcp/src/mcp-http-tools.scopes.test.ts b/packages/mcp/src/mcp-http-tools.scopes.test.ts new file mode 100644 index 0000000000..9706a43e5b --- /dev/null +++ b/packages/mcp/src/mcp-http-tools.scopes.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * OAuth tool-family scope gating (#2698). + * + * `grantedScopes` narrows which tool families REGISTER on the per-request + * server: `data:read` → list/describe/query/get, `data:write` → + * create/update/delete, `actions:execute` → list_actions/run_action. + * A tool outside the grant is absent from tools/list AND rejected on + * tools/call (unknown tool) — enforcement at dispatch, fail-closed. + * `grantedScopes: undefined` (API-key / session provenance) keeps the full + * surface — the regression guard for the unchanged headless track. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + MCP_OAUTH_SCOPE_DATA_READ, + MCP_OAUTH_SCOPE_DATA_WRITE, + MCP_OAUTH_SCOPE_ACTIONS, +} from '@objectstack/spec/ai'; + +import { MCPServerRuntime } from './mcp-server-runtime.js'; +import type { McpActionBridge, McpDataBridge } from './mcp-http-tools.js'; + +const READ_TOOLS = ['list_objects', 'describe_object', 'query_records', 'get_record']; +const WRITE_TOOLS = ['create_record', 'update_record', 'delete_record']; +const ACTION_TOOLS = ['list_actions', 'run_action']; + +function makeBridge(): McpDataBridge & McpActionBridge & { calls: any[] } { + const calls: any[] = []; + return { + calls, + async listObjects() { calls.push(['listObjects']); return [{ name: 'task', label: 'Task' }]; }, + async describeObject(name: string) { calls.push(['describeObject', name]); return { name }; }, + async query(object: string, opts: any) { calls.push(['query', object, opts]); return { object, records: [] }; }, + async get(object: string, id: string) { calls.push(['get', object, id]); return { id }; }, + async create(object: string, data: any) { calls.push(['create', object, data]); return { object, id: 'n1' }; }, + async update(object: string, id: string, data: any) { calls.push(['update', object, id, data]); return { object, id }; }, + async remove(object: string, id: string) { calls.push(['remove', object, id]); return { object, id, deleted: true }; }, + async listActions() { calls.push(['listActions']); return [{ name: 'complete_task', objectName: 'task' }]; }, + async runAction(name: string, input: any) { calls.push(['runAction', name, input]); return { ok: true }; }, + }; +} + +async function call(runtime: MCPServerRuntime, bridge: any, grantedScopes: string[] | undefined, body: unknown) { + const req = new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body), + }); + const res = await runtime.handleHttpRequest(req, { + bridge, + parsedBody: body, + ...(grantedScopes ? { toolOptions: { grantedScopes } } : {}), + }); + return { status: res.status, json: res.status === 202 ? null : await res.json() }; +} + +async function listTools(runtime: MCPServerRuntime, bridge: any, grantedScopes?: string[]) { + const { json } = await call(runtime, bridge, grantedScopes, { jsonrpc: '2.0', id: 1, method: 'tools/list' }); + return (json.result?.tools ?? []).map((t: any) => t.name).sort(); +} + +describe('MCP OAuth scope → tool-family gating', () => { + let runtime: MCPServerRuntime; + let bridge: ReturnType; + + beforeEach(() => { + runtime = new MCPServerRuntime({ name: 't', version: '1.0.0' }); + bridge = makeBridge(); + }); + + it('undefined grantedScopes (API key / session) keeps the FULL tool surface', async () => { + const names = await listTools(runtime, bridge, undefined); + expect(names).toEqual([...READ_TOOLS, ...WRITE_TOOLS, ...ACTION_TOOLS].sort()); + }); + + it('data:read alone exposes only the read family', async () => { + const names = await listTools(runtime, bridge, [MCP_OAUTH_SCOPE_DATA_READ]); + expect(names).toEqual([...READ_TOOLS].sort()); + }); + + it('data:write alone exposes only the write family', async () => { + const names = await listTools(runtime, bridge, [MCP_OAUTH_SCOPE_DATA_WRITE]); + expect(names).toEqual([...WRITE_TOOLS].sort()); + }); + + it('actions:execute alone exposes only the action family', async () => { + const names = await listTools(runtime, bridge, [MCP_OAUTH_SCOPE_ACTIONS]); + expect(names).toEqual([...ACTION_TOOLS].sort()); + }); + + it('all three scopes expose the full surface; unknown extras are ignored', async () => { + const names = await listTools(runtime, bridge, [ + 'openid', + MCP_OAUTH_SCOPE_DATA_READ, + MCP_OAUTH_SCOPE_DATA_WRITE, + MCP_OAUTH_SCOPE_ACTIONS, + ]); + expect(names).toEqual([...READ_TOOLS, ...WRITE_TOOLS, ...ACTION_TOOLS].sort()); + }); + + it('an empty grant registers nothing (fail-closed)', async () => { + const { json } = await call(runtime, bridge, [], { jsonrpc: '2.0', id: 1, method: 'tools/list' }); + // No tools registered → the SDK never wires tools/list at all. + expect(json.result).toBeUndefined(); + expect(json.error).toBeDefined(); + }); + + it('calling a write tool with a read-only grant is rejected WITHOUT touching the bridge', async () => { + const { json } = await call(runtime, bridge, [MCP_OAUTH_SCOPE_DATA_READ], { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'create_record', arguments: { objectName: 'task', data: { title: 'x' } } }, + }); + // Unregistered tool → JSON-RPC/tool error, never a successful result. + const failed = Boolean(json.error) || json.result?.isError === true; + expect(failed).toBe(true); + expect(bridge.calls.find((c: any[]) => c[0] === 'create')).toBeUndefined(); + }); + + it('a granted read tool still works alongside a denied write family', async () => { + const { json } = await call(runtime, bridge, [MCP_OAUTH_SCOPE_DATA_READ], { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'query_records', arguments: { objectName: 'task' } }, + }); + expect(json.result.isError).toBeFalsy(); + expect(bridge.calls.find((c: any[]) => c[0] === 'query')).toBeDefined(); + }); + + it('run_action with only data scopes is rejected without touching the bridge', async () => { + const { json } = await call( + runtime, + bridge, + [MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_DATA_WRITE], + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'run_action', arguments: { actionName: 'complete_task' } }, + }, + ); + const failed = Boolean(json.error) || json.result?.isError === true; + expect(failed).toBe(true); + expect(bridge.calls.find((c: any[]) => c[0] === 'runAction')).toBeUndefined(); + }); +}); diff --git a/packages/mcp/src/mcp-http-tools.ts b/packages/mcp/src/mcp-http-tools.ts index 6be75875b7..cb5e63873a 100644 --- a/packages/mcp/src/mcp-http-tools.ts +++ b/packages/mcp/src/mcp-http-tools.ts @@ -22,6 +22,11 @@ import { z } from 'zod'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + MCP_OAUTH_SCOPE_DATA_READ, + MCP_OAUTH_SCOPE_DATA_WRITE, + MCP_OAUTH_SCOPE_ACTIONS, +} from '@objectstack/spec/ai'; export interface McpObjectSummary { name: string; @@ -58,6 +63,17 @@ export interface RegisterObjectToolsOptions { allowSystemObjects?: boolean; /** Hard cap on `query_records` page size. Default 50. */ maxQueryLimit?: number; + /** + * OAuth 2.1 scopes granted to the caller's access token (#2698). + * UNDEFINED = not scope-limited (API-key / session provenance) — the full + * principal-bound tool surface registers, today's behavior. An ARRAY + * narrows registration to the granted tool families, fail-closed: + * `data:read` → list/describe/query/get, `data:write` → create/update/ + * delete, `actions:execute` → list_actions/run_action. An empty array + * registers nothing. Scopes only bound the tool surface — every call + * still runs under the principal's permissions and RLS. + */ + grantedScopes?: readonly string[]; } /** One declared input parameter of a business action, LLM-facing. */ @@ -126,6 +142,12 @@ export interface McpActionBridge { export interface RegisterActionToolsOptions { /** Expose actions on `sys_*` system objects too. Default false (fail-closed). */ allowSystemObjects?: boolean; + /** + * OAuth 2.1 scopes granted to the caller's access token (#2698) — same + * contract as {@link RegisterObjectToolsOptions.grantedScopes}. The action + * tools require `actions:execute`; undefined = not scope-limited. + */ + grantedScopes?: readonly string[]; } const DEFAULT_MAX_LIMIT = 50; @@ -163,6 +185,12 @@ export function registerObjectTools( ): void { const allowSystem = options.allowSystemObjects === true; const maxLimit = options.maxQueryLimit ?? DEFAULT_MAX_LIMIT; + // OAuth tool-family gating (#2698). undefined = not scope-limited. + // A tool outside the grant is NOT registered at all — the SDK then + // rejects it as unknown, which doubles as dispatch-time enforcement. + const scopes = options.grantedScopes; + const canRead = !scopes || scopes.includes(MCP_OAUTH_SCOPE_DATA_READ); + const canWrite = !scopes || scopes.includes(MCP_OAUTH_SCOPE_DATA_WRITE); /** Fail-closed object-name guard shared by every object-scoped tool. */ const guard = (objectName: string): string | undefined => { @@ -173,172 +201,176 @@ export function registerObjectTools( return undefined; }; - server.registerTool( - 'list_objects', - { - description: - 'List the data objects (tables) available in this app. Returns each object\'s name, label and field count.', - inputSchema: {}, - annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, - }, - async () => { - try { - const objects = await bridge.listObjects(); - const visible = allowSystem ? objects : objects.filter((o) => !isSystemObject(o.name)); - return textResult({ objects: visible, totalCount: visible.length }); - } catch (err) { - return errorResult(messageOf(err)); - } - }, - ); + if (canRead) { + server.registerTool( + 'list_objects', + { + description: + 'List the data objects (tables) available in this app. Returns each object\'s name, label and field count.', + inputSchema: {}, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + }, + async () => { + try { + const objects = await bridge.listObjects(); + const visible = allowSystem ? objects : objects.filter((o) => !isSystemObject(o.name)); + return textResult({ objects: visible, totalCount: visible.length }); + } catch (err) { + return errorResult(messageOf(err)); + } + }, + ); - server.registerTool( - 'describe_object', - { - description: - 'Get the schema of a data object: its fields (name, type, label, required) and enabled features.', - inputSchema: { objectName: z.string().describe('The object/table name, e.g. "task"') }, - annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, - }, - async ({ objectName }) => { - const bad = guard(objectName); - if (bad) return errorResult(bad); - try { - const def = await bridge.describeObject(objectName); - if (!def) return errorResult(`Object "${objectName}" not found`); - return textResult(def); - } catch (err) { - return errorResult(messageOf(err)); - } - }, - ); + server.registerTool( + 'describe_object', + { + description: + 'Get the schema of a data object: its fields (name, type, label, required) and enabled features.', + inputSchema: { objectName: z.string().describe('The object/table name, e.g. "task"') }, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + }, + async ({ objectName }) => { + const bad = guard(objectName); + if (bad) return errorResult(bad); + try { + const def = await bridge.describeObject(objectName); + if (!def) return errorResult(`Object "${objectName}" not found`); + return textResult(def); + } catch (err) { + return errorResult(messageOf(err)); + } + }, + ); - server.registerTool( - 'query_records', - { - description: - 'Query records from an object with optional filter, field selection, sorting and pagination. ' + - 'Runs under the caller\'s permissions and row-level security.', - inputSchema: { - objectName: z.string().describe('The object/table name'), - where: z - .record(z.string(), z.unknown()) - .optional() - .describe('Filter conditions, e.g. {"status":"open"}'), - fields: z.array(z.string()).optional().describe('Field names to return (defaults to all)'), - limit: z.number().int().positive().max(maxLimit).optional().describe(`Max rows (≤ ${maxLimit})`), - offset: z.number().int().nonnegative().optional().describe('Rows to skip'), - orderBy: z - .array(z.object({ field: z.string(), order: z.enum(['asc', 'desc']) })) - .optional() - .describe('Sort order'), + server.registerTool( + 'query_records', + { + description: + 'Query records from an object with optional filter, field selection, sorting and pagination. ' + + 'Runs under the caller\'s permissions and row-level security.', + inputSchema: { + objectName: z.string().describe('The object/table name'), + where: z + .record(z.string(), z.unknown()) + .optional() + .describe('Filter conditions, e.g. {"status":"open"}'), + fields: z.array(z.string()).optional().describe('Field names to return (defaults to all)'), + limit: z.number().int().positive().max(maxLimit).optional().describe(`Max rows (≤ ${maxLimit})`), + offset: z.number().int().nonnegative().optional().describe('Rows to skip'), + orderBy: z + .array(z.object({ field: z.string(), order: z.enum(['asc', 'desc']) })) + .optional() + .describe('Sort order'), + }, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, }, - annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, - }, - async ({ objectName, where, fields, limit, offset, orderBy }) => { - const bad = guard(objectName); - if (bad) return errorResult(bad); - try { - const result = await bridge.query(objectName, { - where, - fields, - limit: Math.min(limit ?? maxLimit, maxLimit), - offset, - orderBy, - }); - return textResult(result); - } catch (err) { - return errorResult(messageOf(err)); - } - }, - ); + async ({ objectName, where, fields, limit, offset, orderBy }) => { + const bad = guard(objectName); + if (bad) return errorResult(bad); + try { + const result = await bridge.query(objectName, { + where, + fields, + limit: Math.min(limit ?? maxLimit, maxLimit), + offset, + orderBy, + }); + return textResult(result); + } catch (err) { + return errorResult(messageOf(err)); + } + }, + ); - server.registerTool( - 'get_record', - { - description: 'Fetch a single record by id.', - inputSchema: { - objectName: z.string().describe('The object/table name'), - recordId: z.string().describe('The record id'), + server.registerTool( + 'get_record', + { + description: 'Fetch a single record by id.', + inputSchema: { + objectName: z.string().describe('The object/table name'), + recordId: z.string().describe('The record id'), + }, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, }, - annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, - }, - async ({ objectName, recordId }) => { - const bad = guard(objectName); - if (bad) return errorResult(bad); - try { - const record = await bridge.get(objectName, recordId); - if (record == null) return errorResult(`Record "${recordId}" not found in "${objectName}"`); - return textResult(record); - } catch (err) { - return errorResult(messageOf(err)); - } - }, - ); + async ({ objectName, recordId }) => { + const bad = guard(objectName); + if (bad) return errorResult(bad); + try { + const record = await bridge.get(objectName, recordId); + if (record == null) return errorResult(`Record "${recordId}" not found in "${objectName}"`); + return textResult(record); + } catch (err) { + return errorResult(messageOf(err)); + } + }, + ); + } // end canRead (data:read) - server.registerTool( - 'create_record', - { - description: 'Create a new record. Runs under the caller\'s permissions and validations.', - inputSchema: { - objectName: z.string().describe('The object/table name'), - data: z.record(z.string(), z.unknown()).describe('Field values for the new record'), + if (canWrite) { + server.registerTool( + 'create_record', + { + description: 'Create a new record. Runs under the caller\'s permissions and validations.', + inputSchema: { + objectName: z.string().describe('The object/table name'), + data: z.record(z.string(), z.unknown()).describe('Field values for the new record'), + }, + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, }, - annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, - }, - async ({ objectName, data }) => { - const bad = guard(objectName); - if (bad) return errorResult(bad); - try { - return textResult(await bridge.create(objectName, data)); - } catch (err) { - return errorResult(messageOf(err)); - } - }, - ); + async ({ objectName, data }) => { + const bad = guard(objectName); + if (bad) return errorResult(bad); + try { + return textResult(await bridge.create(objectName, data)); + } catch (err) { + return errorResult(messageOf(err)); + } + }, + ); - server.registerTool( - 'update_record', - { - description: 'Update fields on an existing record by id.', - inputSchema: { - objectName: z.string().describe('The object/table name'), - recordId: z.string().describe('The record id'), - data: z.record(z.string(), z.unknown()).describe('Field values to change'), + server.registerTool( + 'update_record', + { + description: 'Update fields on an existing record by id.', + inputSchema: { + objectName: z.string().describe('The object/table name'), + recordId: z.string().describe('The record id'), + data: z.record(z.string(), z.unknown()).describe('Field values to change'), + }, + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, }, - annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, - }, - async ({ objectName, recordId, data }) => { - const bad = guard(objectName); - if (bad) return errorResult(bad); - try { - return textResult(await bridge.update(objectName, recordId, data)); - } catch (err) { - return errorResult(messageOf(err)); - } - }, - ); + async ({ objectName, recordId, data }) => { + const bad = guard(objectName); + if (bad) return errorResult(bad); + try { + return textResult(await bridge.update(objectName, recordId, data)); + } catch (err) { + return errorResult(messageOf(err)); + } + }, + ); - server.registerTool( - 'delete_record', - { - description: 'Delete a record by id. This is destructive.', - inputSchema: { - objectName: z.string().describe('The object/table name'), - recordId: z.string().describe('The record id'), + server.registerTool( + 'delete_record', + { + description: 'Delete a record by id. This is destructive.', + inputSchema: { + objectName: z.string().describe('The object/table name'), + recordId: z.string().describe('The record id'), + }, + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }, }, - annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }, - }, - async ({ objectName, recordId }) => { - const bad = guard(objectName); - if (bad) return errorResult(bad); - try { - return textResult(await bridge.remove(objectName, recordId)); - } catch (err) { - return errorResult(messageOf(err)); - } - }, - ); + async ({ objectName, recordId }) => { + const bad = guard(objectName); + if (bad) return errorResult(bad); + try { + return textResult(await bridge.remove(objectName, recordId)); + } catch (err) { + return errorResult(messageOf(err)); + } + }, + ); + } // end canWrite (data:write) } /** @@ -359,6 +391,11 @@ export function registerActionTools( options: RegisterActionToolsOptions = {}, ): void { const allowSystem = options.allowSystemObjects === true; + // OAuth tool-family gating (#2698): the whole action surface requires + // `actions:execute`. Not registered = unknown tool = fail-closed. + if (options.grantedScopes && !options.grantedScopes.includes(MCP_OAUTH_SCOPE_ACTIONS)) { + return; + } server.registerTool( 'list_actions', diff --git a/packages/mcp/src/skill.test.ts b/packages/mcp/src/skill.test.ts index 66094490c1..56f11dbc60 100644 --- a/packages/mcp/src/skill.test.ts +++ b/packages/mcp/src/skill.test.ts @@ -41,11 +41,14 @@ describe('renderSkillMarkdown', () => { expect(renderSkillMarkdown({ envName: 'Acme CRM' })).toContain('**Acme CRM**'); }); - it('documents auth via x-api-key (not Bearer, which is session auth)', () => { + it('documents BOTH auth tracks: OAuth (interactive) and x-api-key (headless)', () => { const md = renderSkillMarkdown(); + // OAuth is the human-client track (#2698): self-serve, browser login. + expect(md).toContain('OAuth'); + expect(md).toMatch(/authorization\s+server/); + // API key stays the headless track, unchanged. expect(md).toContain('x-api-key'); expect(md).toContain('Authorization: ApiKey'); - expect(md).not.toMatch(/Authorization:\s*Bearer/); }); it('lists the object-CRUD tools and a discover-first instruction', () => { diff --git a/packages/mcp/src/skill.ts b/packages/mcp/src/skill.ts index fd330b1d69..dde6d9b136 100644 --- a/packages/mcp/src/skill.ts +++ b/packages/mcp/src/skill.ts @@ -79,15 +79,30 @@ This skill drives the MCP server at: ${url} \`\`\` -Authenticate with an ObjectStack API key sent as a request header (the key is -shown to you once when created; treat it like a password): +Two authentication tracks are supported: + +**OAuth (recommended for interactive clients).** Add the URL as a remote MCP +server with no credentials; the deployment is its own OAuth 2.1 authorization +server, so an OAuth-capable client (claude.ai custom connectors, Claude +Desktop, Claude Code) discovers it automatically, registers itself, and opens +a browser login. You sign in as yourself and every tool call runs under your +own permissions. Example (Claude Code): + +\`\`\` +claude mcp add --transport http objectstack ${url} +\`\`\` + +**API key (headless: CI, scripts, agents without a browser).** Send an +ObjectStack API key as a request header (the key is shown to you once when +created; treat it like a password): \`\`\` x-api-key: \`\`\` -(The header \`Authorization: ApiKey \` is also accepted.) If your -MCP client supports custom headers on a remote server, set the header there. +(\`Authorization: ApiKey \` and \`Authorization: Bearer +\` are also accepted.) If your MCP client supports custom +headers on a remote server, set the header there. ## Discover before you act diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 37818b8def..a17b8aec01 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -27,7 +27,8 @@ "@objectstack/platform-objects": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", - "better-auth": "^1.6.23" + "better-auth": "^1.6.23", + "jose": "^6.2.3" }, "devDependencies": { "@types/node": "^26.1.0", diff --git a/packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts b/packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts new file mode 100644 index 0000000000..ede9a75c02 --- /dev/null +++ b/packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts @@ -0,0 +1,289 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * MCP OAuth 2.1 track (#2698) — authorization-server wiring + the + * resource-server half that lives on AuthManager. + * + * Token verification tests use REAL jose-signed JWTs against a locally + * generated JWKS (mocked `getApi().getJwks`), so the crypto path — signature, + * issuer, audience, expiry — is exercised for real, fail-closed on each axis. + * The full discovery → DCR → PKCE browser flow is covered end-to-end against + * a live dev server (see the PR's verification notes); better-auth's own + * endpoint behavior is not re-tested here. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SignJWT, generateKeyPair, exportJWK } from 'jose'; +import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; + +import { + AuthManager, + resolveOidcProviderEnabled, + resolveDcrEnabled, + isOAuthEligibleBaseUrl, +} from './auth-manager'; + +// Mock better-auth so plugin-registration tests can capture the config +// without booting a real instance (same pattern as auth-manager.test.ts). +vi.mock('better-auth', () => ({ + betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })), +})); +vi.mock('@better-auth/oauth-provider', () => ({ + oauthProvider: vi.fn((opts: any) => ({ id: 'oauth-provider', _opts: opts })), +})); + +import { betterAuth } from 'better-auth'; +import { oauthProvider } from '@better-auth/oauth-provider'; + +const ENV_KEYS = ['OS_MCP_SERVER_ENABLED', 'OS_OIDC_PROVIDER_ENABLED', 'OS_OIDC_DCR_ENABLED'] as const; +const savedEnv: Record = {}; + +beforeEach(() => { + vi.clearAllMocks(); + for (const k of ENV_KEYS) { + savedEnv[k] = process.env[k]; + delete process.env[k]; + } +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } +}); + +describe('isOAuthEligibleBaseUrl (OAuth 2.1 TLS rule, loopback exempt)', () => { + it.each([ + ['https://acme.example.com', true], + ['https://intranet.corp', true], + ['http://localhost:3000', true], + ['http://127.0.0.1:8080', true], + ['http://[::1]:3000', true], + ['http://myapp.localhost:3000', true], + ['http://intranet.corp:3000', false], + ['http://10.0.0.5', false], + ['ftp://localhost', false], + ['not a url', false], + ])('%s → %s', (url, expected) => { + expect(isOAuthEligibleBaseUrl(url)).toBe(expected); + }); +}); + +describe('enable-flag resolution (env → config → follows MCP surface)', () => { + it('defaults OFF when neither env nor config nor MCP is on', () => { + expect(resolveOidcProviderEnabled({})).toBe(false); + expect(resolveDcrEnabled({})).toBe(false); + }); + + it('follows OS_MCP_SERVER_ENABLED (the self-serve MCP connect default)', () => { + process.env.OS_MCP_SERVER_ENABLED = 'true'; + expect(resolveOidcProviderEnabled({})).toBe(true); + expect(resolveDcrEnabled({})).toBe(true); + }); + + it('explicit env override wins over the MCP default (operator can force off)', () => { + process.env.OS_MCP_SERVER_ENABLED = 'true'; + process.env.OS_OIDC_PROVIDER_ENABLED = 'false'; + process.env.OS_OIDC_DCR_ENABLED = 'false'; + expect(resolveOidcProviderEnabled({})).toBe(false); + expect(resolveDcrEnabled({})).toBe(false); + }); + + it('config file wins over the MCP default but loses to env', () => { + expect(resolveOidcProviderEnabled({ oidcProvider: true })).toBe(true); + expect(resolveDcrEnabled({ dynamicClientRegistration: true } as any)).toBe(true); + process.env.OS_OIDC_PROVIDER_ENABLED = 'false'; + expect(resolveOidcProviderEnabled({ oidcProvider: true })).toBe(false); + }); +}); + +describe('canonical issuer / resource URLs', () => { + const manager = () => + new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'https://acme.example.com', + }); + + it('issuer = baseUrl + auth basePath (matches the jwt plugin iss claim)', () => { + expect(manager().getAuthIssuer()).toBe('https://acme.example.com/api/v1/auth'); + }); + + it('MCP resource = baseUrl + api prefix + /mcp (derived from the auth basePath)', () => { + expect(manager().getMcpResourceUrl()).toBe('https://acme.example.com/api/v1/mcp'); + }); + + it('protected-resource metadata points at THIS deployment as the AS and lists the MCP scopes', () => { + process.env.OS_MCP_SERVER_ENABLED = 'true'; + const md = manager().getMcpProtectedResourceMetadata() as any; + expect(md.resource).toBe('https://acme.example.com/api/v1/mcp'); + expect(md.authorization_servers).toEqual(['https://acme.example.com/api/v1/auth']); + for (const scope of MCP_OAUTH_SCOPES) expect(md.scopes_supported).toContain(scope); + expect(md.scopes_supported).toContain('offline_access'); + expect(md.bearer_methods_supported).toEqual(['header']); + }); + + it('resource metadata URL is null when the AS is off (nothing advertised, fail-closed)', () => { + expect(manager().getMcpResourceMetadataUrl()).toBeNull(); + }); + + it('resource metadata URL is null on plain-HTTP non-loopback even with the AS on (TLS rule)', () => { + process.env.OS_MCP_SERVER_ENABLED = 'true'; + const m = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://intranet.corp:3000', + }); + expect(m.isMcpOAuthEnabled()).toBe(false); + expect(m.getMcpResourceMetadataUrl()).toBeNull(); + }); + + it('advertises the metadata URL when MCP + AS are on over an eligible origin', () => { + process.env.OS_MCP_SERVER_ENABLED = 'true'; + expect(manager().getMcpResourceMetadataUrl()).toBe( + 'https://acme.example.com/.well-known/oauth-protected-resource', + ); + }); +}); + +describe('verifyMcpAccessToken (local JWKS verification, fail-closed)', () => { + const ISSUER = 'https://acme.example.com/api/v1/auth'; + const AUDIENCE = 'https://acme.example.com/api/v1/mcp'; + + let privateKey: CryptoKey; + let jwks: { keys: any[] }; + + beforeEach(async () => { + process.env.OS_MCP_SERVER_ENABLED = 'true'; + const pair = await generateKeyPair('RS256'); + privateKey = pair.privateKey as CryptoKey; + const jwk = await exportJWK(pair.publicKey); + jwks = { keys: [{ ...jwk, alg: 'RS256', kid: 'test-key' }] }; + }); + + function manager(): AuthManager { + const m = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'https://acme.example.com', + }); + vi.spyOn(m, 'getApi').mockResolvedValue({ getJwks: async () => jwks } as any); + return m; + } + + function signToken(overrides: Record = {}, opts: { expired?: boolean } = {}) { + const now = Math.floor(Date.now() / 1000); + const jwt = new SignJWT({ + scope: 'data:read data:write', + azp: 'client-abc', + ...overrides, + }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuer((overrides.iss as string) ?? ISSUER) + .setAudience((overrides.aud as string) ?? AUDIENCE) + .setSubject((overrides.sub as string) ?? 'user-1') + .setIssuedAt(opts.expired ? now - 7200 : now) + .setExpirationTime(opts.expired ? now - 3600 : now + 3600); + return jwt.sign(privateKey); + } + + it('resolves the principal + scopes + client from a valid token', async () => { + const token = await signToken(); + const res = await manager().verifyMcpAccessToken(token); + expect(res).toEqual({ userId: 'user-1', scopes: ['data:read', 'data:write'], clientId: 'client-abc' }); + }); + + it('rejects an expired token', async () => { + const token = await signToken({}, { expired: true }); + expect(await manager().verifyMcpAccessToken(token)).toBeNull(); + }); + + it('rejects a token minted for a DIFFERENT audience (no cross-resource replay)', async () => { + const token = await signToken({ aud: 'https://acme.example.com/api/v1/auth/oauth2/userinfo' }); + expect(await manager().verifyMcpAccessToken(token)).toBeNull(); + }); + + it('rejects a token from a different issuer', async () => { + const token = await signToken({ iss: 'https://evil.example.com/api/v1/auth' }); + expect(await manager().verifyMcpAccessToken(token)).toBeNull(); + }); + + it('rejects a token signed by an UNKNOWN key (signature check is real)', async () => { + const rogue = await generateKeyPair('RS256'); + const now = Math.floor(Date.now() / 1000); + const token = await new SignJWT({ scope: 'data:read' }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setSubject('user-1') + .setIssuedAt(now) + .setExpirationTime(now + 3600) + .sign(rogue.privateKey as CryptoKey); + expect(await manager().verifyMcpAccessToken(token)).toBeNull(); + }); + + it('rejects a sub-less (client-credentials / M2M) token — MCP is principal-bound', async () => { + const now = Math.floor(Date.now() / 1000); + const token = await new SignJWT({ scope: 'data:read' }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setIssuedAt(now) + .setExpirationTime(now + 3600) + .sign(privateKey); + expect(await manager().verifyMcpAccessToken(token)).toBeNull(); + }); + + it('rejects garbage / non-JWT input without touching the JWKS', async () => { + const m = manager(); + expect(await m.verifyMcpAccessToken('')).toBeNull(); + expect(await m.verifyMcpAccessToken('osk_not_a_jwt')).toBeNull(); + expect(await m.verifyMcpAccessToken('a.b')).toBeNull(); + expect(m.getApi).not.toHaveBeenCalled(); + }); + + it('rejects every token when the OAuth track is off (provider disabled)', async () => { + delete process.env.OS_MCP_SERVER_ENABLED; + const token = await signToken(); + expect(await manager().verifyMcpAccessToken(token)).toBeNull(); + }); +}); + +describe('oauthProvider plugin wiring (DCR + scopes + audiences)', () => { + async function capturePluginOpts(env: Record): Promise { + for (const [k, v] of Object.entries(env)) process.env[k] = v; + (betterAuth as any).mockImplementation((config: any) => ({ handler: vi.fn(), api: {}, _cfg: config })); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'https://acme.example.com', + }); + await manager.getAuthInstance(); + } finally { + warnSpy.mockRestore(); + } + const call = (oauthProvider as any).mock.calls.at(-1); + return call?.[0]; + } + + it('enables DCR (incl. unauthenticated) and advertises MCP scopes when the MCP surface is on', async () => { + const opts = await capturePluginOpts({ OS_MCP_SERVER_ENABLED: 'true' }); + expect(opts).toBeDefined(); + expect(opts.allowDynamicClientRegistration).toBe(true); + expect(opts.allowUnauthenticatedClientRegistration).toBe(true); + for (const scope of MCP_OAUTH_SCOPES) expect(opts.scopes).toContain(scope); + expect(opts.scopes).toEqual(expect.arrayContaining(['openid', 'profile', 'email', 'offline_access'])); + // RFC 8707: the MCP resource must be a valid audience or token minting fails. + expect(opts.validAudiences).toContain('https://acme.example.com/api/v1/mcp'); + expect(opts.validAudiences).toContain('https://acme.example.com/api/v1/auth'); + }); + + it('OS_OIDC_DCR_ENABLED=false forces DCR off even with MCP on', async () => { + const opts = await capturePluginOpts({ OS_MCP_SERVER_ENABLED: 'true', OS_OIDC_DCR_ENABLED: 'false' }); + expect(opts.allowDynamicClientRegistration).toBe(false); + expect(opts.allowUnauthenticatedClientRegistration).toBe(false); + }); + + it('does not register the oauthProvider plugin at all when nothing enables it', async () => { + await capturePluginOpts({}); + expect((oauthProvider as any).mock.calls.length).toBe(0); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 89e44200b6..951f130619 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -14,6 +14,7 @@ import type { IDataEngine } from '@objectstack/core'; import type { IEmailService } from '@objectstack/spec/contracts'; import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveOrgLimit } from '@objectstack/types'; import { mapMembershipRole, BUILTIN_IDENTITY_PLATFORM_ADMIN } from '@objectstack/spec'; +import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js'; import { AUTH_USER_CONFIG, @@ -159,6 +160,67 @@ function readSsoOnlyEnv(): boolean | undefined { return readBooleanEnv('OS_AUTH_SSO_ONLY'); } +/** Whether this runtime serves the HTTP MCP surface (`/api/v1/mcp`). */ +export function readMcpServerEnabledEnv(): boolean { + return readBooleanEnv('OS_MCP_SERVER_ENABLED') ?? false; +} + +/** + * SINGLE decision point for "is the embedded OAuth/OIDC authorization server + * on?" — shared by `buildPluginList()`, the `/auth/config` features block and + * the discovery-route mounting in `auth-plugin.ts`, so the wired plugin, the + * advertised feature flag and the `.well-known` documents can never disagree. + * + * Resolution order: `OS_OIDC_PROVIDER_ENABLED` env (operator override, wins) + * → config file → **on when the MCP server surface is enabled** (#2698: the + * MCP endpoint's human-client track is OAuth 2.1 and every deployment is its + * own authorization server, so enabling MCP without an AS would strand every + * OAuth-capable client on admin-minted API keys). + */ +export function resolveOidcProviderEnabled(pluginConfig?: Partial): boolean { + return readBooleanEnv('OS_OIDC_PROVIDER_ENABLED') ?? pluginConfig?.oidcProvider ?? readMcpServerEnabledEnv(); +} + +/** + * Whether RFC 7591 Dynamic Client Registration is allowed on the embedded + * authorization server. `OS_OIDC_DCR_ENABLED` env wins, then the config + * field, then it FOLLOWS the MCP surface: DCR is what lets a generic MCP + * client self-register against any deployment (no central client registry + * exists), so it defaults on exactly when MCP is on. + */ +export function resolveDcrEnabled(pluginConfig?: Partial): boolean { + return ( + readBooleanEnv('OS_OIDC_DCR_ENABLED') ?? + pluginConfig?.dynamicClientRegistration ?? + readMcpServerEnabledEnv() + ); +} + +/** + * OAuth 2.1 §1.5 transport rule for the MCP OAuth track: authorization/token + * exchanges and bearer usage require TLS, with loopback exempt (dev). A + * plain-HTTP non-loopback deployment keeps the API-key track only — the + * OAuth surface (protected-resource metadata, bearer acceptance) stays dark, + * fail-closed, and is logged once at mount time. + */ +export function isOAuthEligibleBaseUrl(url: string): boolean { + try { + const u = new URL(url); + if (u.protocol === 'https:') return true; + if (u.protocol !== 'http:') return false; + const host = u.hostname.toLowerCase(); + return ( + host === 'localhost' || + host === '127.0.0.1' || + host === '[::1]' || + host === '::1' || + host.endsWith('.localhost') + ); + } catch { + return false; + } +} + /** * Extended options for AuthManager */ @@ -1147,7 +1209,6 @@ export class AuthManager { // platform-standard truthy set (`true`/`1`/`yes`/`on`, case-insensitive) // instead of only the literal string `'true'` — a repeated operator footgun // (`OS_SSO_ENABLED=1` silently parsed as disabled). - const oidcFromEnv = readBooleanEnv('OS_OIDC_PROVIDER_ENABLED'); const ssoFromEnv = readBooleanEnv('OS_SSO_ENABLED'); const scimFromEnv = readBooleanEnv('OS_SCIM_ENABLED'); // Opt-in DNS domain-verification for external SSO providers (ADR-0024 ②). @@ -1171,7 +1232,9 @@ export class AuthManager { passwordRejectBreached: hibpFromEnv ?? pluginConfig.passwordRejectBreached ?? false, passkeys: pluginConfig.passkeys ?? false, magicLink: pluginConfig.magicLink ?? false, - oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false, + // Shared decision point (env → config → follows OS_MCP_SERVER_ENABLED), + // see resolveOidcProviderEnabled — keep auth-plugin.ts / features in sync. + oidcProvider: resolveOidcProviderEnabled(pluginConfig), deviceAuthorization: pluginConfig.deviceAuthorization ?? false, admin: pluginConfig.admin ?? scimEffective, sso: ssoFromEnv ?? (pluginConfig as any).sso ?? false, @@ -1510,6 +1573,7 @@ export class AuthManager { const { oauthProvider } = await import('@better-auth/oauth-provider'); const baseUrl = (this.config.baseUrl ?? '').replace(/\/$/, ''); const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, ''); + const dcr = resolveDcrEnabled(pluginConfig); plugins.push(oauthProvider({ // Console SPA renders both pages (replaces the legacy Account SPA at // /_account). Override `uiBasePath` in AuthConfig if Console is @@ -1517,6 +1581,25 @@ export class AuthManager { loginPage: `${baseUrl}${uiBase}/login`, consentPage: `${baseUrl}${uiBase}/oauth/consent`, schema: buildOauthProviderPluginSchema(), + // ── MCP OAuth track (#2698) ──────────────────────────────── + // Coarse tool-family scopes for the platform's own MCP endpoint, + // advertised alongside the standard OIDC scopes. Names are + // single-sourced in @objectstack/spec so AS / resource server / + // tool layer cannot drift. + scopes: ['openid', 'profile', 'email', 'offline_access', ...MCP_OAUTH_SCOPES], + // MCP clients bind tokens to the resource via RFC 8707 + // (`resource=`); the AS only mints audiences it knows. + // The auth base (better-auth's default audience) stays valid for + // plain OIDC SSO flows. + validAudiences: [this.getAuthIssuer(), this.getMcpResourceUrl()], + // RFC 7591 Dynamic Client Registration. `allowUnauthenticated…` is + // required: MCP clients register BEFORE any user is logged in (the + // whole point of the self-serve flow). Registration is rate-limited + // by the plugin, clients get only the scopes advertised above, and + // every token still requires an interactive PKCE login + consent — + // an anonymous registration mints no authority by itself. + allowDynamicClientRegistration: dcr, + allowUnauthenticatedClientRegistration: dcr, })); } @@ -1962,6 +2045,143 @@ export class AuthManager { return (auth as any).$context; } + // --------------------------------------------------------------------------- + // MCP OAuth 2.1 resource-server support (#2698) + // + // The embedded @better-auth/oauth-provider plugin is the AUTHORIZATION + // server; the runtime dispatcher's `/api/v1/mcp` endpoint is the RESOURCE + // server. These helpers are the resource-server half: canonical issuer / + // resource URLs (also used to build the RFC 9728 protected-resource + // metadata) and local verification of the JWT access tokens the provider + // mints (signed by the jwt plugin, validated against our own JWKS — + // in-process, no self-HTTP hop, no client credentials needed). + // --------------------------------------------------------------------------- + + /** Cached JWKS for local access-token verification (5-minute TTL). */ + private jwksCache?: { jwks: any; fetchedAtMs: number }; + private static readonly JWKS_CACHE_TTL_MS = 5 * 60_000; + + /** Canonical origin of this deployment (config `baseUrl`, auto-detected in dev). */ + private getCanonicalOrigin(): string { + return (this.config.baseUrl || 'http://localhost:3000').replace(/\/$/, ''); + } + + /** + * The OAuth issuer identifier: better-auth's `baseURL` INCLUDING `basePath` + * (e.g. `https://acme.example.com/api/v1/auth`) — this is the `iss` claim + * the jwt plugin stamps on access tokens and what the AS metadata reports. + */ + getAuthIssuer(): string { + const basePath = this.config.basePath || '/api/v1/auth'; + return `${this.getCanonicalOrigin()}${basePath.startsWith('/') ? basePath : `/${basePath}`}`; + } + + /** + * The MCP resource identifier (RFC 8707 `resource` / token `aud`): + * `/mcp`. Derived from the auth basePath so the two can + * never disagree about the API prefix. + */ + getMcpResourceUrl(): string { + const basePath = this.config.basePath || '/api/v1/auth'; + const apiPrefix = basePath.replace(/\/auth\/?$/, ''); + return `${this.getCanonicalOrigin()}${apiPrefix}/mcp`; + } + + /** + * Whether the OAuth track for MCP is live on this deployment: the embedded + * AS must be enabled AND the canonical origin must satisfy the OAuth 2.1 + * transport rule (TLS, loopback exempt). When this is false the MCP + * endpoint is API-key-only and no OAuth metadata is advertised. + */ + isMcpOAuthEnabled(): boolean { + return ( + resolveOidcProviderEnabled(this.config.plugins) && + isOAuthEligibleBaseUrl(this.getCanonicalOrigin()) + ); + } + + /** + * Absolute URL of the RFC 9728 protected-resource metadata document — + * advertised in `WWW-Authenticate` on 401s from `/api/v1/mcp` so clients + * can bootstrap the flow. `null` when the OAuth track is off (API keys + * remain; nothing is advertised, fail-closed). + */ + getMcpResourceMetadataUrl(): string | null { + if (!this.isMcpOAuthEnabled()) return null; + return `${this.getCanonicalOrigin()}/.well-known/oauth-protected-resource`; + } + + /** + * RFC 9728 protected-resource metadata for the MCP endpoint. Served at + * `/.well-known/oauth-protected-resource` (and its path-inserted variant) + * by the auth plugin's discovery routes. + */ + getMcpProtectedResourceMetadata(): Record { + return { + resource: this.getMcpResourceUrl(), + authorization_servers: [this.getAuthIssuer()], + // offline_access lets clients hold refresh tokens for long-lived + // connections; the tool-family scopes bound what the tools expose. + scopes_supported: [...MCP_OAUTH_SCOPES, 'offline_access'], + bearer_methods_supported: ['header'], + resource_name: `${this.getAppName()} MCP`, + }; + } + + /** + * Verify an OAuth 2.1 Bearer ACCESS TOKEN minted by THIS deployment's + * authorization server and resolve the principal it is bound to. + * + * Verification is local and fail-closed (`null` on ANY doubt): JWS + * signature against our own JWKS, `iss` must be this deployment's issuer, + * `aud` must be the MCP resource URL (tokens minted for other audiences — + * userinfo, plain OIDC SSO — do NOT unlock MCP), `exp`/`nbf` enforced by + * jose. Client-credentials (M2M) tokens carry no `sub` and are rejected: + * the MCP surface is principal-bound by design; headless callers use API + * keys. Revocation note: JWT access tokens are not server-tracked, so + * revocation takes effect at expiry (≤1h default); refresh tokens ARE + * revocable immediately via `/oauth2/revoke`. + * + * The caller (runtime dispatcher) maps the returned principal through + * `resolveAuthzContext` — the single shared authorization resolver — so + * OAuth is a second *provenance* for the principal, never a second authz + * model. + */ + async verifyMcpAccessToken( + token: string, + ): Promise<{ userId: string; scopes: string[]; clientId?: string } | null> { + try { + if (!token || token.split('.').length !== 3) return null; // not a JWS compact token + if (!this.isMcpOAuthEnabled()) return null; + + const { createLocalJWKSet, jwtVerify } = await import('jose'); + + const now = Date.now(); + if (!this.jwksCache || now - this.jwksCache.fetchedAtMs > AuthManager.JWKS_CACHE_TTL_MS) { + const api = await this.getApi(); + const jwks = await (api as any).getJwks?.(); + if (!jwks || !Array.isArray(jwks.keys)) return null; + this.jwksCache = { jwks, fetchedAtMs: now }; + } + + const { payload } = await jwtVerify(token, createLocalJWKSet(this.jwksCache.jwks), { + issuer: this.getAuthIssuer(), + audience: this.getMcpResourceUrl(), + }); + + const userId = typeof payload.sub === 'string' && payload.sub ? payload.sub : undefined; + if (!userId) return null; + const scopes = + typeof payload.scope === 'string' + ? payload.scope.split(' ').filter(Boolean) + : []; + const clientId = typeof (payload as any).azp === 'string' ? (payload as any).azp : undefined; + return { userId, scopes, ...(clientId ? { clientId } : {}) }; + } catch { + return null; // unknown/expired/wrong-audience/garbage → no principal + } + } + // --------------------------------------------------------------------------- // Device Flow (CLI browser-based login) // @@ -2067,11 +2287,6 @@ export class AuthManager { const termsUrl = resolveLegalUrl(rawTermsUrl, DEFAULT_TERMS_URL); const privacyUrl = resolveLegalUrl(rawPrivacyUrl, DEFAULT_PRIVACY_URL); - // OIDC Provider — same env-var override as in `buildPlugins()`. The - // /auth/config response MUST match what's actually wired, otherwise the - // frontend will render UI for endpoints that 404. - const oidcEnv = (globalThis as any)?.process?.env?.OS_OIDC_PROVIDER_ENABLED; - const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === 'true' : undefined; const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR'); const features = { @@ -2080,7 +2295,10 @@ export class AuthManager { magicLink: pluginConfig.magicLink ?? false, organization: pluginConfig.organization ?? true, multiOrgEnabled, - oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false, + // Shared decision point with `buildPluginList()` — the /auth/config + // response MUST match what's actually wired, otherwise the frontend + // renders UI for endpoints that 404. + oidcProvider: resolveOidcProviderEnabled(pluginConfig), // Coarse "is the @better-auth/sso plugin wired" flag. The `/auth/config` // route refines this to "usable" (≥1 provider configured) via // `isSsoUsable()` so the login UI can hide the "Sign in with SSO" button diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 2aac1b1fd0..6e658496b0 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -11,7 +11,12 @@ import { } from '@objectstack/platform-objects/apps'; import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages'; import { resolveMultiOrgEnabled } from '@objectstack/types'; -import { AuthManager, type AuthManagerOptions } from './auth-manager.js'; +import { + AuthManager, + resolveOidcProviderEnabled, + readMcpServerEnabledEnv, + type AuthManagerOptions, +} from './auth-manager.js'; import { ensureDefaultOrganization } from './ensure-default-organization.js'; import { runSetInitialPassword } from './set-initial-password.js'; import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js'; @@ -1366,14 +1371,13 @@ export class AuthPlugin implements Plugin { // `oauthProviderOpenIdConfigMetadata`) which we mount here so external // OIDC clients can discover the IdP at the canonical paths. // - // Honour the same `OS_OIDC_PROVIDER_ENABLED` env-var override that - // `AuthManager.buildPlugins()` uses — without this check the - // discovery routes would NOT mount when an operator flipped the - // env var on without editing the config file, leaving external - // OIDC clients unable to discover the IdP. - const oidcEnv = (globalThis as any)?.process?.env?.OS_OIDC_PROVIDER_ENABLED; - const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === 'true' : undefined; - const oidcEnabled = oidcFromEnv ?? this.options.plugins?.oidcProvider ?? false; + // Shared decision point with `AuthManager.buildPluginList()` + // (`resolveOidcProviderEnabled`: env override → config → follows + // OS_MCP_SERVER_ENABLED) — without this the discovery routes would not + // mount when an operator flipped the env var on without editing the + // config file, leaving external OIDC/MCP clients unable to discover + // the authorization server. + const oidcEnabled = resolveOidcProviderEnabled(this.options.plugins); if (oidcEnabled) { void this.registerOidcDiscoveryRoutes(rawApp, ctx).catch((error) => { ctx.logger.error('Failed to register OIDC discovery routes', error as Error); @@ -1416,6 +1420,47 @@ export class AuthPlugin implements Plugin { rawApp.get('/.well-known/oauth-authorization-server', (c: any) => withDiscoveryCache(authServerHandler, c.req.raw)); rawApp.get('/.well-known/openid-configuration', (c: any) => withDiscoveryCache(openidConfigHandler, c.req.raw)); + // RFC 8414 §3.1 path-insertion variant. Our issuer identifier carries a + // path component (`/api/v1/auth`), so spec-conforming clients + // (including every MCP client bootstrapping from protected-resource + // metadata) request `/.well-known/oauth-authorization-server/api/v1/auth` + // — alias it to the same document. + const basePath = (this.options.basePath ?? '/api/v1/auth').replace(/\/$/, ''); + rawApp.get(`/.well-known/oauth-authorization-server${basePath}`, (c: any) => + withDiscoveryCache(authServerHandler, c.req.raw), + ); + + // ── MCP protected-resource metadata (RFC 9728, #2698) ────────────── + // `/api/v1/mcp` is an OAuth 2.1 protected resource; its metadata points + // clients at THIS deployment's embedded authorization server. Mounted + // only when the MCP OAuth track is live (MCP surface on + AS on + TLS + // rule satisfied — loopback exempt): when it is off, nothing is + // advertised and the endpoint stays API-key-only, fail-closed. + const manager = this.authManager!; + if (readMcpServerEnabledEnv() && typeof manager.isMcpOAuthEnabled === 'function') { + if (manager.isMcpOAuthEnabled()) { + const prmHandler = () => { + const body = JSON.stringify(manager.getMcpProtectedResourceMetadata()); + return new Response(body, { + status: 200, + headers: { 'content-type': 'application/json', 'cache-control': DISCOVERY_CACHE }, + }); + }; + const mcpPath = new URL(manager.getMcpResourceUrl()).pathname; // e.g. /api/v1/mcp + rawApp.get('/.well-known/oauth-protected-resource', prmHandler); + // RFC 9728 §3.1 path-insertion variant for the resource's own path. + rawApp.get(`/.well-known/oauth-protected-resource${mcpPath}`, prmHandler); + ctx.logger.info( + `MCP protected-resource metadata mounted at /.well-known/oauth-protected-resource (resource: ${mcpPath})`, + ); + } else { + ctx.logger.warn( + 'MCP server is enabled but the OAuth track is NOT live (base URL fails the OAuth 2.1 TLS rule — ' + + 'https required, loopback exempt). /api/v1/mcp stays API-key-only; no OAuth metadata is advertised.', + ); + } + } + ctx.logger.info( 'OIDC discovery endpoints mounted at /.well-known/{oauth-authorization-server,openid-configuration}', ); diff --git a/packages/runtime/src/http-dispatcher.mcp-oauth.test.ts b/packages/runtime/src/http-dispatcher.mcp-oauth.test.ts new file mode 100644 index 0000000000..64acf68d6f --- /dev/null +++ b/packages/runtime/src/http-dispatcher.mcp-oauth.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * OAuth 2.1 bearer authentication on the MCP surface (#2698). + * + * Driven through `dispatch()` (not `handleMcp` directly) so the full + * per-request pipeline runs: resolveExecutionContext with the MCP-only + * `acceptOAuthAccessToken` opt-in → shared resolveAuthzContext → handleMcp's + * WWW-Authenticate / scope enforcement. The token VERIFIER is faked (that + * half lives in @objectstack/plugin-auth and has its own tests); what these + * tests pin down is the dispatcher's fail-closed contract around it: + * + * - anonymous → 401, advertising resource_metadata ONLY when the OAuth + * track is live (API-key-only deployments keep the plain 401) + * - verified token → principal-bound ExecutionContext + grantedScopes + * forwarded to the MCP runtime + * - token with no MCP scope → 403 insufficient_scope + * - presented-but-invalid JWT bearer → 401 even when a cookie session + * exists (no ambient-session fallback for a dead credential) + * - the API-key track is byte-for-byte unchanged (regression) + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { hashApiKey } from '@objectstack/core'; + +import { HttpDispatcher } from './http-dispatcher.js'; + +const VALID_JWT = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1NDIifQ.sig'; // shape only — verifier is faked +const RAW_API_KEY = 'osk_test_regression_key'; + +interface HarnessOptions { + /** Scopes the fake verifier grants for VALID_JWT; null = verification fails. */ + oauthScopes?: string[] | null; + /** Whether the auth service advertises the OAuth track (resource metadata). */ + oauthAdvertised?: boolean; + /** Session returned by api.getSession (cookie path). */ + session?: any; +} + +function makeHarness(opts: HarnessOptions = {}) { + const recorded: any[] = []; + const apiKeyHash = hashApiKey(RAW_API_KEY); + + const ql = { + insert: async (_o: string, data: any, o: any) => { + recorded.push(o?.context); + return { id: 'new1', ...data }; + }, + find: async (object: string, q: any) => { + if (object === 'sys_api_key' && q?.where?.key === apiKeyHash && q?.where?.revoked === false) { + return [{ id: 'k1', key: apiKeyHash, user_id: 'keyUser', revoked: false }]; + } + return []; + }, + update: async () => ({}), + delete: async () => ({}), + }; + + const metadata = { + listObjects: async () => [{ name: 'task', fields: { title: {} } }], + getObject: async (n: string) => (n === 'task' ? { name: 'task', fields: {} } : null), + }; + + const mcpService: any = { + lastOpts: undefined, + handleHttpRequest: async (_req: Request, o: any) => { + mcpService.lastOpts = o; + const created = await o.bridge.create('task', { title: 'x' }); + return new Response(JSON.stringify({ ok: true, created }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }; + + const authService: any = { + api: { getSession: async () => opts.session }, + verifyMcpAccessToken: async (token: string) => + token === VALID_JWT && opts.oauthScopes != null + ? { userId: 'u42', scopes: opts.oauthScopes, clientId: 'client-1' } + : null, + }; + if (opts.oauthAdvertised !== false) { + authService.getMcpResourceMetadataUrl = () => + 'https://env.example.com/.well-known/oauth-protected-resource'; + } + + const services: Record = { metadata, objectql: ql, mcp: mcpService, auth: authService }; + const kernel: any = { + getService: (n: string) => services[n], + getServiceAsync: async (n: string) => services[n], + }; + const dispatcher = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); + return { dispatcher, mcpService, recorded, services }; +} + +function requestWithHeaders(headers: Record) { + return { + method: 'POST', + url: '/api/v1/mcp', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + host: 'env.example.com', + ...headers, + }, + }; +} + +const BODY = { jsonrpc: '2.0', id: 1, method: 'tools/list' }; + +async function dispatchMcp(dispatcher: HttpDispatcher, headers: Record) { + return dispatcher.dispatch('POST', '/mcp', BODY, {}, { request: requestWithHeaders(headers) } as any, '/api/v1'); +} + +describe('HttpDispatcher — OAuth bearer on /mcp (#2698)', () => { + const prev = process.env.OS_MCP_SERVER_ENABLED; + beforeEach(() => { + process.env.OS_MCP_SERVER_ENABLED = 'true'; + }); + afterEach(() => { + if (prev === undefined) delete process.env.OS_MCP_SERVER_ENABLED; + else process.env.OS_MCP_SERVER_ENABLED = prev; + }); + + it('anonymous → 401 with WWW-Authenticate advertising resource metadata (OAuth track live)', async () => { + const { dispatcher } = makeHarness({ oauthScopes: null }); + const res = await dispatchMcp(dispatcher, {}); + expect(res.response!.status).toBe(401); + const www = res.response!.headers?.['WWW-Authenticate']; + expect(www).toContain('Bearer'); + expect(www).toContain('resource_metadata="https://env.example.com/.well-known/oauth-protected-resource"'); + }); + + it('anonymous → plain 401 without WWW-Authenticate when the OAuth track is off (API-key-only)', async () => { + const { dispatcher } = makeHarness({ oauthScopes: null, oauthAdvertised: false }); + const res = await dispatchMcp(dispatcher, {}); + expect(res.response!.status).toBe(401); + expect(res.response!.headers?.['WWW-Authenticate']).toBeUndefined(); + }); + + it('verified bearer → runs as the token principal with grantedScopes forwarded', async () => { + const { dispatcher, mcpService, recorded } = makeHarness({ oauthScopes: ['data:read', 'data:write'] }); + const res = await dispatchMcp(dispatcher, { authorization: `Bearer ${VALID_JWT}` }); + expect(res.response!.status).toBe(200); + expect(mcpService.lastOpts.toolOptions?.grantedScopes).toEqual(['data:read', 'data:write']); + // The bridge ran under the token's principal — not system, not anonymous. + expect(recorded[0]?.userId).toBe('u42'); + expect(recorded[0]?.isSystem).toBe(false); + expect(recorded[0]?.oauthScopes).toEqual(['data:read', 'data:write']); + }); + + it('verified bearer with NO MCP scope → 403 insufficient_scope', async () => { + const { dispatcher, mcpService } = makeHarness({ oauthScopes: ['openid', 'profile'] }); + const res = await dispatchMcp(dispatcher, { authorization: `Bearer ${VALID_JWT}` }); + expect(res.response!.status).toBe(403); + expect(res.response!.headers?.['WWW-Authenticate']).toContain('insufficient_scope'); + expect(mcpService.lastOpts).toBeUndefined(); // never reached the MCP runtime + }); + + it('invalid/expired JWT bearer → 401 even when a cookie session exists (fail-closed, no fallback)', async () => { + const { dispatcher } = makeHarness({ + oauthScopes: null, // verifier rejects everything + session: { user: { id: 'cookieUser' } }, + }); + const res = await dispatchMcp(dispatcher, { + authorization: 'Bearer eyJhbGciOiJSUzI1NiJ9.ZGVhZA.sig', + cookie: 'better-auth.session=abc', + }); + expect(res.response!.status).toBe(401); + }); + + it('a non-JWT bearer (opaque session token) still resolves through the session path', async () => { + const { dispatcher, mcpService, recorded } = makeHarness({ + oauthScopes: null, + session: { user: { id: 'cookieUser' } }, + }); + const res = await dispatchMcp(dispatcher, { authorization: 'Bearer opaque-session-token' }); + expect(res.response!.status).toBe(200); + expect(recorded[0]?.userId).toBe('cookieUser'); + // Session provenance is NOT scope-limited. + expect(mcpService.lastOpts.toolOptions).toBeUndefined(); + expect(recorded[0]?.oauthScopes).toBeUndefined(); + }); + + it('REGRESSION: the API-key track is unchanged — x-api-key resolves the key principal, unscoped', async () => { + const { dispatcher, mcpService, recorded } = makeHarness({ oauthScopes: ['data:read'] }); + const res = await dispatchMcp(dispatcher, { 'x-api-key': RAW_API_KEY }); + expect(res.response!.status).toBe(200); + expect(recorded[0]?.userId).toBe('keyUser'); + expect(mcpService.lastOpts.toolOptions).toBeUndefined(); + expect(recorded[0]?.oauthScopes).toBeUndefined(); + }); + + it('REGRESSION: Bearer osk_-prefixed API key still routes to the API-key path, not OAuth', async () => { + const { dispatcher, recorded } = makeHarness({ oauthScopes: null }); + const res = await dispatchMcp(dispatcher, { authorization: `Bearer ${RAW_API_KEY}` }); + expect(res.response!.status).toBe(200); + expect(recorded[0]?.userId).toBe('keyUser'); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 894947884b..5c1f640f1e 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -2,6 +2,7 @@ import { ObjectKernel, getEnv, resolveLocale, evaluateAuthGate, isAuthGateAllowlisted } from '@objectstack/core'; import { CoreServiceName } from '@objectstack/spec/system'; +import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; import { pluralToSingular, PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { setPackageDisabled } from './package-state-store.js'; @@ -431,7 +432,49 @@ export class HttpDispatcher { const ec = context.executionContext; if (!ec || (!ec.userId && !ec.isSystem)) { - return { handled: true, response: this.error('Unauthorized: a valid API key is required', 401) }; + // Per the MCP authorization spec (RFC 9728 §5.1), a 401 from the + // protected resource advertises where its metadata lives so an + // OAuth-capable client can bootstrap discovery → DCR → PKCE. + // Only advertised when the OAuth track is actually live (AS on + + // TLS rule satisfied); API-key-only deployments return a plain 401. + const resourceMetadataUrl = await this.getMcpResourceMetadataUrl(context); + const response = this.error( + resourceMetadataUrl + ? 'Unauthorized: a valid OAuth access token or API key is required' + : 'Unauthorized: a valid API key is required', + 401, + ) as { status: number; body: any; headers?: Record }; + if (resourceMetadataUrl) { + response.headers = { + 'WWW-Authenticate': + `Bearer realm="ObjectStack MCP", resource_metadata="${resourceMetadataUrl}"`, + }; + } + return { handled: true, response }; + } + + // ── OAuth scope → tool-family enforcement (fail-closed, #2698) ── + // `oauthScopes` is set ONLY for OAuth-token provenance. A token that + // grants none of the MCP tool families gets 403 insufficient_scope + // up front; a partial grant narrows the tool set at registration + // time inside the MCP runtime. API-key / session principals + // (`oauthScopes` undefined) keep the full principal-bound surface. + const grantedScopes = Array.isArray((ec as any).oauthScopes) + ? ((ec as any).oauthScopes as string[]) + : undefined; + if (grantedScopes && !grantedScopes.some((s) => (MCP_OAUTH_SCOPES as readonly string[]).includes(s))) { + const resourceMetadataUrl = await this.getMcpResourceMetadataUrl(context); + const response = this.error( + `Forbidden: the access token grants none of the MCP scopes (${MCP_OAUTH_SCOPES.join(', ')})`, + 403, + ) as { status: number; body: any; headers?: Record }; + response.headers = { + 'WWW-Authenticate': + 'Bearer error="insufficient_scope"' + + `, scope="${MCP_OAUTH_SCOPES.join(' ')}"` + + (resourceMetadataUrl ? `, resource_metadata="${resourceMetadataUrl}"` : ''), + }; + return { handled: true, response }; } // The MCP transport needs a Web-standard Request. The runtime HTTP @@ -445,7 +488,13 @@ export class HttpDispatcher { const bridge = this.buildMcpBridge(context); let webRes: Response; try { - webRes = await mcp.handleHttpRequest(webRequest, { bridge, parsedBody: body }); + webRes = await mcp.handleHttpRequest(webRequest, { + bridge, + parsedBody: body, + // undefined = not scope-limited (API key / session); an array + // narrows the registered tool families inside the MCP runtime. + ...(grantedScopes ? { toolOptions: { grantedScopes } } : {}), + }); } catch (err: any) { return { handled: true, response: this.error(err?.message ?? 'MCP request failed', 500) }; } @@ -472,6 +521,22 @@ export class HttpDispatcher { return typeof process !== 'undefined' && process.env?.OS_MCP_SERVER_ENABLED === 'true'; } + /** + * Absolute URL of the RFC 9728 protected-resource metadata for the MCP + * endpoint, advertised via `WWW-Authenticate` (#2698). `null` when the + * OAuth track is off — the auth service owns the decision (AS enabled + + * OAuth 2.1 TLS rule), the dispatcher only relays it. Never throws. + */ + private async getMcpResourceMetadataUrl(context: HttpProtocolContext): Promise { + try { + const authService: any = await this.resolveService('auth', context.environmentId); + const url = authService?.getMcpResourceMetadataUrl?.(); + return typeof url === 'string' && url ? url : null; + } catch { + return null; + } + } + /** * Normalise the inbound request into a Web-standard `Request` for the MCP * transport. Accepts an already-Web `Request`, or a node/Hono-style req @@ -3701,6 +3766,12 @@ export class HttpDispatcher { return this.getObjectQLService(context.environmentId); }, request: context.request, + // OAuth 2.1 access tokens are honoured ONLY on the MCP + // surface (#2698): their coarse tool-family scopes are + // enforced at MCP tool dispatch, which other routes don't do. + // Matches the plain and `/projects/:id`-scoped route forms + // (the scoped prefix is stripped only later, below). + acceptOAuthAccessToken: /^(?:\/projects\/[^/]+)?\/mcp(?:[/?]|$)/.test(cleanPath), }); } catch { // anonymous request — leave executionContext undefined diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index dd41ede6e0..74d9b3c13a 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -33,6 +33,33 @@ interface ResolveOptions { getQl: () => Promise | any; /** The raw incoming HTTP request (Fetch Request, Node IncomingMessage, …). */ request: any; + /** + * Opt-in (#2698): also accept an OAuth 2.1 ACCESS TOKEN as the Bearer + * credential, verified against this deployment's embedded authorization + * server (`authService.verifyMcpAccessToken`). ONLY the MCP dispatch path + * sets this — OAuth tokens carry coarse tool-family scopes that are + * enforced at MCP tool dispatch, so honouring them on other surfaces + * (REST/GraphQL) would bypass that scope model entirely. + * + * Fail-closed: when a JWT-shaped Bearer is presented and does NOT verify + * (unknown/expired/revoked/wrong audience), the request resolves as + * ANONYMOUS — it never falls back to a cookie session, so a dead token + * can't ride along on ambient browser state. + */ + acceptOAuthAccessToken?: boolean; +} + +/** + * A compact-JWS-shaped Bearer token (three dot-separated segments) that is + * not an ObjectStack API key. better-auth session bearers are opaque (no + * dots) and API keys carry the `osk_` prefix, so the shape alone routes the + * token to the right verifier without ambiguity. + */ +function extractJwtBearer(headers: Headers): string | undefined { + const auth = headers.get('authorization'); + const bearer = auth?.match(/^Bearer\s+(\S+)$/i)?.[1]; + if (!bearer || bearer.startsWith('osk_')) return undefined; + return bearer.split('.').length === 3 ? bearer : undefined; } /** @@ -60,6 +87,30 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise ({ user: { id: oauthPrincipal!.userId } }) + : oauthBearerPresented + ? async () => undefined + : getSession; + + const authz = await resolveAuthzContext({ ql, headers, getSession: getSessionForProvenance }); const ctx: ExecutionContext = { positions: authz.positions, @@ -101,6 +164,13 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise; export type MCPServerRef = z.infer; export type MCPApprovalPolicy = z.infer; export type MCPToolBinding = z.infer; + +/** + * OAuth 2.1 scopes for the platform's OWN MCP endpoint (`/api/v1/mcp`). + * + * These are the coarse, tool-family-level grants an OAuth access token can + * carry when a human-connected MCP client (claude.ai, Claude Desktop, + * Claude Code, …) authorizes against a deployment's embedded authorization + * server. Scopes bound the *tool surface* only — every call still executes + * under the resolved principal's permissions and row-level security, so a + * scope can never grant more than the logged-in user could do anyway. + * + * Deliberately minimal (see #2698): finer grades can be added later without + * breaking these. Constants live in the spec so the authorization server + * (`@objectstack/plugin-auth`), the resource server (`@objectstack/runtime`) + * and the tool layer (`@objectstack/mcp`) can never drift on the names. + */ +/** Read-family tools: `list_objects`, `describe_object`, `query_records`, `get_record`. */ +export const MCP_OAUTH_SCOPE_DATA_READ = 'data:read'; +/** Write-family tools: `create_record`, `update_record`, `delete_record`. */ +export const MCP_OAUTH_SCOPE_DATA_WRITE = 'data:write'; +/** Business-action tools: `list_actions`, `run_action`. */ +export const MCP_OAUTH_SCOPE_ACTIONS = 'actions:execute'; + +/** All MCP tool-family scopes, in the order they are advertised. */ +export const MCP_OAUTH_SCOPES = [ + MCP_OAUTH_SCOPE_DATA_READ, + MCP_OAUTH_SCOPE_DATA_WRITE, + MCP_OAUTH_SCOPE_ACTIONS, +] as const; + +export type McpOauthScope = (typeof MCP_OAUTH_SCOPES)[number]; diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts index f6db521eff..2edbeca323 100644 --- a/packages/spec/src/kernel/execution-context.zod.ts +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -167,6 +167,17 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ */ skipTriggers: z.boolean().optional(), + /** + * OAuth 2.1 scopes granted to the access token that authenticated this + * request, when the principal was resolved from an OAuth bearer token + * (the MCP surface's human-client track, #2698). UNDEFINED for every + * other provenance (session cookie, API key) — consumers must treat + * "undefined" as "not scope-limited" and a present-but-empty array as + * "no scopes granted" (fail-closed). Scopes only narrow the exposed tool + * surface; permissions/RLS still bind every operation to the principal. + */ + oauthScopes: z.array(z.string()).optional(), + /** Raw access token (for external API call pass-through) */ accessToken: z.string().optional(), diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index ae11eaf323..7984caecfb 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -43,6 +43,22 @@ export const AuthPluginConfigSchema = lazySchema(() => z.object({ oidcProvider: z.boolean().default(false).describe( 'Enable the OpenID Connect provider plugin (acts as an OIDC IdP)', ), + /** + * Allow OAuth 2.0 Dynamic Client Registration (RFC 7591) against the + * embedded authorization server (`POST /oauth2/register`, unauthenticated). + * + * DCR is what lets a *generic* MCP client (claude.ai custom connectors, + * Claude Desktop, Claude Code) connect self-serve to ANY deployment: since + * every deployment is its own authorization server, clients cannot ship + * pre-registered client IDs — they must be able to register themselves. + * + * Tri-state on purpose: when left UNSET it follows the MCP server surface + * (`OS_MCP_SERVER_ENABLED`) — on when MCP is on, off otherwise. Set it (or + * the `OS_OIDC_DCR_ENABLED` env var, which wins) to force either way. + */ + dynamicClientRegistration: z.boolean().optional().describe( + 'Allow unauthenticated RFC 7591 Dynamic Client Registration (default: follows OS_MCP_SERVER_ENABLED)', + ), /** * Enable better-auth's `device-authorization` plugin so that CLIs and * other input-constrained devices can sign in via the standard diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d4be2dc4c..e260d89eeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1316,6 +1316,9 @@ importers: better-auth: specifier: ^1.6.23 version: 1.6.23(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.66.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3(@typescript-eslint/types@8.62.1))(vite@8.0.16(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.55.3(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.0.16(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(better-sqlite3@12.11.1)(mongodb@7.4.0(socks@2.8.9))(mysql2@3.22.5(@types/node@26.1.0))(next@16.2.10(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(svelte@5.55.3(@typescript-eslint/types@8.62.1))(vitest@4.1.10) + jose: + specifier: ^6.2.3 + version: 6.2.3 devDependencies: '@types/node': specifier: ^26.1.0