PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.2
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.2
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 / domain / token-set.php

token-set.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.2, at src/myyoast-client/domain/token-set.php

336 lines 9.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Yoast\WP\SEO\MyYoast_Client\Domain;
4
5 use InvalidArgumentException;
6 use SensitiveParameter;
7
8 /**
9 * Immutable value object representing a set of OAuth tokens.
10 *
11 * Can represent both user-level tokens (from authorization code flow, with
12 * refresh_token and id_token) and site-level tokens (from client_credentials,
13 * access_token only).
14 */
15 class Token_Set {
16
17 /**
18 * Seconds before actual expiry to consider the token expired.
19 * Accounts for request latency and minor clock differences.
20 */
21 private const EXPIRY_BUFFER_SECONDS = 60;
22
23 /**
24 * The access token (opaque string — do not parse).
25 *
26 * @var string
27 */
28 private $access_token;
29
30 /**
31 * The Unix timestamp at which the access token expires.
32 *
33 * @var int
34 */
35 private $expires_at;
36
37 /**
38 * The refresh token, if available (user-level tokens only).
39 *
40 * @var string|null
41 */
42 private $refresh_token;
43
44 /**
45 * The OIDC ID token, if available (user-level tokens only).
46 *
47 * @var string|null
48 */
49 private $id_token;
50
51 /**
52 * The granted scope string.
53 *
54 * @var string|null
55 */
56 private $scope;
57
58 /**
59 * The token type (an Auth_Token_Type constant).
60 *
61 * @var string
62 */
63 private $token_type;
64
65 /**
66 * The number of consecutive refresh errors.
67 *
68 * @var int
69 */
70 private $error_count;
71
72 /**
73 * The RFC 8707 resource indicator this token was minted for.
74 *
75 * @var Resource_Indicator
76 */
77 private $resource_indicator;
78
79 /**
80 * Token_Set constructor.
81 *
82 * @param string $access_token The access token.
83 * @param int $expires_at Unix timestamp of token expiry.
84 * @param string $token_type An Auth_Token_Type constant.
85 * @param string|null $refresh_token The refresh token.
86 * @param string|null $id_token The OIDC ID token.
87 * @param string|null $scope The granted scope.
88 * @param int $error_count The number of consecutive refresh errors.
89 * @param Resource_Indicator|null $resource_indicator The resource indicator (RFC 8707) the token was minted for. Null is treated as Resource_Indicator::default().
90 *
91 * @throws InvalidArgumentException If required fields are empty or invalid.
92 */
93 public function __construct(
94 // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPNativeAttributeFound -- No-op on PHP < 8.2; redacts parameter from stack traces on PHP 8.2+.
95 #[SensitiveParameter]
96 string $access_token,
97 int $expires_at,
98 string $token_type = Auth_Token_Type::DPOP,
99 // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPNativeAttributeFound -- No-op on PHP < 8.2; redacts parameter from stack traces on PHP 8.2+.
100 #[SensitiveParameter]
101 ?string $refresh_token = null,
102 ?string $id_token = null,
103 ?string $scope = null,
104 int $error_count = 0,
105 ?Resource_Indicator $resource_indicator = null
106 ) {
107 if ( $access_token === '' ) {
108 throw new InvalidArgumentException( 'Token_Set requires a non-empty access_token.' );
109 }
110 if ( $expires_at <= 0 ) {
111 throw new InvalidArgumentException( 'Token_Set requires a positive expires_at timestamp.' );
112 }
113 if ( $token_type === '' ) {
114 throw new InvalidArgumentException( 'Token_Set requires a non-empty token_type.' );
115 }
116
117 $this->access_token = $access_token;
118 $this->expires_at = $expires_at;
119 $this->token_type = $token_type;
120 $this->refresh_token = $refresh_token;
121 $this->id_token = $id_token;
122 $this->scope = $scope;
123 $this->error_count = $error_count;
124 $this->resource_indicator = ( $resource_indicator ?? new Resource_Indicator( null ) );
125 }
126
127 /**
128 * Returns the access token.
129 *
130 * @return string
131 */
132 public function get_access_token(): string {
133 return $this->access_token;
134 }
135
136 /**
137 * Returns the Unix timestamp at which the access token expires.
138 *
139 * @return int
140 */
141 public function get_expires_at(): int {
142 return $this->expires_at;
143 }
144
145 /**
146 * Returns the token type.
147 *
148 * @return string
149 */
150 public function get_token_type(): string {
151 return $this->token_type;
152 }
153
154 /**
155 * Returns the refresh token, or null if not available.
156 *
157 * @return string|null
158 */
159 public function get_refresh_token(): ?string {
160 return $this->refresh_token;
161 }
162
163 /**
164 * Returns the OIDC ID token, or null if not available.
165 *
166 * @return string|null
167 */
168 public function get_id_token(): ?string {
169 return $this->id_token;
170 }
171
172 /**
173 * Returns the granted scope string, or null if not available.
174 *
175 * @return string|null
176 */
177 public function get_scope(): ?string {
178 return $this->scope;
179 }
180
181 /**
182 * Checks if the token set has the required scope(s).
183 * Returns true if AT LEAST all required scopes are granted, false otherwise.
184 *
185 * @param string[] $required_scopes The required scopes as an array of strings.
186 *
187 * @return bool True if all required scopes are granted, false otherwise.
188 */
189 public function has_scopes( array $required_scopes ): bool {
190 if ( $this->scope === null ) {
191 return \count( $required_scopes ) === 0;
192 }
193 $granted_scopes = \explode( ' ', $this->scope );
194 return \count( \array_diff( $required_scopes, $granted_scopes ) ) === 0;
195 }
196
197 /**
198 * Returns the number of consecutive refresh errors.
199 *
200 * @return int
201 */
202 public function get_error_count(): int {
203 return $this->error_count;
204 }
205
206 /**
207 * Returns the RFC 8707 resource indicator this token was minted for.
208 *
209 * @return Resource_Indicator
210 */
211 public function get_resource_indicator(): Resource_Indicator {
212 return $this->resource_indicator;
213 }
214
215 /**
216 * Returns a new Token_Set bound to the given resource indicator.
217 *
218 * @param Resource_Indicator $resource_indicator The resource indicator.
219 *
220 * @return self
221 */
222 public function with_resource_indicator( Resource_Indicator $resource_indicator ): self {
223 return new self(
224 $this->access_token,
225 $this->expires_at,
226 $this->token_type,
227 $this->refresh_token,
228 $this->id_token,
229 $this->scope,
230 $this->error_count,
231 $resource_indicator,
232 );
233 }
234
235 /**
236 * Returns a new Token_Set with an incremented error count.
237 *
238 * @return self
239 */
240 public function with_incremented_error_count(): self {
241 return new self(
242 $this->access_token,
243 $this->expires_at,
244 $this->token_type,
245 $this->refresh_token,
246 $this->id_token,
247 $this->scope,
248 $this->error_count + 1,
249 $this->resource_indicator,
250 );
251 }
252
253 /**
254 * Whether the access token has expired.
255 *
256 * Uses a 60-second buffer to allow for request latency.
257 *
258 * @return bool
259 */
260 public function is_expired(): bool {
261 return \time() >= ( $this->expires_at - self::EXPIRY_BUFFER_SECONDS );
262 }
263
264 /**
265 * Converts the token set to an associative array for storage.
266 *
267 * @return array<string, string|int|null> The token set as an array.
268 */
269 public function to_array(): array {
270 return [
271 'access_token' => $this->access_token,
272 'expires_at' => $this->expires_at,
273 'token_type' => $this->token_type,
274 'refresh_token' => $this->refresh_token,
275 'id_token' => $this->id_token,
276 'scope' => $this->scope,
277 'error_count' => $this->error_count,
278 'resource_indicator' => $this->resource_indicator->value(),
279 ];
280 }
281
282 /**
283 * Creates a Token_Set from a stored array.
284 *
285 * @param array<string, string|int|null> $data The stored array data.
286 *
287 * @return self
288 */
289 public static function from_array( array $data ): self {
290 $stored_indicator = ( $data['resource_indicator'] ?? null );
291
292 return new self(
293 (string) ( $data['access_token'] ?? '' ),
294 (int) ( $data['expires_at'] ?? 0 ),
295 ( $data['token_type'] ?? Auth_Token_Type::DPOP ),
296 ( $data['refresh_token'] ?? null ),
297 ( $data['id_token'] ?? null ),
298 ( $data['scope'] ?? null ),
299 (int) ( $data['error_count'] ?? 0 ),
300 new Resource_Indicator( ( \is_string( $stored_indicator ) && $stored_indicator !== '' ) ? $stored_indicator : null ),
301 );
302 }
303
304 /**
305 * Creates a Token_Set from a token endpoint response.
306 *
307 * Per RFC 8707 §3 the AS may echo a `resource` field to confirm the audience
308 * it minted the token for. The spec does not require the client to honour
309 * the echo, and trusting an unverified echo into storage could later violate
310 * §2 on refresh. We deliberately ignore the echoed field; the caller is
311 * expected to stamp the requested indicator via with_resource_indicator().
312 *
313 * @param array<string, string|int|null> $response The parsed JSON response from the token endpoint.
314 *
315 * @return self
316 *
317 * @throws InvalidArgumentException If the response is missing a valid access_token.
318 */
319 public static function from_response( array $response ): self {
320 if ( empty( $response['access_token'] ) || ! \is_string( $response['access_token'] ) ) {
321 throw new InvalidArgumentException( 'Token response is missing a valid access_token.' );
322 }
323
324 $expires_in = (int) ( $response['expires_in'] ?? 900 );
325
326 return new self(
327 $response['access_token'],
328 ( \time() + $expires_in ),
329 ( $response['token_type'] ?? Auth_Token_Type::DPOP ),
330 ( $response['refresh_token'] ?? null ),
331 ( $response['id_token'] ?? null ),
332 ( $response['scope'] ?? null ),
333 );
334 }
335 }
336