WP Manifestindependent plugin directory
manifest / security / oauth-pilot

OAuth Pilot

[READ ONLY] OAuth server for WordPress MCP

by WP Elevator · github.com/wpelevator/oauth-pilot · website

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/wpelevator/oauth-pilot/archive/refs/heads/main.zip

Declares an update source (https://updates.wpelevator.com/wp-json/update-pilot/v1/plugins), so updates arrive through the plugin's own updater.

Readme

OAuth Pilot

An OAuth 2.1 authorization server for WordPress, built with WordPress and PHP primitives and no runtime OAuth, JWT or cryptography dependency.

The concrete goal is that an agent MCP client — a Claude or ChatGPT connector, a local agent CLI — can point at a WordPress site, register itself, have a human approve the connection once, and keep working from then on.

What it is

  • OAuth 2.1 authorization code grant with mandatory PKCE S256.
  • Refresh tokens with rotation and reuse detection.
  • Public clients (token_endpoint_auth_method=none) and confidential clients using client_secret_basic or client_secret_post.
  • RFC 7591 Dynamic Client Registration, off by default, with an optional allow-list of redirect URI hosts. Deprecated by MCP as of the 2026-07-28 specification and kept as a backward-compatible fallback for connectors that still need it, so a new site exposes no unauthenticated endpoint that writes client rows.
  • Client ID Metadata Documents (CIMD), the MCP 2026-07-28 replacement for DCR: the client_id is an HTTPS URL, and the server fetches a client metadata document from that URL, validates it, and caches it. See Client ID Metadata Documents.
  • RFC 8414 authorization server metadata and RFC 9728 protected resource metadata.
  • RFC 8707 resource indicators: every token is bound to exactly one audience.
  • RFC 9207 iss on every authorization response.
  • RFC 7009 token revocation.
  • A bearer token validation API other plugins call to protect their own endpoints.

What it is not

  • Not an OpenID Connect provider. No ID tokens, UserInfo, JWKS or login federation.
  • Not a JWT issuer. Access tokens are opaque, which makes them revocable.
  • Not an OAuth client, and it never proxies tokens to upstream APIs.
  • Not a replacement for roles and capabilities. A scope narrows a token; WordPress capabilities still decide what the represented user may do. OAuth Pilot can never grant a user a capability they do not already have.
  • Implicit, password, client credentials and device grants are not implemented.

Deferred to a later release: token introspection (RFC 7662), a security event log, and subdirectory or multisite issuers. Multisite storage is supported — one shared client registration per network, with tokens scoped per site; see Storage, retention and cleanup.

Requirements

WordPress 6.6, PHP 7.4, HTTPS, and pretty permalinks — the well-known discovery documents are served through rewrite rules.

The issuer works best at the root of its domain. RFC 8414 itself supports path-based issuers — for issuer https://host/path the metadata document lives at https://host/.well-known/oauth-authorization-server/path, with the issuer path appended after the well-known segment. The catch is placement, not the spec: a plain subdirectory install cannot route that root-level path into WordPress with its own rewrite rules, so the settings screen warns and suggests a server-level rewrite or proxy rule, or a root install. On a subdirectory multisite network the root-level request does reach WordPress but resolves to the main site of the network, so subdirectory sub-sites cannot serve their own metadata yet; subdomain sub-sites have path-less issuers and work. The settings screen warns when a subdirectory issuer is detected, and the plugin never publishes metadata at a convenient but non-conforming URL instead.

Test playground

The package includes a loopback-only dummy OAuth client for exercising a site from a browser. It discovers the server metadata, prepares a dynamic, CIMD, or pre-registered client, starts a real authorization and consent flow with PKCE, exchanges the callback for tokens, and can refresh, replay, revoke, or use those tokens against a REST route. All client and site configuration is editable in the page.

Start the repository's Docker environment, activate and configure OAuth Pilot on the target site, then run:

npm run dev-client --workspace=@wpelevator/oauth-pilot

Open http://127.0.0.1:9925. The default values target https://oauth.basement.localhost; replace the site and resource URLs in the page to use another OAuth Pilot installation. A remote installation with publicly trusted HTTPS does not require Docker, although the local CIMD fixture is specifically integrated with this repository's Docker WordPress environment.

The callback listens on http://127.0.0.1:9925/callback. For a pre-registered client, use that exact redirect URI when creating the client in WordPress and paste the resulting credentials into the page. For DCR, press Register (DCR); dynamically registered clients are always public, so the playground uses token_endpoint_auth_method=none. For CIMD, press Enable CIMD; the playground serves the metadata document over a temporary self-signed HTTPS listener and installs a local-only must-use plugin that permits WordPress to fetch it. Press Disable CIMD, Reset session, or stop the playground normally to remove that helper.

Set DEV_CLIENT_PORT, DEV_CLIENT_CIMD_PORT, or DEV_CLIENT_CIMD_HOST before starting the command to override the two listener ports or the hostname WordPress uses to reach the CIMD listener. If DEV_CLIENT_PORT changes, register the matching callback URL shown in the playground log.

Endpoints

URL Purpose
/.well-known/oauth-protected-resource[/<path>] RFC 9728 resource metadata.
/.well-known/oauth-authorization-server[/<issuer-path>] RFC 8414 server metadata.
/wp-json/oauth-pilot/v1/authorize Authorization request.
/wp-json/oauth-pilot/v1/token Token and refresh grants.
/wp-json/oauth-pilot/v1/register Dynamic client registration.
/wp-json/oauth-pilot/v1/revoke Token revocation.

The login and consent screen lives on wp-login.php and is deliberately not advertised: it is the only step of the flow that reads a WordPress cookie.

The four protocol routes are dispatched anonymously. WordPress cookie authentication and Application Passwords are both disabled for exactly those routes, so a logged-in browser session can never change a protocol response and client_secret_basic cannot collide with an Application Password.

Connecting an agent client

With a Client ID Metadata Document (recommended). The client hosts its own registration document at an HTTPS URL and uses that URL as its client_id. Give the client the site URL and it does the rest — no registration request, nothing to configure. This is the mechanism the MCP 2026-07-28 specification recommends; see Client ID Metadata Documents.

With dynamic registration. The client discovers the metadata, registers itself through RFC 7591, and sends the user to the consent screen. Grant types and response types are narrowed to the ones this server runs, exactly as for a metadata document, and RFC 7591 has the registration response report back what was actually registered. Deprecated by MCP since 2026-07-28 and kept as a fallback for clients that do not support metadata documents yet.

With a pre-registered client. Under Settings → OAuth Pilot → Clients, add a client with the exact callback URL the connector documents. Choose Confidential if the connector asks for a client secret; the secret is shown once and only its SHA-256 hash is stored. Choose the authentication method the connector uses — some send credentials in the Authorization header (client_secret_basic), others in the request body (client_secret_post).

Either way a scope is only ever granted by someone who holds the capability behind it, and every request the client later makes still goes through normal WordPress capability checks.

A scope request is narrowed rather than refused, because one client sends one scope string to every server it talks to. Scopes this server never registered — offline_access, the OpenID Connect set — and scopes belonging to a different resource than the one being requested are ignored, as RFC 6749 permits, leaving the scopes that can actually be granted. Only a request where nothing at all can be granted is refused, with invalid_scope naming the scopes the resource does support.

The person approving is narrowed the same way, and for the same reason. An MCP client following the specification's scope selection strategy asks for every scope the resource advertises, so the request routinely exceeds what the user signing in holds capabilities for. Scopes that user cannot grant are dropped at the consent screen instead of failing the connection: for an integration that defines a read scope available to subscribers and a write scope requiring edit_posts, an editor gets both while a subscriber gets the read scope alone, and only a user who can grant nothing is turned away. The granted set is what the consent screen lists, what the code carries and what the token response reports, so a client is always told what it received.

Client ID Metadata Documents

How the standard works

Client ID Metadata Documents (CIMD, draft-ietf-oauth-client-id-metadata-document) remove the registration round trip from OAuth. Instead of the server handing out a client ID, the client publishes a JSON document describing itself at an HTTPS URL it controls, and uses that URL as its client_id in every authorization and token request:

{
  "client_id": "https://app.example.com/oauth/client-metadata.json",
  "client_name": "Example MCP Client",
  "redirect_uris": ["http://127.0.0.1:3000/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

When an authorization request arrives with a URL-shaped client_id, the authorization server fetches the document from exactly that URL and validates it. The request's redirect_uri must exactly match one of the redirect_uris the document declares. The document's client_id must match the URL it was fetched from, so a document published anywhere cannot impersonate a client identified elsewhere.

The trust model is the origin that serves the document. Whoever controls app.example.com decides what clients that origin can register, and the authorization code is only ever delivered to a callback published on that same origin. What it proves is control of an origin — not the vendor's brand: client_name and every other field are self-asserted, and no signature or third party is involved.

Because the document is fetched from a public URL it cannot carry a shared secret. A CIMD client is always a public client (token_endpoint_auth_method=none), which is safe under OAuth 2.1's mandatory PKCE: the code is useless to an intercepted redirect without the verifier that never leaves the client. Confidential CIMD clients would require asymmetric client authentication (private_key_jwt, mTLS), which OAuth Pilot does not implement.

The MCP 2026-07-28 specification formally deprecates Dynamic Client Registration in favor of this mechanism. Clients that support everything pick, in order: pre-registered credentials, then CIMD when the authorization server advertises it, then DCR as a fallback.

Read the full README on GitHub →