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 / myyoast-client / infrastructure / oidc / discovery-client.php

discovery-client.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI trunk, at src/myyoast-client/infrastructure/oidc/discovery-client.php

170 lines 5.5 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\OIDC;
5
6 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Discovery_Failed_Exception;
7 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Server_Capability_Exception;
8 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Discovery_Interface;
9 use Yoast\WP\SEO\MyYoast_Client\Domain\Discovery_Document;
10 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Http\HTTP_Client;
11 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
12 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
13 use YoastSEO_Vendor\Psr\Log\NullLogger;
14
15 /**
16 * Fetches and caches the OpenID Connect discovery document.
17 *
18 * Discovers all endpoint URLs dynamically from `{issuer}/.well-known/openid-configuration`.
19 * Caches the document as a WordPress transient for 24 hours.
20 */
21 class Discovery_Client implements Discovery_Interface, LoggerAwareInterface {
22 use LoggerAwareTrait;
23
24 private const CACHE_TRANSIENT_PREFIX = 'wpseo_myyoast_oidc_';
25 private const CACHE_TTL = \DAY_IN_SECONDS;
26
27 /**
28 * The issuer configuration.
29 *
30 * @var Issuer_Config
31 */
32 private $issuer_config;
33
34 /**
35 * The HTTP client.
36 *
37 * @var HTTP_Client
38 */
39 private $http_client;
40
41 /**
42 * In-memory cache of the discovery document.
43 *
44 * @var Discovery_Document|null
45 */
46 private $cached_document = null;
47
48 /**
49 * Discovery_Client constructor.
50 *
51 * @param Issuer_Config $issuer_config The issuer configuration.
52 * @param HTTP_Client $http_client The HTTP client.
53 */
54 public function __construct( Issuer_Config $issuer_config, HTTP_Client $http_client ) {
55 $this->issuer_config = $issuer_config;
56 $this->http_client = $http_client;
57 $this->logger = new NullLogger();
58 }
59
60 /**
61 * Returns the validated discovery document.
62 *
63 * @return Discovery_Document The validated discovery document.
64 *
65 * @throws Discovery_Failed_Exception If the document cannot be fetched.
66 * @throws Server_Capability_Exception If the server lacks required capabilities.
67 */
68 public function get_document(): Discovery_Document {
69 // Check in-memory cache: invalidate if issuer has changed.
70 if ( $this->cached_document !== null ) {
71 if ( $this->cached_document->get_issuer() === $this->issuer_config->get_issuer_url() ) {
72 return $this->cached_document;
73 }
74 $this->cached_document = null;
75 }
76
77 $cached = \get_transient( $this->get_cache_key() );
78 if ( \is_array( $cached ) && ! empty( $cached ) ) {
79 try {
80 $this->cached_document = new Discovery_Document( $cached );
81 return $this->cached_document;
82 } catch ( Discovery_Failed_Exception |Server_Capability_Exception $e ) {
83 // Cached data is corrupted or no longer compatible — fetch fresh.
84 $this->logger->info( 'Invalidating cached discovery document: {error}', [ 'error' => $e->getMessage() ] );
85 \delete_transient( $this->get_cache_key() );
86 }
87 }
88
89 return $this->fetch_and_cache();
90 }
91
92 /**
93 * Invalidates the cached discovery document.
94 *
95 * @return void
96 */
97 public function invalidate_cache(): void {
98 $this->cached_document = null;
99 \delete_transient( $this->get_cache_key() );
100 }
101
102 /**
103 * Returns the issuer-specific transient cache key.
104 *
105 * The issuer URL is hashed into the key so that switching issuers
106 * (e.g. via environment variable or filter) naturally causes a cache miss
107 * instead of returning stale data from a different server.
108 *
109 * @return string The transient key.
110 */
111 private function get_cache_key(): string {
112 return self::CACHE_TRANSIENT_PREFIX . $this->issuer_config->get_issuer_key();
113 }
114
115 /**
116 * Fetches the discovery document from the server, validates it, and caches it.
117 *
118 * @return Discovery_Document The validated discovery document.
119 *
120 * @throws Discovery_Failed_Exception If the document cannot be fetched or parsed.
121 */
122 private function fetch_and_cache(): Discovery_Document {
123 $url = $this->issuer_config->get_discovery_url();
124 $result = $this->http_client->request(
125 'GET',
126 $url,
127 [
128 'timeout' => 10,
129 'headers' => [ 'Accept' => 'application/json' ],
130 ],
131 );
132
133 if ( $result->is_transport_failure() ) {
134 $error_message = (string) $result->get_body_value( 'error_description', '' );
135 throw new Discovery_Failed_Exception(
136 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
137 \sprintf( 'Failed to fetch OIDC discovery document from %s: %s', $url, $error_message ),
138 );
139 }
140
141 if ( $result->get_status() !== 200 ) {
142 throw new Discovery_Failed_Exception(
143 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
144 \sprintf( 'OIDC discovery returned HTTP %d from %s.', $result->get_status(), $url ),
145 );
146 }
147
148 $body = $result->get_body();
149 if ( ! \is_array( $body ) ) {
150 throw new Discovery_Failed_Exception( 'OIDC discovery returned invalid JSON.' );
151 }
152
153 $document = new Discovery_Document( $body );
154
155 // OIDC Discovery 1.0 Section 4.1: the issuer in the document must match the expected issuer.
156 $expected_issuer = $this->issuer_config->get_issuer_url();
157 if ( $document->get_issuer() !== $expected_issuer ) {
158 throw new Discovery_Failed_Exception(
159 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
160 \sprintf( 'Issuer mismatch: expected %s, got %s.', $expected_issuer, $document->get_issuer() ),
161 );
162 }
163
164 \set_transient( $this->get_cache_key(), $body, self::CACHE_TTL );
165 $this->cached_document = $document;
166
167 return $document;
168 }
169 }
170