PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 27.9
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v27.9
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 / dpop / dpop-handler.php

dpop-handler.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 27.9, at src/myyoast-client/infrastructure/dpop/dpop-handler.php

219 lines 6.7 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\DPoP;
5
6 use Exception;
7 use SensitiveParameter;
8 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption_Exception;
9 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\JWT_Signer;
10 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\JWT_Signing_Exception;
11 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Key_Pair_Manager;
12 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Encoding\Base64url;
13 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\OIDC\Issuer_Config;
14
15 /**
16 * Creates DPoP proof JWTs per RFC 9449 and manages server-provided nonces.
17 *
18 * DPoP (Demonstrating Proof of Possession) binds access tokens to a
19 * cryptographic key pair, preventing token theft.
20 */
21 class DPoP_Handler {
22
23 private const NONCE_TRANSIENT_PREFIX = 'wpseo_myyoast_dpop_nonce_';
24 private const NONCE_TTL_IN_SECONDS = ( \MINUTE_IN_SECONDS * 5 );
25 private const PROOF_ALG = 'EdDSA';
26
27 /**
28 * The key pair manager.
29 *
30 * @var Key_Pair_Manager
31 */
32 private $key_pair_manager;
33
34 /**
35 * The JWT signer.
36 *
37 * @var JWT_Signer
38 */
39 private $jwt_signer;
40
41 /**
42 * The issuer configuration.
43 *
44 * @var Issuer_Config
45 */
46 private $issuer_config;
47
48 /**
49 * DPoP_Handler constructor.
50 *
51 * @param Key_Pair_Manager $key_pair_manager The key pair manager.
52 * @param JWT_Signer $jwt_signer The JWT signer.
53 * @param Issuer_Config $issuer_config The issuer configuration.
54 */
55 public function __construct( Key_Pair_Manager $key_pair_manager, JWT_Signer $jwt_signer, Issuer_Config $issuer_config ) {
56 $this->key_pair_manager = $key_pair_manager;
57 $this->jwt_signer = $jwt_signer;
58 $this->issuer_config = $issuer_config;
59 }
60
61 /**
62 * Creates a DPoP proof JWT.
63 *
64 * @param string $http_method The HTTP method (e.g. "POST", "GET").
65 * @param string $url The request URL (scheme, host, and path — no query/fragment).
66 * @param string|null $access_token The access token to bind (for resource requests, includes ath claim).
67 *
68 * @return string The signed DPoP proof JWT.
69 *
70 * @throws DPoP_Proof_Exception If proof generation fails.
71 */
72 public function create_proof(
73 string $http_method,
74 string $url,
75 // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPNativeAttributeFound -- No-op on PHP < 8.2; redacts parameter from stack traces on PHP 8.2+.
76 #[SensitiveParameter]
77 ?string $access_token = null
78 ): string {
79 try {
80 $key_pair = $this->key_pair_manager->get_or_create_key_pair( Key_Pair_Manager::PURPOSE_DPOP );
81 }
82 catch ( Encryption_Exception $e ) {
83 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
84 throw new DPoP_Proof_Exception( 'DPoP key pair generation failed: ' . $e->getMessage(), 0, $e );
85 }
86
87 $jwk = $this->key_pair_manager->get_public_key_jwk( $key_pair );
88
89 $header = [
90 'typ' => 'dpop+jwt',
91 'alg' => self::PROOF_ALG,
92 'jwk' => $jwk,
93 ];
94
95 // Strip query and fragment from URL per RFC 9449.
96 $htu = $this->normalize_url( $url );
97 try {
98 $jti = $this->jwt_signer->generate_jti();
99 }
100 catch ( Exception $e ) {
101 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
102 throw new DPoP_Proof_Exception( 'Failed to generate jti for DPoP proof: ' . $e->getMessage(), 0, $e );
103 }
104
105 $payload = [
106 'htm' => \strtoupper( $http_method ),
107 'htu' => $htu,
108 'iat' => \time(),
109 'jti' => $jti,
110 ];
111
112 // Include nonce if the server has provided one.
113 $nonce = $this->get_stored_nonce();
114 if ( $nonce !== null ) {
115 $payload['nonce'] = $nonce;
116 }
117
118 // Include ath (access token hash) for resource requests.
119 if ( $access_token !== null ) {
120 $payload['ath'] = Base64url::encode(
121 \hash( 'sha256', $access_token, true ),
122 );
123 }
124
125 try {
126 return $this->jwt_signer->sign( $header, $payload, $key_pair->get_private_key() );
127 }
128 catch ( JWT_Signing_Exception $e ) {
129 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
130 throw new DPoP_Proof_Exception( 'DPoP proof signing failed: ' . $e->getMessage(), 0, $e );
131 }
132 }
133
134 /**
135 * Extracts and stores a DPoP-Nonce from response headers.
136 *
137 * @param array<string, string|string[]> $response_headers The response headers.
138 *
139 * @return void
140 */
141 public function handle_nonce_response( array $response_headers ): void {
142 $nonce = $this->extract_header( $response_headers, 'dpop-nonce' );
143 if ( $nonce === null ) {
144 return;
145 }
146
147 // Transients are safe here: DPoP nonces are optional per RFC 9449 Section 4.1.
148 // If the nonce is missing or stale, the server responds with `use_dpop_nonce`
149 // and provides a fresh one. HTTP_Client retries automatically, so broken
150 // transients only cause an extra round-trip, never a functional failure.
151 // We use a generic key since the MyYoast server uses one nonce for all endpoints.
152 \set_transient( $this->get_nonce_transient_key(), $nonce, self::NONCE_TTL_IN_SECONDS );
153 }
154
155 /**
156 * Returns the stored DPoP nonce.
157 *
158 * @return string|null The stored nonce, or null if none exists.
159 */
160 public function get_stored_nonce(): ?string {
161 $nonce = \get_transient( $this->get_nonce_transient_key() );
162 if ( \is_string( $nonce ) && $nonce !== '' ) {
163 return $nonce;
164 }
165
166 return null;
167 }
168
169 /**
170 * Returns the issuer-scoped transient key for the DPoP nonce.
171 *
172 * @return string The transient key.
173 */
174 private function get_nonce_transient_key(): string {
175 return self::NONCE_TRANSIENT_PREFIX . $this->issuer_config->get_issuer_key();
176 }
177
178 /**
179 * Normalizes a URL by removing query string and fragment.
180 *
181 * @param string $url The URL to normalize.
182 *
183 * @return string The normalized URL (scheme + host + path).
184 */
185 private function normalize_url( string $url ): string {
186 $parsed = \wp_parse_url( $url );
187 if ( $parsed === false ) {
188 return $url;
189 }
190
191 $scheme = ( $parsed['scheme'] ?? 'https' );
192 $host = ( $parsed['host'] ?? '' );
193 $port = isset( $parsed['port'] ) ? ':' . $parsed['port'] : '';
194 $path = ( $parsed['path'] ?? '/' );
195
196 return $scheme . '://' . $host . $port . $path;
197 }
198
199 /**
200 * Extracts a header value from response headers (case-insensitive).
201 *
202 * @param array<string, string|string[]> $headers The response headers.
203 * @param string $header_name The header name to find.
204 *
205 * @return string|null The header value, or null if not found.
206 */
207 private function extract_header( array $headers, string $header_name ): ?string {
208 $header_name_lower = \strtolower( $header_name );
209
210 foreach ( $headers as $key => $value ) {
211 if ( \strtolower( $key ) === $header_name_lower ) {
212 return \is_array( $value ) ? $value[0] : (string) $value;
213 }
214 }
215
216 return null;
217 }
218 }
219