Skip to content

Commit

Permalink
Allow WooPay to request session data from merchant (#8268)
Browse files Browse the repository at this point in the history
  • Loading branch information
bborman22 committed Mar 21, 2024
1 parent 9a5b112 commit c363808
Show file tree
Hide file tree
Showing 8 changed files with 303 additions and 36 deletions.
4 changes: 4 additions & 0 deletions changelog/add-woopay-merchant-request
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: update

Allow WooPay to request full session data from store.
6 changes: 3 additions & 3 deletions client/checkout/woopay/direct-checkout/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ window.addEventListener( 'load', async () => {
if ( isThirdPartyCookieEnabled ) {
if ( await WooPayDirectCheckout.isUserLoggedIn() ) {
WooPayDirectCheckout.maybePrefetchEncryptedSessionData();
WooPayDirectCheckout.redirectToWooPay( checkoutElements );
WooPayDirectCheckout.redirectToWooPay( checkoutElements, true );
}

return;
}

// Pass true to append '&checkout_redirect=1' and let WooPay decide the checkout flow.
WooPayDirectCheckout.redirectToWooPay( checkoutElements, true );
// Pass false to indicate we are not sure if the user is logged in or not.
WooPayDirectCheckout.redirectToWooPay( checkoutElements, false );
} );

jQuery( ( $ ) => {
Expand Down
112 changes: 106 additions & 6 deletions client/checkout/woopay/direct-checkout/woopay-direct-checkout.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import request from 'wcpay/checkout/utils/request';
import { buildAjaxURL } from 'wcpay/payment-request/utils';
import UserConnect from 'wcpay/checkout/woopay/connect/user-connect';
import SessionConnect from 'wcpay/checkout/woopay/connect/session-connect';
import { getTracksIdentity } from 'tracks';

/**
* The WooPayDirectCheckout class is responsible for injecting the WooPayConnectIframe into the
Expand Down Expand Up @@ -94,11 +95,12 @@ class WooPayDirectCheckout {

/**
* Resolves the redirect URL to the WooPay checkout page or throws an error if the request fails.
* This function should only be called when we have determined the shopper is already logged in to WooPay.
*
* @return {string} The redirect URL.
* @throws {Error} If the session data could not be sent to WooPay.
*/
static async resolveWooPayRedirectUrl() {
static async getWooPayCheckoutUrl() {
// We're intentionally adding a try-catch block to catch any errors
// that might occur other than the known validation errors.
try {
Expand All @@ -121,6 +123,16 @@ class WooPayDirectCheckout {
throw new Error( 'Could not retrieve WooPay checkout URL.' );
}

const { redirect_url: redirectUrl } = woopaySessionData;
if (
! this.validateRedirectUrl(
redirectUrl,
'platform_checkout_key'
)
) {
throw new Error( 'Invalid WooPay session URL: ' + redirectUrl );
}

return woopaySessionData.redirect_url;
} catch ( error ) {
throw new Error( error.message );
Expand All @@ -143,6 +155,46 @@ class WooPayDirectCheckout {
);
}

/**
* Gets the necessary merchant data to create session from WooPay request or throws an error if the request fails.
* This function should only be called if we still need to determine if the shopper is logged into WooPay or not.
*
* @return {string} WooPay redirect URL with parameters.
*/
static async getWooPayMinimumSessionUrl() {
const redirectData = await this.getWooPayMinimumSesssionDataFromMerchant();
if ( redirectData?.success === false ) {
throw new Error(
'Could not retrieve redirect data from merchant.'
);
}

if ( ! this.isValidEncryptedSessionData( redirectData ) ) {
throw new Error( 'Invalid encrypted session data.' );
}

const testMode = getConfig( 'testMode' );
const redirectParams = new URLSearchParams( {
checkout_redirect: 1,
blog_id: redirectData.blog_id,
session: redirectData.data.session,
iv: redirectData.data.iv,
hash: redirectData.data.hash,
testMode,
source_url: window.location.href,
} );

const tracksUserId = await getTracksIdentity();
if ( tracksUserId ) {
redirectParams.append( 'tracksUserIdentity', tracksUserId );
}

const redirectUrl =
getConfig( 'woopayHost' ) + '/woopay/?' + redirectParams.toString();

return redirectUrl;
}

/**
* Gets the checkout redirect elements.
*
Expand Down Expand Up @@ -183,9 +235,9 @@ class WooPayDirectCheckout {
* Adds a click-event listener to the given elements that redirects to the WooPay checkout page.
*
* @param {*[]} elements The elements to add a click-event listener to.
* @param {boolean} useCheckoutRedirect Whether to use the `checkout_redirect` flag to let WooPay handle the checkout flow.
* @param {boolean} userIsLoggedIn True if we determined the user is already logged in, false otherwise.
*/
static redirectToWooPay( elements, useCheckoutRedirect = false ) {
static redirectToWooPay( elements, userIsLoggedIn = false ) {
/**
* Adds a loading spinner to the given element.
*
Expand Down Expand Up @@ -258,9 +310,11 @@ class WooPayDirectCheckout {
event.preventDefault();

try {
let woopayRedirectUrl = await this.resolveWooPayRedirectUrl();
if ( useCheckoutRedirect ) {
woopayRedirectUrl += '&checkout_redirect=1';
let woopayRedirectUrl = '';
if ( userIsLoggedIn ) {
woopayRedirectUrl = await this.getWooPayCheckoutUrl();
} else {
woopayRedirectUrl = await this.getWooPayMinimumSessionUrl();
}

this.teardown();
Expand Down Expand Up @@ -291,6 +345,52 @@ class WooPayDirectCheckout {
);
}

/**
* Gets the WooPay redirect data.
*
* @return {Promise<Promise<*>|*>} Resolves to the WooPay redirect response.
*/
static async getWooPayMinimumSesssionDataFromMerchant() {
// This should always be defined, but fallback to a request in case of the unexpected.
if ( getConfig( 'woopayMinimumSessionData' ) ) {
return getConfig( 'woopayMinimumSessionData' );
}

return request(
buildAjaxURL(
getConfig( 'wcAjaxUrl' ),
'get_woopay_minimum_session_data'
),
{
_ajax_nonce: getConfig( 'woopaySessionNonce' ),
}
);
}

/**
* Validates a WooPay redirect URL.
*
* @param {string} redirectUrl The URL to validate.
* @param {string} requiredParam The URL parameter that is required in the URL.
*
* @return {boolean} True if URL is valid, false otherwise.
*/
static validateRedirectUrl( redirectUrl, requiredParam ) {
try {
const parsedUrl = new URL( redirectUrl );
if (
parsedUrl.origin !== getConfig( 'woopayHost' ) ||
! parsedUrl.searchParams.has( requiredParam )
) {
return false;
}

return true;
} catch ( error ) {
return false;
}
}

/**
* Prefetches the encrypted session data if not on the product page.
*/
Expand Down
96 changes: 96 additions & 0 deletions includes/admin/class-wc-rest-woopay-session-controller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php
/**
* Class WC_REST_WooPay_Session_Controller
*
* @package WooCommerce\Payments\Admin
*/

defined( 'ABSPATH' ) || exit;

use WCPay\WooPay\WooPay_Session;
use Automattic\Jetpack\Connection\Rest_Authentication;

/**
* REST controller to check get WooPay extension data for user.
*/
class WC_REST_WooPay_Session_Controller extends WP_REST_Controller {

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

/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'woopay/session';

/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_session_data' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}

/**
* Retrieve WooPay session data.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_Error|WP_REST_Response
*/
public function get_session_data( WP_REST_Request $request ): WP_REST_Response {
// phpcs:ignore
/**
* @psalm-suppress UndefinedClass
*/
$response = WooPay_Session::get_init_session_request();
// This was needed as the preloaded requests were not honoring the cart token and so were empty carts.
// It would be ideal to get this to successfully preload the cart data so WooPay doesn't need to make
// a separate request to get the cart data.
unset( $response['preloaded_requests'] );

return rest_ensure_response( $response );
}

/**
* Check permission confirms that the request is from WooPay.
*
* @return bool True if request is from WooPay and has a valid signature.
*/
public function check_permission() {
return $this->is_request_from_woopay() && $this->has_valid_request_signature();
}

/**
* Returns true if the request that's currently being processed is signed with the blog token.
*
* @return bool True if the request signature is valid.
*/
private function has_valid_request_signature() {
return apply_filters( 'wcpay_woopay_is_signed_with_blog_token', Rest_Authentication::is_signed_with_blog_token() );
}

/**
* Returns true if the request that's currently being processed is from WooPay, false
* otherwise.
*
* @return bool True if request is from WooPay.
*/
private function is_request_from_woopay(): bool {
return isset( $_SERVER['HTTP_USER_AGENT'] ) && 'WooPay' === $_SERVER['HTTP_USER_AGENT'];
}
}

3 changes: 2 additions & 1 deletion includes/class-wc-payments-checkout.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
use WC_Payment_Gateway_WCPay;
use WCPay\WooPay\WooPay_Utilities;
use WCPay\Payment_Methods\UPE_Payment_Method;

use WCPay\WooPay\WooPay_Session;

/**
* WC_Payments_Checkout
Expand Down Expand Up @@ -202,6 +202,7 @@ public function get_payment_fields_js_config() {
'woopaySessionNonce' => wp_create_nonce( 'woopay_session_nonce' ),
'woopayMerchantId' => Jetpack_Options::get_option( 'id' ),
'icon' => $this->gateway->get_icon_url(),
'woopayMinimumSessionData' => WooPay_Session::get_woopay_minimum_session_data(),
];

/**
Expand Down
5 changes: 5 additions & 0 deletions includes/class-wc-payments.php
Original file line number Diff line number Diff line change
Expand Up @@ -1054,6 +1054,10 @@ public static function init_rest_api() {
$reports_authorizations_controller = new WC_REST_Payments_Reports_Authorizations_Controller( self::$api_client );
$reports_authorizations_controller->register_routes();

include_once WCPAY_ABSPATH . 'includes/admin/class-wc-rest-woopay-session-controller.php';
$woopay_session_controller = new WC_REST_WooPay_Session_Controller();
$woopay_session_controller->register_routes();

}

/**
Expand Down Expand Up @@ -1426,6 +1430,7 @@ public static function maybe_register_woopay_hooks() {
add_action( 'wc_ajax_wcpay_init_woopay', [ WooPay_Session::class, 'ajax_init_woopay' ] );
add_action( 'wc_ajax_wcpay_get_woopay_session', [ WooPay_Session::class, 'ajax_get_woopay_session' ] );
add_action( 'wc_ajax_wcpay_get_woopay_signature', [ __CLASS__, 'ajax_get_woopay_signature' ] );
add_action( 'wc_ajax_wcpay_get_woopay_minimum_session_data', [ WooPay_Session::class, 'ajax_get_woopay_minimum_session_data' ] );

// This injects the payments API and draft orders into core, so the WooCommerce Blocks plugin is not necessary.
// We should remove this once both features are available by default in the WC minimum supported version.
Expand Down
Loading

0 comments on commit c363808

Please sign in to comment.