$v ) { if ( 0 === strcasecmp( (string) $k, $name ) ) { return trim( (string) $v ); } } } return null; } /** * Count of a header's occurrences, as far as PHP can tell. PHP joins * repeated headers with ", ", so a comma in a single-valued header such as * DPoP (a JWT, which never contains one) means it was sent more than once. * * @param string $name Header name. */ public static function header_is_repeated( string $name ): bool { $v = self::header( $name ); return null !== $v && str_contains( $v, ',' ); } /** * Form-encoded POST parameters, raw (unslashed). OAuth parameter values are * opaque strings (JWTs, URLs, codes); callers validate each one they use. * * @return array */ public static function form(): array { // OAuth endpoints authenticate the client with a signed assertion, not a // WordPress nonce; the assertion is the CSRF defence. // phpcs:disable WordPress.Security.NonceVerification.Missing $out = array(); foreach ( (array) $_POST as $k => $v ) { if ( is_string( $k ) && is_string( $v ) ) { $out[ $k ] = (string) wp_unslash( $v ); } } // phpcs:enable return $out; } /** * Whether any token-like parameter arrived in the query string. Spec 4.3: * tokens are never accepted from a URL. */ public static function token_in_query(): bool { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- presence check only. foreach ( array( 'access_token', 'refresh_token', 'assertion', 'client_assertion' ) as $k ) { if ( isset( $_GET[ $k ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return true; } } return false; } /** * Remote address, for rate limiting only (never stored in events). */ public static function client_ip(): string { return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; } /** * Send a JSON response and stop. * * @param int $status HTTP status. * @param array $body JSON body. * @param array $headers Extra headers. */ public static function json( int $status, array $body, array $headers = array() ): void { if ( ! headers_sent() ) { status_header( $status ); header( 'Content-Type: application/json; charset=utf-8' ); header( 'X-Content-Type-Options: nosniff' ); foreach ( $headers as $k => $v ) { header( $k . ': ' . $v ); } } echo wp_json_encode( $body, JSON_UNESCAPED_SLASHES ); self::stop(); } /** * A response with no body (the feedback endpoint's 202, spec §8), and stop. * * @param int $status HTTP status. * @param array $headers Extra headers. */ public static function no_content( int $status, array $headers = array() ): void { if ( ! headers_sent() ) { status_header( $status ); header( 'Content-Length: 0' ); foreach ( $headers as $k => $v ) { header( $k . ': ' . $v ); } } self::stop(); } /** * An OAuth error response (RFC 6749 §5.2 shape). Always no-store: error * paths sit beside token paths and must not be cached either. * * @param int $status HTTP status. * @param string $error Error code. * @param string $description Human-readable detail (no account data). * @param array $headers Extra headers. * @param array $extra Extra body members. */ public static function oauth_error( int $status, string $error, string $description = '', array $headers = array(), array $extra = array() ): void { $body = array( 'error' => $error ) + ( '' !== $description ? array( 'error_description' => $description ) : array() ) + $extra; self::json( $status, $body, array( 'Cache-Control' => 'no-store', 'Pragma' => 'no-cache' ) + $headers ); } /** * Over a rate limit, at the token endpoint and the other endpoints the * protocol defines (spec 4.3): HTTP 429, Retry-After, and the body * exactly {"error": "rate_limited"}. * * @param int $retry_after Seconds the agent must wait. */ public static function rate_limited( int $retry_after ): void { self::oauth_error( 429, 'rate_limited', '', array( 'Retry-After' => (string) max( 1, $retry_after ) ) ); } /** * Redirect and stop. For discovery only: the caller builds the target * from the configured public origin, never from the request. * * @param string $url Absolute https URL on this store. * @param int $status 301 or 308. */ public static function redirect( string $url, int $status ): void { if ( ! headers_sent() ) { status_header( $status ); header( 'Location: ' . esc_url_raw( $url ) ); header( 'Cache-Control: public, max-age=300' ); header( 'Access-Control-Allow-Origin: *' ); } self::stop(); } /** * Stop the request. A filter so the in-process test harness can replace * exit with an exception it catches; production always exits. */ private static function stop(): void { if ( apply_filters( 'agentadapt_http_exit', true ) ) { exit; } throw new AgentAdapt_Http_Sent(); } } /** * Thrown instead of exit when the harness disables exiting. */ final class AgentAdapt_Http_Sent extends RuntimeException {}