PluginProbe
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF / trunk
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF vtrunk
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.9 2.2.8 trunk 1.10 1.3.3 1.3.4 1.3.5 1.3.5.1 1.3.5.2 1.3.6 1.3.6.1 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 All 103 releases
imagify / vendor / wp-media / mcp-oauth / inc / Auth / CimdResolver.php

CimdResolver.php in Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF trunk, at vendor/wp-media/mcp-oauth/inc/Auth/CimdResolver.php

364 lines 10.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Client ID Metadata Document (CIMD) resolver.
4 *
5 * Replaces Dynamic Client Registration (RFC 7591). Instead of pre-registering
6 * a client and minting a client_id/secret, the client presents an HTTPS URL as
7 * its client_id; this resolver dereferences that URL, validates the returned
8 * metadata document, and produces a normalised client record compatible with
9 * the one AuthorizeEndpoint previously read from ClientRegistration::get_client().
10 *
11 * CIMD is the default client-registration model in the 2025-11-25 MCP
12 * specification (IETF OAuth WG, adopted October 2025).
13 */
14
15 declare(strict_types=1);
16
17 namespace WPMedia\MCP\OAuth\Auth;
18
19 use WPMedia\MCP\OAuth\Logging\McpLogger;
20
21 class CimdResolver {
22 /**
23 * Maximum size (bytes) of a metadata document we will read.
24 */
25 const MAX_DOCUMENT_BYTES = 5120;
26
27 /**
28 * HTTP request timeout (seconds) for fetching a document.
29 */
30 const FETCH_TIMEOUT = 5;
31
32 /**
33 * Transient key prefix for cached, validated documents.
34 */
35 const CACHE_PREFIX = 'mcp_cimd_';
36
37 /**
38 * Grant types this server supports.
39 */
40 const SUPPORTED_GRANT_TYPES = [ 'authorization_code', 'refresh_token' ];
41
42 /**
43 * Trusted-publisher verifier.
44 *
45 * @var ClaudeClientVerifier
46 */
47 private ClaudeClientVerifier $verifier;
48
49 /**
50 * Constructor.
51 *
52 * @param ClaudeClientVerifier $verifier Trusted-publisher verifier.
53 */
54 public function __construct( ClaudeClientVerifier $verifier ) {
55 $this->verifier = $verifier;
56 }
57
58 /**
59 * Resolve a client_id URL into a normalised client record.
60 *
61 * @param string $client_id The client_id URL presented by the client.
62 * @return array<string, mixed>|null Normalised client record, or null on any failure.
63 */
64 public function resolve( string $client_id ): ?array {
65 if ( ! $this->is_valid_client_id_url( $client_id ) ) {
66 McpLogger::log( 'CIMD', 'rejected: invalid client_id url', [ 'client_id' => $client_id ] );
67 return null;
68 }
69
70 // Gate the network fetch on the trusted-publisher host allowlist. The
71 // authorize endpoint is unauthenticated, so dereferencing an arbitrary
72 // client_id URL would let any caller use this server as an outbound
73 // fetch proxy and pollute the transient cache. Only allowlisted hosts
74 // are ever fetched; exact client_id verification still happens later.
75 if ( ! $this->verifier->is_trusted_host( $client_id ) ) {
76 McpLogger::log( 'CIMD', 'rejected: client_id host not in trusted-publisher allowlist', [ 'client_id' => $client_id ] );
77 return null;
78 }
79
80 $cached = $this->cache_get( $client_id );
81 if ( null !== $cached ) {
82 return $cached;
83 }
84
85 $fetched = $this->fetch_document( $client_id );
86 if ( null === $fetched ) {
87 return null;
88 }
89
90 $record = $this->validate_document( $fetched['doc'], $client_id );
91 if ( null === $record ) {
92 return null;
93 }
94
95 $verification = $this->verifier->verify( $client_id, $fetched['doc'] );
96 $record['verified'] = (bool) $verification['verified'];
97 $record['publisher'] = (string) $verification['publisher'];
98
99 $this->cache_set( $client_id, $record, $fetched['ttl'] );
100
101 return $record;
102 }
103
104 /**
105 * Validate the shape of a client_id URL per the CIMD spec.
106 *
107 * Requires an HTTPS URL with a path and no fragment or userinfo.
108 *
109 * @param string $client_id The client_id URL.
110 * @return bool
111 */
112 private function is_valid_client_id_url( string $client_id ): bool {
113 if ( '' === $client_id ) {
114 return false;
115 }
116
117 $parts = wp_parse_url( $client_id );
118
119 if ( ! is_array( $parts ) ) {
120 return false;
121 }
122
123 if ( 'https' !== ( $parts['scheme'] ?? '' ) ) {
124 return false;
125 }
126
127 if ( empty( $parts['host'] ) ) {
128 return false;
129 }
130
131 $path = (string) ( $parts['path'] ?? '' );
132 if ( '' === $path || '/' === $path ) {
133 return false;
134 }
135
136 if ( isset( $parts['fragment'] ) || isset( $parts['user'] ) || isset( $parts['pass'] ) ) {
137 return false;
138 }
139
140 return true;
141 }
142
143 /**
144 * Fetch a metadata document with SSRF guards and a size cap.
145 *
146 * @param string $url The client_id URL.
147 * @return array{doc: array<string, mixed>, ttl: int}|null Decoded document and cache TTL, or null on failure.
148 */
149 private function fetch_document( string $url ) {
150 $response = wp_safe_remote_get(
151 $url,
152 [
153 'timeout' => self::FETCH_TIMEOUT,
154 'redirection' => 0,
155 'limit_response_size' => self::MAX_DOCUMENT_BYTES,
156 'headers' => [ 'Accept' => 'application/json' ],
157 ]
158 );
159
160 if ( is_wp_error( $response ) ) {
161 McpLogger::log(
162 'CIMD',
163 'rejected: fetch failed',
164 [
165 'client_id' => $url,
166 'error' => $response->get_error_message(),
167 ]
168 );
169 return null;
170 }
171
172 $status = (int) wp_remote_retrieve_response_code( $response );
173 if ( 200 !== $status ) {
174 McpLogger::log(
175 'CIMD',
176 'rejected: non-200 status',
177 [
178 'client_id' => $url,
179 'status' => $status,
180 ]
181 );
182 return null;
183 }
184
185 $body = (string) wp_remote_retrieve_body( $response );
186 if ( strlen( $body ) > self::MAX_DOCUMENT_BYTES ) {
187 McpLogger::log(
188 'CIMD',
189 'rejected: document too large',
190 [
191 'client_id' => $url,
192 'bytes' => strlen( $body ),
193 ]
194 );
195 return null;
196 }
197
198 $content_type = (string) wp_remote_retrieve_header( $response, 'content-type' );
199 if ( '' !== $content_type && false === strpos( $content_type, 'application/json' ) ) {
200 McpLogger::log(
201 'CIMD',
202 'warning: unexpected content-type',
203 [
204 'client_id' => $url,
205 'content_type' => $content_type,
206 ]
207 );
208 }
209
210 $doc = json_decode( $body, true );
211 if ( ! is_array( $doc ) || empty( $doc ) ) {
212 McpLogger::log( 'CIMD', 'rejected: body is not a JSON object', [ 'client_id' => $url ] );
213 return null;
214 }
215
216 return [
217 'doc' => $doc,
218 'ttl' => $this->parse_ttl( (string) wp_remote_retrieve_header( $response, 'cache-control' ) ),
219 ];
220 }
221
222 /**
223 * Validate a metadata document and build a normalized client record.
224 *
225 * @param array<string, mixed> $doc The decoded metadata document.
226 * @param string $url The client_id URL it was fetched from.
227 * @return array<string, mixed>|null Normalized record, or null on validation failure.
228 */
229 private function validate_document( array $doc, string $url ): ?array {
230 // The document's client_id MUST exactly equal the document URL.
231 $doc_client_id = isset( $doc['client_id'] ) && is_string( $doc['client_id'] ) ? $doc['client_id'] : '';
232 if ( '' === $doc_client_id || $url !== $doc_client_id ) {
233 McpLogger::log(
234 'CIMD',
235 'rejected: client_id mismatch',
236 [
237 'client_id' => $url,
238 'document_client_id' => $doc_client_id,
239 ]
240 );
241 return null;
242 }
243
244 // Only public clients are supported (no shared secret to authenticate).
245 $auth_method = isset( $doc['token_endpoint_auth_method'] ) ? (string) $doc['token_endpoint_auth_method'] : 'none';
246 if ( 'none' !== $auth_method ) {
247 McpLogger::log(
248 'CIMD',
249 'rejected: unsupported token_endpoint_auth_method',
250 [
251 'client_id' => $url,
252 'method' => $auth_method,
253 ]
254 );
255 return null;
256 }
257
258 // redirect_uris is required.
259 $redirect_uris = $doc['redirect_uris'] ?? [];
260 if ( ! is_array( $redirect_uris ) || empty( $redirect_uris ) ) {
261 McpLogger::log( 'CIMD', 'rejected: missing redirect_uris', [ 'client_id' => $url ] );
262 return null;
263 }
264
265 $redirect_uris = array_values(
266 array_filter(
267 array_map( 'esc_url_raw', array_map( 'strval', $redirect_uris ) )
268 )
269 );
270
271 if ( empty( $redirect_uris ) ) {
272 McpLogger::log( 'CIMD', 'rejected: no valid redirect_uris after sanitisation', [ 'client_id' => $url ] );
273 return null;
274 }
275
276 // grant_types: intersect with what we support; must include authorization_code.
277 $requested_grants = isset( $doc['grant_types'] ) && is_array( $doc['grant_types'] )
278 ? array_map( 'strval', $doc['grant_types'] )
279 : self::SUPPORTED_GRANT_TYPES;
280
281 $grant_types = array_values( array_intersect( self::SUPPORTED_GRANT_TYPES, $requested_grants ) );
282 if ( ! in_array( 'authorization_code', $grant_types, true ) ) {
283 McpLogger::log(
284 'CIMD',
285 'rejected: authorization_code grant not offered',
286 [
287 'client_id' => $url,
288 'grant_types' => $requested_grants,
289 ]
290 );
291 return null;
292 }
293
294 $client_name = isset( $doc['client_name'] ) ? sanitize_text_field( (string) $doc['client_name'] ) : '';
295 if ( '' === $client_name ) {
296 $client_name = (string) wp_parse_url( $url, PHP_URL_HOST );
297 }
298
299 $client_uri = isset( $doc['client_uri'] ) && is_string( $doc['client_uri'] ) ? esc_url_raw( $doc['client_uri'] ) : '';
300
301 return [
302 'client_id' => $url,
303 'client_name' => $client_name,
304 'client_uri' => $client_uri,
305 'redirect_uris' => $redirect_uris,
306 'grant_types' => $grant_types,
307 'token_endpoint_auth_method' => 'none',
308 'source' => 'cimd',
309 'verified' => false,
310 'publisher' => '',
311 ];
312 }
313
314 /**
315 * Parse a max-age TTL from a Cache-Control header, clamped to a sane range.
316 *
317 * @param string $cache_control The Cache-Control header value.
318 * @return int TTL in seconds.
319 */
320 private function parse_ttl( string $cache_control ): int {
321 $default = HOUR_IN_SECONDS;
322
323 if ( '' !== $cache_control && preg_match( '/max-age\s*=\s*(\d+)/i', $cache_control, $matches ) ) {
324 $default = (int) $matches[1];
325 }
326
327 return (int) max( 300, min( $default, DAY_IN_SECONDS ) );
328 }
329
330 /**
331 * Build the transient cache key for a client_id URL.
332 *
333 * @param string $url The client_id URL.
334 * @return string
335 */
336 private function cache_key( string $url ): string {
337 return self::CACHE_PREFIX . md5( $url );
338 }
339
340 /**
341 * Retrieve a cached, validated client record.
342 *
343 * @param string $url The client_id URL.
344 * @return array<string, mixed>|null
345 */
346 private function cache_get( string $url ): ?array {
347 $cached = get_transient( $this->cache_key( $url ) );
348
349 return is_array( $cached ) ? $cached : null;
350 }
351
352 /**
353 * Cache a validated client record. Only successful documents are ever cached.
354 *
355 * @param string $url The client_id URL.
356 * @param array<string, mixed> $record The normalised record.
357 * @param int $ttl Cache TTL in seconds.
358 * @return void
359 */
360 private function cache_set( string $url, array $record, int $ttl ): void {
361 set_transient( $this->cache_key( $url ), $record, $ttl );
362 }
363 }
364