PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / trunk
Yoast SEO – Advanced SEO with real-time guidance and built-in AI vtrunk
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 / ai / authentication / application / oauth-auth-strategy.php

oauth-auth-strategy.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI trunk, at src/ai/authentication/application/oauth-auth-strategy.php

338 lines 14.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
4
5 namespace Yoast\WP\SEO\AI\Authentication\Application;
6
7 use WP_User;
8 use WPSEO_Utils;
9 use Yoast\WP\SEO\AI\Authentication\Domain\Exceptions\Auth_Strategy_Unavailable_Exception;
10 use Yoast\WP\SEO\AI\HTTP_Request\Application\Response_Validator;
11 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Bad_Request_Exception;
12 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Consent_Required_Exception;
13 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Forbidden_Exception;
14 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Insufficient_Scope_Exception;
15 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Remote_Request_Exception;
16 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Unauthorized_Exception;
17 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\WP_Request_Exception;
18 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Request;
19 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Response;
20 use Yoast\WP\SEO\AI\HTTP_Request\Infrastructure\API_Client;
21 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Token_Request_Failed_Exception;
22 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Token_Storage_Exception;
23 use Yoast\WP\SEO\MyYoast_Client\Application\MyYoast_Client;
24 use Yoast\WP\SEO\MyYoast_Client\Domain\HTTP_Response;
25 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
26 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
27 use YoastSEO_Vendor\Psr\Log\NullLogger;
28
29 /**
30 * Authenticates AI requests with a MyYoast-issued, DPoP-bound `client_credentials` access token.
31 *
32 * Delegates the actual HTTP call to `MyYoast_Client::authenticated_request()`, which owns DPoP
33 * proof generation, nonce handling, and the use_dpop_nonce auto-retry. This strategy keeps only
34 * the AI-specific concerns: scope selection, identifying the WP user on every call (POST → body,
35 * GET → query parameter), and translating the HTTP_Response into the AI Response domain object.
36 */
37 class OAuth_Auth_Strategy implements Auth_Strategy_Interface, LoggerAwareInterface {
38
39 use LoggerAwareTrait;
40
41 private const AI_SCOPE = 'service:ai:consume';
42
43 /**
44 * The MyYoast OAuth client.
45 *
46 * @var MyYoast_Client
47 */
48 private $myyoast_client;
49
50 /**
51 * The AI API client (used to resolve the full URL and pick up the configured timeout).
52 *
53 * @var API_Client
54 */
55 private $api_client;
56
57 /**
58 * The response validator.
59 *
60 * @var Response_Validator
61 */
62 private $response_validator;
63
64 /**
65 * Constructor.
66 *
67 * @param MyYoast_Client $myyoast_client The MyYoast OAuth client.
68 * @param API_Client $api_client The AI API client.
69 * @param Response_Validator $response_validator The response validator.
70 */
71 public function __construct( MyYoast_Client $myyoast_client, API_Client $api_client, Response_Validator $response_validator ) {
72 $this->myyoast_client = $myyoast_client;
73 $this->api_client = $api_client;
74 $this->response_validator = $response_validator;
75 $this->logger = new NullLogger();
76 }
77
78 // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- Response_Validator and the OAuth-specific catches throw a wider family than is practical to enumerate.
79
80 /**
81 * Acquires a site token, dispatches via MyYoast_Client::authenticated_request, and translates the response.
82 *
83 * The WP user is identified to yoast-ai on every call because the site-level OAuth token is
84 * shared across users. POST requests carry `user_id` in the body; GET requests carry it as a
85 * query parameter.
86 *
87 * @param Request $request The base request.
88 * @param WP_User $user The WP user.
89 *
90 * @return Response The parsed response.
91 *
92 * @throws Auth_Strategy_Unavailable_Exception When the site token cannot be acquired, so the sender falls back without claiming the request itself failed.
93 * @throws WP_Request_Exception On transport failure, matching the error identifier the legacy Token path reports.
94 * @throws Bad_Request_Exception When the status doesn't match a more specific exception.
95 * @throws Insufficient_Scope_Exception When the response is a 403 insufficient_scope, so callers can keep consent untouched.
96 * @throws Consent_Required_Exception When the response is a 403 whose message indicates consent is required, so the sender skips the fallback and the caller can re-prompt.
97 * @throws Forbidden_Exception When the response is any other 403; callers revoke consent, same as the legacy path.
98 * @throws Unauthorized_Exception When the response is a 401 (the cached site token is cleared only when the challenge reports `invalid_token`).
99 * @throws Remote_Request_Exception When MyYoast_Client::authenticated_request throws for any reason not covered above, including unexpected HTTP responses.
100 */
101 public function send( Request $request, WP_User $user ): Response {
102 $resource = $this->api_client->get_resource_url();
103
104 try {
105 $token_set = $this->myyoast_client->get_site_token( [ self::AI_SCOPE ], $resource );
106 } catch ( Token_Request_Failed_Exception |Token_Storage_Exception $exception ) {
107 $this->logger->warning( 'OAuth send: site token unavailable ({error}); surfacing as OAUTH_TOKEN_UNAVAILABLE.', [ 'error' => $exception->getMessage() ] );
108 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception data, not output.
109 throw new Auth_Strategy_Unavailable_Exception( 'OAUTH_TOKEN_UNAVAILABLE', 0, 'OAUTH_TOKEN_UNAVAILABLE', $exception );
110 // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
111 }
112
113 $method = $request->get_http_method();
114 $url = $this->api_client->get_url( $request->get_action_path() );
115 $user_id = (string) $user->ID;
116
117 $options = [
118 'headers' => \array_merge( $request->get_headers(), [ 'Content-Type' => 'application/json' ] ),
119 'timeout' => $this->api_client->get_request_timeout(),
120 ];
121
122 if ( $method === Request::METHOD_POST ) {
123 $body = \array_merge( ( $request->get_body() ?? [] ), [ 'user_id' => $user_id ] );
124 $options['body'] = WPSEO_Utils::format_json_encode( $body );
125 }
126 else {
127 $url = \add_query_arg( [ 'user_id' => $user_id ], $url );
128 }
129
130 $http_response = $this->myyoast_client->authenticated_request( $method, $url, $token_set, $options );
131
132 if ( $http_response->is_transport_failure() ) {
133 $this->logger->warning( 'OAuth send: transport failure reaching yoast-ai; surfacing as WP_HTTP_REQUEST_ERROR.' );
134
135 throw new WP_Request_Exception( \esc_html( $http_response->get_body_value( 'error_description', '' ) ) );
136 }
137
138 try {
139 return $this->response_validator->assert_success( $this->to_response( $http_response ) );
140 } catch ( Unauthorized_Exception $exception ) {
141 $error_code = $this->error_code( $exception );
142 // Only drop the cached token when the challenge says the token itself is the problem
143 // (`invalid_token`). A replayed DPoP proof (`invalid_dpop_proof`) or any other 401 leaves
144 // a still-valid token in place — discarding it would force a needless re-issue, and the
145 // retry must come with a fresh DPoP proof, not a fresh token.
146 if ( $error_code === 'invalid_token' ) {
147 $this->logger->debug( 'OAuth send: 401 invalid_token from yoast-ai; clearing cached site token before rethrowing.' );
148 $this->myyoast_client->clear_site_token( $resource );
149 }
150 else {
151 $this->logger->warning(
152 'OAuth send: 401 from yoast-ai ({error_code}: {message}); keeping cached site token.',
153 [
154 'error_code' => $this->error_label( $error_code ),
155 'message' => $exception->getMessage(),
156 ],
157 );
158 }
159 throw $exception;
160 } catch ( Forbidden_Exception $exception ) {
161 if ( $this->is_insufficient_scope( $exception ) ) {
162 $this->logger->warning( 'OAuth send: yoast-ai returned insufficient_scope.' );
163 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive.
164 throw new Insufficient_Scope_Exception(
165 'INSUFFICIENT_SCOPE',
166 $exception->getCode(),
167 'INSUFFICIENT_SCOPE',
168 $exception,
169 $exception->get_response_headers(),
170 );
171 // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
172 }
173 if ( $this->is_consent_required( $exception ) ) {
174 $this->logger->warning( 'OAuth send: yoast-ai requires user consent.' );
175 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive.
176 throw new Consent_Required_Exception(
177 'CONSENT_REQUIRED',
178 $exception->getCode(),
179 'CONSENT_REQUIRED',
180 $exception,
181 $exception->get_response_headers(),
182 );
183 // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
184 }
185 throw $exception;
186 } catch ( Remote_Request_Exception $exception ) {
187 $this->logger->warning(
188 'OAuth send: remote request failed ({error_code}: {message}); rethrowing.',
189 [
190 'error_code' => $this->error_label( $this->error_code( $exception ) ),
191 'message' => $exception->getMessage(),
192 ],
193 );
194 throw $exception;
195 }
196 }
197
198 // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
199
200 /**
201 * Converts a MyYoast HTTP_Response into the AI Response domain object.
202 *
203 * HTTP_Response already carries a json-decoded body when the upstream returned JSON. For non-200
204 * responses we extract `message`, `error_code`, and (for 402/429) `missing_licenses` from that
205 * decoded body. The body is re-encoded as a JSON string so the AI Response, which expects a
206 * string body, stays consistent with what the legacy Token path produces.
207 *
208 * @param HTTP_Response $http_response The MyYoast HTTP response.
209 *
210 * @return Response The AI domain response.
211 */
212 private function to_response( HTTP_Response $http_response ): Response {
213 $status = $http_response->get_status();
214 $headers = $http_response->get_headers();
215 $body = $http_response->get_body();
216
217 $message = '';
218 $error_code = '';
219 $missing_licenses = [];
220
221 if ( $status !== 200 && $status !== 0 && \is_array( $body ) ) {
222 $message = (string) ( $body['message'] ?? '' );
223 $error_code = (string) ( $body['error_code'] ?? '' );
224 if ( $status === 402 || $status === 429 ) {
225 $missing_licenses = (array) ( $body['missing_licenses'] ?? [] );
226 }
227 }
228
229 // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Mirroring the body-encoding convention used elsewhere in the AI path.
230 $body_string = \is_array( $body ) ? WPSEO_Utils::format_json_encode( $body ) : (string) $body;
231
232 return new Response( $body_string, $status, $message, $error_code, $missing_licenses, $headers );
233 }
234
235 /**
236 * Whether the forbidden response is an insufficient_scope error.
237 *
238 * @param Forbidden_Exception $exception The exception to inspect.
239 *
240 * @return bool True if the response indicates missing scope.
241 */
242 private function is_insufficient_scope( Forbidden_Exception $exception ): bool {
243 return ( $this->error_code( $exception ) === 'insufficient_scope' );
244 }
245
246 /**
247 * Whether the forbidden response means the user's consent is required.
248 *
249 * The yoast-ai consent gate returns a 403 carrying neither an `error_code` nor a
250 * `WWW-Authenticate` challenge — only the free-text message "The consent of the user is required
251 * to perform this action". With no machine-readable discriminator available, the message is the
252 * only signal, so this matches the word "consent" case-insensitively. The check runs only after
253 * the insufficient_scope branch, so a scope failure is never misread as a consent failure.
254 *
255 * @param Forbidden_Exception $exception The exception to inspect.
256 *
257 * @return bool True if the response indicates consent is required.
258 */
259 private function is_consent_required( Forbidden_Exception $exception ): bool {
260 return ( \stripos( $exception->getMessage(), 'consent' ) !== false );
261 }
262
263 /**
264 * Resolves the best available error code for an errored response.
265 *
266 * The body's `error_code` is not guaranteed to be present (the RFC 6750/9449 spec carries the
267 * machine-readable code in the `WWW-Authenticate` challenge instead), so the challenge's `error="…"`
268 * token is preferred and the body `error_code` is the fallback. Returns an empty string when
269 * neither is available.
270 *
271 * @param Remote_Request_Exception $exception The exception to inspect.
272 *
273 * @return string The error code, lower-cased, or an empty string.
274 */
275 private function error_code( Remote_Request_Exception $exception ): string {
276 $challenge_error = $this->challenge_error( $exception->get_response_headers() );
277 if ( $challenge_error !== '' ) {
278 return $challenge_error;
279 }
280
281 return \strtolower( $exception->get_error_identifier() );
282 }
283
284 /**
285 * Returns a log-friendly label for an error code, substituting `unknown` for an empty code.
286 *
287 * @param string $error_code The resolved error code.
288 *
289 * @return string The label to log.
290 */
291 private function error_label( string $error_code ): string {
292 return ( $error_code === '' ) ? 'unknown' : $error_code;
293 }
294
295 /**
296 * Extracts the `error="…"` token from a `WWW-Authenticate` challenge header.
297 *
298 * Parses the RFC 6750 § 3 / RFC 9449 § 7.1 challenge (e.g. `Bearer error="invalid_token"`,
299 * `DPoP error="invalid_dpop_proof"`) and returns the lower-cased code, or an empty string when no
300 * challenge or `error` parameter is present.
301 *
302 * @param array<string, string|array<string>> $headers The (normalized) response headers.
303 *
304 * @return string The challenge error code, lower-cased, or an empty string.
305 */
306 private function challenge_error( array $headers ): string {
307 $www_authenticate = $this->get_header_value( $headers, 'www-authenticate' );
308 if ( $www_authenticate === null ) {
309 return '';
310 }
311
312 if ( \preg_match( '/error\s*=\s*"([^"]*)"/i', $www_authenticate, $matches ) === 1 ) {
313 return \strtolower( $matches[1] );
314 }
315
316 return '';
317 }
318
319 /**
320 * Returns the value of the given header, or null if missing/empty.
321 *
322 * Keys are already lower-cased by MyYoast HTTP_Client / Response_Parser, so callers pass the
323 * lower-cased name they expect.
324 *
325 * @param array<string, string|array<string>> $headers The (normalized) headers.
326 * @param string $name The header name (lower-case).
327 *
328 * @return string|null The header value, or null.
329 */
330 private function get_header_value( array $headers, string $name ): ?string {
331 $value = ( $headers[ $name ] ?? null );
332 if ( \is_array( $value ) ) {
333 $value = \reset( $value );
334 }
335 return ( \is_string( $value ) && $value !== '' ) ? $value : null;
336 }
337 }
338