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 / oidc / id-token-validator.php

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

270 lines 9.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\OIDC;
5
6 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Discovery_Failed_Exception;
7 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\ID_Token_Validation_Exception;
8 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Server_Capability_Exception;
9 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\ID_Token_Validator_Interface;
10 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\JWT_Signature_Exception;
11 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\JWT_Signer;
12 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\JWT_Validation_Exception;
13 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Encoding\Base64url;
14 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Http\HTTP_Client;
15 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
16 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
17 use YoastSEO_Vendor\Psr\Log\NullLogger;
18
19 /**
20 * Validates OIDC ID tokens.
21 *
22 * Fetches the server's JWKS, verifies the EdDSA signature, and validates
23 * required claims (iss, aud, exp, nonce).
24 */
25 class ID_Token_Validator implements ID_Token_Validator_Interface, LoggerAwareInterface {
26 use LoggerAwareTrait;
27
28 private const JWKS_TRANSIENT_PREFIX = 'wpseo_myyoast_jwks_';
29 private const JWKS_TTL = \MONTH_IN_SECONDS;
30
31 private const EXPECTED_ALG = 'EdDSA';
32 private const EXPECTED_KTY = 'OKP';
33 private const EXPECTED_CRV = 'Ed25519';
34
35 /**
36 * The discovery client.
37 *
38 * @var Discovery_Client
39 */
40 private $discovery_client;
41
42 /**
43 * The JWT signer.
44 *
45 * @var JWT_Signer
46 */
47 private $jwt_signer;
48
49 /**
50 * The HTTP client.
51 *
52 * @var HTTP_Client
53 */
54 private $http_client;
55
56 /**
57 * The issuer configuration.
58 *
59 * @var Issuer_Config
60 */
61 private $issuer_config;
62
63 /**
64 * ID_Token_Validator constructor.
65 *
66 * @param Discovery_Client $discovery_client The discovery client.
67 * @param JWT_Signer $jwt_signer The JWT signer.
68 * @param HTTP_Client $http_client The HTTP client.
69 * @param Issuer_Config $issuer_config The issuer configuration.
70 */
71 public function __construct( Discovery_Client $discovery_client, JWT_Signer $jwt_signer, HTTP_Client $http_client, Issuer_Config $issuer_config ) {
72 $this->discovery_client = $discovery_client;
73 $this->jwt_signer = $jwt_signer;
74 $this->http_client = $http_client;
75 $this->issuer_config = $issuer_config;
76 $this->logger = new NullLogger();
77 }
78
79 /**
80 * Validates an ID token.
81 *
82 * @param string $id_token The raw ID token JWT.
83 * @param string $client_id The expected client_id (audience).
84 * @param string $expected_nonce The nonce sent in the authorization request.
85 *
86 * @return array<string, string|int|array<string>> The validated ID token payload (claims).
87 *
88 * @throws ID_Token_Validation_Exception If validation fails.
89 * @throws Discovery_Failed_Exception If the discovery document cannot be fetched.
90 * @throws Server_Capability_Exception If the server lacks required capabilities.
91 *
92 * phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- Discovery exceptions are thrown by get_document().
93 */
94 public function validate( string $id_token, string $client_id, string $expected_nonce ): array {
95 // Parse the header to get the kid.
96 $parts = \explode( '.', $id_token );
97 if ( \count( $parts ) !== 3 ) {
98 throw new ID_Token_Validation_Exception( 'Invalid ID token format.' );
99 }
100
101 $header = \json_decode( Base64url::decode( $parts[0] ), true );
102 if ( ! \is_array( $header ) ) {
103 throw new ID_Token_Validation_Exception( 'Invalid ID token header.' );
104 }
105
106 if ( ( $header['alg'] ?? '' ) !== self::EXPECTED_ALG ) {
107 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
108 throw new ID_Token_Validation_Exception( 'Unsupported ID token algorithm: ' . ( $header['alg'] ?? 'none' ) );
109 }
110
111 // Fetch the public key from JWKS.
112 $public_key = $this->get_public_key( ( $header['kid'] ?? '' ) );
113 if ( $public_key === null ) {
114 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
115 throw new ID_Token_Validation_Exception( 'No matching key found in JWKS for kid: ' . ( $header['kid'] ?? 'none' ) );
116 }
117
118 // Verify signature and time-based claims (exp, nbf, iat).
119 try {
120 $result = $this->jwt_signer->verify( $id_token, $public_key );
121 } catch ( JWT_Signature_Exception $e ) {
122 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
123 throw new ID_Token_Validation_Exception( 'ID token signature verification failed: ' . $e->getMessage(), 0, $e );
124 } catch ( JWT_Validation_Exception $e ) {
125 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
126 throw new ID_Token_Validation_Exception( 'ID token rejected: ' . $e->getMessage(), 0, $e );
127 }
128
129 $payload = $result['payload'];
130
131 // OIDC Core 1.0 §2: exp, iat and sub are required claims.
132 foreach ( [ 'exp', 'iat', 'sub' ] as $required_claim ) {
133 if ( ! isset( $payload[ $required_claim ] ) ) {
134 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
135 throw new ID_Token_Validation_Exception( 'ID token is missing required claim: ' . $required_claim );
136 }
137 }
138
139 // Validate issuer.
140 $expected_issuer = $this->discovery_client->get_document()->get_issuer();
141 if ( ! \hash_equals( $expected_issuer, ( $payload['iss'] ?? '' ) ) ) {
142 throw new ID_Token_Validation_Exception( 'ID token issuer mismatch.' );
143 }
144
145 // Validate audience.
146 $aud = ( $payload['aud'] ?? '' );
147 if ( \is_array( $aud ) ) {
148 if ( ! \in_array( $client_id, $aud, true ) ) {
149 throw new ID_Token_Validation_Exception( 'ID token audience does not contain client_id.' );
150 }
151 // OIDC Core 1.0 §2: when aud has multiple values, azp MUST equal client_id.
152 $azp = ( $payload['azp'] ?? '' );
153 if ( $azp !== '' && $azp !== $client_id ) {
154 throw new ID_Token_Validation_Exception( 'ID token azp claim does not match client_id.' );
155 }
156 }
157 elseif ( $aud !== $client_id ) {
158 throw new ID_Token_Validation_Exception( 'ID token audience mismatch.' );
159 }
160
161 // Validate nonce.
162 if ( ! \hash_equals( $expected_nonce, ( $payload['nonce'] ?? '' ) ) ) {
163 throw new ID_Token_Validation_Exception( 'ID token nonce mismatch.' );
164 }
165
166 return $payload;
167 }
168
169 /**
170 * Returns the issuer-scoped transient key for the JWKS cache.
171 *
172 * @return string The transient key.
173 */
174 private function get_jwks_transient_key(): string {
175 return self::JWKS_TRANSIENT_PREFIX . $this->issuer_config->get_issuer_key();
176 }
177
178 /**
179 * Fetches the public key from JWKS for the given kid.
180 *
181 * @param string $kid The key ID to find.
182 *
183 * @return string|null The 32-byte Ed25519 public key, or null if not found.
184 */
185 private function get_public_key( string $kid ): ?string {
186 $jwks = $this->fetch_jwks();
187 $key = ( $jwks !== null ) ? $this->find_ed25519_key( $jwks, $kid ) : null;
188 if ( $key !== null ) {
189 return $key;
190 }
191
192 // Kid not found in cache — try refreshing JWKS.
193 $this->logger->debug( 'Key kid={kid} not found in cached JWKS, refreshing.', [ 'kid' => $kid ] );
194 \delete_transient( $this->get_jwks_transient_key() );
195 $jwks = $this->fetch_jwks();
196
197 return ( $jwks !== null ) ? $this->find_ed25519_key( $jwks, $kid ) : null;
198 }
199
200 /**
201 * Finds an Ed25519 public key by kid in a JWKS.
202 *
203 * @param array<string, array<int, array<string, string>>> $jwks The JWKS.
204 * @param string $kid The key ID to find.
205 *
206 * @return string|null The 32-byte Ed25519 public key, or null if not found.
207 */
208 private function find_ed25519_key( array $jwks, string $kid ): ?string {
209 foreach ( ( $jwks['keys'] ?? [] ) as $key ) {
210 if ( ( $key['kid'] ?? '' ) === $kid && ( $key['kty'] ?? '' ) === self::EXPECTED_KTY && ( $key['crv'] ?? '' ) === self::EXPECTED_CRV ) {
211 $x = Base64url::decode( ( $key['x'] ?? '' ) );
212 if ( $x !== false && \strlen( $x ) === \SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES ) {
213 return $x;
214 }
215 }
216 }
217
218 return null;
219 }
220
221 /**
222 * Fetches the JWKS from the server (cached).
223 *
224 * @return array<string, array<int, array<string, string>>>|null The JWKS, or null on failure.
225 */
226 private function fetch_jwks(): ?array {
227 $cached = \get_transient( $this->get_jwks_transient_key() );
228 if ( \is_array( $cached ) && ! empty( $cached['keys'] ) ) {
229 return $cached;
230 }
231
232 try {
233 $jwks_uri = $this->discovery_client->get_document()->get_jwks_uri();
234 } catch ( Discovery_Failed_Exception |Server_Capability_Exception $e ) {
235 $this->logger->warning( 'Cannot fetch JWKS: discovery failed: {error}', [ 'error' => $e->getMessage() ] );
236 return null;
237 }
238
239 $result = $this->http_client->request(
240 'GET',
241 $jwks_uri,
242 [
243 'timeout' => 10,
244 'headers' => [ 'Accept' => 'application/json' ],
245 ],
246 );
247
248 if ( $result->get_status() !== 200 ) {
249 $this->logger->warning(
250 'JWKS fetch returned HTTP {status} from {url}.',
251 [
252 'status' => $result->get_status(),
253 'url' => $jwks_uri,
254 ],
255 );
256 return null;
257 }
258
259 $body = $result->get_body();
260 if ( ! \is_array( $body ) || empty( $body['keys'] ) ) {
261 $this->logger->warning( 'JWKS response from {url} has no keys.', [ 'url' => $jwks_uri ] );
262 return null;
263 }
264
265 \set_transient( $this->get_jwks_transient_key(), $body, self::JWKS_TTL );
266
267 return $body;
268 }
269 }
270