PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / mcp-server / Auth / OAuth / RecordStore.php

RecordStore.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at modules/mcp-server/Auth/OAuth/RecordStore.php

834 lines 26.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Storage for delegated-approval records (spec 044, FR-030, FR-033, FR-036,
4 * FR-037, FR-042).
5 *
6 * ## One option row PER RECORD — the reason there is no lock
7 *
8 * Registered clients, approval grants and issued credentials each get their own
9 * non-autoloaded option row, keyed by a hash, so the RECORDS themselves are
10 * never subject to a lost update. That matters because WordPress offers no
11 * portable row-locking primitive: `wp_cache_add` is unreliable without a
12 * persistent object cache, and MySQL's `GET_LOCK` is unavailable on some hosts.
13 * The reference implementation keeps clients, codes and tokens in ONE option
14 * under read-modify-write with no locking, so two clients registering and
15 * exchanging concurrently lose a write.
16 *
17 * ## …but `templately_mcp_oauth_index` IS shared mutable state
18 *
19 * An earlier version of this note claimed the read-modify-write cycle had been
20 * "removed rather than defended". That was wrong: the index is one option, and
21 * `put()`/`deindex()`/`sweep()` all read-modify-write it without a lock.
22 *
23 * The dangerous part was the ASYMMETRY. Authentication resolves a token by
24 * reading its option key directly, while revocation walked only the index — so
25 * a lost index update produced a token row that still authenticated but that
26 * "Revoke" could not see or delete, and the UI reported success over a
27 * connection that kept working.
28 *
29 * So: the index is a CACHE that makes the sweep cheap. Anything
30 * security-relevant — revoking, or deciding whether the site holds a credential
31 * at all — goes through `option_names()`, which asks the database.
32 *
33 * Lookup is O(1): a presented secret is hashed and the row key derived from the
34 * hash, so nothing is scanned.
35 *
36 * @package Templately\Modules\McpServer\Auth\OAuth
37 */
38
39 namespace Templately\Modules\McpServer\Auth\OAuth;
40
41 use Templately\Modules\McpCore\Registry\ToolDescriptor;
42
43 class RecordStore {
44
45 const CLIENT_PREFIX = 'templately_mcp_client_';
46 const GRANT_PREFIX = 'templately_mcp_grant_';
47 const TOKEN_PREFIX = 'templately_mcp_token_';
48 /** Tombstones for spent refresh tokens — a hash and a chain id, never a secret. */
49 const SPENT_PREFIX = 'templately_mcp_spent_';
50 const INDEX_OPTION = 'templately_mcp_oauth_index';
51 const SWEEP_HOOK = 'templately_mcp_oauth_sweep';
52
53 const GRANT_TTL = 60;
54 const ACCESS_TTL = 3600;
55 const REFRESH_TTL = 2592000;
56 const CLIENT_TTL = 2592000;
57
58 // ------------------------------------------------------------- clients
59
60 /**
61 * @param string $client_name
62 * @param array $redirect_uris
63 * @return array
64 */
65 public static function create_client( string $client_name, array $redirect_uris ): array {
66 $client_id = 'tmpl_' . bin2hex( random_bytes( 16 ) );
67
68 $record = [
69 'client_id' => $client_id,
70 // Self-reported and untrusted — escaped wherever it is displayed.
71 'client_name' => $client_name,
72 'redirect_uris' => array_values( $redirect_uris ),
73 'created_at' => time(),
74 // An EXPLICIT expiry, because sweep() only reclaims index entries
75 // whose expiry is > 0. Without it a client row registered by an
76 // unauthenticated caller was immortal: read-time expiry never fires
77 // for a record nobody ever reads back, so garbage registrations
78 // accumulated forever and made the index grow without bound.
79 'expires_at' => time() + self::CLIENT_TTL,
80 ];
81
82 self::put( self::CLIENT_PREFIX . $client_id, $record );
83
84 return $record;
85 }
86
87 /**
88 * @param string $client_id
89 * @return array|null
90 */
91 public static function get_client( string $client_id ): ?array {
92 return self::get( self::CLIENT_PREFIX . $client_id, self::CLIENT_TTL );
93 }
94
95 // -------------------------------------------------------------- grants
96
97 /**
98 * @param array $grant
99 * @return string The one-time code (never stored in plaintext).
100 */
101 public static function create_grant( array $grant ): string {
102 $code = bin2hex( random_bytes( 32 ) );
103
104 $grant['created_at'] = time();
105
106 // Defaulted HERE, not left to the caller. `put()` indexes a record with no
107 // `expires_at` as expiry 0, and `sweep()` only reclaims entries whose
108 // expiry is > 0 — so a grant that is abandoned mid-flow (the user opens the
109 // approval screen, approves, and the client never exchanges the code) would
110 // be immortal. Read-time expiry never fires for a row nobody reads back.
111 // That is the same trap `create_client()` was fixed for, and it also keeps
112 // `has_any()` — which counts grant rows — reporting the site as connected
113 // forever, holding the endpoint out of its inert state (FR-023).
114 if ( empty( $grant['expires_at'] ) ) {
115 $grant['expires_at'] = time() + self::GRANT_TTL;
116 }
117
118 self::put( self::GRANT_PREFIX . hash( 'sha256', $code ), $grant );
119
120 return $code;
121 }
122
123 /**
124 * Fetch a grant AND DELETE IT, whether or not the caller then accepts it.
125 *
126 * Deleting before verification is deliberate (FR-033): it makes a failed
127 * exchange unretryable, so a leaked code cannot be brute-forced against the
128 * proof-of-origination check.
129 *
130 * @param string $code
131 * @return array|null
132 */
133 public static function consume_grant( string $code ): ?array {
134 $key = self::GRANT_PREFIX . hash( 'sha256', $code );
135 $grant = self::get( $key, self::GRANT_TTL );
136
137 delete_option( $key );
138 self::deindex( $key );
139
140 return $grant;
141 }
142
143 // -------------------------------------------------------------- tokens
144
145 /**
146 * @param array $token
147 * @param int $ttl
148 * @return string The secret (never stored in plaintext).
149 */
150 public static function create_token( array $token, int $ttl ): string {
151 $secret = bin2hex( random_bytes( 32 ) );
152
153 $token['created_at'] = time();
154 $token['expires_at'] = time() + $ttl;
155
156 self::put( self::TOKEN_PREFIX . hash( 'sha256', $secret ), $token );
157
158 return $secret;
159 }
160
161 /**
162 * @param string $secret
163 * @return array|null
164 */
165 public static function find_access_token( string $secret ): ?array {
166 $record = self::get( self::TOKEN_PREFIX . hash( 'sha256', $secret ), self::ACCESS_TTL );
167
168 if ( null === $record || 'access' !== ( $record['type'] ?? '' ) ) {
169 return null;
170 }
171
172 // Audience check — RFC 9700 §2.3 / MCP: a resource server MUST refuse a
173 // token that was not meant for it. Exactly one acceptable value; the
174 // transitional arm that also accepted the issuer is gone, because the
175 // issuer is now path-scoped and no token ever carried that string.
176 if ( ! empty( $record['resource'] ) && $record['resource'] !== self::audience() ) {
177 return null;
178 }
179
180 return $record;
181 }
182
183 /**
184 * @param string $secret
185 * @return array|null
186 */
187 public static function find_refresh_token( string $secret ): ?array {
188 $record = self::get( self::TOKEN_PREFIX . hash( 'sha256', $secret ), self::REFRESH_TTL );
189
190 if ( null === $record || 'refresh' !== ( $record['type'] ?? '' ) ) {
191 return null;
192 }
193
194 // Same audience check as the access token. Not exploitable today (a
195 // refresh token can never be presented as a bearer), but the asymmetry
196 // becomes live the moment this store is shared across sites.
197 if ( ! empty( $record['resource'] ) && $record['resource'] !== self::audience() ) {
198 return null;
199 }
200
201 return $record;
202 }
203
204 // ------------------------------------------------- grant chains (reuse)
205
206 /**
207 * Remember that a refresh token was spent, WITHOUT keeping the credential.
208 *
209 * Rotation alone cannot detect replay: once the old row is deleted, a stolen
210 * token presented afterwards is indistinguishable from a random string, so
211 * the attacker's theft is invisible and the legitimate client simply falls
212 * off. Keeping a tombstone — the hash and the chain it belonged to, no
213 * secret — is what makes the second presentation recognisable as REUSE.
214 *
215 * OAuth 2.1 §4.3.1 describes revoking "the active refresh token as well as
216 * the access authorization grant associated with it" on detection. (RFC 9700
217 * §4.14.2 revokes only the active token — the MUST is on having a detection
218 * mechanism at all, not on the breadth of the response. This takes the
219 * stronger of the two.)
220 *
221 * @param string $hash sha256 of the spent secret.
222 * @param string $chain Grant chain the token belonged to.
223 * @param int $ttl
224 * @return void
225 */
226 public static function tombstone_refresh( string $hash, string $chain, int $ttl ): void {
227 self::put(
228 self::SPENT_PREFIX . $hash,
229 [
230 'chain' => $chain,
231 'created_at' => time(),
232 'expires_at' => time() + $ttl,
233 ]
234 );
235 }
236
237 /**
238 * @param string $secret
239 * @return string '' when this token was never spent.
240 */
241 public static function spent_chain( string $secret ): string {
242 $record = self::get( self::SPENT_PREFIX . hash( 'sha256', $secret ), self::REFRESH_TTL );
243
244 return is_array( $record ) ? (string) ( $record['chain'] ?? '' ) : '';
245 }
246
247 /**
248 * Revoke every credential descended from one authorization grant.
249 *
250 * @param string $chain
251 * @return int Number of records removed.
252 */
253 public static function revoke_chain( string $chain ): int {
254 if ( '' === $chain ) {
255 return 0;
256 }
257
258 $removed = 0;
259
260 foreach ( array_merge(
261 self::option_names( self::TOKEN_PREFIX ),
262 self::option_names( self::SPENT_PREFIX )
263 ) as $key ) {
264 $record = get_option( $key, null );
265
266 if ( ! is_array( $record ) || (string) ( $record['chain'] ?? '' ) !== $chain ) {
267 continue;
268 }
269
270 delete_option( $key );
271 self::deindex( $key );
272
273 ++$removed;
274 }
275
276 return $removed;
277 }
278
279 /**
280 * @return string
281 */
282 public static function new_chain(): string {
283 return bin2hex( random_bytes( 16 ) );
284 }
285
286 /**
287 * Take a refresh token AND spend it, letting the DELETE arbitrate the race.
288 *
289 * `delete_option()` returns false when the row was already gone, and its
290 * underlying DELETE reports rows-affected — so of two concurrent refreshes
291 * presenting the same token, exactly one sees `true` and may mint. The
292 * previous find-then-delete let BOTH pass the lookup and both issue a token
293 * pair, leaving an extra live credential per race.
294 *
295 * @param string $secret
296 * @return array|null The record, or null if it was not ours to spend.
297 */
298 public static function consume_refresh_token( string $secret ): ?array {
299 $key = self::TOKEN_PREFIX . hash( 'sha256', $secret );
300 $record = self::get( $key, self::REFRESH_TTL );
301
302 if ( null === $record || 'refresh' !== ( $record['type'] ?? '' ) ) {
303 return null;
304 }
305
306 if ( ! empty( $record['resource'] ) && $record['resource'] !== self::audience() ) {
307 return null;
308 }
309
310 if ( ! delete_option( $key ) ) {
311 return null;
312 }
313
314 self::deindex( $key );
315
316 return $record;
317 }
318
319 /**
320 * @param string $secret
321 * @return void
322 */
323 public static function delete_token( string $secret ): void {
324 self::delete_token_by_hash( hash( 'sha256', $secret ) );
325 }
326
327 /**
328 * For a token known only by its hash — the paired access token recorded on a
329 * refresh token, which is stored hashed and never held in plaintext.
330 *
331 * @param string $hash
332 * @return void
333 */
334 public static function delete_token_by_hash( string $hash ): void {
335 $key = self::TOKEN_PREFIX . $hash;
336
337 delete_option( $key );
338 self::deindex( $key );
339 }
340
341 /**
342 * This authorization server's ISSUER identifier (RFC 8414 §2).
343 *
344 * PATH-SCOPED — `https://site.test/templately`, not the bare origin.
345 *
346 * RFC 8414 gives an origin exactly ONE authorization-server document, at
347 * `/.well-known/oauth-authorization-server`. Claiming the bare origin as our
348 * issuer therefore put us in a single global slot that any other OAuth-
349 * serving plugin on the same site claims too — whoever registered its
350 * rewrite first won, and a client could be handed the wrong server's
351 * authorization endpoint. We won that race by accident, not by design.
352 *
353 * §3.1 resolves it: for an issuer with a path component, the metadata URL is
354 * built by INSERTING the well-known segment between host and path —
355 * `https://site.test/.well-known/oauth-authorization-server/templately`. That
356 * is a slot only this plugin can occupy, so two MCP plugins can coexist.
357 *
358 * @return string
359 */
360 public static function issuer(): string {
361 return untrailingslashit( home_url( '/templately' ) );
362 }
363
364 /**
365 * @deprecated Use issuer() (identity) or audience() (what a token is for).
366 * Kept because the two were conflated under this one name.
367 * @return string
368 */
369 public static function resource_id(): string {
370 return self::issuer();
371 }
372
373 /**
374 * The AUDIENCE a token is minted for — the MCP endpoint's canonical URI.
375 *
376 * Distinct from the issuer above, which it used to be conflated with. Both
377 * were `resource_id()`, so the "audience check" in find_access_token() was
378 * really an issuer check that happened to be self-consistent. RFC 8707 binds
379 * a token to a RESOURCE, and RFC 9700 §2.3 makes rejecting a token meant for
380 * a different resource a MUST at the resource server — which is
381 * unimplementable while the two identifiers are the same string.
382 *
383 * This is also the only value this server will accept in a client's
384 * `resource` parameter.
385 *
386 * @return string
387 */
388 public static function audience(): string {
389 return rest_url( 'templately/v1/mcp' );
390 }
391
392 /**
393 * Whether a client-supplied `resource` indicator names THIS server.
394 *
395 * MCP requires clients to send `resource` on both the authorization and the
396 * token request. Accepting one that names somebody else's server and issuing
397 * a token anyway is how a client ends up believing it holds a credential for
398 * a resource it does not — the token-passthrough shape the MCP security
399 * guidance calls out.
400 *
401 * An ABSENT indicator is accepted: RFC 8707 §2.1 leaves that to local policy,
402 * and this server has exactly one resource, so there is nothing to disambiguate.
403 *
404 * @param string $requested
405 * @return bool
406 */
407 public static function resource_matches( string $requested ): bool {
408 if ( '' === $requested ) {
409 return true;
410 }
411
412 $normalise = static function ( $value ) {
413 $parts = wp_parse_url( untrailingslashit( $value ) );
414
415 if ( empty( $parts['host'] ) ) {
416 return '';
417 }
418
419 return strtolower( $parts['scheme'] ?? '' ) . '://' . strtolower( $parts['host'] )
420 . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' )
421 . ( $parts['path'] ?? '' );
422 };
423
424 return '' !== $normalise( $requested ) && $normalise( $requested ) === $normalise( self::audience() );
425 }
426
427 // ----------------------------------------------------------- admin view
428
429 /** Settings-list id prefix, so one revoke route can serve both systems. */
430 const PUBLIC_ID_PREFIX = 'oauth_';
431
432 /**
433 * Delegated connections for the Settings → AI Agents list, grouped BY CLIENT.
434 *
435 * Grouping matters. A live connection is a rotating pair of rows — an access
436 * token that expires hourly and a refresh token that replaces it — so
437 * listing raw token rows would show a connection vanishing an hour after it
438 * was made and reappearing under a new id. One row per application is what
439 * an administrator actually means by "the ChatGPT connection".
440 *
441 * Before this, the settings list read only `Credentials` (pairing tokens),
442 * so a site whose only connection was granted through OAuth showed an empty
443 * list and no way to inspect or revoke it.
444 *
445 * Shaped like `Credentials::list_public()` so the same table renders both.
446 *
447 * @return array
448 */
449 public static function list_connections(): array {
450 $clients = [];
451
452 foreach ( self::option_names( self::TOKEN_PREFIX ) as $key ) {
453
454 $record = get_option( $key, null );
455
456 if ( ! is_array( $record ) || empty( $record['client_id'] ) ) {
457 continue;
458 }
459
460 $expires = (int) ( $record['expires_at'] ?? 0 );
461
462 if ( $expires > 0 && $expires < time() ) {
463 continue;
464 }
465
466 $client_id = (string) $record['client_id'];
467 $created = (int) ( $record['created_at'] ?? 0 );
468
469 if ( ! isset( $clients[ $client_id ] ) ) {
470 $clients[ $client_id ] = [
471 'user_id' => (int) ( $record['user_id'] ?? 0 ),
472 'access_level' => (string) ( $record['access_level'] ?? '' ),
473 'created_at' => $created,
474 'expires_at' => $expires,
475 ];
476
477 continue;
478 }
479
480 // Granted-at is the OLDEST surviving token; valid-until the newest.
481 $clients[ $client_id ]['created_at'] = min( $clients[ $client_id ]['created_at'], $created );
482 $clients[ $client_id ]['expires_at'] = max( $clients[ $client_id ]['expires_at'], $expires );
483 }
484
485 $rows = [];
486
487 foreach ( $clients as $client_id => $data ) {
488 $client = self::get_client( $client_id );
489 $user = $data['user_id'] ? get_userdata( $data['user_id'] ) : null;
490
491 $rows[] = [
492 'id' => self::PUBLIC_ID_PREFIX . $client_id,
493 // Distinguishes the two systems in the UI: a delegated grant has
494 // no secret an administrator could ever have copied.
495 'source' => 'oauth',
496 'name' => ( $client && '' !== $client['client_name'] )
497 ? $client['client_name']
498 : __( 'Unnamed application', 'templately' ),
499 'user_id' => $data['user_id'],
500 'user_login' => $user ? $user->user_login : '',
501 // Never '' — the settings table feeds this straight into a
502 // controlled <select> whose options are read|full, and an empty
503 // value renders a blank row with no selection.
504 'access_level' => ToolDescriptor::ACCESS_FULL === $data['access_level']
505 ? ToolDescriptor::ACCESS_FULL
506 : ToolDescriptor::ACCESS_READ,
507 'created_at' => $data['created_at'],
508 // Not tracked per-request for delegated tokens; the UI shows a dash.
509 'last_used_at' => null,
510 'expires_at' => $data['expires_at'],
511 ];
512 }
513
514 // A plain closure, not `fn()` — this tree must parse on the advertised
515 // PHP 7.2 floor. Nothing from the enclosing scope is read, so there is
516 // no `use` clause; `static` keeps `$this` unbound as before.
517 usort( $rows, static function( $a, $b ) {
518 return $b['created_at'] <=> $a['created_at'];
519 } );
520
521 return $rows;
522 }
523
524 /**
525 * @param string $public_id
526 * @return string '' when the id does not belong to this store.
527 */
528 public static function client_id_from_public_id( string $public_id ): string {
529 return 0 === strpos( $public_id, self::PUBLIC_ID_PREFIX )
530 ? substr( $public_id, strlen( self::PUBLIC_ID_PREFIX ) )
531 : '';
532 }
533
534 /**
535 * Change what an already-connected application may do, WITHOUT making the
536 * user disconnect and re-approve. Applies to the tokens it holds now and to
537 * anything it refreshes into, since a refresh copies the level forward.
538 *
539 * @param string $client_id
540 * @param string $level
541 * @return bool
542 */
543 public static function set_client_access_level( string $client_id, string $level ): bool {
544 // Normalised HERE and not left to the caller: this widens what an agent
545 // may do, so it must not depend on every future call site remembering.
546 // Toward READ, matching the consent screen's least-privilege default.
547 $level = ToolDescriptor::ACCESS_FULL === $level
548 ? ToolDescriptor::ACCESS_FULL
549 : ToolDescriptor::ACCESS_READ;
550
551 $found = false;
552
553 foreach ( self::option_names( self::TOKEN_PREFIX ) as $key ) {
554
555 $record = get_option( $key, null );
556
557 if ( ! is_array( $record ) || (string) ( $record['client_id'] ?? '' ) !== $client_id ) {
558 continue;
559 }
560
561 // Skip rows already past their expiry, consistent with
562 // list_connections() — no point rewriting a dead credential.
563 $expires = (int) ( $record['expires_at'] ?? 0 );
564
565 if ( $expires > 0 && $expires < time() ) {
566 continue;
567 }
568
569 $record['access_level'] = $level;
570
571 update_option( $key, $record, 'no' );
572
573 $found = true;
574 }
575
576 return $found;
577 }
578
579 /**
580 * Revoke every record belonging to one application — its tokens, any grant
581 * still in flight, and the registration itself, so a stale authorize link
582 * cannot resurrect it.
583 *
584 * @param string $client_id
585 * @return bool
586 */
587 public static function revoke_client( string $client_id ): bool {
588 $index = self::index();
589 $removed = [];
590
591 // From the DATABASE, not the index — a revoke that misses a live row is
592 // the whole failure mode this guards against.
593 $keys = array_merge(
594 self::option_names( self::TOKEN_PREFIX ),
595 self::option_names( self::GRANT_PREFIX )
596 );
597
598 foreach ( $keys as $key ) {
599 $record = get_option( $key, null );
600
601 if ( ! is_array( $record ) || (string) ( $record['client_id'] ?? '' ) !== $client_id ) {
602 continue;
603 }
604
605 delete_option( $key );
606
607 $removed[] = $key;
608 }
609
610 $client_key = self::CLIENT_PREFIX . $client_id;
611
612 if ( null !== get_option( $client_key, null ) ) {
613 delete_option( $client_key );
614
615 $removed[] = $client_key;
616 }
617
618 if ( empty( $removed ) ) {
619 return false;
620 }
621
622 // ONE index write for the whole revocation. Calling deindex() per key
623 // would re-read and re-write the index for every row.
624 foreach ( $removed as $key ) {
625 unset( $index[ $key ] );
626 }
627
628 update_option( self::INDEX_OPTION, $index, 'no' );
629
630 return true;
631 }
632
633 // ------------------------------------------------------------ lifecycle
634
635 /**
636 * Whether this store holds anything that can actually AUTHENTICATE.
637 *
638 * Counts tokens and in-flight grants only — NOT client registrations. A bare
639 * registration confers nothing, and `/mcp/oauth/register` is deliberately
640 * public, so counting the whole index let any unauthenticated caller flip
641 * `Credentials::site_has_any()` to true for good: that is the FR-023 gate
642 * that keeps the endpoint inert until an administrator connects, the gate on
643 * serving discovery, and the `connected` flag in the settings UI. It also
644 * made revoking the last connection leave the page claiming "connected" over
645 * an empty list, because a registration row (indexed with no expiry) is
646 * never reclaimed by the sweep.
647 *
648 * @return bool
649 */
650 public static function has_any(): bool {
651 return ! empty( self::option_names( self::TOKEN_PREFIX ) )
652 || ! empty( self::option_names( self::GRANT_PREFIX ) );
653 }
654
655 /**
656 * Kill switch — clears every delegated record (FR-024a).
657 */
658 public static function purge_all(): void {
659 // Enumerated from the database. Walking the index would silently spare
660 // any row whose index entry had been lost to a concurrent write — and a
661 // kill switch that leaves a working credential behind is worse than no
662 // kill switch, because it reports success.
663 $keys = array_merge(
664 self::option_names( self::CLIENT_PREFIX ),
665 self::option_names( self::GRANT_PREFIX ),
666 self::option_names( self::TOKEN_PREFIX )
667 );
668
669 foreach ( $keys as $key ) {
670 delete_option( $key );
671 }
672
673 delete_option( self::INDEX_OPTION );
674 }
675
676 /**
677 * Reclaim rows nothing ever read back. Expiry is also enforced on read, so
678 * this only matters for records abandoned mid-flow (FR-030).
679 */
680 public static function sweep(): void {
681 $index = self::index();
682 $changed = false;
683
684 foreach ( $index as $key => $expires ) {
685 if ( $expires > 0 && $expires < time() ) {
686 delete_option( $key );
687 unset( $index[ $key ] );
688 $changed = true;
689 }
690 }
691
692 if ( $changed ) {
693 update_option( self::INDEX_OPTION, $index, 'no' );
694 }
695 }
696
697 /**
698 * How many records are past their expiry right now.
699 *
700 * Read-only; used by the shared cleanup service to estimate before it runs.
701 */
702 public static function count_expired(): int {
703 $expired = 0;
704
705 foreach ( self::index() as $expires ) {
706 if ( $expires > 0 && $expires < time() ) {
707 $expired++;
708 }
709 }
710
711 return $expired;
712 }
713
714 /**
715 * Bind the sweep to its legacy hook.
716 *
717 * NO LONGER SCHEDULES ANYTHING. This module used to register its own daily
718 * WP-Cron event; scheduling moved to `modules/utilities/` (spec 052), which
719 * runs the plugin's ONE cleanup sweep and unschedules this event where a site
720 * already stored it. The hook binding is kept so a stored event that fires
721 * before that unscheduling still does the right thing rather than nothing.
722 */
723 public static function schedule_sweep(): void {
724 add_action( self::SWEEP_HOOK, [ self::class, 'sweep' ] );
725 }
726
727 // -------------------------------------------------------------- internals
728
729 /**
730 * @param string $key
731 * @param array $record
732 * @param int $ttl
733 */
734 private static function put( string $key, array $record, int $ttl = 0 ): void {
735 update_option( $key, $record, 'no' );
736
737 $expires = $record['expires_at'] ?? ( $ttl > 0 ? time() + $ttl : 0 );
738
739 $index = self::index();
740 $index[ $key ] = (int) $expires;
741
742 update_option( self::INDEX_OPTION, $index, 'no' );
743 }
744
745 /**
746 * Expiry enforced on read: a row past its time is treated as absent AND
747 * deleted, so a stale record can never authenticate even if the sweep has
748 * not run.
749 *
750 * @param string $key
751 * @param int $max_age Fallback when the record carries no expires_at.
752 * @return array|null
753 */
754 private static function get( string $key, int $max_age ) {
755 $record = get_option( $key, null );
756
757 if ( ! is_array( $record ) ) {
758 return null;
759 }
760
761 $expires = (int) ( $record['expires_at'] ?? ( (int) ( $record['created_at'] ?? 0 ) + $max_age ) );
762
763 if ( $expires > 0 && $expires < time() ) {
764 delete_option( $key );
765 self::deindex( $key );
766
767 return null;
768 }
769
770 return $record;
771 }
772
773 /**
774 * @return array
775 */
776 private static function index(): array {
777 $index = get_option( self::INDEX_OPTION, [] );
778
779 return is_array( $index ) ? $index : [];
780 }
781
782 /**
783 * Every option name this store owns, read from the DATABASE rather than the
784 * index.
785 *
786 * The index is a convenience cache, not a source of truth, and treating it
787 * as one was a real hole: authentication resolves a token by reading its
788 * option key DIRECTLY (`find_access_token`), while revocation and the sweep
789 * walked only the index. Those writes are unlocked read-modify-write cycles
790 * on one shared option, so a rotation landing concurrently with a sweep or a
791 * revoke could lose its index entries — leaving a token row that still
792 * authenticates for its full lifetime but is invisible to "Revoke", which
793 * would report success over a connection that kept working.
794 *
795 * Anything security-relevant — revoking, counting credentials — must ask the
796 * database. The index survives only to make the sweep cheap.
797 *
798 * @param string $prefix
799 * @return string[]
800 */
801 private static function option_names( string $prefix ): array {
802 global $wpdb;
803
804 if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
805 return array_values(
806 array_filter(
807 array_keys( self::index() ),
808 static function ( $key ) use ( $prefix ) {
809 return 0 === strpos( $key, $prefix );
810 }
811 )
812 );
813 }
814
815 $names = $wpdb->get_col(
816 $wpdb->prepare(
817 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb.
818 $wpdb->esc_like( $prefix ) . '%'
819 )
820 );
821
822 return is_array( $names ) ? $names : [];
823 }
824
825 private static function deindex( string $key ): void {
826 $index = self::index();
827
828 if ( isset( $index[ $key ] ) ) {
829 unset( $index[ $key ] );
830 update_option( self::INDEX_OPTION, $index, 'no' );
831 }
832 }
833 }
834