PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / trunk
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar vtrunk
3.3.1 3.3.0 3.2.14 3.2.13 3.2.12 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 trunk 0.2.5.5 0.2.5.6 0.2.5.7 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2.0 1.2.1 All 156 releases
notificationx / includes / MCP / OAuth.php

OAuth.php in NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar trunk, at includes/MCP/OAuth.php

532 lines 19.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Minimal OAuth 2.1 authorization server for the NotificationX MCP endpoint.
4 *
5 * Implements exactly what MCP clients such as Claude need for a one-click
6 * connection:
7 * - RFC 8414 authorization-server metadata + RFC 9728 protected-resource
8 * metadata (served by {@see Manager}),
9 * - RFC 7591 dynamic client registration (public clients, no secret),
10 * - the authorization-code grant with mandatory PKCE (S256),
11 * - refresh-token rotation.
12 *
13 * Access/refresh tokens are stored only as SHA-256 hashes; the raw value is
14 * returned to the client once and never persisted. Every issued grant is bound
15 * to the WordPress admin who approved it.
16 *
17 * @package NotificationX\MCP
18 */
19
20 namespace NotificationX\MCP;
21
22 use NotificationX\GetInstance;
23
24 if ( ! defined( 'ABSPATH' ) ) {
25 exit;
26 }
27
28 /**
29 * @method static OAuth get_instance( $args = null )
30 */
31 class OAuth {
32
33 use GetInstance;
34
35 const OPTION = 'notificationx_mcp_oauth';
36 const ACCESS_TTL = 3600; // 1 hour.
37 const REFRESH_TTL = 2592000; // 30 days.
38 const CODE_TTL = 60; // 1 minute, single use.
39 const MAX_CLIENTS = 50;
40 const CLIENT_TTL = 86400; // 24 hours for unused clients.
41
42 /**
43 * The OAuth issuer/authorization identifier. Path-based so it stays
44 * specific to this plugin and can coexist with another plugin's MCP OAuth.
45 *
46 * @return string
47 */
48 public function issuer() {
49 return home_url( '/notificationx/mcp' );
50 }
51
52 /**
53 * The protected resource identifier (the MCP endpoint URL).
54 *
55 * @return string
56 */
57 public function resource() {
58 return home_url( '/notificationx/mcp' );
59 }
60
61 /**
62 * RFC 8414 authorization-server metadata document.
63 *
64 * @return array
65 */
66 public function authorization_server_metadata() {
67 $base = home_url( '/notificationx/mcp' );
68 return array(
69 'issuer' => $this->issuer(),
70 'authorization_endpoint' => home_url( '/notificationx/authorize' ),
71 'token_endpoint' => rest_url( 'notificationx/v1/mcp/oauth/token' ),
72 'registration_endpoint' => rest_url( 'notificationx/v1/mcp/oauth/register' ),
73 'scopes_supported' => array( 'read', 'write', 'mcp' ),
74 'response_types_supported' => array( 'code' ),
75 'grant_types_supported' => array( 'authorization_code', 'refresh_token' ),
76 'code_challenge_methods_supported' => array( 'S256' ),
77 'token_endpoint_auth_methods_supported' => array( 'none' ),
78 );
79 }
80
81 /**
82 * RFC 9728 protected-resource metadata document.
83 *
84 * @return array
85 */
86 public function protected_resource_metadata() {
87 return array(
88 'resource' => $this->resource(),
89 'authorization_servers' => array( $this->issuer() ),
90 'scopes_supported' => array( 'read', 'write', 'mcp' ),
91 'bearer_methods_supported' => array( 'header' ),
92 );
93 }
94
95 /* --------------------------------------------------------------------- */
96 /* Dynamic client registration */
97 /* --------------------------------------------------------------------- */
98
99 /**
100 * Register a public client (RFC 7591).
101 *
102 * @param array $params Registration request body.
103 * @return array|\WP_Error Registration response or error.
104 */
105 public function register_client( $params ) {
106 $redirect_uris = isset( $params['redirect_uris'] ) ? (array) $params['redirect_uris'] : array();
107 $redirect_uris = array_values( array_filter( array_map( array( $this, 'clean_redirect_uri' ), $redirect_uris ) ) );
108
109 if ( empty( $redirect_uris ) ) {
110 return new \WP_Error( 'invalid_redirect_uri', __( 'At least one valid redirect_uri is required.', 'notificationx' ), array( 'status' => 400 ) );
111 }
112
113 $state = $this->store();
114 $this->prune_clients( $state );
115
116 if ( count( $state['clients'] ) >= self::MAX_CLIENTS ) {
117 return new \WP_Error( 'too_many_clients', __( 'Client registration limit reached.', 'notificationx' ), array( 'status' => 400 ) );
118 }
119
120 $client_id = 'nx_' . bin2hex( random_bytes( 16 ) );
121
122 $state['clients'][ $client_id ] = array(
123 'redirect_uris' => $redirect_uris,
124 'client_name' => isset( $params['client_name'] ) ? sanitize_text_field( $params['client_name'] ) : '',
125 'created_at' => time(),
126 );
127 $this->save( $state );
128
129 return array(
130 'client_id' => $client_id,
131 'redirect_uris' => $redirect_uris,
132 'token_endpoint_auth_method' => 'none',
133 'grant_types' => array( 'authorization_code', 'refresh_token' ),
134 'response_types' => array( 'code' ),
135 );
136 }
137
138 /* --------------------------------------------------------------------- */
139 /* Authorization code */
140 /* --------------------------------------------------------------------- */
141
142 /**
143 * Validate an incoming /authorize request.
144 *
145 * @param array $params Query params.
146 * @return array|\WP_Error Cleaned request or error.
147 */
148 public function validate_authorize_request( $params ) {
149 $client_id = isset( $params['client_id'] ) ? sanitize_text_field( $params['client_id'] ) : '';
150 $redirect_uri = isset( $params['redirect_uri'] ) ? $this->clean_redirect_uri( $params['redirect_uri'] ) : '';
151 $response_type = isset( $params['response_type'] ) ? sanitize_text_field( $params['response_type'] ) : '';
152 $challenge = isset( $params['code_challenge'] ) ? sanitize_text_field( $params['code_challenge'] ) : '';
153 $method = isset( $params['code_challenge_method'] ) ? sanitize_text_field( $params['code_challenge_method'] ) : '';
154 $scope = isset( $params['scope'] ) ? sanitize_text_field( $params['scope'] ) : 'read write';
155 $state = isset( $params['state'] ) ? sanitize_text_field( $params['state'] ) : '';
156
157 $store = $this->store();
158 $client = isset( $store['clients'][ $client_id ] ) ? $store['clients'][ $client_id ] : null;
159
160 if ( ! $client ) {
161 return new \WP_Error( 'invalid_client', __( 'Unknown client.', 'notificationx' ), array( 'status' => 400 ) );
162 }
163 if ( ! $redirect_uri || ! in_array( $redirect_uri, $client['redirect_uris'], true ) ) {
164 return new \WP_Error( 'invalid_redirect_uri', __( 'redirect_uri mismatch.', 'notificationx' ), array( 'status' => 400 ) );
165 }
166 if ( 'code' !== $response_type ) {
167 return new \WP_Error( 'unsupported_response_type', __( 'Only the authorization code flow is supported.', 'notificationx' ), array( 'status' => 400 ) );
168 }
169 if ( '' === $challenge || 'S256' !== $method ) {
170 return new \WP_Error( 'invalid_request', __( 'PKCE with S256 is required.', 'notificationx' ), array( 'status' => 400 ) );
171 }
172
173 return array(
174 'client_id' => $client_id,
175 'redirect_uri' => $redirect_uri,
176 'code_challenge' => $challenge,
177 'scope' => $scope,
178 'state' => $state,
179 );
180 }
181
182 /**
183 * Issue a single-use authorization code bound to the approving user.
184 *
185 * @param array $request Validated authorize request.
186 * @param int $user_id Approving admin user id.
187 * @return string The authorization code.
188 */
189 public function issue_code( $request, $user_id ) {
190 $code = bin2hex( random_bytes( 24 ) );
191 $store = $this->store();
192
193 $store['codes'][ $this->hash( $code ) ] = array(
194 'client_id' => $request['client_id'],
195 'redirect_uri' => $request['redirect_uri'],
196 'code_challenge' => $request['code_challenge'],
197 'scope' => $request['scope'],
198 'user_id' => (int) $user_id,
199 'expires' => time() + self::CODE_TTL,
200 );
201 $this->save( $store );
202
203 return $code;
204 }
205
206 /* --------------------------------------------------------------------- */
207 /* Token endpoint */
208 /* --------------------------------------------------------------------- */
209
210 /**
211 * Handle a token request (authorization_code or refresh_token grant).
212 *
213 * @param array $params Token request body.
214 * @return array|\WP_Error Token response or error.
215 */
216 public function handle_token_request( $params ) {
217 $grant_type = isset( $params['grant_type'] ) ? sanitize_text_field( $params['grant_type'] ) : '';
218
219 if ( 'authorization_code' === $grant_type ) {
220 return $this->grant_authorization_code( $params );
221 }
222 if ( 'refresh_token' === $grant_type ) {
223 return $this->grant_refresh_token( $params );
224 }
225 return new \WP_Error( 'unsupported_grant_type', __( 'Unsupported grant type.', 'notificationx' ), array( 'status' => 400 ) );
226 }
227
228 /**
229 * Exchange an authorization code (+ PKCE verifier) for tokens.
230 *
231 * @param array $params Token request body.
232 * @return array|\WP_Error
233 */
234 protected function grant_authorization_code( $params ) {
235 $code = isset( $params['code'] ) ? (string) $params['code'] : '';
236 $client_id = isset( $params['client_id'] ) ? sanitize_text_field( $params['client_id'] ) : '';
237 $redirect_uri = isset( $params['redirect_uri'] ) ? $this->clean_redirect_uri( $params['redirect_uri'] ) : '';
238 $verifier = isset( $params['code_verifier'] ) ? (string) $params['code_verifier'] : '';
239
240 $store = $this->store();
241 $code_key = $this->hash( $code );
242 $record = isset( $store['codes'][ $code_key ] ) ? $store['codes'][ $code_key ] : null;
243
244 // Single use: delete on lookup regardless of outcome.
245 if ( $record ) {
246 unset( $store['codes'][ $code_key ] );
247 $this->save( $store );
248 }
249
250 if ( ! $record || $record['expires'] < time() ) {
251 return new \WP_Error( 'invalid_grant', __( 'Authorization code is invalid or expired.', 'notificationx' ), array( 'status' => 400 ) );
252 }
253 if ( $record['client_id'] !== $client_id || $record['redirect_uri'] !== $redirect_uri ) {
254 return new \WP_Error( 'invalid_grant', __( 'Authorization code does not match this client.', 'notificationx' ), array( 'status' => 400 ) );
255 }
256
257 // PKCE: BASE64URL(SHA256(verifier)) === stored challenge.
258 $computed = $this->base64url( hash( 'sha256', $verifier, true ) );
259 if ( '' === $verifier || ! hash_equals( $record['code_challenge'], $computed ) ) {
260 return new \WP_Error( 'invalid_grant', __( 'PKCE verification failed.', 'notificationx' ), array( 'status' => 400 ) );
261 }
262
263 return $this->issue_tokens( $record['user_id'], $client_id, $record['scope'] );
264 }
265
266 /**
267 * Rotate a refresh token for a new access/refresh pair.
268 *
269 * @param array $params Token request body.
270 * @return array|\WP_Error
271 */
272 protected function grant_refresh_token( $params ) {
273 $refresh = isset( $params['refresh_token'] ) ? (string) $params['refresh_token'] : '';
274 $client_id = isset( $params['client_id'] ) ? sanitize_text_field( $params['client_id'] ) : '';
275
276 $store = $this->store();
277 $key = $this->hash( $refresh );
278 $record = isset( $store['refresh'][ $key ] ) ? $store['refresh'][ $key ] : null;
279
280 if ( $record ) {
281 unset( $store['refresh'][ $key ] );
282 $this->save( $store );
283 }
284
285 if ( ! $record || $record['expires'] < time() || $record['client_id'] !== $client_id ) {
286 return new \WP_Error( 'invalid_grant', __( 'Refresh token is invalid or expired.', 'notificationx' ), array( 'status' => 400 ) );
287 }
288
289 return $this->issue_tokens( $record['user_id'], $client_id, $record['scope'] );
290 }
291
292 /**
293 * Mint and store a new access + refresh token pair (hashed at rest).
294 *
295 * @param int $user_id Bound admin user id.
296 * @param string $client_id Client id.
297 * @param string $scope Space-separated scope string.
298 * @return array Token response.
299 */
300 protected function issue_tokens( $user_id, $client_id, $scope ) {
301 $access = bin2hex( random_bytes( 32 ) );
302 $refresh = bin2hex( random_bytes( 32 ) );
303
304 $store = $this->store();
305 $this->prune_tokens( $store );
306
307 $store['tokens'][ $this->hash( $access ) ] = array(
308 'user_id' => (int) $user_id,
309 'client_id' => $client_id,
310 'scope' => $scope,
311 'expires' => time() + self::ACCESS_TTL,
312 );
313 $store['refresh'][ $this->hash( $refresh ) ] = array(
314 'user_id' => (int) $user_id,
315 'client_id' => $client_id,
316 'scope' => $scope,
317 'expires' => time() + self::REFRESH_TTL,
318 );
319 $this->save( $store );
320
321 return array(
322 'access_token' => $access,
323 'token_type' => 'Bearer',
324 'expires_in' => self::ACCESS_TTL,
325 'refresh_token' => $refresh,
326 'scope' => $scope,
327 );
328 }
329
330 /**
331 * Validate a bearer access token.
332 *
333 * @param string $token Presented access token.
334 * @return array|false Grant record on success, false otherwise.
335 */
336 public function validate_token( $token ) {
337 if ( '' === (string) $token ) {
338 return false;
339 }
340 $store = $this->store();
341 $record = isset( $store['tokens'][ $this->hash( $token ) ] ) ? $store['tokens'][ $this->hash( $token ) ] : null;
342 if ( ! $record || $record['expires'] < time() ) {
343 return false;
344 }
345 return $record;
346 }
347
348 /**
349 * Whether a scope string grants only read access.
350 *
351 * @param string $scope Space-separated scope string.
352 * @return bool
353 */
354 public function scope_is_read_only( $scope ) {
355 $scopes = preg_split( '/\s+/', trim( (string) $scope ) );
356 return ! in_array( 'write', $scopes, true ) && ! in_array( 'mcp', $scopes, true );
357 }
358
359 /**
360 * Revoke every issued token, refresh token and code. Used by disconnect.
361 *
362 * @return void
363 */
364 public function revoke_all() {
365 $store = $this->store();
366 $store['tokens'] = array();
367 $store['refresh'] = array();
368 $store['codes'] = array();
369 $this->save( $store );
370 }
371
372 /**
373 * List OAuth clients that currently hold a live access or refresh token,
374 * for the "Connected apps" admin list.
375 *
376 * @return array[] Each: { client_id, name, scope, read_only, last_seen }.
377 */
378 public function list_active_clients() {
379 $store = $this->store();
380 $this->prune_tokens( $store );
381
382 $by_client = array();
383 foreach ( array( 'tokens', 'refresh' ) as $bucket ) {
384 foreach ( $store[ $bucket ] as $record ) {
385 $cid = isset( $record['client_id'] ) ? $record['client_id'] : '';
386 if ( '' === $cid ) {
387 continue;
388 }
389 if ( ! isset( $by_client[ $cid ] ) ) {
390 $name = isset( $store['clients'][ $cid ]['client_name'] ) && $store['clients'][ $cid ]['client_name']
391 ? $store['clients'][ $cid ]['client_name']
392 : $cid;
393 $by_client[ $cid ] = array(
394 'client_id' => $cid,
395 'name' => $name,
396 'scope' => isset( $record['scope'] ) ? $record['scope'] : 'read',
397 'read_only' => $this->scope_is_read_only( isset( $record['scope'] ) ? $record['scope'] : '' ),
398 );
399 }
400 }
401 }
402
403 return array_values( $by_client );
404 }
405
406 /**
407 * Revoke all tokens issued to a single OAuth client.
408 *
409 * @param string $client_id Client id.
410 * @return void
411 */
412 public function revoke_client( $client_id ) {
413 $store = $this->store();
414 foreach ( array( 'tokens', 'refresh', 'codes' ) as $bucket ) {
415 foreach ( $store[ $bucket ] as $key => $record ) {
416 if ( isset( $record['client_id'] ) && $record['client_id'] === $client_id ) {
417 unset( $store[ $bucket ][ $key ] );
418 }
419 }
420 }
421 $this->save( $store );
422 }
423
424 /* --------------------------------------------------------------------- */
425 /* Storage helpers */
426 /* --------------------------------------------------------------------- */
427
428 /**
429 * Load the OAuth store, pruning expired entries lazily.
430 *
431 * @return array
432 */
433 protected function store() {
434 $store = get_option( self::OPTION, array() );
435 if ( ! is_array( $store ) ) {
436 $store = array();
437 }
438 $store += array(
439 'clients' => array(),
440 'codes' => array(),
441 'tokens' => array(),
442 'refresh' => array(),
443 );
444 return $store;
445 }
446
447 /**
448 * Persist the OAuth store.
449 *
450 * @param array $store Store array.
451 * @return void
452 */
453 protected function save( $store ) {
454 update_option( self::OPTION, $store, false );
455 }
456
457 /**
458 * Drop expired tokens/refresh/codes.
459 *
460 * @param array $store Store array (by reference).
461 * @return void
462 */
463 protected function prune_tokens( &$store ) {
464 $now = time();
465 foreach ( array( 'tokens', 'refresh', 'codes' ) as $bucket ) {
466 foreach ( $store[ $bucket ] as $key => $record ) {
467 if ( empty( $record['expires'] ) || $record['expires'] < $now ) {
468 unset( $store[ $bucket ][ $key ] );
469 }
470 }
471 }
472 }
473
474 /**
475 * Evict old, unused registered clients.
476 *
477 * @param array $store Store array (by reference).
478 * @return void
479 */
480 protected function prune_clients( &$store ) {
481 $now = time();
482 foreach ( $store['clients'] as $client_id => $client ) {
483 if ( ! empty( $client['created_at'] ) && ( $now - $client['created_at'] ) > self::CLIENT_TTL ) {
484 // Keep clients that still have live tokens.
485 $has_token = false;
486 foreach ( array( 'tokens', 'refresh' ) as $bucket ) {
487 foreach ( $store[ $bucket ] as $record ) {
488 if ( isset( $record['client_id'] ) && $record['client_id'] === $client_id ) {
489 $has_token = true;
490 break 2;
491 }
492 }
493 }
494 if ( ! $has_token ) {
495 unset( $store['clients'][ $client_id ] );
496 }
497 }
498 }
499 }
500
501 /**
502 * SHA-256 hash used for token storage.
503 *
504 * @param string $value Raw value.
505 * @return string
506 */
507 protected function hash( $value ) {
508 return hash( 'sha256', (string) $value );
509 }
510
511 /**
512 * Base64url encoding (no padding).
513 *
514 * @param string $data Raw bytes.
515 * @return string
516 */
517 protected function base64url( $data ) {
518 return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
519 }
520
521 /**
522 * Validate and normalise a redirect URI.
523 *
524 * @param string $uri Candidate redirect URI.
525 * @return string Cleaned URI or empty string if invalid.
526 */
527 protected function clean_redirect_uri( $uri ) {
528 $uri = esc_url_raw( trim( (string) $uri ), array( 'https', 'http' ) );
529 return $uri ? $uri : '';
530 }
531 }
532