key_pair_manager = $key_pair_manager; $this->jwt_signer = $jwt_signer; $this->issuer_config = $issuer_config; } /** * Creates a DPoP proof JWT. * * @param string $http_method The HTTP method (e.g. "POST", "GET"). * @param string $url The request URL (scheme, host, and path — no query/fragment). * @param string|null $access_token The access token to bind (for resource requests, includes ath claim). * * @return string The signed DPoP proof JWT. * * @throws DPoP_Proof_Exception If proof generation fails. */ public function create_proof( string $http_method, string $url, // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPNativeAttributeFound -- No-op on PHP < 8.2; redacts parameter from stack traces on PHP 8.2+. #[SensitiveParameter] ?string $access_token = null ): string { try { $key_pair = $this->key_pair_manager->get_or_create_key_pair( Key_Pair_Manager::PURPOSE_DPOP ); } catch ( Encryption_Exception $e ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. throw new DPoP_Proof_Exception( 'DPoP key pair generation failed: ' . $e->getMessage(), 0, $e ); } $jwk = $this->key_pair_manager->get_public_key_jwk( $key_pair ); $header = [ 'typ' => 'dpop+jwt', 'alg' => self::PROOF_ALG, 'jwk' => $jwk, ]; // Strip query and fragment from URL per RFC 9449. $htu = $this->normalize_url( $url ); try { $jti = $this->jwt_signer->generate_jti(); } catch ( Exception $e ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. throw new DPoP_Proof_Exception( 'Failed to generate jti for DPoP proof: ' . $e->getMessage(), 0, $e ); } $payload = [ 'htm' => \strtoupper( $http_method ), 'htu' => $htu, 'iat' => \time(), 'jti' => $jti, ]; // Include nonce if the server has provided one. $nonce = $this->get_stored_nonce(); if ( $nonce !== null ) { $payload['nonce'] = $nonce; } // Include ath (access token hash) for resource requests. if ( $access_token !== null ) { $payload['ath'] = Base64url::encode( \hash( 'sha256', $access_token, true ), ); } try { return $this->jwt_signer->sign( $header, $payload, $key_pair->get_private_key() ); } catch ( JWT_Signing_Exception $e ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. throw new DPoP_Proof_Exception( 'DPoP proof signing failed: ' . $e->getMessage(), 0, $e ); } } /** * Extracts and stores a DPoP-Nonce from response headers. * * @param array $response_headers The response headers. * * @return void */ public function handle_nonce_response( array $response_headers ): void { $nonce = $this->extract_header( $response_headers, 'dpop-nonce' ); if ( $nonce === null ) { return; } // Transients are safe here: DPoP nonces are optional per RFC 9449 Section 4.1. // If the nonce is missing or stale, the server responds with `use_dpop_nonce` // and provides a fresh one. HTTP_Client retries automatically, so broken // transients only cause an extra round-trip, never a functional failure. // We use a generic key since the MyYoast server uses one nonce for all endpoints. \set_transient( $this->get_nonce_transient_key(), $nonce, self::NONCE_TTL_IN_SECONDS ); } /** * Returns the stored DPoP nonce. * * @return string|null The stored nonce, or null if none exists. */ public function get_stored_nonce(): ?string { $nonce = \get_transient( $this->get_nonce_transient_key() ); if ( \is_string( $nonce ) && $nonce !== '' ) { return $nonce; } return null; } /** * Returns the issuer-scoped transient key for the DPoP nonce. * * @return string The transient key. */ private function get_nonce_transient_key(): string { return self::NONCE_TRANSIENT_PREFIX . $this->issuer_config->get_issuer_key(); } /** * Normalizes a URL by removing query string and fragment. * * @param string $url The URL to normalize. * * @return string The normalized URL (scheme + host + path). */ private function normalize_url( string $url ): string { $parsed = \wp_parse_url( $url ); if ( $parsed === false ) { return $url; } $scheme = ( $parsed['scheme'] ?? 'https' ); $host = ( $parsed['host'] ?? '' ); $port = isset( $parsed['port'] ) ? ':' . $parsed['port'] : ''; $path = ( $parsed['path'] ?? '/' ); return $scheme . '://' . $host . $port . $path; } /** * Extracts a header value from response headers (case-insensitive). * * @param array $headers The response headers. * @param string $header_name The header name to find. * * @return string|null The header value, or null if not found. */ private function extract_header( array $headers, string $header_name ): ?string { $header_name_lower = \strtolower( $header_name ); foreach ( $headers as $key => $value ) { if ( \strtolower( $key ) === $header_name_lower ) { return \is_array( $value ) ? $value[0] : (string) $value; } } return null; } }