dpop_handler = $dpop_handler; $this->expiring_store = $expiring_store; $this->logger = new NullLogger(); } /** * Sends an HTTP request with optional DPoP proof. * * @param string $method The HTTP method. * @param string $url The request URL. * @param array $options Request options: 'headers', 'body', 'timeout', 'dpop' (bool), 'access_token'. * * @return HTTP_Response The parsed response. */ public function request( string $method, string $url, array $options = [] ): HTTP_Response { $cached_response = $this->get_cached_rate_limit_response( $url ); if ( $cached_response !== null ) { return $cached_response; } $result = $this->do_request( $method, $url, $options ); // Handle DPoP nonce lifecycle when DPoP is active. if ( ! empty( $options['dpop'] ) ) { $this->dpop_handler->handle_nonce_response( $result->get_headers() ); // Retry once on use_dpop_nonce error with the fresh nonce. if ( $this->is_dpop_nonce_error( $result ) ) { $this->logger->debug( 'Retrying request with fresh DPoP nonce for {method} {url}.', [ 'method' => $method, 'url' => $url, ], ); $result = $this->do_request( $method, $url, $options ); $this->dpop_handler->handle_nonce_response( $result->get_headers() ); } } return $result; } /** * Sends an authenticated resource request with DPoP proof. * Authenticate with a (DPoP bound) access token or refresh token, an Initial Access Token, or a Client Registration Access Token, depending on the token type and endpoint requirements. * * @param string $method The HTTP method. * @param string $url The resource URL. * @param string $access_token The access token. * @param string $token_type An Auth_Token_Type constant. * @param array $options Additional request options. * * @return HTTP_Response The parsed response. */ public function authenticated_request( string $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, string $token_type = Auth_Token_Type::DPOP, array $options = [] ): HTTP_Response { $headers = ( $options['headers'] ?? [] ); $headers['Authorization'] = $token_type . ' ' . $access_token; return $this->request( $method, $url, \array_merge( $options, [ 'headers' => $headers, 'dpop' => ( $token_type === Auth_Token_Type::DPOP ), 'access_token' => $access_token, ], ), ); } /** * Checks if the response indicates a use_dpop_nonce challenge. * * Covers both signalling shapes per RFC 9449 ยงยง5.2 and 8: * - Authorization-server style: response body carries `{"error":"use_dpop_nonce"}`. * - Resource-server style: HTTP 401 with `WWW-Authenticate: DPoP error="use_dpop_nonce"` and a fresh `DPoP-Nonce` header. * * @param HTTP_Response $result The parsed response. * * @return bool Whether this is a DPoP nonce challenge. */ private function is_dpop_nonce_error( HTTP_Response $result ): bool { if ( $result->get_body_value( 'error' ) === 'use_dpop_nonce' ) { return true; } if ( $result->get_status() !== 401 ) { return false; } $headers = $result->get_headers(); if ( ! isset( $headers['www-authenticate'], $headers['dpop-nonce'] ) ) { return false; } $www_authenticate = $headers['www-authenticate']; if ( \is_array( $www_authenticate ) ) { $www_authenticate = (string) \reset( $www_authenticate ); } return ( \stripos( (string) $www_authenticate, 'use_dpop_nonce' ) !== false ); } /** * Executes a single HTTP request with optional DPoP proof injection. * * @param string $method The HTTP method. * @param string $url The request URL. * @param array $options Request options. * * @return HTTP_Response The parsed response. */ private function do_request( string $method, string $url, array $options ): HTTP_Response { $headers = ( $options['headers'] ?? [] ); $timeout = ( $options['timeout'] ?? 10 ); // Add DPoP proof header if requested. if ( ! empty( $options['dpop'] ) ) { $access_token = ( $options['access_token'] ?? null ); try { $headers['DPoP'] = $this->dpop_handler->create_proof( $method, $url, $access_token ); } catch ( DPoP_Proof_Exception $e ) { $this->logger->error( 'DPoP proof generation failed for {method} {url}: {error}', [ 'method' => $method, 'url' => $url, 'error' => $e->getMessage(), ], ); return new HTTP_Response( 0, [], [ 'error' => 'dpop_proof_failed', 'error_description' => $e->getMessage(), ], ); } } $wp_args = [ 'method' => \strtoupper( $method ), 'headers' => $headers, 'timeout' => $timeout, ]; if ( isset( $options['body'] ) ) { $wp_args['body'] = $options['body']; } $response = \wp_remote_request( $url, $wp_args ); return $this->parse_response( $response, $url ); } /** * Parses a WordPress HTTP API response into a standardized format. * * @param array>|WP_Error $response The raw WordPress response. * @param string $url The request URL (for error messages). * * @return HTTP_Response The parsed response. */ private function parse_response( $response, string $url ): HTTP_Response { if ( \is_wp_error( $response ) ) { $this->logger->warning( 'Network error for {url}: {error}', [ 'url' => $url, 'error' => $response->get_error_message(), ], ); return new HTTP_Response( 0, [], [ 'error' => 'network_error', 'error_description' => $response->get_error_message(), ], ); } $status = \wp_remote_retrieve_response_code( $response ); $headers = \wp_remote_retrieve_headers( $response ); $body = \wp_remote_retrieve_body( $response ); $headers_array = []; if ( $headers instanceof CaseInsensitiveDictionary ) { $headers_array = (array) $headers->getAll(); } elseif ( \is_array( $headers ) ) { $headers_array = $headers; } $decoded = \json_decode( $body, true ); $parsed = new HTTP_Response( (int) $status, $headers_array, ( \is_array( $decoded ) ? $decoded : $body ), ); if ( (int) $status === 429 ) { $this->logger->warning( 'Rate limited (429) by {url}.', [ 'url' => $url ] ); $this->store_rate_limit_response( $url, $parsed ); } return $parsed; } /** * Returns a cached 429 response with an updated Retry-After header, or null if not rate limited. * * @param string $url The request URL. * * @return HTTP_Response|null The cached response or null. */ private function get_cached_rate_limit_response( string $url ): ?HTTP_Response { $key = $this->get_rate_limit_key( $url ); try { $cached = $this->expiring_store->get( $key ); } catch ( Key_Not_Found_Exception | Corrupted_Value_Exception $e ) { return null; } if ( ! \is_array( $cached ) || ! isset( $cached['stored_at'], $cached['backoff_seconds'], $cached['status'], $cached['headers'], $cached['body'] ) ) { return null; } $remaining = ( ( $cached['stored_at'] + $cached['backoff_seconds'] ) - \time() ); if ( $remaining <= 0 ) { return null; } $headers = $cached['headers']; $headers['retry-after'] = (string) $remaining; return new HTTP_Response( (int) $cached['status'], $headers, $cached['body'] ); } /** * Stores a 429 response in the expiring store for later replay. * * @param string $url The request URL. * @param HTTP_Response $response The parsed 429 response. * * @return void */ private function store_rate_limit_response( string $url, HTTP_Response $response ): void { $headers = $response->get_headers(); $retry_after = ( $headers['retry-after'] ?? null ); if ( \is_numeric( $retry_after ) ) { $backoff_seconds = (int) $retry_after; } else { $backoff_seconds = self::DEFAULT_BACKOFF_SECONDS; } $this->expiring_store->persist( $this->get_rate_limit_key( $url ), [ 'stored_at' => \time(), 'backoff_seconds' => $backoff_seconds, 'status' => $response->get_status(), 'headers' => $headers, 'body' => $response->get_body(), ], $backoff_seconds, ); } /** * Builds a rate limit key from a URL using its host and path. * * @param string $url The request URL. * * @return string The rate limit key. */ private function get_rate_limit_key( string $url ): string { $parsed = \wp_parse_url( $url ); $host = ( $parsed['host'] ?? 'unknown' ); $path = ( $parsed['path'] ?? '/' ); return self::RATE_LIMIT_KEY_PREFIX . $host . $path; } }