PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 All 128 releases
extendify / app / Mcp / OAuth / Metadata.php

Metadata.php in Extendify 3.2.2, at app/Mcp/OAuth/Metadata.php

172 lines 5.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * The OAuth discovery documents for the MCP endpoint.
5 */
6
7 namespace Extendify\Mcp\OAuth;
8
9 defined('ABSPATH') || die('No direct access.');
10
11 use Extendify\Config;
12 use Extendify\Mcp\Allowed;
13
14 /**
15 * The site plays both OAuth roles: the MCP endpoint is the protected resource,
16 * and extendify/v1/oauth is the authorization server.
17 *
18 * Nothing is served at the site root. A client finds the resource document
19 * through the pointer on a 401, and the server document by appending
20 * /.well-known/openid-configuration to the issuer once the root forms 404.
21 */
22 class Metadata
23 {
24 // phpcs:disable PSR12.Properties.ConstantVisibility.NotFound
25 const AUTHORIZE_PAGE = 'extendify-mcp-authorize';
26
27 const RESOURCE_DOCUMENT = 'oauth-protected-resource';
28 // phpcs:enable PSR12.Properties.ConstantVisibility.NotFound
29
30 /**
31 * @return void
32 */
33 public static function register()
34 {
35 \add_action('rest_api_init', [self::class, 'registerRoute']);
36 }
37
38 /**
39 * @return void
40 */
41 public static function registerRoute()
42 {
43 \register_rest_route(
44 Config::$slug . '/' . Config::$apiVersion,
45 '/oauth/\.well-known/'
46 . '(?P<document>oauth-protected-resource|openid-configuration|oauth-authorization-server)',
47 [
48 'methods' => 'GET',
49 'callback' => [self::class, 'serve'],
50 // Discovery is read before any credential exists.
51 'permission_callback' => '__return_true',
52 'show_in_index' => false,
53 ]
54 );
55 }
56
57 /**
58 * @param \WP_REST_Request $request - The incoming request.
59 * @return \WP_REST_Response
60 */
61 public static function serve(\WP_REST_Request $request)
62 {
63 $document = $request->get_param('document') === self::RESOURCE_DOCUMENT
64 ? self::protectedResource()
65 : self::authorizationServer();
66
67 return new \WP_REST_Response($document);
68 }
69
70 /**
71 * @param boolean $presented - Whether the request carried a credential.
72 * @return string
73 */
74 public static function challenge($presented)
75 {
76 $params = $presented ? ['error="invalid_token"'] : [];
77 $params[] = 'resource_metadata="' . self::url('oauth/.well-known/' . self::RESOURCE_DOCUMENT) . '"';
78 $params[] = 'scope="' . (Allowed::writable() ? 'read write' : 'read') . '"';
79
80 return 'Bearer ' . implode(', ', $params);
81 }
82
83 /**
84 * @return string
85 */
86 public static function resource()
87 {
88 return self::url('mcp');
89 }
90
91 /**
92 * @param string $url - The resource a client named.
93 * @return boolean
94 */
95 public static function isResource($url)
96 {
97 return self::canonical($url) === self::canonical(self::resource());
98 }
99
100 /**
101 * RFC 3986: scheme and host compare case-insensitively, the path does not.
102 *
103 * @param string $url - A URL to compare.
104 * @return string
105 */
106 private static function canonical($url)
107 {
108 $parts = \wp_parse_url($url);
109 if (!is_array($parts) || empty($parts['host'])) {
110 return '';
111 }
112
113 return strtolower(($parts['scheme'] ?? '') . '://' . $parts['host'])
114 . (isset($parts['port']) ? ':' . $parts['port'] : '')
115 . \untrailingslashit($parts['path'] ?? '')
116 . (isset($parts['query']) ? '?' . $parts['query'] : '');
117 }
118
119 /**
120 * @return string
121 */
122 public static function issuer()
123 {
124 return self::url('oauth');
125 }
126
127 /**
128 * @return array
129 */
130 private static function protectedResource()
131 {
132 return [
133 'resource' => self::resource(),
134 'authorization_servers' => [self::issuer()],
135 'scopes_supported' => ['read', 'write'],
136 'bearer_methods_supported' => ['header'],
137 'resource_name' => \wp_specialchars_decode(\get_option('blogname'), ENT_QUOTES),
138 ];
139 }
140
141 /**
142 * offline_access is what makes a client ask for a refresh token.
143 *
144 * @return array
145 */
146 private static function authorizationServer()
147 {
148 return [
149 'issuer' => self::issuer(),
150 'authorization_endpoint' => \admin_url('admin.php?page=' . self::AUTHORIZE_PAGE),
151 'token_endpoint' => self::url('oauth/token'),
152 'registration_endpoint' => self::url('oauth/register'),
153 'response_types_supported' => ['code'],
154 'grant_types_supported' => ['authorization_code', 'refresh_token'],
155 'code_challenge_methods_supported' => ['S256'],
156 'token_endpoint_auth_methods_supported' => ['none'],
157 'client_id_metadata_document_supported' => true,
158 'authorization_response_iss_parameter_supported' => true,
159 'scopes_supported' => ['read', 'write', 'offline_access'],
160 ];
161 }
162
163 /**
164 * @param string $path - The route under the plugin's REST namespace.
165 * @return string
166 */
167 private static function url($path)
168 {
169 return \rest_url(Config::$slug . '/' . Config::$apiVersion . '/' . $path);
170 }
171 }
172