Skip to content

Releases: BrainWeb/payload-mcp-oauth

v0.5.0 — scope correctness + review sweep

Choose a tag to compare

@ricbwood ricbwood released this 02 Sep 11:43
94b8660

Fixes the two reported bugs and the review sweep around them. Full detail in #76.

Upgrading — three behaviour changes

1. Grants are resolved against your live config on every request. A scoped token names a fixed set and can now only shrink: one issued while posts:delete was enabled loses it as soon as you disable it, instead of keeping it until expiry. A no-scope token means "everything enabled now" and tracks your config in both directions, as before.

2. A copied mcpPluginOptions now throws at boot (MCP_OPTIONS_NOT_SHARED). Passing a spread or a fresh literal to one plugin and the original to the other was always broken — OAuth tokens 401 while API keys keep working — but it booted. It now fails loudly, naming the mistake. If you hit this on upgrade, assign the options to a const and pass that same const to both mcpPlugin() and payloadMcpOAuth().

3. The login redirect honours routes.admin and admin.routes.login. If you customise either, unauthenticated users mid-flow now reach your real login page rather than a hardcoded /admin/login. A new loginPath option overrides it for sign-in pages outside the admin panel.

Fixes

  • #75 — tokens issued without a scope recorded capabilities: {}, which read as "no permissions" to anything inspecting the row while requests ran with full access via a fallback. Claude.ai's Custom Connector completes the flow without a scope, so this affected every such token. The row now records the real grant; scopeToCapabilities returns a discriminated result so "no scope requested" and "invalid scope" can no longer be confused. Existing tokens keep working — no migration, no backfill.
  • #71 — a http://localhost issuer no longer blocks a local next build. next build/next start set NODE_ENV=production whether or not you are deploying, and a loopback URL is unreachable off the machine. Non-loopback http issuers are still refused; a loopback one in production now warns, in case it is an unset SERVER_URL on a real deployment.
  • Scoped tokens previously used their stored capabilities verbatim, so a grant could outlive the permission that justified it.
  • iss is now set on error and deny authorization responses, not just success (RFC 9207 §2).
  • A malformed oauth-clients row returned a 500 instead of invalid_redirect_uri.

Additions

  • scopes_supported in both discovery documents, derived from your mcpPluginOptions — clients can now discover what to request. Also advertises authorization_response_iss_parameter_supported.
  • loginPath option.
  • The plugin registers slug plugin-mcp-oauth with options and a RegisteredPlugins augmentation, for cross-plugin discovery.

Docs

Scopes are documented for the first time. The adminAccess default was described as "any authenticated user in your userCollection", omitting the role check — if your operators carry a role other than admin, they are locked out of the OAuth screens and now the README says so. threat-model.md T2/E1 described the pre-narrowing design and are rewritten.

Also

#50 endpoints are derived rather than mutated in place. #51 the copied-options detection above, plus a record of the two alternatives that are structurally impossible. #52 plugin slug/order/options set directly — deliberately not via definePlugin, which only exists from payload 3.83.0 while the peer range is ^3.0.0. #53 closed: Payload's KV store has no compare-and-set, so moving auth codes to it would make single-use redemption a race.

Verified with 434 unit tests and the from-scratch packaged install test (31/31), which packs the tarball into a fresh Payload app and runs the full OAuth handshake.

v0.3.5

Choose a tag to compare

@ricbwood ricbwood released this 15 Jun 20:15

What's changed

  • fix(serve): switch serve.mjs temp dir to mkdtempSync + pointer file, resolving CodeQL alert #18 (js/insecure-temporary-file, CWE-377/378). The app's random temp dir path is persisted in scripts/install-test/.serve-dir (outside tmpdir) so --reuse continues to work across restarts. This change is in dev tooling only — the published package is unchanged.

Note: The dependency audit CI check fails on this repo due to a pre-existing transitive esbuild < 0.28.1 vulnerability via @payloadcms/db-sqlite → drizzle-kit → @esbuild-kit/core-utils. This is upstream of Payload and not introduced by this release.

v0.3.4

Choose a tag to compare

@ricbwood ricbwood released this 08 Jun 11:34
72c0f19

Docs + metadata refresh so the npm package page shows current instructions. No runtime change since v0.3.3 (packages/plugin/src is byte-identical).

  • Adds the "Making the MCP usable for AI agents" docs (#57)
  • Nests instructions/serverInfo under serverOptions — copy-paste landmine fix (#58)
  • Updates repository/homepage/bugs URLs to the BrainWeb org (#60)

v0.3.3 — disabled option + MCP-disabled boot fix

Choose a tag to compare

@ricbwood ricbwood released this 06 Jun 11:01
a148f0d

Adds a disabled option and stops a disabled MCP plugin from crashing boot

disabled?: boolean

Turn the OAuth layer off without uninstalling: no endpoints, no token-validation wiring, mcpPluginOptions left untouched (the MCP server keeps working with API keys only). The OAuth collections stay registered — they're relationally isolated (text FKs, lockDocuments: false), so this keeps the DB schema consistent for migrations, matching @payloadcms/plugin-mcp and the official plugin template.

Fix: disabled MCP plugin no longer crashes boot (#55)

@payloadcms/plugin-mcp registers no /mcp endpoint when its own disabled is set, which made our detectMcpEndpoints() throw PLUGIN_ORDER and 500 the app. We now read the shared mcpPluginOptions.disabled and treat it as our disabled state (no throw, no overrideAuth mutation, collections kept). We still throw PLUGIN_ORDER when MCP is enabled but no /mcp endpoint exists (genuine misconfig).

Both reduce to one no-op condition: options.disabled || mcpPluginOptions.disabled.

Closes #49, #55. Patch release — purely additive.

Full diff: #56

v0.3.2 — fix SQLite push error when adding the plugin to an existing DB

Choose a tag to compare

@ricbwood ricbwood released this 06 Jun 10:01
faebd1a

Fixes SQLITE_ERROR: no such column: oauth_clients_id on pnpm dev

Adding the plugin to a SQLite database that had already been pushed once failed while Payload rebuilt payload_locked_documents_rels to add the new collections' FK columns — the rebuild's INSERT…SELECT referenced columns that didn't exist on the old table yet.

Fix: the four OAuth collections (oauth-clients, oauth-auth-codes, oauth-tokens, oauth-csrf-nonces) now set lockDocuments: false. They're server-managed and don't need admin document-locking, so Payload omits them from payload_locked_documents_rels — no rebuild of that table, no failing query. Fresh installs are unaffected; existing 0.3.x installs get only a safe column-removal rebuild on upgrade.

Recommended for anyone adding the plugin to an existing SQLite app. Upgrade from 0.3.1.

Full diff: #48

v0.3.1 — fix OAuth consent POST 401 in real browsers

Choose a tag to compare

@ricbwood ricbwood released this 05 Jun 19:53
1bd4dad

Fixes the OAuth consent Approve returning 401 access_denied / "Authentication required" in real browsers

The consent page sent Referrer-Policy: no-referrer, which per the Fetch spec makes browsers send Origin: null on its form POST. Payload rejects null-origin cookie auth on mutations, so POST /api/oauth/consent lost req.user → 401. The GET render worked (no Origin on navigations), so the failure only appeared on the Approve submit — and only in a real browser (the install-test POSTs programmatically, so CI never caught it).

Fix: consent page (and htmlResponse) now use Referrer-Policy: strict-origin-when-cross-origin — the value the threat model (row I6) already specified. Keeps the real Origin on the same-origin consent POST while still stripping the full URL from the cross-origin client callback.

This affects any deployment doing the OAuth flow through a real browser (e.g. Claude.ai custom connectors). Upgrade from 0.3.0.

Full diff: #46

v0.3.0 — OAuth admin collections under MCP + adminAccess

Choose a tag to compare

@ricbwood ricbwood released this 05 Jun 17:19
bbf013f

Fixes the OAuth admin UI being unreachable

The 0.2.0 lockdown (#33) locked oauth-clients/oauth-tokens to read: () => false, which also removed them from the admin nav and 404'd their routes. The intended replacement custom views never worked under Payload v3 (wrong prop contract, no nav links, gated on a role field the default Users collection lacks).

What changed

  • OAuth Clients and OAuth Tokens are now native admin collections under the MCP nav group (beside the MCP plugin's API Keys).
  • New overridable adminAccess option gates read/update/delete. Default: authenticated users in your userCollection. create stays denied; the public REST/GraphQL surface stays closed. oauth-auth-codes and oauth-csrf-nonces remain hidden and locked.
  • The plugin no longer registers custom admin components, so generate:importmap is no longer required. The v3-incompatible ClientsView/TokensView exports are repaired and marked @deprecated.
  • Docs updated (README, INSTALL_FOR_AGENTS, threat model); install-test hardened.

Breaking / behavioral (why minor)

  • New adminAccess public option; changed default access on the two collections; removed the ClientsViewProps/TokensViewProps type exports.
  • Mixed-role apps: if your userCollection contains untrusted end-users, pass a stricter adminAccess (e.g. ({ req }) => req.user?.role === 'admin').

Full diff: #44

v0.2.0 — Scope narrowing, single-use CSRF nonces + security hardening

Choose a tag to compare

@ricbwood ricbwood released this 05 Jun 09:55
fe44997

A substantial security release bundling everything since 0.1.7 (#38#41). Upgrade recommended.

✨ Features

  • Per-scope capability narrowing (#35). Access tokens are now granted only the MCP capabilities their OAuth scope maps to (<slug>:read|write|delete), instead of always the full operator grant. The operator-configured set remains the hard ceiling; an empty scope still grants the full set. An invalid/no-longer-grantable scope is rejected with invalid_scope (no silent escalation).
  • Single-use, time-bound CSRF nonces (#27). Consent CSRF tokens are session-bound, expiring, and now single-use, backed by the new oauth-csrf-nonces collection (with an efficient bulk sweep).

🔒 Security hardening

  • Consent endpoint is session-bound — identity is derived from req.user, never the request body; a mismatching body user_id is rejected (#38).
  • CSRF/consent input hardened — non-string token guard, numeric user_id coercion (#39).
  • IP-only rate-limit key — rotating client_id/client_name can no longer mint fresh buckets and bypass the per-IP limit; empty/whitespace IPs coalesced (#40, #41).
  • /register: reject redirect URIs with a fragment (RFC 6749 §3.1.2), allow IPv6 loopback [::1], and cap software_id/software_version (#40, #41).
  • Closed backlog issues #26 #28 #29 #30 #31 and reconciled the threat model (#40).

Upgrade

pnpm add @brainwebuk/payload-plugin-mcp-oauth@0.2.0

No config changes required. If you adopt scoped tokens, requested scopes use <collectionOrGlobalSlug>:read|write|delete.

v0.1.7 — Package metadata + consent wording

Choose a tag to compare

@ricbwood ricbwood released this 04 Jun 18:04
3e2268b

Maintenance release. No functional or runtime changes.

  • Add author metadata to the published package (Richard Wood / BrainWeb).
  • README footer attribution to BrainWeb.
  • Ships the consent-screen integrity wording from #36 (the consent screen now states plainly that approval grants all tools enabled on the server, acting as the user; requested scopes are informational — see #35 for true per-scope enforcement).
pnpm add @brainwebuk/payload-plugin-mcp-oauth@0.1.7

v0.1.6 — Security patch

Choose a tag to compare

@ricbwood ricbwood released this 04 Jun 17:17
c169f15

🔒 Security release — upgrade strongly recommended

This patch fixes access-control vulnerabilities present in 0.1.5 and earlier. All deployments should upgrade.

Fixed

  • OAuth collections were writable by any authenticated user. oauth-clients, oauth-auth-codes, and oauth-tokens gated REST/GraphQL access on "is the request authenticated" — and admin: { hidden } only hides the UI, not the API. Any authenticated Payload user could:

    • rewrite a registered client's redirectUris → steal authorization codes / take over connected accounts,
    • read all token rows and revoke/delete other users' tokens.

    These collections now deny all public REST/GraphQL access; they are managed exclusively server-side (endpoints use overrideAccess, admin views use the Local API). The full OAuth handshake is unaffected.

  • No HTTPS enforcement on the issuer in production. resolveConfig now refuses to boot when NODE_ENV=production and the issuer is not https:// (auth codes and bearer tokens travel to/from the advertised endpoints).

  • Silent insecure token-pepper fallback. The built-in development pepper was used in any non-production environment. It is now used only when NODE_ENV is explicitly development or test; production/staging/unset require a real ≥32-char PMOAUTH_TOKEN_PEPPER.

Upgrade

pnpm add @brainwebuk/payload-plugin-mcp-oauth@0.1.6

No config changes required. If you run with an unset NODE_ENV in production, ensure PMOAUTH_TOKEN_PEPPER is set (≥32 chars) and your issuer is https://.

See #33 for the full fix.