Releases: BrainWeb/payload-mcp-oauth
Release list
v0.5.0 — scope correctness + review sweep
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;scopeToCapabilitiesreturns 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://localhostissuer no longer blocks a localnext build.next build/next startsetNODE_ENV=productionwhether 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 unsetSERVER_URLon a real deployment. - Scoped tokens previously used their stored capabilities verbatim, so a grant could outlive the permission that justified it.
issis now set on error and deny authorization responses, not just success (RFC 9207 §2).- A malformed
oauth-clientsrow returned a 500 instead ofinvalid_redirect_uri.
Additions
scopes_supportedin both discovery documents, derived from yourmcpPluginOptions— clients can now discover what to request. Also advertisesauthorization_response_iss_parameter_supported.loginPathoption.- The plugin registers slug
plugin-mcp-oauthwithoptionsand aRegisteredPluginsaugmentation, 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
What's changed
- fix(serve): switch
serve.mjstemp dir tomkdtempSync+ pointer file, resolving CodeQL alert #18 (js/insecure-temporary-file, CWE-377/378). The app's random temp dir path is persisted inscripts/install-test/.serve-dir(outsidetmpdir) so--reusecontinues 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.1vulnerability via@payloadcms/db-sqlite → drizzle-kit → @esbuild-kit/core-utils. This is upstream of Payload and not introduced by this release.
v0.3.4
Docs + metadata refresh so the npm package page shows current instructions. No runtime change since v0.3.3 (packages/plugin/src is byte-identical).
v0.3.3 — disabled option + MCP-disabled boot fix
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
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
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
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
adminAccessoption gatesread/update/delete. Default: authenticated users in youruserCollection.createstays denied; the public REST/GraphQL surface stays closed.oauth-auth-codesandoauth-csrf-noncesremain hidden and locked. - The plugin no longer registers custom admin components, so
generate:importmapis no longer required. The v3-incompatibleClientsView/TokensViewexports are repaired and marked@deprecated. - Docs updated (README, INSTALL_FOR_AGENTS, threat model); install-test hardened.
Breaking / behavioral (why minor)
- New
adminAccesspublic option; changed default access on the two collections; removed theClientsViewProps/TokensViewPropstype exports. - Mixed-role apps: if your
userCollectioncontains untrusted end-users, pass a stricteradminAccess(e.g.({ req }) => req.user?.role === 'admin').
Full diff: #44
v0.2.0 — Scope narrowing, single-use CSRF nonces + security hardening
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
scopemaps 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 withinvalid_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-noncescollection (with an efficient bulk sweep).
🔒 Security hardening
- Consent endpoint is session-bound — identity is derived from
req.user, never the request body; a mismatching bodyuser_idis rejected (#38). - CSRF/consent input hardened — non-string token guard, numeric
user_idcoercion (#39). - IP-only rate-limit key — rotating
client_id/client_namecan 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 capsoftware_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.0No config changes required. If you adopt scoped tokens, requested scopes use <collectionOrGlobalSlug>:read|write|delete.
v0.1.7 — Package metadata + consent wording
Maintenance release. No functional or runtime changes.
- Add
authormetadata 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.7v0.1.6 — Security patch
🔒 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, andoauth-tokensgated REST/GraphQL access on "is the request authenticated" — andadmin: { 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. - rewrite a registered client's
-
No HTTPS enforcement on the issuer in production.
resolveConfignow refuses to boot whenNODE_ENV=productionand the issuer is nothttps://(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-
productionenvironment. It is now used only whenNODE_ENVis explicitlydevelopmentortest; production/staging/unset require a real ≥32-charPMOAUTH_TOKEN_PEPPER.
Upgrade
pnpm add @brainwebuk/payload-plugin-mcp-oauth@0.1.6No 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.