WooCommerce Code Reference

MobileAppQRLogin.php

Source code

<?php
/**
 * REST API Mobile App QR Login controller.
 *
 * Handles requests to generate and exchange QR login tokens for direct mobile
 * app authentication via Application Passwords. Token generation is gated on
 * the `manage_woocommerce` capability (administrators and shop managers by
 * default).
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Admin\API;

use Automattic\WooCommerce\Admin\API\RateLimits\QRLoginRateLimits;

defined( 'ABSPATH' ) || exit;

/**
 * Mobile App QR Login controller.
 *
 * @internal
 */
class MobileAppQRLogin extends \WC_REST_Data_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'mobile-app';

	/**
	 * Token TTL in seconds (5 minutes).
	 */
	const TOKEN_TTL = 300;

	/**
	 * Transient prefix for QR login tokens.
	 */
	const TOKEN_TRANSIENT_PREFIX = '_wc_qr_login_token_';

	/**
	 * Max tokens per user per 15-minute window.
	 */
	const MAX_TOKENS_PER_WINDOW = 5;

	/**
	 * Max exchange attempts per valid token per 15-minute window.
	 */
	const MAX_EXCHANGE_ATTEMPTS = 10;

	/**
	 * Max invalid-token exchange attempts per IP per 15-minute window.
	 */
	const MAX_INVALID_EXCHANGE_ATTEMPTS = 100;

	/**
	 * Max invalid-token scan attempts per IP per 15-minute window.
	 */
	const MAX_INVALID_SCAN_ATTEMPTS = 100;

	/**
	 * Broad anonymous exchange abuse guard per IP per 15-minute window.
	 */
	const MAX_EXCHANGE_IP_ATTEMPTS = 1000;

	/**
	 * Option prefix for database-backed atomic token claims.
	 */
	const CLAIM_OPTION_PREFIX = '_wc_qr_login_claim_';

	/**
	 * Scan-claim option prefix. Independent from `CLAIM_OPTION_PREFIX` so the
	 * scan and exchange mutexes can't deadlock each other; they protect
	 * different write windows on the same token record.
	 */
	const SCAN_CLAIM_OPTION_PREFIX = '_wc_qr_login_scan_claim_';

	/**
	 * Approval-claim option prefix. Prevents concurrent number choices from
	 * racing the one-strike scanned -> approved/rejected transition.
	 */
	const APPROVE_CLAIM_OPTION_PREFIX = '_wc_qr_login_approve_claim_';

	/**
	 * Stable Application Passwords `app_id` for credentials issued by this
	 * flow. Lets administrators identify QR-issued credentials in the
	 * Application Passwords screen and revoke them in bulk.
	 */
	const APP_ID = '0b540e2f-86b7-4b8a-8e0c-f61e9bfbde59';

	/**
	 * Transient prefix for the "token consumed" record written after a successful
	 * exchange. The wc-admin UI polls a status endpoint that reads this so it can
	 * transition to a confirmation panel and surface the device that signed in.
	 */
	const CONSUMED_TRANSIENT_PREFIX = '_wc_qr_login_consumed_';

	/**
	 * Max status checks per user per 15-minute window. The polling client hits
	 * this every ~2.5s while a QR is on screen; 600/15min ≈ 40/min, comfortably
	 * above the polling rate but tight enough to short-circuit a misbehaving
	 * client or a credential-stuffing scan.
	 */
	const MAX_STATUS_CHECKS_PER_WINDOW = 600;

	/**
	 * Max revoke attempts per user per 15-minute window.
	 */
	const MAX_REVOKE_ATTEMPTS = 10;

	/**
	 * Whitelisted keys for the `device` payload sent by the mobile app on the
	 * scan call. Anything outside this set is dropped before storage.
	 *
	 * `brand` is Android-only (`Build.BRAND`, e.g. "google", "samsung"); iOS
	 * doesn't have a direct analogue and clients that don't have the field
	 * just leave it absent.
	 *
	 * @var string[]
	 */
	const DEVICE_PAYLOAD_KEYS = array( 'os', 'os_version', 'model', 'brand', 'app_version' );

	/**
	 * Maximum length (chars) for any individual sanitized device-payload field.
	 * Defends against accidental or hostile bloat ending up in transients and
	 * the Application Password name.
	 */
	const DEVICE_FIELD_MAX_LENGTH = 64;

	/**
	 * State machine values for the per-token record. Transitions are gated
	 * by an explicit `current_state` check at the top of each handler
	 * (scan/approve/exchange) so the only writers are the handlers themselves.
	 */
	const STATE_PENDING  = 'pending';
	const STATE_SCANNED  = 'scanned';
	const STATE_APPROVED = 'approved';
	const STATE_REJECTED = 'rejected';
	const STATE_EXPIRED  = 'expired';
	const STATE_CONSUMED = 'consumed';

	/**
	 * Pick window after the app scans a QR (seconds). The merchant has this
	 * long to tap the matching number on wc-admin before the session
	 * auto-rejects. Short enough to limit replay; long enough for a confused
	 * user to read the phone, find their browser, and click.
	 */
	const CHALLENGE_TTL_SECONDS = 90;

	/**
	 * Length (bytes pre-bin2hex) of the exchange-grant nonce minted on
	 * approval. The grant gates the final `/qr-login-exchange` call so an
	 * attacker who learned the token can't race the legit app to exchange
	 * after approval. 32 bytes = 64 hex chars = 256 bits of entropy.
	 */
	const EXCHANGE_GRANT_BYTES = 32;

	/**
	 * Invalid exchange grants allowed before the token is terminally rejected.
	 */
	const MAX_INVALID_GRANT_ATTEMPTS = 3;

	/**
	 * Transient prefix mapping `session_id` → `token_hash` so the mobile-side
	 * `/qr-login-session-status` poll can resolve a session id back to the
	 * underlying token record without exposing the original token to the
	 * polling channel.
	 */
	const SESSION_TRANSIENT_PREFIX = '_wc_qr_login_session_';

	/**
	 * Rate limit for /qr-login-scan (per IP per 15 min).
	 */
	const MAX_SCAN_PER_WINDOW = 10;

	/**
	 * Rate limit for /qr-login-approve (per user per 15 min).
	 */
	const MAX_APPROVE_PER_WINDOW = 20;

	/**
	 * Rate limit for /qr-login-session-status (per session id per 15 min).
	 *
	 * Accounts for ~2-s polling over a 90-s challenge window plus headroom.
	 */
	const MAX_SESSION_STATUS_PER_WINDOW = 60;

	/**
	 * Register routes.
	 *
	 * @return void
	 */
	public function register_routes() {
		// Generate a QR login token (requires authentication and `manage_woocommerce` capability).
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-token',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'generate_token' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		// Exchange a QR login token for Application Password (no authentication required).
		// The device payload is captured at /qr-login-scan time and sourced from the
		// approved record — the exchange call only needs the token + grant nonce.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-exchange',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'exchange_token' ),
					'permission_callback' => '__return_true',
					'args'                => array(
						'token'          => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
						// Soft-required: the handler enforces presence + validity
						// via constant-time comparison and returns a clear
						// `invalid_exchange_grant` 412 if missing. We don't make
						// it `required: true` at the schema layer because that
						// would short-circuit earlier checks (HTTPS, invalid
						// token, rate limit) with a generic WP validation 400
						// before our diagnostic responses can fire.
						'exchange_grant' => array(
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		// Poll for token status (consumed yet?). Used by wc-admin to transition
		// the modal from "QR shown" to "Signed in successfully on {device}".
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-status',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'get_status' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => array(
						'token' => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		// Revoke (delete) the Application Password issued by an exchange. The
		// user must own the AP — verified inside the callback via the WP API.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-revoke',
			array(
				array(
					'methods'             => \WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'revoke_password' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => array(
						'uuid' => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		// Mobile app reports the QR was scanned. Server generates the
		// number-match challenge and returns the *real* number to the app
		// only. Public — token + capability flag are the auth.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-scan',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'scan_token' ),
					'permission_callback' => '__return_true',
					'args'                => array(
						'token'                    => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
						// The device payload is required: it shows up in the
						// merchant's "match this number" device card, in the
						// Application Password name, and in the sign-in
						// notification email. Mobile clients always have these
						// fields available from the platform SDK (Build.MODEL,
						// UIDevice.current.model, etc.), so requiring the
						// payload at the protocol level keeps every downstream
						// surface honest.
						'device'                   => array(
							'required'   => true,
							'type'       => 'object',
							'properties' => array(
								'os'          => array( 'type' => 'string' ),
								'os_version'  => array( 'type' => 'string' ),
								'model'       => array( 'type' => 'string' ),
								'brand'       => array( 'type' => 'string' ),
								'app_version' => array( 'type' => 'string' ),
							),
						),
						// Capability flag the mobile app sets to advertise that
						// it implements the number-matching protocol. Reserved
						// for future protocol bumps that might gate behavior on
						// further capability bits.
						'supports_number_matching' => array(
							'required' => true,
							'type'     => 'boolean',
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		// Merchant taps a number on wc-admin. Server validates against the
		// stored real number with hash_equals; correct → approved, wrong →
		// rejected (terminal, no retry).
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-approve',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'approve_token' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => array(
						'token'  => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
						'choice' => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		// Mobile app polls this with the session id returned from /scan.
		// While in `scanned` we say so; on `approved` we hand over the
		// short-lived `exchange_grant` nonce required by the final
		// /qr-login-exchange call.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-session-status',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_session_status' ),
					'permission_callback' => '__return_true',
					'args'                => array(
						'session_id' => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
						'token_hash' => array(
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		// Cheap up-front capability check so wc-admin can render a permanently
		// disabled QR card (rather than spin up a token request that will fail)
		// when application passwords are unavailable on this site. Same gate as
		// the token endpoint so a subscriber cannot probe site configuration.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-availability',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_availability' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		parent::register_routes();
	}

	/**
	 * Check whether the current user can generate a QR login token.
	 *
	 * Requires the `manage_woocommerce` capability, which covers administrators and
	 * shop managers out of the box. The check is deliberately explicit (not routed
	 * through `wc_rest_check_manager_permissions()`) so it cannot be loosened by the
	 * `woocommerce_rest_check_permissions` filter that other Admin API endpoints share.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request The REST request (unused).
	 * @return \WP_Error|bool True if the user has the required capability, WP_Error otherwise.
	 */
	public function get_items_permissions_check( $request ) {
		unset( $request );
		// Parameter required by WP REST contract but unused here.

		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			return new \WP_Error(
				'woocommerce_rest_cannot_view',
				__( 'Sorry, you are not allowed to generate a mobile app QR login token.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}

		return true;
	}

	/**
	 * Check if Application Passwords are available.
	 *
	 * @return bool
	 */
	private function are_application_passwords_available() {
		return function_exists( 'wp_is_application_passwords_available' )
			&& wp_is_application_passwords_available();
	}

	/**
	 * Return a REST response carrying WordPress' no-cache headers.
	 *
	 * @param array<string, mixed> $data Response payload.
	 * @return \WP_REST_Response
	 */
	private function rest_ensure_nocache_response( array $data ): \WP_REST_Response {
		$response = rest_ensure_response( $data );

		foreach ( wp_get_nocache_headers() as $header_name => $header_value ) {
			if ( false === $header_value ) {
				continue;
			}

			$response->header( $header_name, (string) $header_value );
		}

		return $response;
	}

	/**
	 * Reason codes returned by `/qr-login-availability` so wc-admin can
	 * tailor the disabled card to the specific cause.
	 */
	const AVAILABILITY_REASON_HTTPS_REQUIRED        = 'https_required';
	const AVAILABILITY_REASON_AP_UNSUPPORTED        = 'application_passwords_unsupported';
	const AVAILABILITY_REASON_AP_DISABLED_BY_FILTER = 'application_passwords_disabled_by_filter';

	/**
	 * Report whether QR login is currently available on this site.
	 *
	 * Lets wc-admin render a permanently-disabled QR card with the right
	 * explanation up-front, instead of mounting `<QRDirectLoginCode />`,
	 * spinning, calling `/qr-login-token`, and only then showing a generic
	 * error. The reason code is the heuristic best we can do without each
	 * security plugin self-identifying:
	 *
	 *  - `https_required` — `is_ssl()` is false or the raw/final `siteurl` is
	 *    `http://`. The most common cause is a local dev environment;
	 *    production sites without HTTPS can't use QR login at all.
	 *  - `application_passwords_unsupported` — WordPress core's own support
	 *    gate (`wp_is_application_passwords_supported()`) returns false.
	 *    Ships true on every modern WP host that has SSL or is local; false
	 *    here typically means the site is non-local + non-SSL.
	 *  - `application_passwords_disabled_by_filter` — the WP support gate
	 *    passes, but the `wp_is_application_passwords_available` filter
	 *    returns false. This is the case where a security plugin (Wordfence,
	 *    Solid Security, etc.) or a custom code snippet has explicitly
	 *    disabled application passwords. We can't name the exact source from
	 *    the filter alone; the docs link in the merchant-facing UI covers it.
	 *
	 * `nocache_headers()` so an upstream cache cannot pin a stale
	 * "unavailable" response for a site that just installed an HTTPS cert.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request The REST request (unused).
	 * @return \WP_REST_Response
	 */
	public function get_availability( $request ): \WP_REST_Response {
		unset( $request );

		nocache_headers();

		$site_url     = $this->get_secure_site_url();
		$https_ok     = ! is_wp_error( $site_url );
		$ap_supported = function_exists( 'wp_is_application_passwords_supported' )
			&& wp_is_application_passwords_supported();
		$ap_available = $this->are_application_passwords_available();

		$https_ok  = is_ssl() && $https_ok;
		$available = $https_ok && $ap_available;
		$reason    = null;

		if ( ! $available ) {
			if ( ! $https_ok ) {
				$reason = self::AVAILABILITY_REASON_HTTPS_REQUIRED;
			} elseif ( ! $ap_supported ) {
				$reason = self::AVAILABILITY_REASON_AP_UNSUPPORTED;
			} else {
				$reason = self::AVAILABILITY_REASON_AP_DISABLED_BY_FILTER;
			}
		}

		return $this->rest_ensure_nocache_response(
			array(
				'available' => $available,
				'reason'    => $reason,
			)
		);
	}

	/**
	 * Check rate limit for token generation.
	 *
	 * @param int $user_id The user ID.
	 * @return bool True if within rate limit.
	 */
	private function check_generation_rate_limit( $user_id ) {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_GENERATION, (string) $user_id );
	}

	/**
	 * Broad anonymous abuse guard for token exchange.
	 *
	 * This intentionally has a high ceiling. It is only meant to slow obvious
	 * unauthenticated floods; valid-token and invalid-token traffic use separate
	 * lower buckets so a few random requests from a shared proxy IP cannot block
	 * legitimate QR login exchanges.
	 *
	 * @return bool True if within rate limit.
	 */
	private function check_exchange_ip_rate_limit() {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_EXCHANGE_IP, $this->get_client_ip() );
	}

	/**
	 * Check rate limit for random/nonexistent exchange tokens.
	 *
	 * @return bool True if within rate limit.
	 */
	private function check_invalid_exchange_rate_limit() {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_INVALID_EXCHANGE, $this->get_client_ip() );
	}

	/**
	 * Check rate limit for random/nonexistent scan tokens.
	 *
	 * @return bool True if within rate limit.
	 */
	private function check_invalid_scan_rate_limit() {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_INVALID_SCAN, $this->get_client_ip() );
	}

	/**
	 * Check rate limit for exchange attempts against a valid token.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @return bool True if within rate limit.
	 */
	private function check_valid_exchange_rate_limit( $token_hash ) {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_VALID_EXCHANGE, $token_hash );
	}

	/**
	 * Get the client IP address used as the per-IP rate-limit key.
	 *
	 * Uses `REMOTE_ADDR` exclusively. We intentionally do not honor
	 * `HTTP_X_FORWARDED_FOR` here: the exchange endpoint is unauthenticated, and
	 * without a project-wide trusted-proxy list we cannot tell a legitimate
	 * proxy header from an attacker-supplied one. Trusting the first XFF value
	 * would let any client choose a fresh rate-limit bucket per request and
	 * bypass per-IP caps. On sites behind a CDN/load balancer that all clients
	 * share, REMOTE_ADDR is the proxy IP, so exchange uses broad IP throttling
	 * only as an abuse guard and relies on token-scoped buckets for security.
	 *
	 * @return string
	 */
	private function get_client_ip() {
		if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
			return sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
		}
		return '';
	}

	/**
	 * Build the option name used for a token exchange claim.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @return string
	 */
	private function get_token_claim_key( $token_hash ) {
		return self::CLAIM_OPTION_PREFIX . $token_hash;
	}

	/**
	 * Atomically claim a token for exchange using the options table.
	 *
	 * `add_option()` is backed by a unique option_name constraint, so it works
	 * across PHP workers even on default installs without a persistent object
	 * cache. Stale claims are cleaned only if their stored value still matches
	 * the value this request observed, avoiding deletion of another worker's
	 * fresh claim.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @param int    $expires_at Unix timestamp when the token expires.
	 * @return bool True if the claim was acquired.
	 */
	private function claim_token_for_exchange( $token_hash, $expires_at ) {
		return $this->claim_token_with_option_key(
			$this->get_token_claim_key( $token_hash ),
			$expires_at
		);
	}

	/**
	 * Atomically claim a token using an option key.
	 *
	 * @param string $claim_key Option key used as the claim mutex.
	 * @param int    $expires_at Unix timestamp when the token expires.
	 * @return bool True if the claim was acquired.
	 */
	private function claim_token_with_option_key( $claim_key, $expires_at ) {
		$claim_expires_at = max( time() + 30, (int) $expires_at );

		if ( add_option( $claim_key, (string) $claim_expires_at, '', false ) ) {
			return true;
		}

		$existing_expires_at = (int) get_option( $claim_key, 0 );
		if ( $existing_expires_at > 0 && $existing_expires_at <= time() ) {
			$this->delete_claim_if_value_matches( $claim_key, (string) $existing_expires_at );
			return add_option( $claim_key, (string) $claim_expires_at, '', false );
		}

		return false;
	}

	/**
	 * Delete a claim option only if it still has the value this request observed.
	 *
	 * @param string $claim_key            Option key used as the claim mutex.
	 * @param string $observed_claim_value Claim expiry value previously read from the option.
	 * @return bool True if the observed stale claim was deleted.
	 */
	private function delete_claim_if_value_matches( $claim_key, $observed_claim_value ) {
		global $wpdb;

		$result = $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
				$claim_key,
				$observed_claim_value
			)
		);

		if ( false === $result ) {
			wc_get_logger()->warning(
				sprintf(
					'QR login stale-claim cleanup query failed for %s: %s',
					$claim_key,
					$wpdb->last_error
				),
				array( 'source' => 'mobile-app-qr-login' )
			);
		}

		wp_cache_delete( $claim_key, 'options' );

		return (int) $result > 0;
	}

	/**
	 * Release a token exchange claim.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @return void
	 */
	private function release_token_exchange_claim( $token_hash ) {
		delete_option( $this->get_token_claim_key( $token_hash ) );
	}

	/**
	 * Atomically claim a token for scan. Mirrors `claim_token_for_exchange()`
	 * (same `add_option()` unique-constraint mutex, same staleness recovery)
	 * but uses its own option key so the scan and exchange windows are
	 * independent.
	 *
	 * Without this, two concurrent `/qr-login-scan` requests both pass the
	 * `state === pending` gate, both write a fresh challenge, and the last
	 * writer wins — leaving the loser's session_id silently orphaned and
	 * pointing at the wrong challenge.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @param int    $expires_at Unix timestamp when the token expires.
	 * @return bool True if the claim was acquired.
	 */
	private function claim_token_for_scan( $token_hash, $expires_at ) {
		return $this->claim_token_with_option_key(
			self::SCAN_CLAIM_OPTION_PREFIX . $token_hash,
			$expires_at
		);
	}

	/**
	 * Release a token scan claim owned by this request.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @return void
	 */
	private function release_token_scan_claim( $token_hash ) {
		delete_option( self::SCAN_CLAIM_OPTION_PREFIX . $token_hash );
	}

	/**
	 * Atomically claim a token for approval.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @param int    $expires_at Unix timestamp when the claim should expire.
	 * @return bool True if the claim was acquired.
	 */
	private function claim_token_for_approval( $token_hash, $expires_at ) {
		return $this->claim_token_with_option_key(
			self::APPROVE_CLAIM_OPTION_PREFIX . $token_hash,
			$expires_at
		);
	}

	/**
	 * Release a token approval claim owned by this request.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @return void
	 */
	private function release_token_approval_claim( $token_hash ) {
		delete_option( self::APPROVE_CLAIM_OPTION_PREFIX . $token_hash );
	}

	/**
	 * Get the remaining storage TTL for a token record.
	 *
	 * @param array<string, mixed> $token_data Token record.
	 * @return int Remaining TTL in seconds.
	 */
	private function get_token_record_ttl( array $token_data ) {
		return max(
			1,
			isset( $token_data['expires_at'] ) ? (int) $token_data['expires_at'] - time() : self::TOKEN_TTL
		);
	}

	/**
	 * Delete the session-id to token-hash mapping for a token record.
	 *
	 * @param array<string, mixed> $token_data Token record.
	 * @return void
	 */
	private function delete_session_mapping_for_record( array $token_data ) {
		if ( empty( $token_data['challenge']['session_id'] ) ) {
			return;
		}

		delete_transient(
			self::SESSION_TRANSIENT_PREFIX . hash( 'sha256', (string) $token_data['challenge']['session_id'] )
		);
	}

	/**
	 * Validate that the configured site URL is HTTPS and return it.
	 *
	 * `is_ssl()` only tells us the current REQUEST is HTTPS — it says nothing about
	 * the canonical site URL WordPress is configured to advertise. `get_site_url()`
	 * itself is also insufficient because it passes its result through
	 * `set_url_scheme()`, which rewrites the scheme to match `is_ssl()` — so
	 * `get_site_url()` will return `https://…` whenever the request happens to be
	 * HTTPS, masking a stale `http://` `siteurl` option underneath. We therefore
	 * check the RAW stored option, which is what reflects admin configuration
	 * and what shows up in reset-password emails, webhooks, canonical redirects,
	 * etc. If that is `http://`, a misconfigured proxy that terminated TLS before
	 * reaching PHP could still cause this endpoint to hand the mobile app a cleartext
	 * site URL for the token-exchange POST.
	 *
	 * We deliberately reject (rather than silently normalizing to `https://`)
	 * because:
	 *   1. The misconfig usually affects other things (reset-password emails,
	 *      webhooks, canonical redirects). Failing loudly surfaces it.
	 *   2. Normalizing assumes the site actually serves HTTPS on the same host,
	 *      which we cannot verify from within a single request.
	 *   3. A 500 is strictly safer than a leaky success.
	 *
	 * @return string|\WP_Error The HTTPS site URL, or a WP_Error if it is not HTTPS.
	 */
	private function get_secure_site_url() {
		// Raw option: what the admin actually configured, before `set_url_scheme()`
		// inside `get_site_url()` normalizes it based on the current request's scheme.
		$raw_site_url = get_option( 'siteurl' );
		$raw_scheme   = is_string( $raw_site_url ) ? wp_parse_url( $raw_site_url, PHP_URL_SCHEME ) : null;

		if ( 'https' !== $raw_scheme ) {
			return new \WP_Error(
				'insecure_site_url',
				__( 'QR login cannot be used because the site URL is not configured for HTTPS. Please update the WordPress Address (URL) in Settings → General to use https://.', 'woocommerce' ),
				array( 'status' => 500 )
			);
		}

		// Use get_site_url() for the returned value so any scheme normalization
		// or filtering that WordPress applies downstream is preserved, then
		// validate the final value too. A plugin can still filter `site_url`
		// after the raw option check above; never hand the mobile app an
		// HTTP exchange target.
		$site_url     = get_site_url();
		$final_scheme = wp_parse_url( $site_url, PHP_URL_SCHEME );

		if ( 'https' !== $final_scheme ) {
			return new \WP_Error(
				'insecure_site_url',
				__( 'QR login cannot be used because the site URL is not configured for HTTPS. Please update the WordPress Address (URL) in Settings → General to use https://.', 'woocommerce' ),
				array( 'status' => 500 )
			);
		}

		return $site_url;
	}

	/**
	 * Generate a QR login token.
	 *
	 * Creates a short-lived one-time token that can be exchanged for an Application
	 * Password by the mobile app. The caller is assumed to have already passed the
	 * `manage_woocommerce` capability check in `get_items_permissions_check()`.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function generate_token( $request ) {
		unset( $request );
		// Parameter required by WP REST contract but unused here.

		// Check HTTPS.
		if ( ! is_ssl() ) {
			return new \WP_Error(
				'ssl_required',
				__( 'QR login requires an HTTPS connection.', 'woocommerce' ),
				array( 'status' => 403 )
			);
		}

		// Verify the canonical site URL is HTTPS — is_ssl() alone is not enough
		// when WordPress is behind a misconfigured proxy.
		$site_url = $this->get_secure_site_url();
		if ( is_wp_error( $site_url ) ) {
			return $site_url;
		}

		// Check Application Passwords are available.
		if ( ! $this->are_application_passwords_available() ) {
			return new \WP_Error(
				'application_passwords_unavailable',
				__( 'Application Passwords are not available on this site.', 'woocommerce' ),
				array( 'status' => 501 )
			);
		}

		// Check rate limit.
		if ( ! $this->check_generation_rate_limit( get_current_user_id() ) ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many QR login requests. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		// Generate a cryptographically secure token.
		$token      = wp_generate_password( 64, false );
		$token_hash = hash( 'sha256', $token );
		$now        = time();
		$expires_at = $now + self::TOKEN_TTL;

		// Structured state-machine record. Subsequent transitions
		// (scan/approve/exchange) gate themselves on the current state at the
		// top of each handler.
		$token_data = array(
			'state'      => self::STATE_PENDING,
			'created_at' => $now,
			'state_at'   => $now,
			'user_id'    => get_current_user_id(),
			'site_url'   => $site_url,
			'expires_at' => $expires_at,
		);

		set_transient( self::TOKEN_TRANSIENT_PREFIX . $token_hash, $token_data, self::TOKEN_TTL );

		// Build the QR URL (deep link for the mobile app).
		$qr_url = sprintf(
			'woocommerce://qr-login?token=%s&siteUrl=%s',
			rawurlencode( $token ),
			rawurlencode( $site_url )
		);

		return rest_ensure_response(
			array(
				'qr_url'     => $qr_url,
				'expires_at' => $expires_at,
				'ttl'        => self::TOKEN_TTL,
			)
		);
	}

	/**
	 * Exchange a QR login token for an Application Password.
	 *
	 * This endpoint does not require authentication — the token serves
	 * as the authentication mechanism.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function exchange_token( $request ) {
		// Refuse to return credentials over a non-HTTPS request.
		if ( ! is_ssl() ) {
			return new \WP_Error(
				'ssl_required',
				__( 'QR login requires an HTTPS connection.', 'woocommerce' ),
				array( 'status' => 403 )
			);
		}

		// Refuse to return credentials bound to a non-HTTPS site URL — see
		// get_secure_site_url() for rationale. A token that was minted while the
		// siteurl was still https:// but has since been changed to http:// should
		// also be refused here.
		$site_url = $this->get_secure_site_url();
		if ( is_wp_error( $site_url ) ) {
			return $site_url;
		}

		// Defensive sanitize even though the REST `sanitize_callback` already
		// did so — guards against future refactors that bypass the callback.
		$token      = sanitize_text_field( (string) $request->get_param( 'token' ) );
		$token_hash = hash( 'sha256', $token );
		$key        = self::TOKEN_TRANSIENT_PREFIX . $token_hash;

		$token_data = get_transient( $key );
		if ( ! is_array( $token_data ) ) {
			if ( ! $this->check_invalid_exchange_rate_limit() ) {
				return new \WP_Error(
					'rate_limit_exceeded',
					__( 'Too many exchange attempts. Please try again later.', 'woocommerce' ),
					array( 'status' => 429 )
				);
			}

			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		// Broad anonymous abuse guard applies only after token lookup. Random
		// invalid requests use the invalid-token bucket above so they cannot
		// exhaust this shared-IP guard for later valid exchanges behind the same
		// proxy/CDN IP.
		if ( ! $this->check_exchange_ip_rate_limit() ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many exchange attempts. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		if ( ! $this->check_valid_exchange_rate_limit( $token_hash ) ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many exchange attempts. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		if ( ! $this->claim_token_for_exchange( $token_hash, isset( $token_data['expires_at'] ) ? (int) $token_data['expires_at'] : time() + self::TOKEN_TTL ) ) {
			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		// Re-read after acquiring the database claim in case another process
		// consumed or expired the token while this request was waiting.
		$token_data = get_transient( $key );
		if ( ! is_array( $token_data ) ) {
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		// Validate token hasn't expired (belt and suspenders with transient TTL).
		if ( ! empty( $token_data['expires_at'] ) && time() >= (int) $token_data['expires_at'] ) {
			delete_transient( $key );
			$this->delete_session_mapping_for_record( $token_data );
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'token_expired',
				__( 'QR login token has expired.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		// Number-matching enforcement: exchange must be preceded by /scan +
		// /approve. Anything other than `approved` (including `pending` —
		// scan was skipped — and `scanned` — scan completed but merchant
		// didn't tap a number yet) is a hard 412.
		$current_state = isset( $token_data['state'] ) ? (string) $token_data['state'] : self::STATE_PENDING;

		if ( self::STATE_APPROVED !== $current_state ) {
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'qr_login_not_approved',
				__( 'This QR login session has not been approved.', 'woocommerce' ),
				array( 'status' => 412 )
			);
		}

		// Constant-time grant comparison. The grant is bound to this token
		// at /approve time and only handed back to the polling app via
		// /session-status, so an attacker who somehow learned the token
		// can't race the legit app to exchange after approval.
		$submitted_grant = (string) $request->get_param( 'exchange_grant' );
		$stored_grant    = isset( $token_data['exchange_grant'] ) ? (string) $token_data['exchange_grant'] : '';

		if ( '' === $stored_grant || ! hash_equals( $stored_grant, $submitted_grant ) ) {
			$invalid_grant_attempts = isset( $token_data['invalid_grant_attempts'] ) ? (int) $token_data['invalid_grant_attempts'] : 0;
			++$invalid_grant_attempts;

			$token_data['invalid_grant_attempts']          = $invalid_grant_attempts;
			$token_data['invalid_grant_last_attempted_at'] = time();

			if ( $invalid_grant_attempts >= self::MAX_INVALID_GRANT_ATTEMPTS ) {
				$token_data['state']    = self::STATE_REJECTED;
				$token_data['state_at'] = time();

				wc_get_logger()->warning(
					'QR login rejected after repeated invalid exchange grants',
					array(
						'source'  => 'qr-login-security',
						'user_id' => isset( $token_data['user_id'] ) ? (int) $token_data['user_id'] : 0,
						'ip'      => $this->get_client_ip(),
					)
				);
			}

			set_transient( $key, $token_data, $this->get_token_record_ttl( $token_data ) );
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'invalid_exchange_grant',
				__( 'Invalid exchange grant for this QR login session.', 'woocommerce' ),
				array( 'status' => 412 )
			);
		}//end if

		$user_id = $token_data['user_id'];
		$user    = get_userdata( $user_id );

		if ( ! $user ) {
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'user_not_found',
				__( 'User associated with this token no longer exists.', 'woocommerce' ),
				array( 'status' => 404 )
			);
		}

		// Application Passwords may have been disabled after the token was minted.
		if ( ! $this->are_application_passwords_available() ) {
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'application_passwords_unavailable',
				__( 'Application Passwords are not available on this site.', 'woocommerce' ),
				array( 'status' => 501 )
			);
		}

		// Mirror the permission check WP core performs in
		// WP_REST_Application_Passwords_Controller::create_item_permissions_check().
		// Capability or per-user availability filters could have changed in the
		// window between token generation and exchange.
		if ( ! user_can( $user, 'create_app_password', $user_id ) ) {
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'rest_cannot_create_application_passwords',
				__( 'Application passwords are not available for your account. Please contact the site administrator for assistance.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}

		// Source the device payload from the scan record. /qr-login-scan
		// requires a device object, so by the time we reach `approved` it's
		// guaranteed present. Re-sanitize defensively in case the transient
		// was tampered with at the storage layer.
		$device_source = isset( $token_data['challenge']['device'] ) && is_array( $token_data['challenge']['device'] )
			? $token_data['challenge']['device']
			: array();
		$device        = $this->sanitize_device_payload( $device_source );

		// Create an Application Password for the mobile app. The name is
		// descriptive (e.g. "Woo Mobile · iPhone 15 · 2026-04-28") so the user
		// can identify it later in Users → Profile → Application Passwords.
		$app_password_result = \WP_Application_Passwords::create_new_application_password(
			$user_id,
			array(
				'name'   => $this->format_application_password_name( $device ),
				'app_id' => self::APP_ID,
			)
		);

		if ( is_wp_error( $app_password_result ) ) {
			wc_get_logger()->error(
				sprintf(
					'QR login: failed to create Application Password for user %d: %s',
					$user_id,
					$app_password_result->get_error_message()
				),
				array( 'source' => 'mobile-app-qr-login' )
			);
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'application_password_failed',
				__( 'Could not create a mobile-app credential. Please try again, or contact your site administrator.', 'woocommerce' ),
				array( 'status' => 500 )
			);
		}

		list( $new_password, $item ) = $app_password_result;

		// Write a "consumed" record so wc-admin's polling client can transition
		// from "QR shown" to "Signed in successfully on {device}" and surface
		// a revoke button. Same TTL as the original token transient — there's
		// no value in keeping this record longer than the modal that polls it.
		$consumed_record = array(
			'consumed_at' => time(),
			'user_id'     => $user_id,
			'ap_uuid'     => $item['uuid'],
			'ap_name'     => $item['name'],
			'device'      => $device,
		);
		set_transient(
			self::CONSUMED_TRANSIENT_PREFIX . $token_hash,
			$consumed_record,
			self::TOKEN_TTL
		);

		// One-shot: consume only after the Application Password has been
		// successfully created and the consumed record is visible to wc-admin's
		// polling client.
		delete_transient( $key );
		$this->delete_session_mapping_for_record( $token_data );
		$this->release_token_exchange_claim( $token_hash );

		// Notify the merchant out-of-band so they're aware of a fresh sign-in
		// even when they aren't currently looking at wc-admin. Wrapped in a
		// try/catch + filter to keep the exchange path uninterrupted if the
		// site's mailer is misconfigured.
		$this->maybe_send_sign_in_notification_email( $user, $consumed_record );

		return rest_ensure_response(
			array(
				'success'              => true,
				'user_login'           => $user->user_login,
				'user_email'           => $user->user_email,
				'user_id'              => $user_id,
				'site_url'             => $site_url,
				'application_password' => $new_password,
				'uuid'                 => $item['uuid'],
			)
		);
	}

	/**
	 * Get the status of a previously generated QR login token.
	 *
	 * Used by the wc-admin UI to poll while the QR is on screen. Returns one of:
	 *   - `pending`  — token transient exists, has not been exchanged yet.
	 *   - `consumed` — token has been exchanged; payload includes the device that
	 *                  signed in and the AP UUID so the UI can render the
	 *                  confirmation panel and (optionally) revoke the AP.
	 *   - `expired`  — neither transient exists, so the token has expired or
	 *                  was never valid for this user.
	 *
	 * The user calling this endpoint must be the same user who minted the token.
	 * That's defense in depth — tokens are 64 random chars and not realistically
	 * guessable, but cross-user status reads should be impossible regardless.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function get_status( $request ) {
		// Defeat any intermediary cache (Cloudflare, NGINX micro-cache, browser, edge proxy)
		// that might pin this GET to its first response. Polling endpoints are by definition
		// state-bearing — every tick must see the live transient. Returning a stale `scanned`
		// response forever is exactly the symptom we'd see if the cache pins the first hit.
		nocache_headers();

		$user_id = get_current_user_id();

		if ( ! $this->check_status_rate_limit( $user_id ) ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many QR login status checks. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		$token = (string) $request->get_param( 'token' );
		if ( '' === $token ) {
			return $this->rest_ensure_nocache_response( array( 'status' => 'expired' ) );
		}

		$token_hash = hash( 'sha256', $token );

		// Consumed lookup first — once a token has been exchanged the main
		// transient is deleted, but we keep a one-way breadcrumb at the
		// `_wc_qr_login_consumed_` key so the polling client (which still
		// has the plaintext token) can render the success panel.
		$consumed = get_transient( self::CONSUMED_TRANSIENT_PREFIX . $token_hash );
		if ( is_array( $consumed ) ) {
			if ( ! isset( $consumed['user_id'] ) || (int) $consumed['user_id'] !== (int) $user_id ) {
				return $this->rest_ensure_nocache_response( array( 'status' => 'expired' ) );
			}

			return $this->rest_ensure_nocache_response(
				array(
					'status'      => self::STATE_CONSUMED,
					'consumed_at' => isset( $consumed['consumed_at'] ) ? (int) $consumed['consumed_at'] : null,
					'ap_uuid'     => isset( $consumed['ap_uuid'] ) ? (string) $consumed['ap_uuid'] : null,
					'ap_name'     => isset( $consumed['ap_name'] ) ? (string) $consumed['ap_name'] : null,
					'device'      => isset( $consumed['device'] ) && is_array( $consumed['device'] ) ? $consumed['device'] : array(),
				)
			);
		}

		$record = get_transient( self::TOKEN_TRANSIENT_PREFIX . $token_hash );
		if ( ! is_array( $record ) ) {
			return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_EXPIRED ) );
		}

		// Cross-user defense in depth — same as before.
		if ( ! isset( $record['user_id'] ) || (int) $record['user_id'] !== (int) $user_id ) {
			return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_EXPIRED ) );
		}

		$state = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;

		// Rejected / expired states are terminal — surface them directly so
		// wc-admin can render the "Login denied" terminal screen.
		if ( in_array( $state, array( self::STATE_REJECTED, self::STATE_EXPIRED ), true ) ) {
			return $this->rest_ensure_nocache_response( array( 'status' => $state ) );
		}

		if ( ! empty( $record['expires_at'] ) && time() >= (int) $record['expires_at'] ) {
			return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_EXPIRED ) );
		}

		// While in `scanned`, surface the shuffled candidate triple and the
		// device that scanned so wc-admin can render the matching UI. The
		// REAL number is never returned via this endpoint — only the
		// shuffled triple of (real + 2 distractors) is, so an XSS / hostile
		// extension can't read which one is correct from JS state.
		if ( self::STATE_SCANNED === $state ) {
			$challenge = isset( $record['challenge'] ) && is_array( $record['challenge'] ) ? $record['challenge'] : array();
			$numbers   = $this->shuffled_candidate_numbers( $challenge );

			return $this->rest_ensure_nocache_response(
				array(
					'status'     => self::STATE_SCANNED,
					'numbers'    => $numbers,
					'device'     => isset( $challenge['device'] ) && is_array( $challenge['device'] ) ? $challenge['device'] : array(),
					'expires_at' => isset( $challenge['expires_at'] ) ? (int) $challenge['expires_at'] : null,
				)
			);
		}

		// Approved (post-tap, pre-exchange) — surface so a wc-admin tab that
		// reloaded between approve and exchange shows the "Signing in…"
		// transitional state rather than going back to the QR.
		if ( self::STATE_APPROVED === $state ) {
			return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_APPROVED ) );
		}

		// Pending: same shape as before, plus the new `state` field for
		// clients that want to switch on it directly.
		return $this->rest_ensure_nocache_response(
			array(
				'status'     => self::STATE_PENDING,
				'expires_at' => isset( $record['expires_at'] ) ? (int) $record['expires_at'] : null,
			)
		);
	}

	/**
	 * Revoke (delete) the Application Password issued by a QR login exchange.
	 *
	 * The current user must own the AP being revoked — verified via
	 * `WP_Application_Passwords::get_user_application_password()`. We
	 * deliberately do NOT use `current_user_can( 'edit_user', $user_id )`
	 * because that would let a higher-privilege admin revoke another user's AP
	 * here; the QR flow's revoke surface is for "I just authorized this — undo,"
	 * not for site-wide AP management (which lives at Users → Profile).
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function revoke_password( $request ) {
		$user_id = get_current_user_id();

		if ( ! $this->check_revoke_rate_limit( $user_id ) ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many QR login revoke attempts. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		if ( ! $this->are_application_passwords_available() ) {
			return new \WP_Error(
				'application_passwords_unavailable',
				__( 'Application Passwords are not available on this site.', 'woocommerce' ),
				array( 'status' => 501 )
			);
		}

		$uuid = (string) $request->get_param( 'uuid' );

		// Ownership check: the AP must exist AND belong to the current user.
		$ap = \WP_Application_Passwords::get_user_application_password( $user_id, $uuid );
		if ( ! is_array( $ap ) ) {
			return new \WP_Error(
				'application_password_not_found',
				__( 'No matching Application Password to revoke.', 'woocommerce' ),
				array( 'status' => 404 )
			);
		}

		$deleted = \WP_Application_Passwords::delete_application_password( $user_id, $uuid );
		if ( true !== $deleted ) {
			return new \WP_Error(
				'application_password_revoke_failed',
				__( 'Could not revoke the Application Password. Please try again.', 'woocommerce' ),
				array( 'status' => 500 )
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'uuid'    => $uuid,
			)
		);
	}

	/**
	 * Whitelist + sanitize the `device` payload sent by the mobile app.
	 *
	 * Returns an array of strings keyed by the whitelisted keys defined in
	 * `DEVICE_PAYLOAD_KEYS`. Anything outside that whitelist is dropped. Each
	 * value is run through `sanitize_text_field()` and capped at
	 * `DEVICE_FIELD_MAX_LENGTH` characters. The function is total — pass `null`
	 * or anything non-array and you get back `array()`.
	 *
	 * @param mixed $device Raw payload from the request.
	 * @return array<string, string>
	 */
	private function sanitize_device_payload( $device ) {
		if ( ! is_array( $device ) ) {
			return array();
		}

		$sanitized = array();
		foreach ( self::DEVICE_PAYLOAD_KEYS as $key ) {
			if ( ! isset( $device[ $key ] ) || ! is_scalar( $device[ $key ] ) ) {
				continue;
			}
			$value = sanitize_text_field( (string) $device[ $key ] );
			if ( '' === $value ) {
				continue;
			}
			if ( strlen( $value ) > self::DEVICE_FIELD_MAX_LENGTH ) {
				$value = substr( $value, 0, self::DEVICE_FIELD_MAX_LENGTH );
			}
			$sanitized[ $key ] = $value;
		}

		return $sanitized;
	}

	/**
	 * Build a descriptive name for the Application Password issued by the QR
	 * login exchange.
	 *
	 * Preferred: `Woo Mobile · iPhone 15 · 2026-04-28` (model + ISO date).
	 * Falls back to `Woo Mobile · iOS · 2026-04-28` when only the OS is known.
	 * The scan endpoint requires at least model or OS. The legacy fallback is
	 * retained as a defensive guard in case stored token data is corrupted.
	 *
	 * The name is what the merchant sees in WP admin → Users → Profile →
	 * Application Passwords, so it should be human-readable, single-line, and
	 * not contain anything that would only make sense to an engineer.
	 *
	 * @param array<string, string> $device Sanitized device payload.
	 * @return string
	 */
	private function format_application_password_name( array $device ): string {
		$model = isset( $device['model'] ) ? trim( $device['model'] ) : '';
		$os    = isset( $device['os'] ) ? trim( $device['os'] ) : '';

		// Prefer model (e.g. "iPhone 15", "Pixel 10"); fall back to the OS
		// label if a particular device build returns an empty MODEL string.
		// Both fields come from the platform SDK on the mobile side and are
		// effectively always populated, but defending against an empty model
		// is cheaper than chasing the edge case at runtime.
		$descriptor = '' !== $model ? $model : $os;
		if ( '' === $descriptor ) {
			return __( 'WooCommerce Mobile App (QR Login)', 'woocommerce' );
		}

		// Use the site's configured timezone so the date the merchant sees in
		// the AP list matches what they'd see in the rest of wp-admin.
		$date = wp_date( 'Y-m-d' );

		/* translators: 1: device descriptor (model or OS, e.g. "iPhone 15"). 2: ISO date the AP was created. */
		return sprintf( __( 'Woo Mobile · %1$s · %2$s', 'woocommerce' ), $descriptor, $date );
	}

	/**
	 * Per-user rate limit for the polling status endpoint.
	 *
	 * @param int $user_id The user ID.
	 * @return bool True if within rate limit.
	 */
	private function check_status_rate_limit( $user_id ) {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_STATUS, (string) $user_id );
	}

	/**
	 * Per-user rate limit for the revoke endpoint.
	 *
	 * @param int $user_id The user ID.
	 * @return bool True if within rate limit.
	 */
	private function check_revoke_rate_limit( $user_id ) {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_REVOKE, (string) $user_id );
	}

	/**
	 * Mobile app reports the QR was scanned. Generates the number-match
	 * challenge, marks the state as `scanned`, and returns the *real* number
	 * + a session id back to the app. Public — the token is the auth.
	 *
	 * Hard-break compat: clients that don't send `supports_number_matching`
	 * get 426 Upgrade Required. The Android Task 7 PR adds the flag; older
	 * apps in the wild see a clear "update required" error.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function scan_token( $request ) {
		if ( ! is_ssl() ) {
			return new \WP_Error(
				'ssl_required',
				__( 'QR login requires an HTTPS connection.', 'woocommerce' ),
				array( 'status' => 403 )
			);
		}

		if ( true !== (bool) $request->get_param( 'supports_number_matching' ) ) {
			return new \WP_Error(
				'mobile_app_update_required',
				__( 'This Woo mobile app version is no longer supported for QR sign-in. Please update the app and try again.', 'woocommerce' ),
				array( 'status' => 426 )
			);
		}

		$token      = (string) $request->get_param( 'token' );
		$token_hash = hash( 'sha256', $token );
		$key        = self::TOKEN_TRANSIENT_PREFIX . $token_hash;

		$record = get_transient( $key );
		if ( ! is_array( $record ) ) {
			if ( ! $this->check_invalid_scan_rate_limit() ) {
				return new \WP_Error(
					'rate_limit_exceeded',
					__( 'Too many QR login scans. Please try again later.', 'woocommerce' ),
					array( 'status' => 429 )
				);
			}

			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		if ( ! $this->check_scan_rate_limit() ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many QR login scans. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		$device = $this->sanitize_device_payload( $request->get_param( 'device' ) );
		if ( empty( $device['model'] ) && empty( $device['os'] ) ) {
			return new \WP_Error(
				'invalid_device',
				__( 'QR login requires device information from the mobile app.', 'woocommerce' ),
				array( 'status' => 400 )
			);
		}

		// Atomic mutex on the read-mutate-write window. Without this, two
		// concurrent scans both pass the state==pending check below and both
		// write a new challenge — last writer wins, the loser's session_id
		// is silently orphaned. The state check is kept as defense-in-depth
		// for any path that bypasses the claim (e.g. the staleness branch).
		if ( ! $this->claim_token_for_scan(
			$token_hash,
			isset( $record['expires_at'] ) ? (int) $record['expires_at'] : time() + self::TOKEN_TTL
		) ) {
			return new \WP_Error(
				'qr_login_already_scanned',
				__( 'This QR login session is no longer accepting scans.', 'woocommerce' ),
				array( 'status' => 409 )
			);
		}

		$current_state = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
		if ( self::STATE_PENDING !== $current_state ) {
			$this->release_token_scan_claim( $token_hash );
			return new \WP_Error(
				'qr_login_already_scanned',
				__( 'This QR login session is no longer accepting scans.', 'woocommerce' ),
				array( 'status' => 409 )
			);
		}

		$challenge_numbers = $this->generate_challenge_numbers();
		$session_id        = wp_generate_uuid4();
		$now               = time();
		$token_expires_at  = isset( $record['expires_at'] ) ? (int) $record['expires_at'] : $now + self::TOKEN_TTL;

		if ( $token_expires_at <= $now ) {
			$this->release_token_scan_claim( $token_hash );
			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		$challenge_expires_at = min( $now + self::CHALLENGE_TTL_SECONDS, $token_expires_at );
		$challenge_ttl        = max( 1, $challenge_expires_at - $now );

		// Shuffle the candidate triple ONCE at scan time and persist the chosen ordering so
		// every subsequent /qr-login-status poll returns the same array. Re-shuffling per-poll
		// would make the wc-admin tile order flicker every 2.5 s — terrible UX, and makes the
		// merchant doubt they're reading the right number.
		$candidates = array_merge( array( $challenge_numbers['real'] ), $challenge_numbers['distractors'] );
		shuffle( $candidates );

		$record['state']     = self::STATE_SCANNED;
		$record['state_at']  = $now;
		$record['challenge'] = array(
			'real'        => $challenge_numbers['real'],
			'distractors' => $challenge_numbers['distractors'],
			'shuffled'    => $candidates,
			'session_id'  => $session_id,
			'expires_at'  => $challenge_expires_at,
			'device'      => $device,
		);

		// Re-use whatever TTL the original transient had left. The challenge
		// window itself is capped to the remaining token lifetime, while the
		// storage TTL keeps the full challenge visible for normal fresh scans.
		$ttl_left    = max( 1, $token_expires_at - $now );
		$storage_ttl = min( $ttl_left, self::CHALLENGE_TTL_SECONDS + 30 );
		set_transient( $key, $record, $storage_ttl );

		// Sibling transient that resolves session_id → token_hash for the
		// app's session-status poll. Stored hashed so the session id isn't
		// directly indexable in wp_options.
		set_transient(
			self::SESSION_TRANSIENT_PREFIX . hash( 'sha256', $session_id ),
			$token_hash,
			$storage_ttl
		);

		$this->release_token_scan_claim( $token_hash );

		return rest_ensure_response(
			array(
				'session_id'  => $session_id,
				'real_number' => $challenge_numbers['real'],
				'expires_in'  => $challenge_ttl,
			)
		);
	}

	/**
	 * Merchant taps a number on wc-admin. Server validates against the
	 * stored real number with `hash_equals()` (constant-time). Correct →
	 * `approved` + mints `exchange_grant`. Wrong → `rejected` (terminal,
	 * security event logged). One-strike: no retry.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function approve_token( $request ) {
		$user_id = get_current_user_id();

		if ( ! $this->check_approve_rate_limit( $user_id ) ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many QR login approval attempts. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		$token      = (string) $request->get_param( 'token' );
		$token_hash = hash( 'sha256', $token );
		$key        = self::TOKEN_TRANSIENT_PREFIX . $token_hash;

		$record = get_transient( $key );
		if ( ! is_array( $record ) ) {
			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		$approval_claim_expires_at = ! empty( $record['challenge']['expires_at'] )
			? (int) $record['challenge']['expires_at']
			: ( isset( $record['expires_at'] ) ? (int) $record['expires_at'] : time() + self::TOKEN_TTL );
		if ( ! $this->claim_token_for_approval( $token_hash, $approval_claim_expires_at ) ) {
			return new \WP_Error(
				'qr_login_approval_in_progress',
				__( 'This QR login session is already being approved.', 'woocommerce' ),
				array( 'status' => 409 )
			);
		}

		// Re-read after acquiring the database claim in case another request
		// approved, rejected, or expired the challenge while this one was waiting.
		$record = get_transient( $key );
		if ( ! is_array( $record ) ) {
			$this->release_token_approval_claim( $token_hash );
			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		// Same cross-user defense as get_status — only the user that minted
		// the token can approve it.
		if ( ! isset( $record['user_id'] ) || (int) $record['user_id'] !== (int) $user_id ) {
			$this->release_token_approval_claim( $token_hash );
			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		if ( ! empty( $record['expires_at'] ) && time() >= (int) $record['expires_at'] ) {
			$record['state']    = self::STATE_EXPIRED;
			$record['state_at'] = time();
			set_transient( $key, $record, 60 );
			$this->release_token_approval_claim( $token_hash );
			return new \WP_Error(
				'qr_login_expired',
				__( 'The QR login challenge has expired. Please generate a new code.', 'woocommerce' ),
				array( 'status' => 410 )
			);
		}

		$current_state = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
		if ( self::STATE_SCANNED !== $current_state ) {
			$this->release_token_approval_claim( $token_hash );
			return new \WP_Error(
				'qr_login_not_scanned',
				__( 'This QR login session is not waiting for approval.', 'woocommerce' ),
				array( 'status' => 409 )
			);
		}

		// Challenge expiry — normally 90 s after scan, capped by token expiry.
		if ( ! empty( $record['challenge']['expires_at'] ) && time() > (int) $record['challenge']['expires_at'] ) {
			$record['state']    = self::STATE_EXPIRED;
			$record['state_at'] = time();
			set_transient( $key, $record, 60 );
			$this->release_token_approval_claim( $token_hash );
			return new \WP_Error(
				'qr_login_expired',
				__( 'The QR login challenge has expired. Please generate a new code.', 'woocommerce' ),
				array( 'status' => 410 )
			);
		}

		$choice = (string) $request->get_param( 'choice' );
		$real   = isset( $record['challenge']['real'] ) ? (string) $record['challenge']['real'] : '';

		// Constant-time compare. Defends against PHP string-comparison fast
		// paths that can leak prefix-matching info via timing.
		if ( '' === $real || ! hash_equals( $real, $choice ) ) {
			$record['state']    = self::STATE_REJECTED;
			$record['state_at'] = time();
			set_transient( $key, $record, 60 );

			wc_get_logger()->warning(
				'QR login number-match rejected — wrong choice submitted',
				array(
					'source'  => 'qr-login-security',
					'user_id' => (int) $user_id,
					'ip'      => $this->get_client_ip(),
					'device'  => isset( $record['challenge']['device'] ) ? $record['challenge']['device'] : array(),
				)
			);

			$this->release_token_approval_claim( $token_hash );
			return rest_ensure_response( array( 'state' => self::STATE_REJECTED ) );
		}

		$record['state']          = self::STATE_APPROVED;
		$record['state_at']       = time();
		$record['exchange_grant'] = bin2hex( random_bytes( self::EXCHANGE_GRANT_BYTES ) );
		$ttl                      = max(
			1,
			isset( $record['expires_at'] ) ? (int) $record['expires_at'] - time() : self::CHALLENGE_TTL_SECONDS
		);

		set_transient( $key, $record, $ttl );
		$this->release_token_approval_claim( $token_hash );

		return rest_ensure_response( array( 'state' => self::STATE_APPROVED ) );
	}

	/**
	 * Mobile app polls this with the session id from /scan. Returns the
	 * current state of the underlying token, plus — when state is
	 * `approved` — the `exchange_grant` nonce required by /qr-login-exchange.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function get_session_status( $request ) {
		// Defeat any intermediary cache (Cloudflare, NGINX micro-cache, OkHttp's shared
		// cache, edge proxy) that might pin this GET to its first response. Polling
		// endpoints are by definition state-bearing — every tick must see the live
		// transient. Returning a stale `scanned` response forever is exactly the
		// symptom we see if the cache pins the first hit.
		nocache_headers();

		if ( ! is_ssl() ) {
			return new \WP_Error(
				'ssl_required',
				__( 'QR login requires an HTTPS connection.', 'woocommerce' ),
				array( 'status' => 403 )
			);
		}

		$session_id     = (string) $request->get_param( 'session_id' );
		$submitted_hash = (string) $request->get_param( 'token_hash' );

		$token_hash = get_transient( self::SESSION_TRANSIENT_PREFIX . hash( 'sha256', $session_id ) );
		if ( ! is_string( $token_hash ) || '' === $token_hash ) {
			// Either the session never existed or it has expired. Either way,
			// surface as expired to the polling app.
			return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
		}

		// Bind grant delivery to proof of token knowledge: an attacker who
		// learns the session_id alone (mobile logs, network capture, debug
		// output) cannot poll for state transitions and walk away with the
		// `exchange_grant` the moment the merchant approves. The mobile app
		// already holds the plaintext token from the QR scan — passing
		// SHA-256(token) on every poll is essentially free for it.
		// `hash_equals` for constant-time comparison; `expired` opacity so
		// we never leak whether the session_id is real or not.
		if ( ! hash_equals( $token_hash, $submitted_hash ) ) {
			return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
		}

		if ( ! $this->check_session_status_rate_limit( $session_id ) ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many QR login session-status checks. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		$record = get_transient( self::TOKEN_TRANSIENT_PREFIX . $token_hash );
		if ( ! is_array( $record ) ) {
			return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
		}

		$state    = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
		$response = array( 'state' => $state );

		if ( in_array( $state, array( self::STATE_REJECTED, self::STATE_EXPIRED ), true ) ) {
			return $this->rest_ensure_nocache_response( $response );
		}

		if ( ! empty( $record['expires_at'] ) && time() >= (int) $record['expires_at'] ) {
			return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
		}

		if ( self::STATE_APPROVED === $state && ! empty( $record['exchange_grant'] ) ) {
			$response['exchange_grant'] = (string) $record['exchange_grant'];
		}

		return $this->rest_ensure_nocache_response( $response );
	}

	/**
	 * Generate a 1-real + 2-distractor number triple for the match
	 * challenge. Distractors must differ from the real number and from each
	 * other by ≥ 100 — defends against a partial-read leak fingerprinting
	 * the real one (no `042` vs `043` near-misses).
	 *
	 * Uses `random_int()` (CSPRNG-backed) rather than `wp_rand()`, which can
	 * fall back to mt_rand() and is predictable.
	 *
	 * @return array{real: string, distractors: array<int, string>}
	 * @throws \RuntimeException If a valid distractor set cannot be generated.
	 */
	private function generate_challenge_numbers(): array {
		$real             = random_int( 0, 999 );
		$valid_candidates = array();

		for ( $candidate = 0; $candidate <= 999; $candidate++ ) {
			if ( $candidate !== $real && abs( $candidate - $real ) >= 100 ) {
				$valid_candidates[] = $candidate;
			}
		}

		if ( empty( $valid_candidates ) ) {
			throw new \RuntimeException( 'QR login challenge generator could not find a valid first distractor.' );
		}

		$first_index = random_int( 0, count( $valid_candidates ) - 1 );
		$first       = $valid_candidates[ $first_index ];

		$second_candidates = array_values(
			array_filter(
				$valid_candidates,
				static function ( $candidate ) use ( $first ) {
					return abs( $candidate - $first ) >= 100;
				}
			)
		);

		if ( empty( $second_candidates ) ) {
			throw new \RuntimeException( 'QR login challenge generator could not find a valid second distractor.' );
		}

		$second      = $second_candidates[ random_int( 0, count( $second_candidates ) - 1 ) ];
		$distractors = array( $first, $second );

		return array(
			'real'        => str_pad( (string) $real, 3, '0', STR_PAD_LEFT ),
			'distractors' => array_map(
				static function ( $n ) {
					return str_pad( (string) $n, 3, '0', STR_PAD_LEFT );
				},
				$distractors
			),
		);
	}

	/**
	 * Build the shuffled candidate triple returned by `/qr-login-status`
	 * while in the `scanned` state. The order is fixed at scan time (in
	 * `scan_token`) and stored in `challenge.shuffled` so every poll returns
	 * the same array — re-shuffling per-poll caused visible tile flicker on
	 * wc-admin. Falls back to building + shuffling on the fly for any token
	 * record that predates the persisted-shuffle change.
	 *
	 * @param array<string, mixed> $challenge The challenge payload from the token record.
	 * @return array<int, string>
	 */
	private function shuffled_candidate_numbers( array $challenge ): array {
		if ( isset( $challenge['shuffled'] ) && is_array( $challenge['shuffled'] ) ) {
			return array_map( 'strval', $challenge['shuffled'] );
		}

		$real        = isset( $challenge['real'] ) ? (string) $challenge['real'] : '';
		$distractors = isset( $challenge['distractors'] ) && is_array( $challenge['distractors'] )
			? array_map( 'strval', $challenge['distractors'] )
			: array();

		$candidates = array_merge( array( $real ), $distractors );
		shuffle( $candidates );
		return $candidates;
	}

	/**
	 * Per-IP rate limit on /qr-login-scan.
	 *
	 * @return bool True if within rate limit.
	 */
	private function check_scan_rate_limit() {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_SCAN, $this->get_client_ip() );
	}

	/**
	 * Per-user rate limit on /qr-login-approve.
	 *
	 * @param int $user_id The user ID.
	 * @return bool True if within rate limit.
	 */
	private function check_approve_rate_limit( $user_id ) {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_APPROVE, (string) $user_id );
	}

	/**
	 * Per-session rate limit on /qr-login-session-status.
	 *
	 * @param string $session_id The session ID.
	 * @return bool True if within rate limit.
	 */
	private function check_session_status_rate_limit( $session_id ) {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_SESSION_STATUS, $session_id );
	}

	/**
	 * Send the merchant a transactional email summarizing a successful QR
	 * sign-in, unless they (or a site owner) opt out via the
	 * `woocommerce_qr_login_should_send_signin_email` filter.
	 *
	 * Wrapped so a misconfigured mailer cannot break the exchange path. Mailer
	 * false returns and exceptions are logged, but delivery never blocks the API
	 * response.
	 *
	 * @param \WP_User             $user            The user who minted the token (recipient).
	 * @param array<string, mixed> $consumed_record The record persisted to the consumed transient (keys: consumed_at, user_id, ap_uuid, ap_name, device).
	 * @return void
	 */
	private function maybe_send_sign_in_notification_email( \WP_User $user, array $consumed_record ): void {
		/**
		 * Filter whether to send the QR sign-in notification email.
		 *
		 * Default: true. Return false to suppress the send for a specific
		 * user, environment (e.g. staging), or test run.
		 *
		 * @since 10.9.0
		 *
		 * @param bool                 $should_send     Whether to send the email.
		 * @param \WP_User             $user            The user who minted the QR token.
		 * @param array<string, mixed> $consumed_record The consumed record about to be emailed (keys: consumed_at, user_id, ap_uuid, ap_name, device).
		 */
		$should_send = (bool) apply_filters(
			'woocommerce_qr_login_should_send_signin_email',
			true,
			$user,
			$consumed_record
		);

		if ( ! $should_send ) {
			return;
		}

		try {
			if ( ! $this->send_sign_in_notification_email( $user, $consumed_record ) ) {
				wc_get_logger()->warning(
					sprintf(
						'QR sign-in notification email failed for user %d: wp_mail returned false',
						$user->ID
					),
					array( 'source' => 'mobile-app-qr-login' )
				);
			}
		} catch ( \Throwable $e ) {
			// Don't surface mailer failures to the exchange response — the
			// merchant already has the API result, and the email is best-effort.
			// Log instead so a misconfigured mailer is observable rather than
			// invisible. Catch \Throwable so an \Error from the mailer also
			// stays out of the exchange path.
			wc_get_logger()->warning(
				sprintf(
					'QR sign-in notification email failed for user %d: %s',
					$user->ID,
					$e->getMessage()
				),
				array( 'source' => 'mobile-app-qr-login' )
			);
		}
	}

	/**
	 * Render and dispatch the sign-in notification email.
	 *
	 * Uses `wp_mail()` directly with our own minimal HTML shell rather than
	 * `WC()->mailer()->wrap_message()` — the WC wrapper auto-prepends a small
	 * site-name header that duplicates the subject line shown by most clients
	 * and constrains the body width. Owning the wrapper lets us deliver one
	 * coherent layout.
	 *
	 * @param \WP_User             $user            Recipient.
	 * @param array<string, mixed> $consumed_record The consumed record (same shape as `maybe_send_sign_in_notification_email`).
	 * @return bool True if WordPress accepted the message for delivery.
	 */
	private function send_sign_in_notification_email( \WP_User $user, array $consumed_record ): bool {
		$site_name = wp_specialchars_decode(
			(string) get_bloginfo( 'name' ),
			ENT_QUOTES
		);

		/* translators: %s: site name. */
		$subject = sprintf( __( 'A new device signed in to %s', 'woocommerce' ), $site_name );

		$body_html = $this->render_sign_in_notification_email_body( $user, $consumed_record, $site_name, $subject );

		return wp_mail(
			$user->user_email,
			$subject,
			$body_html,
			array( 'Content-Type: text/html; charset=UTF-8' )
		);
	}

	/**
	 * Render the full HTML email document for the sign-in notification.
	 *
	 * @param \WP_User             $user            Recipient.
	 * @param array<string, mixed> $consumed_record The consumed record (same shape as `maybe_send_sign_in_notification_email`).
	 * @param string               $site_name       Decoded site name (passed in to avoid double-decoding).
	 * @param string               $subject         Email subject; rendered as the in-body heading.
	 * @return string Rendered HTML document.
	 */
	private function render_sign_in_notification_email_body( \WP_User $user, array $consumed_record, string $site_name, string $subject ): string {
		$device           = $consumed_record['device'] ?? array();
		$consumed_at      = isset( $consumed_record['consumed_at'] ) ? (int) $consumed_record['consumed_at'] : time();
		$ap_name          = $consumed_record['ap_name'] ?? '';
		$applications_url = admin_url( 'profile.php#application-passwords-section' );

		ob_start();
		include __DIR__ . '/views/mobile-app-qr-login-signin-email.php';
		$html = ob_get_clean();

		return is_string( $html ) ? $html : '';
	}
}