| 1 |
<?php |
| 2 |
/** |
| 3 |
* Static OAuth discovery files — the /.well-known/ escape hatch. |
| 4 |
* |
| 5 |
* Some hosts (SiteGround shared hosting confirmed, #374) resolve every |
| 6 |
* request under the site root's /.well-known/ directory at their Nginx edge |
| 7 |
* as a physical path. No rewrite, no Apache, no WordPress — a missing file is |
| 8 |
* a server-level 404, and no amount of permalink flushing can fix it because |
| 9 |
* the request never reaches PHP. |
| 10 |
* |
| 11 |
* The primary mitigation is that the 401 challenge now advertises a |
| 12 |
* REST-served metadata URL (Mcp_OAuth::resource_metadata_url), which such |
| 13 |
* hosts do pass through. But a client that ignores the challenge pointer and |
| 14 |
* derives the RFC 9728 / RFC 8414 path-insert URLs itself still fetches |
| 15 |
* /.well-known/oauth-*\/thinkrank/mcp — so this class turns the host's own |
| 16 |
* behaviour into the fix: if Nginx insists on serving physical files from |
| 17 |
* /.well-known/, we write the discovery documents AS physical files. |
| 18 |
* |
| 19 |
* Publishing is best-effort and deliberately conservative: |
| 20 |
* - only for a site installed at the domain root (on a subdirectory |
| 21 |
* install the domain's /.well-known/ belongs to a different tree); |
| 22 |
* - only invoked from the self-test, and only after it has measured that |
| 23 |
* the dynamic route is dead (on healthy hosts the rewrites keep serving |
| 24 |
* and no files are written, so metadata can never go stale there); |
| 25 |
* - content is refreshed on every publish, so each self-test run keeps the |
| 26 |
* files current with home_url()/settings changes. |
| 27 |
* |
| 28 |
* Known limitation: the files are extensionless (the URL path has no .json), |
| 29 |
* so an edge server may send them without an application/json Content-Type. |
| 30 |
* Every client observed so far parses the body regardless, and a 200 with a |
| 31 |
* loose Content-Type strictly beats the 404 it replaces. |
| 32 |
* |
| 33 |
* @package ThinkRank\Mcp |
| 34 |
*/ |
| 35 |
|
| 36 |
declare(strict_types=1); |
| 37 |
|
| 38 |
namespace ThinkRank\Mcp; |
| 39 |
|
| 40 |
if ( ! defined( 'ABSPATH' ) ) { |
| 41 |
exit; // Exit if accessed directly. |
| 42 |
} |
| 43 |
|
| 44 |
/** |
| 45 |
* Writes/removes physical /.well-known/ OAuth discovery documents. |
| 46 |
*/ |
| 47 |
final class Mcp_Static_Discovery { |
| 48 |
|
| 49 |
/** |
| 50 |
* The path-insert discovery files, relative to the site root. |
| 51 |
* |
| 52 |
* Only the path-suffixed forms: the bare root forms would need |
| 53 |
* `oauth-protected-resource` to be a file AND a directory at once, and |
| 54 |
* spec-compliant clients derive the suffixed form from our path-based |
| 55 |
* issuer anyway. |
| 56 |
* |
| 57 |
* @return array<string,array<string,mixed>> relative path => document. |
| 58 |
*/ |
| 59 |
private static function files(): array { |
| 60 |
$suffix = Mcp_Pairing::SITE_ENDPOINT_PATH; // thinkrank/mcp. |
| 61 |
return [ |
| 62 |
'.well-known/oauth-protected-resource/' . $suffix => Mcp_OAuth::protected_resource_metadata(), |
| 63 |
'.well-known/oauth-authorization-server/' . $suffix => Mcp_OAuth::authorization_server_metadata(), |
| 64 |
]; |
| 65 |
} |
| 66 |
|
| 67 |
/** |
| 68 |
* Whether static publishing is even applicable here. |
| 69 |
* |
| 70 |
* @return bool |
| 71 |
*/ |
| 72 |
public static function applicable(): bool { |
| 73 |
// Subdirectory install: the domain root (where /.well-known/ lives) |
| 74 |
// is not ours to write into. |
| 75 |
return '/' === ( wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ?? '/' ); |
| 76 |
} |
| 77 |
|
| 78 |
/** |
| 79 |
* Write the discovery documents as physical files. Returns true only when |
| 80 |
* every file exists with current content afterwards. |
| 81 |
* |
| 82 |
* @param string|null $base Base directory (defaults to ABSPATH); a |
| 83 |
* parameter so tests can point it at a sandbox. |
| 84 |
* @return bool |
| 85 |
*/ |
| 86 |
public static function publish( ?string $base = null ): bool { |
| 87 |
if ( null === $base && ! self::applicable() ) { |
| 88 |
return false; |
| 89 |
} |
| 90 |
$base = trailingslashit( $base ?? ABSPATH ); |
| 91 |
|
| 92 |
$all_current = true; |
| 93 |
foreach ( self::files() as $relative => $document ) { |
| 94 |
$path = $base . $relative; |
| 95 |
$json = (string) wp_json_encode( $document ); |
| 96 |
|
| 97 |
// Already current — don't touch the filesystem. |
| 98 |
if ( is_file( $path ) && (string) file_get_contents( $path ) === $json ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file freshness check. |
| 99 |
continue; |
| 100 |
} |
| 101 |
|
| 102 |
if ( ! wp_mkdir_p( dirname( $path ) ) ) { |
| 103 |
$all_current = false; |
| 104 |
continue; |
| 105 |
} |
| 106 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- small static file at a fixed path; WP_Filesystem adds credential prompts this non-interactive path cannot answer. |
| 107 |
if ( false === file_put_contents( $path, $json ) ) { |
| 108 |
$all_current = false; |
| 109 |
} |
| 110 |
} |
| 111 |
|
| 112 |
return $all_current; |
| 113 |
} |
| 114 |
|
| 115 |
/** |
| 116 |
* Remove the published files (and their directories when empty). Called |
| 117 |
* on plugin deactivation so a static copy cannot keep advertising an |
| 118 |
* OAuth server that is no longer running. |
| 119 |
* |
| 120 |
* @param string|null $base Base directory (defaults to ABSPATH). |
| 121 |
* @return void |
| 122 |
*/ |
| 123 |
public static function remove( ?string $base = null ): void { |
| 124 |
$base = trailingslashit( $base ?? ABSPATH ); |
| 125 |
|
| 126 |
foreach ( array_keys( self::files() ) as $relative ) { |
| 127 |
$path = $base . $relative; |
| 128 |
if ( is_file( $path ) ) { |
| 129 |
wp_delete_file( $path ); |
| 130 |
} |
| 131 |
// Prune now-empty directories up to .well-known itself, but never |
| 132 |
// .well-known — other software (ACME, Apple Pay) shares it. |
| 133 |
$dir = dirname( $path ); |
| 134 |
$stop = untrailingslashit( $base . '.well-known' ); |
| 135 |
while ( $dir !== $stop && is_dir( $dir ) && self::dir_is_empty( $dir ) ) { |
| 136 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- removing only directories this class created, verified empty. |
| 137 |
rmdir( $dir ); |
| 138 |
$dir = dirname( $dir ); |
| 139 |
} |
| 140 |
} |
| 141 |
} |
| 142 |
|
| 143 |
/** |
| 144 |
* Whether a directory contains nothing. |
| 145 |
* |
| 146 |
* @param string $dir Directory path. |
| 147 |
* @return bool |
| 148 |
*/ |
| 149 |
private static function dir_is_empty( string $dir ): bool { |
| 150 |
$entries = scandir( $dir ); |
| 151 |
return is_array( $entries ) && count( $entries ) <= 2; // Only . and .. |
| 152 |
} |
| 153 |
} |
| 154 |
|