-
Notifications
You must be signed in to change notification settings - Fork 4
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
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
2185165
Add Salesforce configuration setup
alecgeatches c46651f
Temporary commit
alecgeatches 2d14316
Fix endpoint URLs for token-based authorization
alecgeatches 8839505
Add support for cached authentication and refresh keys
alecgeatches d62660b
Add support for searching product in SFCC
alecgeatches 79f5f75
Remove debug and todo code
alecgeatches 734f860
Remove .wp-env.json changes
alecgeatches 332bc36
Fix comment, remove unused file
alecgeatches 017c268
Merge branch 'trunk' into add/sfcc-token-management
alecgeatches d9336ba
Merge branch 'trunk' into add/sfcc-token-management
maxschmeling aa9a98d
Add <SalesforceCommerceB2CIcon>
maxschmeling c467fff
Fix import order
maxschmeling b5da4a8
Switch to SVG icon from Salesforce brand guide
maxschmeling abcddc4
Fix typo in field mapping
maxschmeling b9876bb
Rename get_refresh_token_key to get_refresh_token_cache_key
maxschmeling File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
240 changes: 240 additions & 0 deletions
240
inc/Integrations/SalesforceB2C/Auth/SalesforceB2CAuth.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ) ); | ||
|
||
$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 ); | ||
} | ||
} |
96 changes: 96 additions & 0 deletions
96
inc/Integrations/SalesforceB2C/Queries/SalesforceB2CGetProductQuery.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; | ||
} | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:
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.
There was a problem hiding this comment.
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.