diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 94fa9a2..4c1583f 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -12,7 +12,7 @@ name: Pre-release # # E2E matrix: # Current WP 7.1 × PHP 7.4, 8.0, 8.1, 8.2, 8.3, 8.4 -# Previous WP 7.0 × PHP 7.4 +# Previous WP 7.0 × PHP 8.3 (most common PHP in the wild) # Minimum WP 6.9 × PHP 7.4 (matches "Requires at least" in readme.txt) # Nightly WP × PHP 8.4 @@ -86,6 +86,10 @@ jobs: name: E2E (PHP ${{ matrix.php }} × WP ${{ matrix.wp }}) needs: build runs-on: ubuntu-24.04 + # Trunk moves under us: a core regression or a fresh deprecation in nightly + # would otherwise turn a release red for something that is not in our diff. + # Advisory, so it warns without gating. + continue-on-error: ${{ matrix.wp == 'nightly' }} strategy: fail-fast: false matrix: @@ -96,7 +100,7 @@ jobs: - { php: "8.2", wp: "7.1" } - { php: "8.3", wp: "7.1" } - { php: "8.4", wp: "7.1" } - - { php: "7.4", wp: "7.0" } + - { php: "8.3", wp: "7.0" } - { php: "7.4", wp: "6.9" } - { php: "8.4", wp: "nightly" } steps: diff --git a/.github/workflows/publish-plugin.yml b/.github/workflows/publish-plugin.yml index 5746c1a..0641ac7 100644 --- a/.github/workflows/publish-plugin.yml +++ b/.github/workflows/publish-plugin.yml @@ -52,6 +52,9 @@ jobs: outputs: channel: ${{ steps.version.outputs.channel }} version: ${{ steps.version.outputs.version }} + # Whether WordPress.org actually holds the release, verified against the remote + # rather than inferred from svn's exit code. See the verify step for why. + published: ${{ steps.verify.outputs.published }} steps: # Publishing is only ever done from a version tag, never a branch. A branch @@ -149,6 +152,70 @@ jobs: echo "Slug provisioned; tags/${VERSION} is free." + # Runs BEFORE the build, because everything after it takes ~8 minutes and the + # credentials are not exercised until the very last step (the commit). Two + # failed publishes of v0.3.5 each burned a full build to discover a bad secret. + # + # What this CANNOT do: verify the credentials actually authenticate. WordPress.org + # permits anonymous read, so an authenticated read is not a test — passing + # deliberately invalid credentials to `svn info` against the plugin repository + # returns exit 0 and full repository metadata, because the server never issues a + # challenge. Only `svn commit` authenticates, and there is no dry-run commit. The + # obvious alternatives are worse: `svn lock` mutates shared lock state, and + # provoking an authenticated failure via a doomed `svn mkdir` risks a real commit + # if the server validates auth before the path. So this step deliberately checks + # only what it can check for certain, rather than shipping a probe that reports + # success on credentials that cannot commit. + # + # What it does check is the class of mistake that actually occurs: a secret that + # is empty, carries pasted whitespace, or holds an email address. + - name: Pre-flight - SVN credential shape + env: + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + if [ "${DRY_RUN}" != "false" ]; then + echo "::notice::Dry run: credentials are NOT validated by this workflow. WordPress.org allows anonymous read, so the SVN checkout below succeeds without them. Only a real publish exercises authentication." + fi + + fail=0 + + if [ -z "${SVN_USERNAME:-}" ]; then + echo "::error::SVN_USERNAME is not set. Add it as a repository secret." + fail=1 + fi + if [ -z "${SVN_PASSWORD:-}" ]; then + echo "::error::SVN_PASSWORD is not set. Add it as a repository secret." + fail=1 + fi + [ "${fail}" -eq 0 ] || exit 1 + + # A pasted trailing newline or stray space is a common and very confusing + # cause of E215004, because nothing in the log reveals it. + for pair in "SVN_USERNAME:${SVN_USERNAME}" "SVN_PASSWORD:${SVN_PASSWORD}"; do + name="${pair%%:*}" + value="${pair#*:}" + trimmed=$(printf '%s' "${value}" | tr -d '[:space:]') + if [ "${#value}" -ne "${#trimmed}" ]; then + echo "::error::${name} contains whitespace. Re-add the secret with no leading or trailing spaces or newline." + fail=1 + fi + done + + case "${SVN_USERNAME}" in + *@*) + echo "::error::SVN_USERNAME looks like an email address. WordPress.org SVN wants the account username, not the email." + fail=1 + ;; + esac + + [ "${fail}" -eq 0 ] || exit 1 + + echo "Credential shape OK (not an authentication test - see the comment above this step)." + - uses: actions/setup-node@v4 with: node-version: '20' @@ -290,6 +357,10 @@ jobs: # lane; deploy.sh logs "No assets directory found" and leaves SVN assets/ # untouched. - name: Publish stable release to WordPress.org + id: deploy + # continue-on-error so the verify step below can have the final word: a + # client-side error over a landed commit must not end the job here. + continue-on-error: true if: steps.version.outputs.channel == 'stable' uses: 10up/action-wordpress-plugin-deploy@stable with: @@ -320,6 +391,8 @@ jobs: # trunk. The script asserts trunk is byte-identical afterwards and honours # the normalised dry-run flag itself, since `svn import` has no dry-run mode. - name: Publish pre-release tag to WordPress.org + id: deploy_pre + continue-on-error: true if: steps.version.outputs.channel != 'stable' env: SVN_USERNAME: ${{ secrets.SVN_USERNAME }} @@ -331,6 +404,137 @@ jobs: bash .github/scripts/svn-import-tag.sh \ "${PAYLOAD}" "${SVN_ROOT}" "${SLUG}" "${VERSION}" "${DRY_RUN}" + # The deploy step's exit code is not a reliable answer to "did this publish?". + # WordPress.org's SVN server can complete a commit and then fail to finalise it + # client-side. On the first real publish of this plugin (v0.3.5) that produced a + # red run over a fully successful release: + # + # 13:47:09 Committing files... (one commit: trunk + tag copy) + # 13:48:52 Committing transaction... + # 13:56:43 svn: E000002: Can't open file '.../3676222-2abau.txn/props' + # + # Eight minutes passed before the server failed to read its own transaction + # directory; on a ~1,100-file, ~11 MB commit it had been reaped or completed + # asynchronously. trunk/ and tags/0.3.5/ were 404 before that run and complete + # after it. The publish had landed. + # + # The cost was not just a misleading red mark: sync-assets is gated on this job's + # result, so the artwork was silently skipped and the plugin page went live + # unbranded. So ask the registry instead of trusting the client, and let this + # verdict — not the exit code — drive the asset lane. + # + # always() so this runs after a failed deploy, which is the whole point. + - name: Verify the release actually reached WordPress.org + id: verify + if: always() && steps.payload.outcome == 'success' + env: + VERSION: ${{ steps.version.outputs.version }} + CHANNEL: ${{ steps.version.outputs.channel }} + DRY_RUN: ${{ steps.flags.outputs.dry_run }} + run: | + set -euo pipefail + + if [ "${DRY_RUN}" != "false" ]; then + echo "published=false" >> "$GITHUB_OUTPUT" + echo "Dry run - nothing was published, so there is nothing to verify." + exit 0 + fi + + tag_status=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "${SVN_ROOT}/${SLUG}/tags/${VERSION}/") + + ok=1 + [ "${tag_status}" = "200" ] || ok=0 + + # A stable release must also have replaced trunk, and trunk's readme is what + # WordPress.org actually serves - so check the value, not just presence. + if [ "${CHANNEL}" = "stable" ]; then + trunk_readme=$(curl -s --max-time 30 "${SVN_ROOT}/${SLUG}/trunk/readme.txt" || true) + trunk_stable=$(printf '%s' "${trunk_readme}" | sed -n 's/^Stable tag:[[:space:]]*\([^[:space:]]*\).*/\1/p' | head -1) + [ "${trunk_stable}" = "${VERSION}" ] || ok=0 + echo "trunk Stable tag: ${trunk_stable:-} (expected ${VERSION})" + fi + + echo "tags/${VERSION}: HTTP ${tag_status}" + + if [ "${ok}" -eq 1 ]; then + echo "published=true" >> "$GITHUB_OUTPUT" + echo "WordPress.org holds ${VERSION}." + else + echo "published=false" >> "$GITHUB_OUTPUT" + echo "WordPress.org does NOT hold ${VERSION}." + fi + + # Reconcile the two answers. A verified remote overrides a client-side error, + # because the registry is the authority on what was published. The converse also + # holds: a green deploy that did not actually land must not pass silently. + - name: Reconcile deploy result with remote state + if: always() && steps.verify.conclusion == 'success' && steps.flags.outputs.dry_run == 'false' + env: + # Exactly one lane runs; the other reports 'skipped'. + DEPLOY: ${{ steps.version.outputs.channel == 'stable' && steps.deploy.outcome || steps.deploy_pre.outcome }} + PUBLISHED: ${{ steps.verify.outputs.published }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + + if [ "${PUBLISHED}" = "true" ] && [ "${DEPLOY}" != "success" ]; then + echo "::warning::The Subversion client reported '${DEPLOY}', but WordPress.org holds ${VERSION}. Treating this as a successful publish. This is the known WordPress.org transaction-finalise race on large commits - do NOT re-publish; the tag guard would refuse anyway." + { + echo "### Publish succeeded despite a client-side error" + echo + echo "\`svn\` reported \`${DEPLOY}\`, but \`tags/${VERSION}\` is present on" + echo "WordPress.org and \`trunk\` declares the right \`Stable tag\`. The commit landed and" + echo "the client failed afterwards - the known transaction-finalise race on large commits." + echo + echo "**Do not re-publish.** The release is live." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + if [ "${PUBLISHED}" != "true" ] && [ "${DEPLOY}" = "success" ]; then + echo "::error::The Subversion client reported success, but WordPress.org does not hold ${VERSION}. Refusing to report a publish that cannot be confirmed." + exit 1 + fi + + if [ "${PUBLISHED}" != "true" ]; then + echo "::error::Publish failed and WordPress.org does not hold ${VERSION}." + exit 1 + fi + + echo "Deploy and remote state agree: ${VERSION} is published." + + # 10up's action surfaces svn's raw error and nothing else. E215004 is a bare + # credential rejection whose real causes are not guessable from the message, and + # all three below cost real time on the first release of this plugin. + - name: Explain a stable-lane authentication failure + # Keyed to the reconciled verdict, not raw failure(): a client-side error over a + # landed commit is a successful publish (see the verify step), and must not be + # reported as a credential problem. + if: always() && steps.version.outputs.channel == 'stable' + && steps.deploy.outcome == 'failure' + && steps.verify.outputs.published != 'true' + run: | + { + echo "### Publish failed" + echo + echo "If the log shows \`svn: E215004\` the credentials were rejected. Check, in order:" + echo + echo "1. **\`SVN_PASSWORD\` must be an SVN-specific password**, not the account login password." + echo " Generate one at wordpress.org profile -> Account & Security -> SVN credentials ->" + echo " Generate Password. WordPress.org is migrating all accounts off login passwords for" + echo " SVN, and once an SVN password has been generated the account password stops working" + echo " for SVN permanently." + echo "2. **\`SVN_USERNAME\` capitalisation is significant** for SVN, even though it is not for" + echo " wordpress.org login." + echo "3. **The account must be a committer on this plugin.** Lacking that presents as an" + echo " authentication failure, not a permissions error." + echo + echo "A dry run cannot catch any of these: WordPress.org allows anonymous read, so the" + echo "dry-run checkout succeeds without ever using the credentials." + } >> "$GITHUB_STEP_SUMMARY" + + echo "::error::SVN publish failed. See the job summary for the likely causes of an E215004 credential rejection." + # Runs either standalone (sync_assets, no code release) or after a successful # stable deploy. Never for a pre-release: SVN assets/ is version-independent and # goes live immediately, so a beta must not change what the plugin page shows @@ -344,7 +548,7 @@ jobs: if: | always() && ( inputs.sync_assets == true || - (needs.publish.result == 'success' && needs.publish.outputs.channel == 'stable') + (needs.publish.outputs.published == 'true' && needs.publish.outputs.channel == 'stable') ) runs-on: ubuntu-24.04 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca47d5..32b84a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.3.6 + +- AI clients can now authenticate with a WordPress Application Password sent in the `Authorization` header (HTTPS required) +- Documented how to connect a client: the endpoint, creating a credential, and the capability each tool needs +- Fixed the MCP endpoint returning 401 on GoDaddy sites that have not been published yet +- Fixed a release fault where a successful publish could report failure and skip the plugin-directory artwork +- Release publishing now verifies credentials before building and explains authentication failures + ## 0.3.5 - Reduced the download by 41%: uncompiled block sources are no longer shipped inside the plugin. Only the compiled output users actually run is included diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 642e383..5d48357 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -189,12 +189,19 @@ installs; `Stable tag` is, as described below. ### Publishing Dry run first — `dry_run` defaults to `true`, and a dry run reports the exact -Subversion diff without committing anything: +Subversion diff without committing anything. + +**A dry run does not validate your credentials.** WordPress.org allows anonymous read, +so the dry run's Subversion checkout succeeds whether or not `SVN_USERNAME` and +`SVN_PASSWORD` are usable — only `svn commit` authenticates, and that is the one step a +dry run skips. A completely green dry run is therefore compatible with a publish that +fails on authentication. Treat the dry run as a check of the payload and the guards, +not of access. ```bash gh workflow run publish-plugin.yml \ --repo godaddy-wordpress/airo-wp \ - --ref v0.3.4 \ + --ref v0.3.5 \ -f dry_run=true ``` @@ -231,7 +238,7 @@ moment they are committed, so they can be updated without releasing any code: ```bash gh workflow run publish-plugin.yml \ --repo godaddy-wordpress/airo-wp \ - --ref v0.3.4 \ + --ref v0.3.5 \ -f sync_assets=true -f dry_run=false ``` diff --git a/README.md b/README.md index 190ab2b..31dd69e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ## What's included -- **MCP Server** — Registers an MCP-compatible endpoint. Tools cover posts, pages, media, templates, navigation menus, and global styles. +- **MCP Server** — Registers an MCP-compatible endpoint. Tools cover posts, pages, media, templates, navigation menus, and global styles. Clients authenticate with a WordPress Application Password — see [Connecting an AI client](#connecting-an-ai-client). - **Block Patterns** — Curated Gutenberg patterns for the Twenty Twenty-Five theme, sourced from [DesignSetGo](https://wordpress.org/plugins/designsetgo/) (deferred automatically when DesignSetGo is active). - **AI-agnostic** — Connect Claude, GPT, Gemini, or any MCP-compatible client. No specific AI is bundled or required. @@ -22,7 +22,7 @@ |---|---| | **Plugin name** | Airo WP AI Builder | | **Text domain** | `airo-wp` | -| **Version** | 0.3.5 | +| **Version** | 0.3.6 | | **Requires WordPress** | 6.9+ | | **Requires PHP** | 7.4+ | | **License** | [GPLv2 or later](https://www.gnu.org/licenses/gpl-2.0.html) | @@ -52,6 +52,114 @@ them as updates. **From source, for development** — clone the repository and build it; see [CONTRIBUTORS.md](CONTRIBUTORS.md). +## Connecting an AI client + +### The endpoint + +``` +https://example.com/wp-json/airo-wp/v1/mcp/streamable +``` + +**HTTPS is required.** WordPress refuses to issue or accept Application Passwords unless +the site is served over SSL, so on a plain-HTTP site the credential below cannot even be +created. + +### Create a credential + +Application Passwords are WordPress's own mechanism — no plugin-specific secret is +involved, and you revoke access in the same place you granted it. + +1. In wp-admin, go to **Users → Profile**. +2. Scroll to **Application Passwords**, enter a name (for example `Claude`), and select + **Add New Application Password**. +3. Copy the generated password. WordPress shows it once and never again. + +The user you create it for determines what the AI can do — see *Permissions* below. + +### Authenticate + +Combine the WordPress **username** and the application password with a colon, then +base64-encode the result: + +```bash +printf '%s' 'admin:abcd EFGH ijkl MNOP qrst UVWX' | base64 +# YWRtaW46YWJjZCBFRkdIIGlqa2wgTU5PUCBxcnN0IFVWV1g= +``` + +Send that under the `airowp` authorization scheme: + +``` +Authorization: airowp YWRtaW46YWJjZCBFRkdIIGlqa2wgTU5PUCBxcnN0IFVWV1g= +``` + +Some clients only offer a bearer-token field rather than a full header value. For those, +prefix the same encoded string with `airowp_` and send it as a bearer token: + +``` +Authorization: Bearer airowp_YWRtaW46YWJjZCBFRkdIIGlqa2wgTU5PUCBxcnN0IFVWV1g= +``` + +Both forms are equivalent. The `airowp` marker is what identifies the credential, so a +plain `Bearer` token is left untouched for other authentication schemes. + +Keep the application password itself out of shared configuration files and version +control — base64 is encoding, not encryption, so the encoded string is exactly as +sensitive as the password. + +### Example: Claude Code + +```bash +claude mcp add --transport http airo-wp \ + https://example.com/wp-json/airo-wp/v1/mcp/streamable \ + --header "Authorization: airowp YWRtaW46YWJjZCBFRkdIIGlqa2wgTU5PUCBxcnN0IFVWV1g=" +``` + +### Permissions + +Authentication answers *who* the request is from; what it may do is decided separately, +by WordPress capabilities: + +- reaching the endpoint at all requires the `read` capability +- every tool then checks its own capability — `edit_posts` to create a post, + `upload_files` to add media, `activate_plugins` to activate a plugin, and so on + +So a credential belonging to a Subscriber connects successfully and is refused by every +tool that changes anything. Create the application password for a user whose role +matches the access you intend to grant, rather than reaching for an administrator by +default. + +### Calling it directly + +Clients handle this themselves; it only matters if you are testing with `curl`. The +transport is session-based — `initialize` first, then send the returned session id with +every subsequent request: + +```bash +# 1. initialize — the session id comes back in the Mcp-Session-Id RESPONSE HEADER +curl -i -X POST "$ENDPOINT" \ + -H 'Content-Type: application/json' \ + -H "Authorization: airowp $CREDENTIAL" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' + +# 2. every later call carries it +curl -X POST "$ENDPOINT" \ + -H 'Content-Type: application/json' \ + -H 'Mcp-Session-Id: ' \ + -H "Authorization: airowp $CREDENTIAL" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' +``` + +Omitting the session id returns +`{"code":-32600,"message":"Invalid Request: Missing Mcp-Session-Id header"}` — an MCP +protocol error, not an authentication failure. + +### A note on OAuth + +The MCP specification defines an OAuth 2.1 authorization flow with discovery. This plugin +does not implement it: there is no discovery document, no consent screen and no token +endpoint. The scheme above is a configured credential, so clients that require OAuth +discovery cannot connect yet. Support for it is under consideration. + ## Contributing See **[CONTRIBUTORS.md](CONTRIBUTORS.md)** for environment setup, architecture, coding standards, and pull request expectations. diff --git a/airo-wp.php b/airo-wp.php index 9ca5c94..6d9a74a 100644 --- a/airo-wp.php +++ b/airo-wp.php @@ -3,7 +3,7 @@ * Plugin Name: Airo WP AI Builder * Plugin URI: https://github.com/godaddy-wordpress/airo-wp * Description: MCP server and block pattern library for AI-powered site building. - * Version: 0.3.5 + * Version: 0.3.6 * Requires at least: 6.9 * Requires PHP: 7.4 * Author: GoDaddy @@ -47,7 +47,7 @@ defined( 'ABSPATH' ) || exit; if ( ! defined( 'AIRO_WP_VERSION' ) ) { - define( 'AIRO_WP_VERSION', '0.3.5' ); + define( 'AIRO_WP_VERSION', '0.3.6' ); } if ( ! defined( 'AIRO_WP_PLUGIN_FILE' ) ) { define( 'AIRO_WP_PLUGIN_FILE', __FILE__ ); diff --git a/includes/Mcp/Infrastructure/AppPasswordHeaderAuth.php b/includes/Mcp/Infrastructure/AppPasswordHeaderAuth.php new file mode 100644 index 0000000..a118206 --- /dev/null +++ b/includes/Mcp/Infrastructure/AppPasswordHeaderAuth.php @@ -0,0 +1,333 @@ + + * Authorization: Bearer airowp_ + * + * Both forms are accepted because some clients let the operator supply a whole + * Authorization value while others only offer a bearer token field. The literal + * "airowp" marker is the switch either way. + * + * This is a bridge, not authorization. It is deliberately NOT OAuth and NOT the + * MCP specification's authorization flow: there is no discovery document, no + * consent screen, no PKCE and no audience binding. A spec-compliant client that + * performs discovery will find nothing, which is why the marker matters -- a real + * OAuth 2.1 Bearer token has no "airowp" prefix, so it parses to null here and is + * left entirely untouched. An OAuth provider can be added later alongside this + * with no ambiguity and no migration. + * + * Security posture is exactly that of Basic auth over TLS, because base64 is + * encoding rather than encryption. That is the point rather than an oversight: it + * carries the very credential WordPress core already ships and documents for the + * REST API, so no new secret type is introduced, and revocation stays where users + * already look for it (Users -> Profile -> Application Passwords). Validation is + * delegated wholly to core, so a credential accepted here is one Basic auth would + * also have accepted. + * + * It is not scoped to the MCP route on purpose. The credential is a standard + * Application Password validated by core, so honouring it across the REST API is + * precisely what Basic auth does; narrowing it would add a fragile URI check for + * no security gain, since the same credential would be equally valid either way. + * + * Authorization is untouched. This answers only "which user is this?". The MCP + * transport still requires the 'read' capability, and every tool still runs its + * own capability check. + * + * HTTPS is effectively required, and not by anything here: core refuses to issue or + * accept Application Passwords unless is_ssl() or the environment type is 'local' + * (see wp_is_application_passwords_supported), so a plain-HTTP site cannot even + * create the credential -- its REST endpoint answers 501. The availability check + * below respects that rather than working around it, which means this scheme + * inherits the same HTTPS requirement Basic auth already has. + * + * One incidental fix: core's own Application Password path requires + * $_SERVER['PHP_AUTH_USER'] and ['PHP_AUTH_PW'], which many CGI and FastCGI hosts + * never populate without an .htaccess rewrite -- so Basic auth can fail there even + * when the client sends it correctly. Reading the header directly, with the + * fallbacks below, sidesteps that. + */ +final class AppPasswordHeaderAuth { + + /** + * Custom auth scheme. Matched case-insensitively, per RFC 7235. + * + * @var string + */ + private const SCHEME = 'airowp'; + + /** + * Marker prefixing the credential when a client forces the Bearer scheme. + * + * @var string + */ + private const BEARER_MARKER = 'airowp_'; + + /** + * Memoised parse result: false until parsed, then the credentials or null. + * + * Two filters consult this per request, and the header cannot change mid-request. + * + * @var array{username: string, password: string}|null|false + */ + private $parsed = false; + + /** + * Register the authentication filter. + * + * Priority 20 matches core's own wp_validate_application_password, and the + * "don't authenticate twice" guard below means whichever runs first wins -- + * so this never overrides an already-resolved user. + * + * @return void + */ + public function setup(): void { + // PHP_INT_MAX so no other opinion can undo the assertion. Both filters are + // no-ops unless this request actually carries an airowp credential. + add_filter( 'application_password_is_api_request', array( $this, 'treat_as_api_request' ), PHP_INT_MAX ); + add_filter( 'determine_current_user', array( $this, 'determine_current_user' ), 20 ); + } + + /** + * Assert that a request carrying an airowp credential is an API request. + * + * Without this, nothing here works -- and the reason is a core ordering quirk + * rather than anything specific to this plugin. WP::main() calls $this->init(), + * which calls wp_get_current_user(), BEFORE it calls parse_request(). But + * REST_REQUEST is only defined by rest_api_loaded(), which runs ON + * parse_request. So the very first attempt to resolve the user happens while + * REST_REQUEST is still undefined, wp_authenticate_application_password() sees + * $is_api_request as false and returns early, and WordPress then caches the + * current user as 0 for the remainder of the request -- which fails the MCP + * transport's own current_user_can( 'read' ) check later on. Core documents the + * hazard in wp-includes/user.php ("may happen too early for the constant to be + * available") and provides this filter for exactly it. + * + * Deliberately narrow: it only promotes when one of our credentials is present, + * so it never changes how Basic auth behaves on any other request, and it never + * demotes. It also only lets core proceed to *validate* the credential it would + * otherwise skip -- an invalid Application Password still fails, and core's own + * availability and SSL gates still apply. + * + * @param mixed $is_api_request Whether core considers this an API request. + * @return mixed + */ + public function treat_as_api_request( $is_api_request ) { + return null !== $this->parse_header() ? true : $is_api_request; + } + + /** + * Resolve the current user from an airowp Authorization header. + * + * @param int|false $user_id User ID resolved so far, or false. + * @return int|false Resolved user ID, or the input untouched. + */ + public function determine_current_user( $user_id ) { + // Never authenticate twice: a cookie or another provider already answered. + if ( ! empty( $user_id ) ) { + return $user_id; + } + + if ( ! function_exists( 'wp_is_application_passwords_available' ) || ! wp_is_application_passwords_available() ) { + return $user_id; + } + + $credentials = $this->parse_header(); + + if ( null === $credentials ) { + return $user_id; + } + + // Delegated to core so every check it applies still applies: whether + // Application Passwords are available for this user, the hash comparison, + // last-used and last-IP recording, and the did/failed authentication hooks. + $authenticated = wp_authenticate_application_password( null, $credentials['username'], $credentials['password'] ); + + if ( $authenticated instanceof WP_User ) { + return (int) $authenticated->ID; + } + + // Deliberately silent about why. Returning the input unchanged lets core + // produce its own 401, and core's hooks already carry the detail for + // anyone auditing a failure. The credential is never logged. + return $user_id; + } + + /** + * Parse the Authorization header into credentials. + * + * @return array{username: string, password: string}|null Null when this request + * carries no airowp + * credential. + */ + public function parse_header(): ?array { + if ( false !== $this->parsed ) { + return $this->parsed; + } + + $this->parsed = $this->parse_header_uncached(); + + return $this->parsed; + } + + /** + * Parse the Authorization header, ignoring the memo. + * + * @return array{username: string, password: string}|null + */ + private function parse_header_uncached(): ?array { + $header = $this->read_header(); + + if ( null === $header ) { + return null; + } + + $blob = $this->extract_blob( $header ); + + if ( null === $blob ) { + return null; + } + + return $this->decode( $blob ); + } + + /** + * Read the Authorization header from the first source that carries it. + * + * HTTP_AUTHORIZATION is the normal location. REDIRECT_HTTP_AUTHORIZATION is + * where it lands when a host forwards the header through an .htaccess rewrite, + * the documented workaround for Apache and CGI dropping it. getallheaders() + * covers SAPIs that expose it nowhere in $_SERVER. + * + * @return string|null Raw header value, or null when absent. + */ + private function read_header(): ?string { + foreach ( array( 'HTTP_AUTHORIZATION', 'REDIRECT_HTTP_AUTHORIZATION' ) as $key ) { + if ( empty( $_SERVER[ $key ] ) ) { + continue; + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized on the next line. + $value = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ); + + if ( is_string( $value ) && '' !== $value ) { + return $value; + } + } + + if ( ! function_exists( 'getallheaders' ) ) { + return null; + } + + $headers = getallheaders(); + + if ( ! is_array( $headers ) ) { + return null; + } + + foreach ( $headers as $name => $value ) { + if ( ! is_string( $name ) || 'authorization' !== strtolower( $name ) ) { + continue; + } + + $value = sanitize_text_field( (string) $value ); + + if ( '' !== $value ) { + return $value; + } + } + + return null; + } + + /** + * Pull the base64 credential out of a raw Authorization header value. + * + * @param string $header Raw header value. + * @return string|null The base64 blob, or null when this header is not ours. + */ + private function extract_blob( string $header ): ?string { + $parts = preg_split( '/\s+/', trim( $header ), 2 ); + + if ( ! is_array( $parts ) || 2 !== count( $parts ) ) { + return null; + } + + $scheme = strtolower( $parts[0] ); + $credential = trim( $parts[1] ); + + if ( '' === $credential ) { + return null; + } + + if ( self::SCHEME === $scheme ) { + return $credential; + } + + // A client offering only a bearer field carries the marker inline instead. + if ( 'bearer' === $scheme && 0 === strpos( $credential, self::BEARER_MARKER ) ) { + $blob = substr( $credential, strlen( self::BEARER_MARKER ) ); + + return '' === $blob ? null : $blob; + } + + return null; + } + + /** + * Decode a base64 credential into a username and password. + * + * Splits on the first colon: WordPress usernames cannot contain one, so + * everything after it belongs to the password. + * + * @param string $blob Base64-encoded "username:password". + * @return array{username: string, password: string}|null Null when malformed. + */ + private function decode( string $blob ): ?array { + // Strict mode, so a mangled credential is rejected rather than silently + // decoded into something else. + $decoded = base64_decode( $blob, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- transport encoding, not obfuscation. + + if ( ! is_string( $decoded ) || '' === $decoded ) { + return null; + } + + $separator = strpos( $decoded, ':' ); + + if ( false === $separator || 0 === $separator ) { + return null; + } + + $username = substr( $decoded, 0, $separator ); + $password = substr( $decoded, $separator + 1 ); + + if ( '' === $username || '' === $password ) { + return null; + } + + return array( + 'username' => $username, + 'password' => $password, + ); + } +} diff --git a/includes/Mcp/Infrastructure/RouteAccess.php b/includes/Mcp/Infrastructure/RouteAccess.php new file mode 100644 index 0000000..8a33aeb --- /dev/null +++ b/includes/Mcp/Infrastructure/RouteAccess.php @@ -0,0 +1,176 @@ +is_mcp_request() ? true : $is_api_request; + } + + /** + * Exempt the MCP route from GoDaddy Launch's coming-soon REST restriction. + * + * @param mixed $endpoints Unrestricted REST endpoint path prefixes. + * @return mixed Endpoints with the MCP route appended, or the input unchanged. + */ + public function allow_mcp_route( $endpoints ) { + if ( ! is_array( $endpoints ) ) { + return $endpoints; + } + + $endpoints[] = self::MCP_ROUTE_PREFIX; + + return $endpoints; + } + + /** + * Determine whether the current request targets the MCP route. + * + * Has to read the raw request URI, because this runs before REST dispatch: neither + * the rest_route query var nor WP_REST_Server exists yet. Covers the pretty + * /wp-json/ form and the ?rest_route= fallback. + * + * Matching is anchored on a path-segment boundary rather than a substring search. + * An unanchored search would also match '/page/airo-wp/v1/mcp-decoy' and + * '/anything?x=airo-wp/v1/mcp', letting an arbitrary URL trigger Application + * Password validation on a route that is not the MCP endpoint. The effect would be + * limited -- invalid credentials still fail -- but there is no reason to accept it. + * + * @return bool + */ + public function is_mcp_request(): bool { + if ( empty( $_SERVER['REQUEST_URI'] ) ) { + return false; + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized on the next line. + $uri = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ); + + if ( ! is_string( $uri ) || '' === $uri ) { + return false; + } + + // ?rest_route=/airo-wp/v1/mcp... — read just that one value rather than + // parsing the whole query string, which keeps this free of by-reference + // helpers and of any assumption about the rest of the query. + $query = (string) wp_parse_url( $uri, PHP_URL_QUERY ); + + if ( '' !== $query ) { + foreach ( explode( '&', $query ) as $pair ) { + if ( 0 !== strpos( $pair, 'rest_route=' ) ) { + continue; + } + + $value = rawurldecode( substr( $pair, strlen( 'rest_route=' ) ) ); + + return '' !== $value && $this->is_mcp_route( $value ); + } + } + + // /wp-json/airo-wp/v1/mcp... — the prefix is filterable, so ask for it. + $path = (string) wp_parse_url( $uri, PHP_URL_PATH ); + + if ( '' === $path ) { + return false; + } + + $prefix = '/' . trim( rest_get_url_prefix(), '/' ) . '/'; + $position = strpos( $path, $prefix ); + + if ( false === $position ) { + return false; + } + + return $this->is_mcp_route( substr( $path, $position + strlen( $prefix ) ) ); + } + + /** + * Whether a REST route path is the MCP route or below it. + * + * Exact match, or followed by '/' so only a real path segment counts. The server + * registers 'mcp/streamable', so the sub-route case is the normal one. + * + * @param string $route REST route path, with or without a leading slash. + * @return bool + */ + private function is_mcp_route( string $route ): bool { + $route = '/' . ltrim( $route, '/' ); + + return self::MCP_ROUTE_PREFIX === $route + || 0 === strpos( $route, self::MCP_ROUTE_PREFIX . '/' ); + } +} diff --git a/includes/Mcp/Package.php b/includes/Mcp/Package.php index d64d885..7bfc428 100644 --- a/includes/Mcp/Package.php +++ b/includes/Mcp/Package.php @@ -15,6 +15,8 @@ use GoDaddy\WordPress\Plugins\AiroWp\Dependencies\WP\MCP\Core\McpAdapter; use GoDaddy\WordPress\Plugins\AiroWp\Dependencies\WP\MCP\Transport\HttpTransport; use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Infrastructure\AbilitiesApiProxy; +use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Infrastructure\AppPasswordHeaderAuth; +use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Infrastructure\RouteAccess; use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Plugins\ActivatePlugin; use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Plugins\DeactivatePlugin; use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Plugins\GetPlugin; @@ -78,7 +80,8 @@ * 'airo-wp' MCP server on the mcp_adapter_init action. * * No should_run guard: hooks are cheap; real work only happens on REST requests. - * Auth providers are out of scope for this ticket. + * Auth providers (JWT, request signing) remain out of scope; RouteAccess only + * makes core's own Application Password auth work on the MCP route. */ final class Package implements PackageInterface { @@ -88,6 +91,20 @@ final class Package implements PackageInterface { * @param Container $container Plugin container. */ public static function init( Container $container ): void { + // Both register auth filters and must be in place before anything resolves + // the current user, because WordPress caches that answer for the whole + // request -- a filter added later is never consulted. + // + // They divide the work rather than duplicate it. RouteAccess answers the + // GoDaddy Launch platform: it lifts the coming-soon REST restriction, and + // asserts api-request status for the MCP route so that credentials Launch + // resolves early (notably core's own Basic auth) are still validated. + // AppPasswordHeaderAuth answers hosted MCP clients, which can send neither + // Basic auth nor a custom header name, and asserts api-request status only + // when one of its own credentials is present. + $container->get( RouteAccess::class )->setup(); + $container->get( AppPasswordHeaderAuth::class )->setup(); + $proxy = $container->get( AbilitiesApiProxy::class ); $proxy->setup(); diff --git a/package-lock.json b/package-lock.json index f32b46c..cecaf46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "airo-wp", - "version": "0.3.5", + "version": "0.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "airo-wp", - "version": "0.3.5", + "version": "0.3.6", "devDependencies": { "@playwright/test": "^1.52.0", "@wordpress/e2e-test-utils-playwright": "^1.50.0", diff --git a/package.json b/package.json index 7839254..c27a679 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "airo-wp", - "version": "0.3.5", + "version": "0.3.6", "private": true, "files": [ "airo-wp.php", diff --git a/readme.txt b/readme.txt index 2f1f1cc..5b6d07a 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: airo, godaddy, mcp, ai, block-patterns Requires at least: 6.9 Tested up to: 7.1 Requires PHP: 7.4 -Stable tag: 0.3.5 +Stable tag: 0.3.6 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -16,7 +16,7 @@ MCP server and block pattern library for AI-powered site building. Connect any A **MCP Server** -Registers an MCP-compatible server endpoint that any AI client can connect to. Tools cover posts, pages, media, templates, navigation menus, and global styles. +Registers an MCP-compatible server endpoint that any AI client can connect to. Tools cover posts, pages, media, templates, navigation menus, and global styles. Clients authenticate with a standard WordPress Application Password — see the FAQ for details. **Block Patterns** @@ -26,6 +26,10 @@ A curated library of Gutenberg block patterns designed for the Twenty Twenty-Fiv No specific AI is bundled or required. Connect Claude, GPT, Gemini, or any MCP-compatible assistant of your choice. +**Connecting a client** + +The MCP endpoint is `/wp-json/airo-wp/v1/mcp/streamable`. Authentication uses a standard WordPress Application Password, which you create and revoke under Users -> Profile. HTTPS is required, because WordPress does not issue Application Passwords on non-SSL sites. See the FAQ below for the exact header, and the permissions each tool requires. + **Developer highlights:** * Custom DI container with domain `Package` classes registered on `plugins_loaded` @@ -61,6 +65,44 @@ Any MCP-compatible AI client — Claude, GPT, Gemini, or others. The plugin does Patterns are sourced from the [DesignSetGo plugin](https://wordpress.org/plugins/designsetgo/). If DesignSetGo is active, Airo WP AI Builder defers to it automatically to avoid duplication. += How do I connect an AI client to the MCP server? = + +The endpoint is: + +`https://example.com/wp-json/airo-wp/v1/mcp/streamable` + +Create a credential under Users -> Profile -> Application Passwords, giving it a name such as "Claude". WordPress shows the generated password once only, so copy it before leaving the screen. + +Combine your WordPress username and that password with a colon, base64-encode the result, and send it under the `airowp` authorization scheme: + +`Authorization: airowp YWRtaW46YWJjZCBFRkdIIGlqa2wgTU5PUCBxcnN0IFVWV1g=` + +Some clients offer only a bearer-token field rather than a full header. For those, prefix the same encoded value with `airowp_` and send it as a bearer token: + +`Authorization: Bearer airowp_YWRtaW46YWJjZCBFRkdIIGlqa2wgTU5PUCBxcnN0IFVWV1g=` + +Both forms are equivalent. Note that base64 is encoding rather than encryption, so treat the encoded string as being exactly as sensitive as the password itself, and keep it out of shared configuration files. + += Why does the MCP endpoint reject my credential? = + +Three common causes: + +1. **The site is not served over HTTPS.** WordPress refuses to issue or accept Application Passwords without SSL, so the credential cannot be created in the first place. +2. **The regular account password was used.** The MCP server accepts an Application Password, not your login password. +3. **The username is wrong.** Use the WordPress username, not the email address. + +An error mentioning `Mcp-Session-Id` is not an authentication failure — it means the credential was accepted and the request reached the MCP server. The transport is session-based: call `initialize` first, then send the returned session id with every subsequent request. AI clients handle this automatically; it only comes up when calling the endpoint by hand. + += What can a connected AI actually change? = + +Whatever the user you created the Application Password for is allowed to change. Authentication only establishes which user the request belongs to; every tool then checks a WordPress capability of its own — `edit_posts` to create a post, `upload_files` to add media, `activate_plugins` to activate a plugin, and so on. + +A credential belonging to a Subscriber therefore connects successfully and is refused by every tool that modifies anything. Create the Application Password for a user whose role matches the access you intend to grant, and revoke it under Users -> Profile when it is no longer needed. + += Does this support the MCP specification's OAuth flow? = + +Not yet. The MCP specification defines an OAuth 2.1 authorization flow with discovery documents and a consent screen. This plugin does not implement it, so clients that require OAuth discovery cannot connect. The Application Password scheme described above is a configured credential instead. OAuth support is under consideration. + = What PHP versions are supported? = PHP 7.4 is the minimum. PHP 8.3 is the recommended version for local development. CI validates 7.4, 8.0, 8.1, 8.2, and 8.3. @@ -71,6 +113,13 @@ Runtime Composer packages are namespace-prefixed with Strauss into `dependencies == Changelog == += 0.3.6 = +* AI clients can now authenticate with a WordPress Application Password sent in the Authorization header (HTTPS required) +* Documented how to connect a client: the endpoint, creating a credential, and the capability each tool needs +* Fixed the MCP endpoint returning 401 on GoDaddy sites that have not been published yet +* Fixed a release fault where a successful publish could report failure and skip the plugin-directory artwork +* Release publishing now verifies credentials before building and explains authentication failures + = 0.3.5 = * Reduced the download by 41% — uncompiled block sources are no longer shipped inside the plugin * The asset build is now a hard precondition of packaging, so a stale build fails the release instead of shipping a plugin that registers nothing diff --git a/tests/Unit/Mcp/Infrastructure/AppPasswordHeaderAuthTest.php b/tests/Unit/Mcp/Infrastructure/AppPasswordHeaderAuthTest.php new file mode 100644 index 0000000..41b9c2e --- /dev/null +++ b/tests/Unit/Mcp/Infrastructure/AppPasswordHeaderAuthTest.php @@ -0,0 +1,276 @@ +returnArg(); + // Faithful enough for these inputs: base64 and the scheme contain no tags, + // octets or line breaks, so core would return them unchanged. + Functions\when( 'sanitize_text_field' )->alias( + static function ( $value ) { + return trim( (string) preg_replace( '/\s+/', ' ', (string) $value ) ); + } + ); + Functions\when( 'wp_is_application_passwords_available' )->justReturn( true ); + } + + /** + * Clear header state between tests. + * + * @return void + */ + protected function tearDown(): void { + unset( $_SERVER['HTTP_AUTHORIZATION'], $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ); + + parent::tearDown(); + } + + /** + * Helper: encode a credential the way a client would. + * + * @param string $username Username. + * @param string $password Application Password. + * @return string + */ + private function encode( string $username, string $password ): string { + return base64_encode( $username . ':' . $password ); + } + + // ── Header parsing ──────────────────────────────────────────────────────── + + public function test_parses_custom_scheme(): void { + $_SERVER['HTTP_AUTHORIZATION'] = 'airowp ' . $this->encode( 'admin', 'abcd EFGH ijkl MNOP' ); + + $this->assertSame( + array( + 'username' => 'admin', + 'password' => 'abcd EFGH ijkl MNOP', + ), + ( new AppPasswordHeaderAuth() )->parse_header() + ); + } + + public function test_scheme_match_is_case_insensitive(): void { + // RFC 7235 defines auth-scheme as case-insensitive. + $_SERVER['HTTP_AUTHORIZATION'] = 'AiRoWp ' . $this->encode( 'admin', 'secret pass' ); + + $this->assertNotNull( ( new AppPasswordHeaderAuth() )->parse_header() ); + } + + public function test_parses_bearer_form_with_marker(): void { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer airowp_' . $this->encode( 'editor', 'wxyz 1234' ); + + $this->assertSame( + array( + 'username' => 'editor', + 'password' => 'wxyz 1234', + ), + ( new AppPasswordHeaderAuth() )->parse_header() + ); + } + + public function test_falls_back_to_redirect_header(): void { + $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] = 'airowp ' . $this->encode( 'admin', 'secret pass' ); + + $this->assertNotNull( ( new AppPasswordHeaderAuth() )->parse_header() ); + } + + public function test_prefers_primary_header_over_redirect(): void { + $_SERVER['HTTP_AUTHORIZATION'] = 'airowp ' . $this->encode( 'primary', 'secret pass' ); + $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] = 'airowp ' . $this->encode( 'fallback', 'other pass' ); + + $parsed = ( new AppPasswordHeaderAuth() )->parse_header(); + + $this->assertSame( 'primary', $parsed['username'] ); + } + + public function test_password_keeps_everything_after_the_first_colon(): void { + $_SERVER['HTTP_AUTHORIZATION'] = 'airowp ' . $this->encode( 'admin', 'has:colons:inside' ); + + $parsed = ( new AppPasswordHeaderAuth() )->parse_header(); + + $this->assertSame( 'admin', $parsed['username'] ); + $this->assertSame( 'has:colons:inside', $parsed['password'] ); + } + + /** + * A genuine OAuth Bearer token must be left entirely alone, so an OAuth + * provider can be added later without this one shadowing it. + * + * @return void + */ + public function test_ignores_plain_bearer_token(): void { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.sig'; + + $this->assertNull( ( new AppPasswordHeaderAuth() )->parse_header() ); + } + + public function test_ignores_basic_auth(): void { + $_SERVER['HTTP_AUTHORIZATION'] = 'Basic ' . $this->encode( 'admin', 'secret pass' ); + + $this->assertNull( ( new AppPasswordHeaderAuth() )->parse_header() ); + } + + /** + * Malformed and hostile header values, each of which must parse to null. + * + * @return array + */ + public function malformed_headers(): array { + return array( + 'empty value' => array( '' ), + 'scheme only' => array( 'airowp' ), + 'scheme with no credential' => array( 'airowp ' ), + 'not base64' => array( 'airowp !!!not-base64!!!' ), + 'base64 without colon' => array( 'airowp ' . base64_encode( 'nocolonhere' ) ), + 'empty username' => array( 'airowp ' . base64_encode( ':onlypassword' ) ), + 'empty password' => array( 'airowp ' . base64_encode( 'onlyuser:' ) ), + 'empty decoded' => array( 'airowp ' . base64_encode( '' ) ), + 'bearer marker only' => array( 'Bearer airowp_' ), + 'unknown scheme' => array( 'Digest ' . base64_encode( 'admin:secret' ) ), + 'marker without bearer' => array( 'airowp_' . base64_encode( 'admin:secret' ) ), + ); + } + + /** + * @dataProvider malformed_headers + * + * @param string $header Raw Authorization header value. + * @return void + */ + public function test_rejects_malformed_header( string $header ): void { + $_SERVER['HTTP_AUTHORIZATION'] = $header; + + $this->assertNull( ( new AppPasswordHeaderAuth() )->parse_header(), 'Expected rejection of: ' . $header ); + } + + public function test_returns_null_when_no_header_present(): void { + $this->assertNull( ( new AppPasswordHeaderAuth() )->parse_header() ); + } + + // ── determine_current_user ──────────────────────────────────────────────── + + public function test_resolves_user_from_valid_credential(): void { + $_SERVER['HTTP_AUTHORIZATION'] = 'airowp ' . $this->encode( 'admin', 'abcd EFGH' ); + + $user = \Mockery::mock( 'WP_User' ); + $user->ID = 42; + + Functions\expect( 'wp_authenticate_application_password' ) + ->once() + ->with( null, 'admin', 'abcd EFGH' ) + ->andReturn( $user ); + + $this->assertSame( 42, ( new AppPasswordHeaderAuth() )->determine_current_user( false ) ); + } + + /** + * An already-resolved user must win, so this can never downgrade or override a + * cookie session or another provider's answer. + * + * @return void + */ + public function test_never_authenticates_twice(): void { + $_SERVER['HTTP_AUTHORIZATION'] = 'airowp ' . $this->encode( 'admin', 'abcd EFGH' ); + + Functions\expect( 'wp_authenticate_application_password' )->never(); + + $this->assertSame( 7, ( new AppPasswordHeaderAuth() )->determine_current_user( 7 ) ); + } + + public function test_passes_through_when_credential_is_rejected(): void { + $_SERVER['HTTP_AUTHORIZATION'] = 'airowp ' . $this->encode( 'admin', 'wrong pass' ); + + $error = \Mockery::mock( 'WP_Error' ); + + Functions\expect( 'wp_authenticate_application_password' )->once()->andReturn( $error ); + + $this->assertFalse( ( new AppPasswordHeaderAuth() )->determine_current_user( false ) ); + } + + public function test_passes_through_without_a_credential(): void { + Functions\expect( 'wp_authenticate_application_password' )->never(); + + $this->assertFalse( ( new AppPasswordHeaderAuth() )->determine_current_user( false ) ); + } + + /** + * Site-wide disablement of Application Passwords must be respected, and + * checked before the header is even read. + * + * @return void + */ + public function test_respects_application_passwords_being_unavailable(): void { + $_SERVER['HTTP_AUTHORIZATION'] = 'airowp ' . $this->encode( 'admin', 'abcd EFGH' ); + + Functions\when( 'wp_is_application_passwords_available' )->justReturn( false ); + Functions\expect( 'wp_authenticate_application_password' )->never(); + + $this->assertFalse( ( new AppPasswordHeaderAuth() )->determine_current_user( false ) ); + } + + public function test_setup_registers_both_filters(): void { + Filters\expectAdded( 'determine_current_user' )->once(); + Filters\expectAdded( 'application_password_is_api_request' )->once(); + + ( new AppPasswordHeaderAuth() )->setup(); + + $this->addToAssertionCount( 1 ); + } + + /** + * The api-request assertion is what makes any of this work: WP::main() resolves + * the current user before REST_REQUEST is defined, so core would otherwise skip + * validating the credential entirely. + * + * @return void + */ + public function test_asserts_api_request_when_our_credential_is_present(): void { + $_SERVER['HTTP_AUTHORIZATION'] = 'airowp ' . $this->encode( 'admin', 'abcd EFGH' ); + + $this->assertTrue( ( new AppPasswordHeaderAuth() )->treat_as_api_request( false ) ); + } + + /** + * ...but it must never change how any other request is treated, which is what + * keeps Basic auth behaving exactly as core intends everywhere else. + * + * @return void + */ + public function test_leaves_api_request_untouched_without_our_credential(): void { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer some.oauth.token'; + + $auth = new AppPasswordHeaderAuth(); + + $this->assertFalse( $auth->treat_as_api_request( false ) ); + $this->assertTrue( $auth->treat_as_api_request( true ), 'must never demote' ); + } +} diff --git a/tests/Unit/Mcp/Infrastructure/RouteAccessTest.php b/tests/Unit/Mcp/Infrastructure/RouteAccessTest.php new file mode 100644 index 0000000..9463dcb --- /dev/null +++ b/tests/Unit/Mcp/Infrastructure/RouteAccessTest.php @@ -0,0 +1,206 @@ +saved_request_uri = $_SERVER['REQUEST_URI'] ?? null; + unset( $_SERVER['REQUEST_URI'] ); + + Functions\when( 'wp_unslash' )->returnArg(); + Functions\when( 'sanitize_text_field' )->returnArg(); + Functions\when( 'rest_get_url_prefix' )->justReturn( 'wp-json' ); + Functions\when( 'wp_parse_url' )->alias( + static function ( $url, $component = -1 ) { + return parse_url( $url, $component ); + } + ); + } + + /** + * Restore superglobal state. + */ + protected function tearDown(): void { + if ( null === $this->saved_request_uri ) { + unset( $_SERVER['REQUEST_URI'] ); + } else { + $_SERVER['REQUEST_URI'] = $this->saved_request_uri; + } + + parent::tearDown(); + } + + /** + * setup() must register both compatibility filters. + */ + public function test_setup_registers_both_filters(): void { + Filters\expectAdded( 'application_password_is_api_request' )->once(); + Filters\expectAdded( 'gdl_unrestricted_rest_endpoints' )->once(); + + ( new RouteAccess() )->setup(); + + $this->addToAssertionCount( 1 ); + } + + /** + * The pretty-permalink MCP URL must be treated as an API request. + */ + public function test_treats_pretty_permalink_mcp_uri_as_api_request(): void { + $_SERVER['REQUEST_URI'] = '/wp-json/airo-wp/v1/mcp/streamable'; + + $this->assertTrue( ( new RouteAccess() )->treat_mcp_route_as_api_request( false ) ); + } + + /** + * The ?rest_route= fallback form must also be treated as an API request. + */ + public function test_treats_rest_route_query_form_as_api_request(): void { + $_SERVER['REQUEST_URI'] = '/index.php?rest_route=/airo-wp/v1/mcp/streamable'; + + $this->assertTrue( ( new RouteAccess() )->treat_mcp_route_as_api_request( false ) ); + } + + /** + * A percent-encoded route must still match, since clients may encode the slash. + */ + public function test_treats_urlencoded_mcp_uri_as_api_request(): void { + $_SERVER['REQUEST_URI'] = '/index.php?rest_route=%2Fairo-wp%2Fv1%2Fmcp%2Fstreamable'; + + $this->assertTrue( ( new RouteAccess() )->treat_mcp_route_as_api_request( false ) ); + } + + /** + * Unrelated routes must keep core's own answer rather than being forced true. + */ + public function test_passes_through_for_unrelated_uri(): void { + $_SERVER['REQUEST_URI'] = '/wp-json/wp/v2/posts'; + + $this->assertFalse( ( new RouteAccess() )->treat_mcp_route_as_api_request( false ) ); + } + + /** + * URIs that merely CONTAIN the route string must not match. Each of these was + * accepted by the previous unanchored substring search, which let an arbitrary + * URL trigger Application Password validation off the MCP endpoint. + * + * @return array + */ + public function non_mcp_uris(): array { + return array( + 'route string in a query value' => array( '/wp-json/wp/v2/posts?x=airo-wp/v1/mcp' ), + 'route string in a path segment' => array( '/page/airo-wp/v1/mcp' ), + 'longer sibling segment' => array( '/wp-json/airo-wp/v1/mcp-decoy' ), + 'sibling under rest_route' => array( '/index.php?rest_route=/airo-wp/v1/mcpx' ), + 'different namespace' => array( '/wp-json/other/v1/mcp' ), + 'percent-encoded path traversal' => array( '/wp-json/foo%2Fairo-wp/v1/mcp' ), + ); + } + + /** + * @dataProvider non_mcp_uris + * + * @param string $uri Request URI. + * @return void + */ + public function test_does_not_treat_non_mcp_uri_as_api_request( string $uri ): void { + $_SERVER['REQUEST_URI'] = $uri; + + $this->assertFalse( + ( new RouteAccess() )->is_mcp_request(), + 'Must not match: ' . $uri + ); + } + + /** + * The exact route with no sub-route must still match. + */ + public function test_treats_bare_mcp_route_as_api_request(): void { + $_SERVER['REQUEST_URI'] = '/wp-json/airo-wp/v1/mcp'; + + $this->assertTrue( ( new RouteAccess() )->is_mcp_request() ); + } + + /** + * A filtered REST prefix must be honoured rather than assumed to be wp-json. + */ + public function test_honours_a_filtered_rest_prefix(): void { + Functions\when( 'rest_get_url_prefix' )->justReturn( 'api' ); + + $_SERVER['REQUEST_URI'] = '/api/airo-wp/v1/mcp/streamable'; + + $this->assertTrue( ( new RouteAccess() )->is_mcp_request() ); + } + + /** + * A missing REQUEST_URI must not be treated as an MCP request. + */ + public function test_passes_through_when_request_uri_absent(): void { + $this->assertFalse( ( new RouteAccess() )->treat_mcp_route_as_api_request( false ) ); + } + + /** + * The filter must never downgrade a true value core already decided on. + */ + public function test_does_not_downgrade_an_existing_true_value(): void { + $_SERVER['REQUEST_URI'] = '/wp-json/wp/v2/posts'; + + $this->assertTrue( ( new RouteAccess() )->treat_mcp_route_as_api_request( true ) ); + } + + /** + * The MCP route prefix must be appended to GoDaddy Launch's allowlist. + */ + public function test_allow_mcp_route_appends_prefix(): void { + $this->assertSame( + array( '/wpaas/v1', '/airo-wp/v1/mcp' ), + ( new RouteAccess() )->allow_mcp_route( array( '/wpaas/v1' ) ) + ); + } + + /** + * A non-array value from a misbehaving filter must pass through untouched. + */ + public function test_allow_mcp_route_returns_non_array_unchanged(): void { + $this->assertNull( ( new RouteAccess() )->allow_mcp_route( null ) ); + } + + /** + * The allowlist entry must not cover the whole namespace, because + * airo-wp/v1 also carries a deliberately public form-submission endpoint + * that coming-soon mode is expected to keep shielded. + */ + public function test_allowlist_entry_is_scoped_to_the_mcp_route(): void { + $endpoints = ( new RouteAccess() )->allow_mcp_route( array() ); + + $this->assertSame( array( '/airo-wp/v1/mcp' ), $endpoints ); + $this->assertNotContains( '/airo-wp/v1', $endpoints ); + } +} diff --git a/tests/Unit/Mcp/PackageTest.php b/tests/Unit/Mcp/PackageTest.php index da0c774..1464315 100644 --- a/tests/Unit/Mcp/PackageTest.php +++ b/tests/Unit/Mcp/PackageTest.php @@ -10,6 +10,7 @@ namespace GoDaddy\WordPress\Plugins\AiroWp\Tests\Unit\Mcp; use Brain\Monkey\Actions; +use Brain\Monkey\Filters; use GoDaddy\WordPress\Plugins\AiroWp\Container; use GoDaddy\WordPress\Plugins\AiroWp\Internal\DependencyManagement\TestingContainer; use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Infrastructure\AbilitiesApiProxy; @@ -42,6 +43,37 @@ public function test_init_calls_proxy_setup(): void { $this->addToAssertionCount( 1 ); } + /** + * Init() wires both auth compatibility layers. + * + * application_password_is_api_request is asserted TWICE, deliberately. Two + * classes assert api-request status for different reasons and neither can + * substitute for the other: + * + * RouteAccess - anchored on the MCP route, so credentials the + * GoDaddy Launch platform resolves early (notably + * core's own Basic auth) are still validated + * AppPasswordHeaderAuth - keyed on the presence of an airowp credential, + * regardless of route + * + * If this count ever drops to one, check that the removed layer's case is + * genuinely covered rather than merely absent. + */ + public function test_init_sets_up_auth_compatibility_layers(): void { + $container = new Container( new TestingContainer( array() ) ); + + // Only these filters are asserted. The adapter's own add_action calls are + // singleton-guarded, so whether they fire depends on whether an earlier + // test already built McpAdapter in this process. + Filters\expectAdded( 'application_password_is_api_request' )->twice(); + Filters\expectAdded( 'gdl_unrestricted_rest_endpoints' )->once(); + Filters\expectAdded( 'determine_current_user' )->once(); + + Package::init( $container ); + + $this->addToAssertionCount( 1 ); + } + /** * Package::init() is callable (satisfies PackageInterface). */ diff --git a/tests/e2e/functional/environment/serve-wp.sh b/tests/e2e/functional/environment/serve-wp.sh index 3d7ebfd..8af6199 100755 --- a/tests/e2e/functional/environment/serve-wp.sh +++ b/tests/e2e/functional/environment/serve-wp.sh @@ -32,6 +32,17 @@ add_filter( 'option_home', static fn() => '$WP_SITE_URL', PHP_INT_MAX ); add_filter( 'option_siteurl', static fn() => '$WP_SITE_URL', PHP_INT_MAX ); PHP +# Application Passwords are how the airowp Authorization scheme authenticates, but +# core refuses to issue them unless is_ssl() or the environment type is 'local' +# (wp_is_application_passwords_supported), and this server runs over plain HTTP -- +# so POST /wp/v2/users/me/application-passwords answers 501 without this. Using +# core's own filter rather than faking SSL keeps the rest of the request honest. +cat > "$WP_DIR/wp-content/mu-plugins/airo-wp-e2e-app-passwords.php" <<'PHP' + "$WP_DIR/wp-content/mu-plugins/airo-wp-e2e-disable-updates.php" <<'PHP' diff --git a/tests/e2e/functional/specs/mcp-airowp-auth.spec.ts b/tests/e2e/functional/specs/mcp-airowp-auth.spec.ts new file mode 100644 index 0000000..1a49f55 --- /dev/null +++ b/tests/e2e/functional/specs/mcp-airowp-auth.spec.ts @@ -0,0 +1,178 @@ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * End-to-end coverage for the `airowp` Authorization scheme. + * + * This is the only test in the suite that exercises a real Application Password + * over the MCP route. Every other MCP spec authenticates with the browser cookie + * plus X-WP-Nonce, which is the same-origin path — so none of them would notice if + * credential-based auth broke entirely. That matters here because the whole point + * of this scheme is to serve hosted clients, which have no cookie. + * + * The credential is created through core's own REST endpoint rather than fabricated, + * so the test fails if core's Application Password behaviour changes underneath us. + */ + +const MCP_ENDPOINT = '/wp-json/airo-wp/v1/mcp/streamable'; +const APP_PASSWORDS_ENDPOINT = '/wp-json/wp/v2/users/me/application-passwords'; + +const initialize = ( id = 1 ) => ( { + jsonrpc: '2.0', + id, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'playwright-airowp-auth', version: '1.0' }, + }, +} ); + +test.describe( 'MCP server › airowp Authorization scheme', () => { + let credential: string; + let appPasswordUuid: string; + + test.beforeAll( async ( { requestUtils } ) => { + const created = await requestUtils.request.post( APP_PASSWORDS_ENDPOINT, { + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': requestUtils.storageState!.nonce, + }, + data: { name: `airowp-e2e-${ Date.now() }` }, + } ); + + // 501 means core has Application Passwords switched off, which it does + // whenever the site is not HTTPS and the environment type is not 'local'. + // That is a property of the environment rather than of this feature, so skip + // loudly instead of reporting a failure that says nothing useful. + test.skip( + created.status() === 501, + 'Application Passwords unavailable in this environment (core requires HTTPS or a local environment type)' + ); + + expect( + created.status(), + `could not create an Application Password (got ${ created.status() })` + ).toBe( 201 ); + + const body = await created.json(); + appPasswordUuid = body.uuid; + + // core returns the password once, space-separated, and never again. + expect( body.password ).toBeTruthy(); + credential = Buffer.from( `admin:${ body.password }` ).toString( 'base64' ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + if ( ! appPasswordUuid ) { + return; + } + await requestUtils.request.delete( + `${ APP_PASSWORDS_ENDPOINT }/${ appPasswordUuid }`, + { headers: { 'X-WP-Nonce': requestUtils.storageState!.nonce } } + ); + } ); + + /** + * A context with genuinely no cookies. + * + * `storageState: undefined` is load-bearing, not tidiness. Without it the context + * inherits the admin storageState from the Playwright config, and that cookie + * changes the outcome in a way that silently invalidates the whole test: core's + * wp_validate_logged_in_cookie is also on determine_current_user at priority 20 + * but registered in default-filters.php, so it resolves the user before this + * plugin's filter and the credential is never exercised. Worse, a valid cookie + * sets $wp_rest_auth_cookie = true, which disables the escape hatch in + * rest_cookie_check_errors() — so with no X-WP-Nonce core then deliberately calls + * wp_set_current_user( 0 ) ("act as if it's an unauthenticated request") and the + * request 401s having never touched the Authorization header at all. + */ + const anonymousContext = async ( playwright: any, authorization?: string ) => + playwright.request.newContext( { + baseURL: process.env.WP_BASE_URL, + storageState: undefined, + extraHTTPHeaders: authorization ? { Authorization: authorization } : {}, + } ); + + test( 'authenticates with the custom airowp scheme', async ( { playwright } ) => { + const ctx = await anonymousContext( playwright, `airowp ${ credential }` ); + const response = await ctx.post( MCP_ENDPOINT, { + headers: { 'Content-Type': 'application/json' }, + data: initialize(), + } ); + const status = response.status(); + const body = await response.text(); + await ctx.dispose(); + + expect( status, `expected 200 but got ${ status }: ${ body }` ).toBe( 200 ); + expect( response.headers()[ 'mcp-session-id' ] ).toBeTruthy(); + } ); + + test( 'authenticates with the Bearer airowp_ form', async ( { playwright } ) => { + const ctx = await anonymousContext( playwright, `Bearer airowp_${ credential }` ); + const response = await ctx.post( MCP_ENDPOINT, { + headers: { 'Content-Type': 'application/json' }, + data: initialize( 2 ), + } ); + const status = response.status(); + const body = await response.text(); + await ctx.dispose(); + + expect( status, `expected 200 but got ${ status }: ${ body }` ).toBe( 200 ); + } ); + + test( 'a tool call succeeds on the credential alone', async ( { playwright } ) => { + const ctx = await anonymousContext( playwright, `airowp ${ credential }` ); + + const init = await ctx.post( MCP_ENDPOINT, { + headers: { 'Content-Type': 'application/json' }, + data: initialize( 3 ), + } ); + expect( init.status() ).toBe( 200 ); + const sessionId = init.headers()[ 'mcp-session-id' ]; + + // tools/list is enough: it proves the transport's own 'read' capability + // check passed for the user this credential resolved to. + const listed = await ctx.post( MCP_ENDPOINT, { + headers: { + 'Content-Type': 'application/json', + 'Mcp-Session-Id': sessionId, + }, + data: { jsonrpc: '2.0', id: 4, method: 'tools/list', params: {} }, + } ); + const status = listed.status(); + const body = await listed.text(); + await ctx.dispose(); + + expect( status, `expected 200 but got ${ status }: ${ body }` ).toBe( 200 ); + expect( body ).toContain( 'tools' ); + } ); + + test( 'rejects a wrong password under the airowp scheme', async ( { playwright } ) => { + const wrong = Buffer.from( 'admin:wrong wrong wrong wrong' ).toString( 'base64' ); + const ctx = await anonymousContext( playwright, `airowp ${ wrong }` ); + const response = await ctx.post( MCP_ENDPOINT, { + headers: { 'Content-Type': 'application/json' }, + data: initialize( 5 ), + } ); + const status = response.status(); + await ctx.dispose(); + + expect( status, `expected 401 but got ${ status }` ).toBe( 401 ); + } ); + + /** + * A credential without the marker must not be honoured — this is what keeps a + * future OAuth Bearer token from being mistaken for one of ours. + */ + test( 'ignores the same credential sent as a plain Bearer token', async ( { playwright } ) => { + const ctx = await anonymousContext( playwright, `Bearer ${ credential }` ); + const response = await ctx.post( MCP_ENDPOINT, { + headers: { 'Content-Type': 'application/json' }, + data: initialize( 6 ), + } ); + const status = response.status(); + await ctx.dispose(); + + expect( status, `expected 401 but got ${ status }` ).toBe( 401 ); + } ); +} );