PluginProbe ʕ •ᴥ•ʔ
Presto Player / trunk
Presto Player vtrunk
4.4.1 4.4.0 4.3.3 4.3.2 4.3.1 4.3.0 4.2.4 4.2.3 4.2.2 4.2.0 4.2.1 trunk 1.10.0 1.10.1 1.10.2 1.11.0 1.12.0 1.13.0 1.14.0 1.14.1 1.5.10 1.5.11 1.5.12 1.5.13 1.5.14 1.5.15 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.10 1.6.11 1.6.12 1.6.13 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.8.0 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.11 2.0.12 2.0.13 2.0.14 2.0.15 2.0.16 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.2.0 2.2.1 2.2.2 2.2.3 2.2.3-beta1 2.3.0 2.3.1 2.3.2 2.3.3 3.0.0 3.0.0-beta1 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.1.0 3.1.1 3.1.2 3.1.3 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4
presto-player / inc / Services / OAuth / Storage / Storage.php
presto-player / inc / Services / OAuth / Storage Last commit date
Storage.php 1 week ago
Storage.php
869 lines
1 <?php
2 /**
3 * Storage (grouped OAuth classes).
4 *
5 * @package PrestoPlayer
6 * @subpackage Services\OAuth\Storage
7 */
8
9 namespace PrestoPlayer\Services\OAuth\Storage;
10
11 use PrestoPlayer\Services\OAuth\Helpers\Tokens;
12
13 /**
14 * $wpdb-backed repository.
15 */
16 class ClientRepository {
17
18 /**
19 * Fully qualified clients table name.
20 *
21 * @var string
22 */
23 protected $table;
24
25 /**
26 * Cache the table name once per request.
27 */
28 public function __construct() {
29 global $wpdb;
30 $this->table = $wpdb->prefix . 'presto_oauth_clients';
31 }
32
33 /**
34 * Create a new client record.
35 *
36 * @param array<string, mixed> $metadata Client metadata (client_name, redirect_uris, client_type, scope, software_id).
37 * @return array<string, mixed> Stored row plus plaintext client_secret when applicable.
38 */
39 public function create( array $metadata ) {
40 global $wpdb;
41
42 $client_name = isset( $metadata['client_name'] ) ? (string) $metadata['client_name'] : '';
43 $client_type = isset( $metadata['client_type'] ) ? (string) $metadata['client_type'] : 'public';
44 $software_id = isset( $metadata['software_id'] ) ? (string) $metadata['software_id'] : null;
45 $scope = isset( $metadata['scope'] ) ? trim( (string) $metadata['scope'] ) : '';
46 $redirect_uris = isset( $metadata['redirect_uris'] ) ? $metadata['redirect_uris'] : array();
47
48 if ( ! is_array( $redirect_uris ) ) {
49 return array();
50 }
51
52 if ( ! in_array( $client_type, array( 'public', 'confidential' ), true ) ) {
53 $client_type = 'public';
54 }
55
56 $redirect_json = wp_json_encode( array_values( $redirect_uris ) );
57 if ( false === $redirect_json ) {
58 return array();
59 }
60
61 $client_id = Tokens::generateOpaqueToken( 32 );
62 $plaintext_secret = null;
63 $secret_hash = null;
64
65 if ( 'confidential' === $client_type ) {
66 $plaintext_secret = Tokens::generateOpaqueToken( 32 );
67 $secret_hash = wp_hash_password( $plaintext_secret );
68 }
69
70 $now = gmdate( 'Y-m-d H:i:s' );
71
72 $row = array(
73 'client_id' => $client_id,
74 'client_secret_hash' => $secret_hash,
75 'client_name' => $client_name,
76 'client_type' => $client_type,
77 'redirect_uris' => $redirect_json,
78 'scope' => $scope,
79 'software_id' => $software_id,
80 'created_at' => $now,
81 'last_used_at' => null,
82 );
83
84 $formats = array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' );
85
86 $inserted = $wpdb->insert( $this->table, $row, $formats ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
87 if ( false === $inserted ) {
88 return array();
89 }
90
91 $record = $this->find( $client_id );
92 if ( null === $record ) {
93 return array();
94 }
95
96 if ( null !== $plaintext_secret ) {
97 $record['client_secret'] = $plaintext_secret;
98 }
99
100 return $record;
101 }
102
103 /**
104 * Look up a client by its public identifier.
105 *
106 * @param string $client_id Client identifier returned from {@see self::create()}.
107 * @return array<string, mixed>|null Row as associative array, or null when not found.
108 */
109 public function find( string $client_id ) {
110 global $wpdb;
111
112 if ( '' === $client_id ) {
113 return null;
114 }
115
116 $row = $wpdb->get_row(
117 $wpdb->prepare(
118 "SELECT * FROM {$this->table} WHERE client_id = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
119 $client_id
120 ),
121 ARRAY_A
122 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
123
124 return $row ? $row : null;
125 }
126
127 /**
128 * Update the last_used_at timestamp on a successful token exchange.
129 *
130 * @param string $client_id Client identifier.
131 * @return void
132 */
133 public function touch( string $client_id ) {
134 global $wpdb;
135
136 if ( '' === $client_id ) {
137 return;
138 }
139
140 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
141 $this->table,
142 array( 'last_used_at' => gmdate( 'Y-m-d H:i:s' ) ),
143 array( 'client_id' => $client_id ),
144 array( '%s' ),
145 array( '%s' )
146 );
147 }
148 }
149
150 /**
151 * $wpdb-backed repository.
152 */
153 class CodeRepository {
154
155 /**
156 * Fully qualified codes table name.
157 *
158 * @var string
159 */
160 protected $table;
161
162 /**
163 * Cache the table name once per request.
164 */
165 public function __construct() {
166 global $wpdb;
167 $this->table = $wpdb->prefix . 'presto_oauth_codes';
168 }
169
170 /**
171 * Issue a one-time authorization code.
172 *
173 * @param string $client_id Client identifier.
174 * @param int $user_id WordPress user id who granted consent.
175 * @param array<int, string> $scopes Granted scopes.
176 * @param string $redirect_uri Redirect URI used in /authorize.
177 * @param string|null $challenge PKCE code challenge, if any.
178 * @param string|null $method PKCE method ("S256" or "plain"), if any.
179 * @param int $ttl Lifetime in seconds.
180 * @return string Plaintext authorization code.
181 */
182 public function issue(
183 string $client_id,
184 int $user_id,
185 array $scopes,
186 string $redirect_uri,
187 ?string $challenge,
188 ?string $method,
189 int $ttl = 600
190 ) {
191 global $wpdb;
192
193 if ( $ttl < 1 ) {
194 $ttl = 600;
195 }
196
197 $plaintext = Tokens::generateOpaqueToken( 32 );
198 $hash = Tokens::hash( $plaintext );
199
200 $row = array(
201 'code_hash' => $hash,
202 'client_id' => $client_id,
203 'user_id' => $user_id,
204 'scopes' => ScopeHelper::toString( $scopes ),
205 'code_challenge' => $challenge,
206 'code_challenge_method' => $method,
207 'redirect_uri' => $redirect_uri,
208 'expires_at' => gmdate( 'Y-m-d H:i:s', time() + $ttl ),
209 'used_at' => null,
210 );
211
212 $formats = array( '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s' );
213
214 $inserted = $wpdb->insert( $this->table, $row, $formats ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
215 if ( false === $inserted ) {
216 // Persisting the hash failed; '' signals the caller not to hand out an unstored code.
217 return '';
218 }
219
220 return $plaintext;
221 }
222
223 /**
224 * Atomically mark a code used and return its row.
225 *
226 * @param string $plaintext Code received from the client.
227 * @return array<string, mixed>|null Row data on success, null otherwise.
228 */
229 public function consume( string $plaintext ) {
230 global $wpdb;
231
232 if ( '' === $plaintext ) {
233 return null;
234 }
235
236 $hash = Tokens::hash( $plaintext );
237 $now = gmdate( 'Y-m-d H:i:s' );
238
239 $affected = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
240 $wpdb->prepare(
241 "UPDATE {$this->table} SET used_at = %s WHERE code_hash = %s AND used_at IS NULL AND expires_at > %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
242 $now,
243 $hash,
244 $now
245 )
246 );
247
248 if ( ! $affected ) {
249 return null;
250 }
251
252 $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
253 $wpdb->prepare(
254 "SELECT * FROM {$this->table} WHERE code_hash = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
255 $hash
256 ),
257 ARRAY_A
258 );
259
260 return $row ? $row : null;
261 }
262 }
263
264 /**
265 * $wpdb-backed repository.
266 */
267 class TokenRepository {
268
269 /**
270 * Fully qualified tokens table name.
271 *
272 * @var string
273 */
274 protected $table;
275
276 /**
277 * Cache the table name once per request.
278 */
279 public function __construct() {
280 global $wpdb;
281 $this->table = $wpdb->prefix . 'presto_oauth_tokens';
282 }
283
284 /**
285 * Issue a new access token.
286 *
287 * @param string $client_id Client identifier.
288 * @param int $user_id WordPress user id.
289 * @param array<int, string> $scopes Granted scopes.
290 * @param int $ttl Lifetime in seconds.
291 * @param string|null $parent Issuing refresh-token hash for chain revocation.
292 * @return string Plaintext access token.
293 */
294 public function issueAccess( string $client_id, int $user_id, array $scopes, int $ttl, ?string $parent = null ) {
295 return $this->insertToken( 'access', $client_id, $user_id, $scopes, $ttl, $parent );
296 }
297
298 /**
299 * Issue a refresh token, optionally chained to a parent token.
300 *
301 * @param string $client_id Client identifier.
302 * @param int $user_id WordPress user id.
303 * @param array<int, string> $scopes Granted scopes.
304 * @param int $ttl Lifetime in seconds.
305 * @param string|null $parent Parent refresh-token hash for rotation.
306 * @param string|null $absolute_expires_at Absolute expiry (UTC 'Y-m-d H:i:s') carried across the whole rotation chain; null for no cap.
307 * @return string Plaintext refresh token.
308 */
309 public function issueRefresh( string $client_id, int $user_id, array $scopes, int $ttl, ?string $parent = null, ?string $absolute_expires_at = null ) {
310 return $this->insertToken( 'refresh', $client_id, $user_id, $scopes, $ttl, $parent, $absolute_expires_at );
311 }
312
313 /**
314 * Shared insertion path for both token types.
315 *
316 * @param string $type 'access' or 'refresh'.
317 * @param string $client_id Client identifier.
318 * @param int $user_id WordPress user id.
319 * @param array<int, string> $scopes Granted scopes.
320 * @param int $ttl Lifetime in seconds.
321 * @param string|null $parent Parent refresh token hash.
322 * @param string|null $absolute_expires_at Absolute expiry (UTC 'Y-m-d H:i:s'), or null for no cap.
323 * @return string Plaintext token.
324 */
325 protected function insertToken( $type, $client_id, $user_id, array $scopes, $ttl, $parent, $absolute_expires_at = null ) {
326 global $wpdb;
327
328 if ( $ttl < 1 ) {
329 $ttl = 3600;
330 }
331
332 $plaintext = Tokens::generateOpaqueToken( 32 );
333 $hash = Tokens::hash( $plaintext );
334 $now = gmdate( 'Y-m-d H:i:s' );
335
336 $row = array(
337 'token_hash' => $hash,
338 'token_type' => $type,
339 'client_id' => $client_id,
340 'user_id' => (int) $user_id,
341 'scopes' => ScopeHelper::toString( $scopes ),
342 'expires_at' => gmdate( 'Y-m-d H:i:s', time() + $ttl ),
343 'revoked_at' => null,
344 'created_at' => $now,
345 'parent_token_hash' => $parent,
346 'absolute_expires_at' => $absolute_expires_at,
347 );
348
349 $formats = array( '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s' );
350
351 $inserted = $wpdb->insert( $this->table, $row, $formats ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
352 if ( false === $inserted ) {
353 // Persisting the hash failed; '' signals the caller not to hand out an unstored token.
354 return '';
355 }
356
357 return $plaintext;
358 }
359
360 /**
361 * Look up a token by plaintext.
362 *
363 * @param string $token Plaintext token from Authorization header.
364 * @param bool $include_revoked Match revoked/expired rows too (for reuse detection).
365 * @return array<string, mixed>|null Row data or null.
366 */
367 public function findByPlaintext( string $token, bool $include_revoked = false ) {
368 global $wpdb;
369
370 if ( '' === $token ) {
371 return null;
372 }
373
374 $hash = Tokens::hash( $token );
375
376 if ( $include_revoked ) {
377 $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
378 $wpdb->prepare(
379 "SELECT * FROM {$this->table} WHERE token_hash = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
380 $hash
381 ),
382 ARRAY_A
383 );
384
385 return $row ? $row : null;
386 }
387
388 $now = gmdate( 'Y-m-d H:i:s' );
389
390 $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
391 $wpdb->prepare(
392 "SELECT * FROM {$this->table} WHERE token_hash = %s AND revoked_at IS NULL AND expires_at > %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
393 $hash,
394 $now
395 ),
396 ARRAY_A
397 );
398
399 return $row ? $row : null;
400 }
401
402 /**
403 * Revoke a single token by its hash.
404 *
405 * Returns the number of rows affected so callers can use it as an atomic
406 * single-use gate: a token already revoked by a concurrent request yields 0.
407 *
408 * @param string $token_hash sha256 hex of the token.
409 * @return int Rows affected (0 or 1).
410 */
411 public function revoke( string $token_hash ) {
412 global $wpdb;
413
414 if ( '' === $token_hash ) {
415 return 0;
416 }
417
418 $affected = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
419 $wpdb->prepare(
420 "UPDATE {$this->table} SET revoked_at = %s WHERE token_hash = %s AND revoked_at IS NULL", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
421 gmdate( 'Y-m-d H:i:s' ),
422 $token_hash
423 )
424 );
425
426 return is_numeric( $affected ) ? (int) $affected : 0;
427 }
428
429 /**
430 * Revoke a refresh token and every descendant in its rotation chain.
431 *
432 * Walks the parent_token_hash chain breadth-first and issues a single bulk
433 * UPDATE per level. Bounded loop prevents accidental runaway on a corrupt
434 * graph.
435 *
436 * @param string $root_hash sha256 hex of the root refresh token.
437 * @return void
438 */
439 public function revokeChain( string $root_hash ) {
440 global $wpdb;
441
442 if ( '' === $root_hash ) {
443 return;
444 }
445
446 $this->revoke( $root_hash );
447
448 $frontier = array( $root_hash );
449 $visited = array( $root_hash => true );
450 $now = gmdate( 'Y-m-d H:i:s' );
451 $depth = 0;
452
453 while ( ! empty( $frontier ) && $depth < 64 ) {
454 $placeholders = implode( ',', array_fill( 0, count( $frontier ), '%s' ) );
455
456 $args = array_merge( array( $now ), $frontier );
457 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
458 $wpdb->prepare(
459 "UPDATE {$this->table} SET revoked_at = %s WHERE revoked_at IS NULL AND parent_token_hash IN ($placeholders)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
460 $args
461 )
462 );
463
464 $select_args = $frontier;
465 $next = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
466 $wpdb->prepare(
467 "SELECT token_hash FROM {$this->table} WHERE parent_token_hash IN ($placeholders)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
468 $select_args
469 )
470 );
471
472 $frontier = array();
473 if ( is_array( $next ) ) {
474 foreach ( $next as $hash ) {
475 if ( ! isset( $visited[ $hash ] ) ) {
476 $visited[ $hash ] = true;
477 $frontier[] = $hash;
478 }
479 }
480 }
481 ++$depth;
482 }
483 }
484 }
485
486 /**
487 * Converts OAuth scopes between the space-separated string used in storage and
488 * a plain PHP array used at runtime.
489 */
490 class ScopeHelper {
491
492 /**
493 * Serialize a scope array to the storage format.
494 *
495 * Duplicates and empty entries are dropped. Order is preserved for the
496 * first occurrence of each scope.
497 *
498 * @param array<int, string> $scopes List of scope strings.
499 * @return string Space-separated scope list.
500 */
501 public static function toString( array $scopes ) {
502 $clean = array();
503 foreach ( $scopes as $scope ) {
504 $scope = is_string( $scope ) ? trim( $scope ) : '';
505 if ( '' === $scope ) {
506 continue;
507 }
508 if ( ! in_array( $scope, $clean, true ) ) {
509 $clean[] = $scope;
510 }
511 }
512 return implode( ' ', $clean );
513 }
514
515 /**
516 * Parse a stored scope string back into an array.
517 *
518 * @param string|null $scopes Stored scope string.
519 * @return array<int, string> List of scope strings.
520 */
521 public static function toArray( $scopes ) {
522 if ( ! is_string( $scopes ) || '' === trim( $scopes ) ) {
523 return array();
524 }
525 $parts = preg_split( '/\s+/', trim( $scopes ) );
526 if ( false === $parts ) {
527 return array();
528 }
529 return array_values(
530 array_filter(
531 $parts,
532 static function ( $s ) {
533 return '' !== (string) $s;
534 }
535 )
536 );
537 }
538 }
539
540 /**
541 * Atomic fixed-window rate limiter for the open DCR endpoint.
542 *
543 * Stores each window's count in an autoloaded-off option and bumps it with a
544 * single $wpdb conditional UPDATE so concurrent registrations on the open
545 * endpoint can't read-modify-write past the ceiling. The window reset time is
546 * encoded in the option value, so the count never slides forward mid-window
547 * and survives a persistent object cache without relying on transient timeouts.
548 */
549 class RateLimiter {
550
551 /**
552 * Atomically bump a window's counter and report whether it stayed under the limit.
553 *
554 * Returns true when the registration is allowed (post-increment count is
555 * within $limit), false when the bump would exceed the ceiling. The whole
556 * read-bump is a single atomic option write per call, so racing callers
557 * serialize on the row and cannot overshoot.
558 *
559 * @param string $key Logical window key (no option prefix).
560 * @param int $limit Maximum hits allowed in the window.
561 * @param int $ttl Window length in seconds.
562 * @return bool True when the hit is within the limit.
563 */
564 public static function hit( string $key, int $limit, int $ttl = HOUR_IN_SECONDS ) {
565 global $wpdb;
566
567 $limit = max( 1, (int) $limit );
568 $ttl = max( 1, (int) $ttl );
569 $option = 'presto_oauth_rl_' . md5( $key );
570 $now = time();
571 $reset = $now + $ttl;
572
573 // add_option() is a race-free INSERT: exactly one concurrent caller wins
574 // when the window doesn't exist yet, and it starts the window at 1.
575 if ( add_option(
576 $option,
577 array(
578 'count' => 1,
579 'reset' => $reset,
580 ),
581 '',
582 false
583 ) ) {
584 return 1 <= $limit;
585 }
586
587 // Compare-and-swap retry loop. A caller that loses the CAS re-reads and
588 // tries again so its own hit is still counted, instead of being silently
589 // dropped (which would let concurrent floods slip past the limit).
590 for ( $attempt = 0; $attempt < 25; $attempt++ ) {
591 wp_cache_delete( $option, 'options' );
592 $raw = get_option( $option );
593
594 if ( ! is_array( $raw ) || empty( $raw['reset'] ) || (int) $raw['reset'] <= $now ) {
595 // Stale or malformed window: reset it to a fresh window of 1.
596 update_option(
597 $option,
598 array(
599 'count' => 1,
600 'reset' => $reset,
601 ),
602 false
603 );
604 return 1 <= $limit;
605 }
606
607 if ( (int) $raw['count'] >= $limit ) {
608 // Ceiling already reached; do not increment past it.
609 return false;
610 }
611
612 $expected = maybe_serialize( $raw );
613 $next = maybe_serialize(
614 array(
615 'count' => (int) $raw['count'] + 1,
616 'reset' => (int) $raw['reset'],
617 )
618 );
619
620 $updated = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
621 $wpdb->prepare(
622 "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
623 $next,
624 $option,
625 $expected
626 )
627 );
628
629 if ( $updated ) {
630 wp_cache_delete( $option, 'options' );
631 return true;
632 }
633 // Lost the CAS race: loop and retry with a fresh read.
634 }
635
636 // Exhausted retries under heavy contention: fail closed.
637 return false;
638 }
639 }
640
641 /**
642 * Static installer + version gate for the OAuth tables.
643 */
644 class Schema {
645
646 /**
647 * Option key storing the current installed schema version.
648 *
649 * @var string
650 */
651 public const VERSION_OPTION = 'presto_oauth_schema_version';
652
653 /**
654 * Current schema version. Bump on any column / index change.
655 *
656 * @var string
657 */
658 public const SCHEMA_VERSION = '1.3.0';
659
660 /**
661 * Install tables only when the stored version differs from the code version.
662 *
663 * Cheap enough to run on every `init` because the option read is cached.
664 *
665 * @return void
666 */
667 public static function installIfNeeded() {
668 $installed = get_option( self::VERSION_OPTION );
669 if ( self::SCHEMA_VERSION === $installed ) {
670 return;
671 }
672 self::install();
673 }
674
675 /**
676 * Create / upgrade all three OAuth tables via dbDelta().
677 *
678 * Safe to call repeatedly; dbDelta diffs against the live schema.
679 *
680 * @return void
681 */
682 public static function install() {
683 global $wpdb;
684
685 if ( ! function_exists( 'dbDelta' ) ) {
686 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
687 }
688
689 $prefix = $wpdb->prefix;
690 $charset_collate = $wpdb->get_charset_collate();
691
692 $clients_table = $prefix . 'presto_oauth_clients';
693 $codes_table = $prefix . 'presto_oauth_codes';
694 $tokens_table = $prefix . 'presto_oauth_tokens';
695
696 $queries = array();
697
698 $queries[] = "CREATE TABLE {$clients_table} (
699 client_id VARCHAR(64) NOT NULL,
700 client_secret_hash VARCHAR(255) NULL,
701 client_name VARCHAR(191) NOT NULL,
702 client_type VARCHAR(16) NOT NULL,
703 redirect_uris LONGTEXT NOT NULL,
704 scope TEXT NULL,
705 software_id VARCHAR(191) NULL,
706 created_at DATETIME NOT NULL,
707 last_used_at DATETIME NULL,
708 PRIMARY KEY (client_id),
709 KEY client_name (client_name)
710 ) {$charset_collate};";
711
712 $queries[] = "CREATE TABLE {$codes_table} (
713 code_hash VARCHAR(64) NOT NULL,
714 client_id VARCHAR(64) NOT NULL,
715 user_id BIGINT UNSIGNED NOT NULL,
716 scopes TEXT NOT NULL,
717 code_challenge VARCHAR(128) NULL,
718 code_challenge_method VARCHAR(16) NULL,
719 redirect_uri TEXT NOT NULL,
720 expires_at DATETIME NOT NULL,
721 used_at DATETIME NULL,
722 PRIMARY KEY (code_hash),
723 KEY client_id (client_id),
724 KEY expires_at (expires_at)
725 ) {$charset_collate};";
726
727 $queries[] = "CREATE TABLE {$tokens_table} (
728 token_hash VARCHAR(64) NOT NULL,
729 token_type VARCHAR(16) NOT NULL,
730 client_id VARCHAR(64) NOT NULL,
731 user_id BIGINT UNSIGNED NOT NULL,
732 scopes TEXT NOT NULL,
733 expires_at DATETIME NOT NULL,
734 revoked_at DATETIME NULL,
735 created_at DATETIME NOT NULL,
736 parent_token_hash VARCHAR(64) NULL,
737 absolute_expires_at DATETIME NULL,
738 PRIMARY KEY (token_hash),
739 KEY client_user (client_id, user_id),
740 KEY expires_at (expires_at),
741 KEY token_type (token_type),
742 KEY parent_token_hash (parent_token_hash)
743 ) {$charset_collate};";
744
745 foreach ( $queries as $sql ) {
746 dbDelta( $sql );
747 }
748
749 update_option( self::VERSION_OPTION, self::SCHEMA_VERSION, false );
750 }
751
752 /**
753 * Drop every issued grant.
754 *
755 * Turning AI access off is the site owner's only revocation control, so it has
756 * to actually revoke: without this, switching the toggle back on would reinstate
757 * every token that was live before, for the rest of its 90-day lifetime. Clients
758 * are left registered — a client row on its own grants nothing until a fresh
759 * consent mints a new token.
760 *
761 * @return void
762 */
763 public static function revokeAllGrants() {
764 global $wpdb;
765
766 $codes_table = $wpdb->prefix . 'presto_oauth_codes';
767 $tokens_table = $wpdb->prefix . 'presto_oauth_tokens';
768
769 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
770 $wpdb->query( "DELETE FROM {$codes_table}" );
771 $wpdb->query( "DELETE FROM {$tokens_table}" );
772 // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
773 }
774
775 /**
776 * Drop everything OAuth owns.
777 *
778 * Called from the plugin's "delete all data" uninstall path. Without it a
779 * reinstall would come back to a live grant table — registered clients and
780 * hashed refresh tokens that nobody consented to a second time.
781 *
782 * @return void
783 */
784 public static function uninstall() {
785 global $wpdb;
786
787 // Table identifiers cannot be bound; these are $wpdb->prefix + literal names.
788 foreach ( array( 'presto_oauth_codes', 'presto_oauth_tokens', 'presto_oauth_clients' ) as $table ) {
789 $wpdb->query( 'DROP TABLE IF EXISTS ' . $wpdb->prefix . $table ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
790 }
791
792 // Rate-limit windows, written as autoload=false options by RateLimiter::hit().
793 $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE 'presto\\_oauth\\_rl\\_%'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
794
795 delete_option( self::VERSION_OPTION );
796 }
797
798 /**
799 * Delete stale rows so the OAuth tables don't grow unbounded.
800 *
801 * Drops expired tokens, plus expired or already-used codes. Revoked-but-not-
802 * yet-expired tokens are deliberately kept so refresh-token reuse detection
803 * (TokenEndpoint::handleRefreshToken) can still match the revoked row and
804 * trigger revokeChain(); do not add `revoked_at IS NOT NULL` here without
805 * accounting for that signal. Intended to run on a daily cron; cheap enough
806 * to call ad hoc.
807 *
808 * @return void
809 */
810 public static function prune() {
811 global $wpdb;
812
813 $now = gmdate( 'Y-m-d H:i:s' );
814 $codes_table = $wpdb->prefix . 'presto_oauth_codes';
815 $tokens_table = $wpdb->prefix . 'presto_oauth_tokens';
816 $clients_table = $wpdb->prefix . 'presto_oauth_clients';
817
818 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
819 $wpdb->prepare(
820 "DELETE FROM {$tokens_table} WHERE expires_at < %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
821 $now
822 )
823 );
824
825 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
826 $wpdb->prepare(
827 "DELETE FROM {$codes_table} WHERE expires_at < %s OR used_at IS NOT NULL", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
828 $now
829 )
830 );
831
832 $abandoned_cutoff = gmdate( 'Y-m-d H:i:s', time() - MONTH_IN_SECONDS );
833 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
834 $wpdb->prepare(
835 "DELETE FROM {$clients_table} WHERE last_used_at IS NULL AND created_at < %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
836 $abandoned_cutoff
837 )
838 );
839
840 // Expired rate-limit windows. RateLimiter::hit() writes presto_oauth_rl_*
841 // options (autoload=false) with a 'reset' timestamp; nothing else removes
842 // them, so the open per-IP /register endpoint would grow wp_options without
843 // bound. Sweep the windows whose reset has already passed.
844 // Swept in batches: reading every matching row in one go is what would run the
845 // cron out of memory on exactly the site that has too many of them.
846 do {
847 $rl_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
848 "SELECT option_id, option_value FROM {$wpdb->options} WHERE option_name LIKE 'presto\\_oauth\\_rl\\_%' LIMIT 500" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
849 );
850 $batch = count( (array) $rl_rows );
851 $expired = array();
852 foreach ( (array) $rl_rows as $row ) {
853 $data = maybe_unserialize( $row->option_value );
854 if ( ! is_array( $data ) || empty( $data['reset'] ) || (int) $data['reset'] <= time() ) {
855 $expired[] = (int) $row->option_id;
856 }
857 }
858 if ( $expired ) {
859 $placeholders = implode( ', ', array_fill( 0, count( $expired ), '%d' ) );
860 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
861 $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_id IN ({$placeholders})", $expired ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
862 );
863 }
864 // A full batch with nothing expired means the rest are live windows too:
865 // they all share one TTL, so scanning further pages just burns queries.
866 } while ( 500 === $batch && $expired );
867 }
868 }
869