Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Salesforce B2C support #228

Merged
merged 15 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion inc/Integrations/Google/Sheets/GoogleSheetsDataSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class GoogleSheetsDataSource extends HttpDataSource {

protected const SERVICE_SCHEMA = [
'type' => 'object',
'properties' => [
'properties' => [
'credentials' => [
'type' => 'object',
'properties' => [
Expand Down
240 changes: 240 additions & 0 deletions inc/Integrations/SalesforceB2C/Auth/SalesforceB2CAuth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
<?php declare(strict_types = 1);

namespace RemoteDataBlocks\Integrations\SalesforceB2C\Auth;

use WP_Error;

/**
* Salesforce B2C Auth class.
*
* This class is used to authenticate with Salesforce B2C using a client ID and secret.
*/
class SalesforceB2CAuth {

/**
* Generate a token from a client ID and secret, or use an existing token if available.
*
* @param string $endpoint The endpoint prefix URL for the data source,
* @param string $organization_id The organization ID for the data source.
* @param string $client_id The client ID (a version 4 UUID).
* @param string $client_secret The client secret.
* @return WP_Error|string The token or an error.
*/
public static function generate_token(
string $endpoint,
string $organization_id,
string $client_id,
string $client_secret
): WP_Error|string {
$saved_access_token = self::get_saved_access_token( $organization_id, $client_id );

if ( null !== $saved_access_token ) {
return $saved_access_token;
}

$saved_refresh_token = self::get_saved_refresh_token( $organization_id, $client_id );
if ( null !== $saved_refresh_token ) {
$access_token = self::get_token_using_refresh_token( $saved_refresh_token, $client_id, $client_secret, $endpoint, $organization_id );
}

if ( null !== $access_token ) {
return $access_token;
}

$access_token = self::get_token_using_client_credentials( $client_id, $client_secret, $endpoint, $organization_id );
return $access_token;
}

// Access token request using top-level credentials

public static function get_token_using_client_credentials(
string $client_id,
string $client_secret,
string $endpoint,
string $organization_id,
): WP_Error|string {
$client_auth_url = sprintf( '%s/shopper/auth/v1/organizations/%s/oauth2/token', $endpoint, $organization_id );
$client_credentials = base64_encode( sprintf( '%s:%s', $client_id, $client_secret ) );

$client_auth_response = wp_remote_post($client_auth_url, [
'body' => [
'grant_type' => 'client_credentials',
'channel_id' => 'RefArch',
],
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
'Authorization' => 'Basic ' . $client_credentials,
],
]);

if ( is_wp_error( $client_auth_response ) ) {
return new WP_Error(
'salesforce_b2c_auth_error_client_credentials',
__( 'Failed to retrieve access token from client credentials', 'remote-data-blocks' )
);
}

$response_code = wp_remote_retrieve_response_code( $client_auth_response );
$response_body = wp_remote_retrieve_body( $client_auth_response );
$response_data = json_decode( $response_body, true );

if ( 400 === $response_code ) {
return new WP_Error(
'salesforce_b2c_auth_error_client_credentials',
/* translators: %s: Technical error message from API containing failure reason */
sprintf( __( 'Failed to retrieve access token from client credentials: "%s"', 'remote-data-blocks' ), $response_data['message'] )
);
}

$access_token = $response_data['access_token'];
$access_token_expires_in = $response_data['expires_in'];
self::save_access_token( $access_token, $access_token_expires_in, $organization_id, $client_id );

$refresh_token = $response_data['refresh_token'];
$refresh_token_expires_in = $response_data['refresh_token_expires_in'];
self::save_refresh_token( $refresh_token, $refresh_token_expires_in, $organization_id, $client_id );

return $access_token;
}

// Access token request using refresh token

public static function get_token_using_refresh_token(
string $refresh_token,
string $client_id,
string $client_secret,
string $endpoint,
string $organization_id,
): ?string {
$client_auth_url = sprintf( '%s/shopper/auth/v1/organizations/%s/oauth2/token', $endpoint, $organization_id );

// Even though we're using a refresh token, authentication is still required to receive a new secret
$client_credentials = base64_encode( sprintf( '%s:%s', $client_id, $client_secret ) );
Copy link
Contributor Author

@alecgeatches alecgeatches Dec 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a bit silly - in order to refresh our auth token using a refresh token, we still need to pass in our client ID and secret. From what I can tell, this is due to the OAuth spec. See the "Client Authentication" section on Refreshing Access Tokens:

If the client was issued a secret, then the client must authenticate this request.

Because we receive a secret in the initial grant, we still need it again here.

It makes me wonder - is it really necessary to save and reuse the refresh token? We'll still sending client credentials every 30 minutes anyway, why not just use the client_credentials grant every time and remove this function entirely?

The current setup seems like the "correct" flow, but the refresh token feels pointless.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the primary reason to use the refresh token is because you're generating revokable tokens on the Authorization Server. So imagine we used the client credentials to get new tokens every time instead of just refreshing the one... and then the user goes in to manage their issued tokens and revoke some... and they see a list of 10,000 tokens because we've created a new one on every refresh.


$client_auth_response = wp_remote_post($client_auth_url, [
'body' => [
'grant_type' => 'refresh_token',
'refresh_token' => $refresh_token,
'channel_id' => 'RefArch',
],
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
'Authorization' => 'Basic ' . $client_credentials,
],
]);

if ( is_wp_error( $client_auth_response ) ) {
return null;
}

$response_code = wp_remote_retrieve_response_code( $client_auth_response );
$response_body = wp_remote_retrieve_body( $client_auth_response );
$response_data = json_decode( $response_body, true );

if ( 400 === $response_code ) {
return null;
}

$access_token = $response_data['access_token'];
$access_token_expires_in = $response_data['expires_in'];
self::save_access_token( $access_token, $access_token_expires_in, $organization_id, $client_id );

// No need to save the refresh token, as it stays the same until we perform a top-level authentication

return $access_token;
}

// Access token cache management

private static function save_access_token( string $access_token, int $expires_in, string $organization_id, string $client_id ): void {
// Expires 10 seconds early as a buffer for request time and drift
$access_token_expires_in = $expires_in - 10;

$access_token_data = [
'token' => $access_token,
'expires_at' => time() + $access_token_expires_in,
];

$access_token_cache_key = self::get_access_token_key( $organization_id, $client_id );

wp_cache_set(
$access_token_cache_key,
$access_token_data,
'oauth-tokens',
// phpcs:ignore WordPressVIPMinimum.Performance.LowExpiryCacheTime.CacheTimeUndetermined -- 'expires_in' defaults to 30 minutes for access tokens.
$access_token_expires_in,
);
}

private static function get_saved_access_token( string $organization_id, string $client_id ): ?string {
$access_token_cache_key = self::get_access_token_key( $organization_id, $client_id );

$saved_access_token = wp_cache_get( $access_token_cache_key, 'oauth-tokens' );

if ( false === $saved_access_token ) {
return null;
}

$access_token = $saved_access_token['token'];
$expires_at = $saved_access_token['expires_at'];

// Ensure the token is still valid
if ( time() >= $expires_at ) {
return null;
}

return $access_token;
}

private static function get_access_token_key( string $organization_id, string $client_id ): string {
$cache_key_suffix = hash( 'sha256', sprintf( '%s-%s', $organization_id, $client_id ) );
return sprintf( 'salesforce_b2c_access_token_%s', $cache_key_suffix );
}

// Refresh token cache management

private static function save_refresh_token( string $refresh_token, int $expires_in, string $organization_id, string $client_id ): void {
// Expires 10 seconds early as a buffer for request time and drift
$refresh_token_expires_in = $expires_in - 10;

$refresh_token_data = [
'token' => $refresh_token,
'expires_at' => time() + $refresh_token_expires_in,
];

$refresh_token_cache_key = self::get_refresh_token_cache_key( $organization_id, $client_id );

wp_cache_set(
$refresh_token_cache_key,
$refresh_token_data,
'oauth-tokens',
// phpcs:ignore WordPressVIPMinimum.Performance.LowExpiryCacheTime.CacheTimeUndetermined -- 'expires_in' defaults to 30 days for refresh tokens.
$refresh_token_expires_in,
);
}

private static function get_saved_refresh_token( string $organization_id, string $client_id ): ?string {
$refresh_token_cache_key = self::get_refresh_token_cache_key( $organization_id, $client_id );

$saved_refresh_token = wp_cache_get( $refresh_token_cache_key, 'oauth-tokens' );

if ( false === $saved_refresh_token ) {
return null;
}

$refresh_token = $saved_refresh_token['token'];
$expires_at = $saved_refresh_token['expires_at'];

// Ensure the token is still valid
if ( time() >= $expires_at ) {
return null;
}

return $refresh_token;
}

private static function get_refresh_token_cache_key( string $organization_id, string $client_id ): string {
$cache_key_suffix = hash( 'sha256', sprintf( '%s-%s', $organization_id, $client_id ) );
return sprintf( 'salesforce_b2c_refresh_token_%s', $cache_key_suffix );
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php declare(strict_types = 1);

namespace RemoteDataBlocks\Integrations\SalesforceB2C\Queries;

use RemoteDataBlocks\Config\QueryContext\HttpQueryContext;
use RemoteDataBlocks\Integrations\SalesforceB2C\Auth\SalesforceB2CAuth;

class SalesforceB2CGetProductQuery extends HttpQueryContext {

public function get_input_schema(): array {
return [
'product_id' => [
'name' => 'Product ID',
'overrides' => [
[
'target' => 'utm_content',
'type' => 'query_var',
],
],
'type' => 'id',
],
];
}

public function get_output_schema(): array {
return [
'is_collection' => false,
'mappings' => [
'id' => [
'name' => 'Product ID',
'path' => '$.id',
'type' => 'id',
],
'name' => [
'name' => 'Name',
'path' => '$.name',
'type' => 'string',
],
'longDescription' => [
'name' => 'Long Description',
'path' => '$.longDescription',
'type' => 'string',
],
'price' => [
'name' => 'Price',
'path' => '$.price',
'type' => 'string',
],
'image_url' => [
'name' => 'Image URL',
'path' => '$.imageGroups[0].images[0].link',
'type' => 'image_url',
],
'image_alt_text' => [
'name' => 'Image Alt Text',
'path' => '$.imageGroups[0].images[0].alt',
'type' => 'image_alt',
],
],
];
}

public function get_request_headers( array $input_variables ): array {
$data_source_config = $this->get_data_source()->to_array();
$data_source_endpoint = $this->get_data_source()->get_endpoint();

$access_token = SalesforceB2CAuth::generate_token(
$data_source_endpoint,
$data_source_config['organization_id'],
$data_source_config['client_id'],
$data_source_config['client_secret']
);

$headers = [
'Content-Type' => 'application/json',
];

if ( is_wp_error( $access_token ) ) {
return $headers;
}

$headers['Authorization'] = sprintf( 'Bearer %s', $access_token );
return $headers;
}

public function get_endpoint( array $input_variables ): string {
$data_source_endpoint = $this->get_data_source()->get_endpoint();
$data_source_config = $this->get_data_source()->to_array();

return sprintf( '%s/product/shopper-products/v1/organizations/%s/products/%s?siteId=RefArchGlobal', $data_source_endpoint, $data_source_config['organization_id'], $input_variables['product_id'] );
}

public function get_query_name(): string {
return $this->config['query_name'] ?? 'Get item';
}
}
Loading
Loading