<?php

namespace WPGraphQL;

use GraphQL\Error\FormattedError;
use WP_User;

/**
 * Class Router
 * This sets up the /graphql endpoint
 *
 * @package WPGraphQL
 * @since   0.0.1
 *
 * phpcs:disable -- PHPStan annotation.
 * @phpstan-import-type SerializableError from \GraphQL\Executor\ExecutionResult
 * @phpstan-import-type SerializableResult from \GraphQL\Executor\ExecutionResult
 *
 * @phpstan-type WPGraphQLResult = SerializableResult|(\GraphQL\Executor\ExecutionResult|array<int,\GraphQL\Executor\ExecutionResult>)
 * phpcs:enable
 */
class Router {

	/**
	 * Sets the route to use as the endpoint
	 *
	 * @var string $route
	 */
	public static $route = 'graphql';

	/**
	 * Holds the Global Post for later resetting
	 *
	 * @var string
	 */
	protected static $global_post = '';

	/**
	 * Set the default status code to 200.
	 *
	 * @var int
	 */
	public static $http_status_code = 200;

	/**
	 * @var ?\WPGraphQL\Request
	 */
	protected static $request;

	/**
	 * Initialize the WPGraphQL Router
	 *
	 * @return void
	 * @throws \Exception
	 */
	public function init() {
		self::$route = graphql_get_endpoint();

		/**
		 * Create the rewrite rule for the route
		 *
		 * @since 0.0.1
		 */
		add_action( 'init', [ $this, 'add_rewrite_rule' ], 10 );

		/**
		 * Add the query var for the route
		 *
		 * @since 0.0.1
		 */
		add_filter( 'query_vars', [ $this, 'add_query_var' ], 1, 1 );

		/**
		 * Redirects the route to the graphql processor
		 *
		 * @since 0.0.1
		 */
		add_action( 'parse_request', [ $this, 'resolve_http_request' ], 10 );

		/**
		 * Adds support for application passwords
		 */
		add_filter( 'application_password_is_api_request', [ $this, 'is_api_request' ] );
	}

	/**
	 * Returns the GraphQL Request being executed
	 */
	public static function get_request(): ?Request {
		return self::$request;
	}

	/**
	 * Adds rewrite rule for the route endpoint
	 *
	 * @return void
	 * @since  0.0.1
	 * @uses   add_rewrite_rule()
	 */
	public static function add_rewrite_rule() {
		add_rewrite_rule(
			self::$route . '/?$',
			'index.php?' . self::$route . '=true',
			'top'
		);
	}

	/**
	 * Determines whether the request is an API request to play nice with
	 * application passwords and potential other WordPress core functionality
	 * for APIs
	 *
	 * @param bool $is_api_request Whether the request is an API request
	 *
	 * @return bool
	 */
	public function is_api_request( $is_api_request ) {
		return true === is_graphql_http_request() ? true : $is_api_request;
	}

	/**
	 * Adds the query_var for the route
	 *
	 * @param string[] $query_vars The array of whitelisted query variables.
	 *
	 * @return string[]
	 * @since  0.0.1
	 */
	public static function add_query_var( $query_vars ) {
		$query_vars[] = self::$route;

		return $query_vars;
	}

	/**
	 * Returns true when the current request is a GraphQL request coming from the HTTP
	 *
	 * NOTE: This will only indicate whether the GraphQL Request is an HTTP request. Many features
	 * need to affect _all_ GraphQL requests, including internal requests using the `graphql()`
	 * function, so be careful how you use this to check your conditions.
	 *
	 * @return bool
	 */
	public static function is_graphql_http_request() {

		/**
		 * Filter whether the request is a GraphQL HTTP Request. Default is null, as the majority
		 * of WordPress requests are NOT GraphQL requests (at least today that's true 😆).
		 *
		 * If this filter returns anything other than null, the function will return now and skip the
		 * default checks.
		 *
		 * @param ?bool $is_graphql_http_request Whether the request is a GraphQL HTTP Request. Default false.
		 * @hookGroup request-lifecycle
		 * @since 0.0.5
		 */
		$pre_is_graphql_http_request = apply_filters( 'graphql_pre_is_graphql_http_request', null );

		/**
		 * If the filter has been applied, return now before executing default checks
		 */
		if ( null !== $pre_is_graphql_http_request ) {
			return (bool) $pre_is_graphql_http_request;
		}

		// Default is false
		$is_graphql_http_request = false;

		// Support wp-graphiql style request to /index.php?graphql.
		if ( isset( $_GET[ self::$route ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification

			$is_graphql_http_request = true;
		} elseif ( isset( $_SERVER['HTTP_HOST'] ) && isset( $_SERVER['REQUEST_URI'] ) ) {
			// Check the server to determine if the GraphQL endpoint is being requested
			$host = wp_unslash( $_SERVER['HTTP_HOST'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
			$uri  = wp_unslash( $_SERVER['REQUEST_URI'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

			if ( ! is_string( $host ) ) {
				return false;
			}

			if ( ! is_string( $uri ) ) {
				return false;
			}

			$parsed_site_url    = wp_parse_url( site_url( self::$route ), PHP_URL_PATH );
			$graphql_url        = ! empty( $parsed_site_url ) ? wp_unslash( $parsed_site_url ) : self::$route;
			$parsed_request_url = wp_parse_url( $uri, PHP_URL_PATH );
			$request_url        = ! empty( $parsed_request_url ) ? wp_unslash( $parsed_request_url ) : '';

			// Determine if the route is indeed a graphql request
			$is_graphql_http_request = str_replace( '/', '', $request_url ) === str_replace( '/', '', $graphql_url );
		}

		/**
		 * Filter whether the request is a GraphQL HTTP Request. Default is false, as the majority
		 * of WordPress requests are NOT GraphQL requests (at least today that's true 😆).
		 *
		 * The request has to "prove" that it is indeed an HTTP request via HTTP for
		 * this to be true.
		 *
		 * Different servers _might_ have different needs to determine whether a request
		 * is a GraphQL request.
		 *
		 * @param bool $is_graphql_http_request Whether the request is a GraphQL HTTP Request. Default false.
		 * @hookGroup request-lifecycle
		 * @since 0.0.5
		 */
		return apply_filters( 'graphql_is_graphql_http_request', $is_graphql_http_request );
	}

	/**
	 * This resolves the http request and ensures that WordPress can respond with the appropriate
	 * JSON response instead of responding with a template from the standard WordPress Template
	 * Loading process
	 *
	 * @return void
	 * @throws \Exception Throws exception.
	 * @throws \Throwable Throws exception.
	 * @since  0.0.1
	 */
	public static function resolve_http_request() {

		/**
		 * Access the $wp_query object
		 */
		global $wp_query;

		/**
		 * Ensure we're on the registered route for graphql route
		 */
		if ( ! self::is_graphql_http_request() || is_graphql_request() ) {
			return;
		}

		/**
		 * Set is_home to false
		 */
		$wp_query->is_home = false;

		/**
		 * Whether it's a GraphQL HTTP Request
		 *
		 * @since 0.0.5
		 */
		if ( ! defined( 'GRAPHQL_HTTP_REQUEST' ) ) {
			define( 'GRAPHQL_HTTP_REQUEST', true );
		}

		/**
		 * Process the GraphQL query Request
		 */
		self::process_http_request();
	}

	/**
	 * Sends an HTTP header.
	 *
	 * @param string $key   Header key.
	 * @param string $value Header value.
	 *
	 * @return void
	 * @since  0.0.5
	 */
	public static function send_header( $key, $value ) {

		/**
		 * Sanitize as per RFC2616 (Section 4.2):
		 *
		 * Any LWS that occurs between field-content MAY be replaced with a
		 * single SP before interpreting the field value or forwarding the
		 * message downstream.
		 */
		$value = preg_replace( '/\s+/', ' ', $value );
		header( apply_filters( 'graphql_send_header', sprintf( '%s: %s', $key, $value ), $key, $value ) );
	}

	/**
	 * Sends an HTTP status code.
	 *
	 * @param int|null $status_code The status code to send.
	 *
	 * @return void
	 */
	protected static function set_status( ?int $status_code = null ) {
		$status_code = null === $status_code ? self::$http_status_code : $status_code;

		// validate that the status code is a valid http status code
		if ( ! is_numeric( $status_code ) || $status_code < 100 || $status_code > 599 ) {
			$status_code = 500;
		}

		status_header( $status_code );
	}

	/**
	 * Returns an array of headers to send with the HTTP response
	 *
	 * @return array<string,string>
	 */
	protected static function get_response_headers() {

		/**
		 * Filtered list of access control headers.
		 *
		 * @param string[] $access_control_headers Array of headers to allow.
		 * @hookGroup request-lifecycle
		 * @since 0.0.5
		 */
		$access_control_allow_headers = apply_filters(
			'graphql_access_control_allow_headers',
			[
				'Authorization',
				'Content-Type',
			]
		);

		// For cache url header, use the domain without protocol. Path for when it's multisite.
		// Remove the starting http://, https://, :// from the full hostname/path.
		$host_and_path = preg_replace( '#^.*?://#', '', graphql_get_endpoint_url() );

		$headers = [
			'Access-Control-Allow-Origin'  => '*',
			'Access-Control-Allow-Headers' => implode( ', ', $access_control_allow_headers ),
			'Access-Control-Max-Age'       => '600', // cache the result of preflight requests (600 is the upper limit for Chromium).
			'Content-Type'                 => 'application/json ; charset=' . get_option( 'blog_charset' ),
			'X-Robots-Tag'                 => 'noindex',
			'X-Content-Type-Options'       => 'nosniff',
			'X-GraphQL-URL'                => (string) $host_and_path,
		];

		// If the Query Analyzer was instantiated
		// Get the headers determined from its Analysis
		if ( self::get_request() instanceof Request && self::get_request()->get_query_analyzer()->is_enabled_for_query() ) {
			$headers = self::get_request()->get_query_analyzer()->get_headers( $headers );
		}

		if ( true === \WPGraphQL::debug() ) {
			$headers['X-hacker'] = __( 'If you\'re reading this, you should visit github.com/wp-graphql/wp-graphql and contribute!', 'wp-graphql' );
		}

		$request          = self::get_request();
		$is_authenticated = $request instanceof Request
			&& $request->app_context->viewer instanceof WP_User
			&& $request->app_context->viewer->exists();
		if ( ! $is_authenticated ) {
			$is_authenticated = is_user_logged_in();
		}
		/**
		 * Filters whether no-cache headers should be sent on the GraphQL HTTP response.
		 *
		 * Prefer the current request's viewer when available (after execution) so we
		 * send no-cache for the request that was actually authenticated, regardless
		 * of global user timing. Fall back to is_user_logged_in() for paths that
		 * run before the Request exists (e.g. 403 auth error, OPTIONS).
		 *
		 * @see https://github.com/wp-graphql/wp-graphql/issues/3340
		 *
		 * @param bool $send_no_cache_headers Whether to send no-cache headers.
		 * @hookGroup request-lifecycle
		 * @since 0.0.5
		 */
		$send_no_cache_headers = apply_filters( 'graphql_send_nocache_headers', $is_authenticated );
		if ( $send_no_cache_headers ) {
			foreach ( wp_get_nocache_headers() as $no_cache_header_key => $no_cache_header_value ) {
				$headers[ $no_cache_header_key ] = $no_cache_header_value;
			}
		}

		/**
		 * Filter the $headers to send
		 *
		 * @param array<string,string> $headers The headers to send
		 * @hookGroup request-lifecycle
		 * @since 0.0.5
		 */
		$headers = apply_filters( 'graphql_response_headers_to_send', $headers );

		return is_array( $headers ) ? $headers : [];
	}

	/**
	 * Set the response headers
	 *
	 * @return void
	 * @since  0.0.1
	 */
	public static function set_headers() {
		if ( false === headers_sent() ) {

			/**
			 * Set the HTTP response status
			 */
			self::set_status( self::$http_status_code );

			/**
			 * Get the response headers
			 */
			$headers = self::get_response_headers();

			/**
			 * If there are headers, set them for the response
			 */
			if ( ! empty( $headers ) && is_array( $headers ) ) {
				foreach ( $headers as $key => $value ) {
					self::send_header( $key, $value );
				}
			}

			/**
			 * Fire an action when the headers are set
			 *
			 * @param array<string,string> $headers The headers sent in the response
			 * @hookGroup request-lifecycle
			 * @since 0.0.5
			 */
			do_action( 'graphql_response_set_headers', $headers );
		}
	}

	/**
	 * Retrieves the raw request entity (body).
	 *
	 * @since  0.0.5
	 *
	 * @global string php://input Raw post data.
	 *
	 * @return string Raw request data.
	 */
	public static function get_raw_data() {
		$input = file_get_contents( 'php://input' ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsRemoteFile

		return ! empty( $input ) ? $input : '';
	}

	/**
	 * This processes the graphql requests that come into the /graphql endpoint via an HTTP request
	 *
	 * @return void
	 * @throws \Throwable Throws Exception.
	 * @global WP_User $current_user The currently authenticated user.
	 * @since  0.0.1
	 */
	public static function process_http_request() {
		global $current_user;

		if ( $current_user instanceof WP_User && ! $current_user->exists() ) {
			/*
			 * If there is no current user authenticated via other means, clear
			 * the cached lack of user, so that an authenticate check can set it
			 * properly.
			 *
			 * This is done because for authentications such as Application
			 * Passwords, we don't want it to be accepted unless the current HTTP
			 * request is a GraphQL API request, which can't always be identified early
			 * enough in evaluation.
			 *
			 * See serve_request in wp-includes/rest-api/class-wp-rest-server.php.
			 */
			$current_user = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride
		}

		/**
		 * Validate authentication BEFORE any GraphQL hooks fire.
		 *
		 * This is critical for security - we must validate/downgrade authentication
		 * before plugins can hook in and potentially expose sensitive information
		 * based on the (not-yet-validated) authenticated user.
		 *
		 * For cookie-authenticated requests:
		 * - No nonce: User is downgraded to guest
		 * - Invalid nonce: Returns error response immediately
		 * - Valid nonce: Proceeds normally
		 *
		 * @since 2.6.0
		 */
		$auth_error = self::validate_http_request_authentication();

		if ( is_wp_error( $auth_error ) ) {
			/**
			 * Filter the HTTP status code returned for authentication errors.
			 *
			 * By default, invalid nonce errors return 403 Forbidden. Some clients
			 * may expect 200 with a GraphQL error response instead.
			 *
			 * @since 2.6.0
			 *
			 * @param int       $status_code The HTTP status code. Default 403.
			 * @param \WP_Error $auth_error  The authentication error.
			 * @hookGroup authentication
			 */
			self::$http_status_code = apply_filters( 'graphql_authentication_error_status_code', 403, $auth_error );
			self::set_headers();
			wp_send_json(
				[
					'errors' => [
						[
							'message' => $auth_error->get_error_message(),
						],
					],
				]
			);
		}

		/**
		 * This action can be hooked to to enable various debug tools,
		 * such as enableValidation from the GraphQL Config.
		 *
		 * @hookGroup request-lifecycle
		 * @since 0.0.4
		 */
		do_action( 'graphql_process_http_request' );

		/**
		 * Respond to pre-flight requests.
		 *
		 * Bail before Request() execution begins.
		 *
		 * @see: https://apollographql.slack.com/archives/C10HTKHPC/p1507649812000123
		 * @see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Preflighted_requests
		 */
		if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'OPTIONS' === $_SERVER['REQUEST_METHOD'] ) {
			self::$http_status_code = 200;
			self::set_headers();
			exit;
		}

		$response       = [];
		$query          = '';
		$operation_name = '';
		$variables      = [];
		self::$request  = new Request();

		try {
			// Start output buffering to prevent any unwanted output from breaking the JSON response
			// This addresses issues like plugins calling wp_print_inline_script_tag() during wp_enqueue_scripts
			ob_start();

			$response = self::$request->execute_http();

			// Discard any captured output that could break the JSON response
			ob_end_clean();

			// Get the operation params from the request.
			$params         = self::$request->get_params();
			$query          = isset( $params->query ) ? $params->query : '';
			$operation_name = isset( $params->operation ) ? $params->operation : '';
			$variables      = isset( $params->variables ) ? $params->variables : null;
		} catch ( \Throwable $error ) {
			// Make sure to clean up the output buffer even if there's an exception
			if ( ob_get_level() > 0 ) {
				ob_end_clean();
			}

			/**
			 * If there are errors, set the status to 500
			 * and format the captured errors to be output properly
			 *
			 * @since 0.0.4
			 */
			self::$http_status_code = 500;

			/**
			 * Filter thrown GraphQL errors
			 *
			 * @var SerializableResult $response
			 *
			 * @param SerializableError[] $errors  The errors array to be sent in the response.
			 * @param \Throwable          $error   Thrown error object.
			 * @param \WPGraphQL\Request  $request WPGraphQL Request object.
			 * @hookGroup request-lifecycle
			 * @since 0.0.4
			 */
			$response['errors'] = apply_filters(
				'graphql_http_request_response_errors',
				[ FormattedError::createFromException( $error, self::$request->get_debug_flag() ) ],
				$error,
				self::$request
			);
		}

		// Previously there was a small distinction between the response and the result, but
		// now that we are delegating to Request, just send the response for both.

		if ( false === headers_sent() ) {
			self::prepare_headers( $response, $response, $query, $operation_name, $variables );
		}

		/**
		 * Run an action after the HTTP Response is ready to be sent back. This might be a good place for tools
		 * to hook in to track metrics, such as how long the process took from `graphql_process_http_request`
		 * to here, etc.
		 *
		 * @param WPGraphQLResult      $response       The GraphQL response
		 * @param WPGraphQLResult      $result         Deprecated. Same as $response.
		 * @param string               $operation_name The name of the operation
		 * @param string               $query          The request that GraphQL executed
		 * @param ?array<string,mixed> $variables      Variables to passed to your GraphQL query
		 * @param int|string           $status_code    The status code for the response
		 *
		 * @hookGroup request-lifecycle
		 * @since 0.0.5
		 */
		do_action( 'graphql_process_http_request_response', $response, $response, $operation_name, $query, $variables, self::$http_status_code );

		/**
		 * Send the response
		 */
		wp_send_json( $response );
	}

	/**
	 * Prepare headers for response
	 *
	 * @param mixed[]|\GraphQL\Executor\ExecutionResult $response       The response of the GraphQL Request.
	 * @param mixed[]|\GraphQL\Executor\ExecutionResult $_deprecated    Deprecated.
	 * @param string                                    $query          The GraphQL query.
	 * @param string                                    $operation_name The operation name of the GraphQL Request.
	 * @param ?array<string,mixed>                      $variables      The variables applied to the GraphQL Request.
	 * @param ?\WP_User                                 $user           The current user object.
	 *
	 * @return void
	 */
	protected static function prepare_headers( $response, $_deprecated, string $query, string $operation_name, $variables, $user = null ) {

		/**
		 * Filter the $status_code before setting the headers
		 *
		 * @param int                                       $status_code    The status code to apply to the headers
		 * @param mixed[]|\GraphQL\Executor\ExecutionResult $response       The response of the GraphQL Request
		 * @param mixed[]|\GraphQL\Executor\ExecutionResult $_deprecated    Use $response instead.
		 * @param string                                    $query          The GraphQL query
		 * @param string                                    $operation_name The operation name of the GraphQL Request
		 * @param ?array<string,mixed>                      $variables      The variables applied to the GraphQL Request
		 * @param ?\WP_User                                 $user           The current user object
		 * @hookGroup request-lifecycle
		 * @since 0.0.5
		 */
		self::$http_status_code = apply_filters( 'graphql_response_status_code', self::$http_status_code, $_deprecated, $response, $query, $operation_name, $variables, $user );

		/**
		 * Set the response headers
		 */
		self::set_headers();
	}

	/**
	 * @deprecated 0.4.1 Use Router::is_graphql_http_request instead. This now resolves to it
	 * @todo remove in v3.0
	 * @codeCoverageIgnore
	 *
	 * @return bool
	 */
	public static function is_graphql_request() {
		_doing_it_wrong(
			__METHOD__,
			sprintf(
				/* translators: %s is the class name */
				esc_html__( 'This method is deprecated and will be removed in the next major version of WPGraphQL. Use %s instead.', 'wp-graphql' ),
				esc_html( self::class . '::is_graphql_http_request()' )
			),
			'0.4.1'
		);
		return self::is_graphql_http_request();
	}

	/**
	 * Validates HTTP request authentication BEFORE any GraphQL processing begins.
	 *
	 * This method provides CSRF protection for cookie-authenticated requests.
	 * It runs before `graphql_process_http_request` and other hooks fire, ensuring
	 * plugins cannot inadvertently expose sensitive data based on a user identity
	 * that hasn't been validated yet.
	 *
	 * For cookie-authenticated requests:
	 * - No nonce provided: User is downgraded to guest (CSRF protection)
	 * - Invalid nonce: Returns WP_Error (caller should return error response)
	 * - Valid nonce: Returns null (authentication preserved)
	 *
	 * @since 2.6.0
	 *
	 * @return \WP_Error|null WP_Error if invalid nonce, null otherwise.
	 */
	public static function validate_http_request_authentication(): ?\WP_Error {
		/**
		 * Only validate for logged-in users.
		 * Guest users don't need validation - they're already unauthenticated.
		 */
		if ( ! is_user_logged_in() ) {
			return null;
		}

		/**
		 * Check if an Authorization header is present.
		 * If so, this is likely a non-cookie auth method (JWT, Application Passwords, etc.)
		 * which are inherently CSRF-safe and don't need nonce validation.
		 */
		$has_auth_header = ! empty( $_SERVER['HTTP_AUTHORIZATION'] )
			|| ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] );

		if ( $has_auth_header ) {
			return null;
		}

		/**
		 * No Authorization header = cookie-based authentication.
		 * Check for nonce in request param or header.
		 */
		$nonce = null;

		if ( isset( $_REQUEST['_wpnonce'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$nonce = $_REQUEST['_wpnonce']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		} elseif ( isset( $_SERVER['HTTP_X_WP_NONCE'] ) ) {
			$nonce = $_SERVER['HTTP_X_WP_NONCE']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		}

		/**
		 * Treat "falsy" nonce values as "no nonce provided".
		 * This handles JavaScript serialization edge cases where null/undefined
		 * get converted to strings.
		 */
		$empty_nonce_values = [ '', 'null', 'undefined', 'false', '0' ];
		if ( in_array( $nonce, $empty_nonce_values, true ) ) {
			$nonce = null;
		}

		/**
		 * Filter whether to require a nonce for cookie-based authentication.
		 *
		 * By default, WPGraphQL requires a nonce (X-WP-Nonce header or _wpnonce parameter)
		 * for cookie-authenticated requests to prevent CSRF attacks.
		 *
		 * @since 2.5.4
		 *
		 * @param bool $require_nonce Whether to require a nonce for cookie auth. Default true.
		 * @param null $request       The Request instance (null in Router context).
		 * @hookGroup authentication
		 */
		$require_nonce = apply_filters( 'graphql_cookie_auth_require_nonce', true, null );

		/**
		 * If nonce is not required, allow the authenticated request.
		 */
		if ( ! $require_nonce ) {
			return null;
		}

		/**
		 * No nonce provided - downgrade to guest (unless plugin prevents it).
		 */
		if ( null === $nonce ) {
			/**
			 * Allow plugins to prevent the downgrade via the graphql_authentication_errors filter.
			 *
			 * @param bool|null                $authentication_errors Null to allow default behavior, false to preserve auth.
			 * @param \WPGraphQL\Request|null  $request               The Request instance (null in Router context).
		 * @hookGroup authentication
		 * @since 0.0.5
			 */
			$filtered = apply_filters( 'graphql_authentication_errors', null, self::get_request() );

			// If a plugin explicitly returned false (no errors), preserve authentication
			if ( false === $filtered ) {
				return null;
			}

			// Downgrade to guest
			wp_set_current_user( 0 );
			return null;
		}

		/**
		 * Nonce provided - validate it.
		 * Support both 'wp_graphql' and 'wp_rest' for backward compatibility.
		 */
		$nonce_valid = wp_verify_nonce( $nonce, 'wp_graphql' ) || wp_verify_nonce( $nonce, 'wp_rest' );

		if ( ! $nonce_valid ) {
			return new \WP_Error(
				'graphql_cookie_invalid_nonce',
				__( 'Cookie nonce is invalid', 'wp-graphql' ),
				[ 'status' => 403 ]
			);
		}

		return null;
	}
}
