PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.4
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.4
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / src / myyoast-client / infrastructure / token / token-storage.php

token-storage.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.4, at src/myyoast-client/infrastructure/token/token-storage.php

231 lines 7.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
3
4 namespace Yoast\WP\SEO\MyYoast_Client\Infrastructure\Token;
5
6 use Exception;
7 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Token_Storage_Exception;
8 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Token_Storage_Interface;
9 use Yoast\WP\SEO\MyYoast_Client\Domain\Resource_Indicator;
10 use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Set;
11 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption;
12 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption_Exception;
13 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\OIDC\Issuer_Config;
14 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
15 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
16 use YoastSEO_Vendor\Psr\Log\NullLogger;
17
18 /**
19 * Stores and retrieves encrypted site-level tokens as WordPress options.
20 *
21 * Used for client_credentials tokens (site-level, no user context). Tokens
22 * are bucketed per RFC 8707 resource indicator so a site can hold one token
23 * per resource server. Each (issuer, resource bucket) pair maps to a
24 * separate option row.
25 *
26 * Key layout:
27 * - Default bucket: wpseo_myyoast_site_tokens_{issuer_key}
28 * - Resource bucket: wpseo_myyoast_site_tokens_{issuer_key}_{sha1_prefix}
29 */
30 class Token_Storage implements Token_Storage_Interface, LoggerAwareInterface {
31 use LoggerAwareTrait;
32
33 private const OPTION_KEY_PREFIX = 'wpseo_myyoast_site_tokens_';
34 private const ENCRYPTION_CONTEXT = 'yoast-myyoast-site-tokens';
35
36 /**
37 * The encryption service.
38 *
39 * @var Encryption
40 */
41 private $encryption;
42
43 /**
44 * The issuer configuration.
45 *
46 * @var Issuer_Config
47 */
48 private $issuer_config;
49
50 /**
51 * Token_Storage constructor.
52 *
53 * @param Encryption $encryption The encryption service.
54 * @param Issuer_Config $issuer_config The issuer configuration.
55 */
56 public function __construct( Encryption $encryption, Issuer_Config $issuer_config ) {
57 $this->encryption = $encryption;
58 $this->issuer_config = $issuer_config;
59 $this->logger = new NullLogger();
60 }
61
62 /**
63 * Stores a token set (encrypted). The resource bucket is derived from the token's own resource indicator.
64 *
65 * @param Token_Set $token_set The token set to store.
66 *
67 * @return void
68 *
69 * @throws Token_Storage_Exception If encryption fails.
70 */
71 public function store( Token_Set $token_set ): void {
72 try {
73 // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Encoding for encrypted storage, not user-facing output.
74 $json = \wp_json_encode( $token_set->to_array() );
75 if ( $json === false ) {
76 throw new Token_Storage_Exception( 'Failed to JSON-encode token set for storage.' );
77 }
78
79 $encrypted = $this->encryption->encrypt( $json, self::ENCRYPTION_CONTEXT );
80 }
81 catch ( Encryption_Exception $e ) {
82 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
83 throw new Token_Storage_Exception( 'Failed to encrypt token set for storage: ' . $e->getMessage(), 0, $e );
84 }
85
86 \update_option( $this->get_option_key( $token_set->get_resource_indicator() ), $encrypted, false );
87 }
88
89 /**
90 * Retrieves the stored token set for a resource bucket.
91 *
92 * @param Resource_Indicator $resource_indicator The resource indicator (use Resource_Indicator::default() for the default bucket).
93 *
94 * @return Token_Set|null The token set, or null if not stored or decryption fails.
95 */
96 public function get( Resource_Indicator $resource_indicator ): ?Token_Set {
97 return $this->decrypt_and_decode( \get_option( $this->get_option_key( $resource_indicator ), '' ) );
98 }
99
100 /**
101 * Deletes the stored token set for a resource bucket.
102 *
103 * @param Resource_Indicator $resource_indicator The resource indicator (use Resource_Indicator::default() for the default bucket).
104 *
105 * @return void
106 */
107 public function delete( Resource_Indicator $resource_indicator ): void {
108 \delete_option( $this->get_option_key( $resource_indicator ) );
109 }
110
111 /**
112 * Returns every stored token set across resource buckets.
113 *
114 * @return Token_Set[] The stored token sets.
115 */
116 public function get_all(): array {
117 global $wpdb;
118 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Accuracy over performance.
119 $rows = $wpdb->get_results(
120 $wpdb->prepare(
121 "SELECT option_value FROM {$wpdb->options} WHERE option_name LIKE %s",
122 $wpdb->esc_like( $this->get_option_key_prefix_for_current_issuer() ) . '%',
123 ),
124 \ARRAY_A,
125 );
126 $tokens = [];
127 foreach ( ( \is_array( $rows ) ? $rows : [] ) as $row ) {
128 $token = $this->decrypt_and_decode( ( $row['option_value'] ?? '' ) );
129 if ( $token !== null ) {
130 $tokens[] = $token;
131 }
132 }
133
134 return $tokens;
135 }
136
137 /**
138 * Deletes every stored token set across resource buckets for the current issuer.
139 *
140 * @return void
141 */
142 public function delete_all(): void {
143 $this->bulk_delete_by_prefix( $this->get_option_key_prefix_for_current_issuer() );
144 }
145
146 /**
147 * Deletes every stored token set across all issuers and resource buckets.
148 *
149 * @return void
150 */
151 public function delete_all_issuers(): void {
152 $this->bulk_delete_by_prefix( self::OPTION_KEY_PREFIX );
153 }
154
155 /**
156 * Deletes every option whose name starts with the given prefix.
157 *
158 * @param string $prefix The option-name prefix.
159 *
160 * @return void
161 */
162 private function bulk_delete_by_prefix( string $prefix ): void {
163 global $wpdb;
164
165 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bulk cleanup.
166 $wpdb->query(
167 $wpdb->prepare(
168 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
169 $wpdb->esc_like( $prefix ) . '%',
170 ),
171 );
172 }
173
174 /**
175 * Decrypts and decodes a stored option value into a Token_Set.
176 *
177 * @param string|false|null $stored The stored value.
178 *
179 * @return Token_Set|null The token set, or null on absence/failure.
180 */
181 private function decrypt_and_decode( $stored ): ?Token_Set {
182 if ( ! \is_string( $stored ) || $stored === '' ) {
183 return null;
184 }
185
186 try {
187 $decrypted = $this->encryption->decrypt( $stored, self::ENCRYPTION_CONTEXT );
188 $data = \json_decode( $decrypted, true, 512, \JSON_THROW_ON_ERROR );
189
190 if ( ! \is_array( $data ) || empty( $data['access_token'] ) ) {
191 return null;
192 }
193
194 return Token_Set::from_array( $data );
195 }
196 catch ( Exception $e ) {
197 $this->logger->error( 'Failed to decrypt stored site token: {error}', [ 'error' => $e->getMessage() ] );
198 return null;
199 }
200 }
201
202 /**
203 * Returns the option key prefix for the current issuer.
204 *
205 * @return string The option key prefix.
206 */
207 private function get_option_key_prefix_for_current_issuer(): string {
208 return self::OPTION_KEY_PREFIX . $this->issuer_config->get_issuer_key();
209 }
210
211 /**
212 * Returns the option key for a resource bucket.
213 *
214 * The default bucket has no suffix and shares its key with pre-RFC-8707
215 * installs. Explicit resource indicators get a sha1-hash suffix joined
216 * by an underscore.
217 *
218 * @param Resource_Indicator $resource_indicator The resource indicator.
219 *
220 * @return string The option key.
221 */
222 private function get_option_key( Resource_Indicator $resource_indicator ): string {
223 $key = $this->get_option_key_prefix_for_current_issuer();
224 if ( $resource_indicator->is_default() ) {
225 return $key;
226 }
227
228 return $key . '_' . \substr( \sha1( $resource_indicator->value() ), 0, 12 );
229 }
230 }
231