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 google sheets data source in settings page #28

Merged
merged 11 commits into from
Sep 12, 2024
149 changes: 149 additions & 0 deletions inc/config/auth/google-auth/google-auth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php

namespace RemoteDataBlocks\Config\Auth;

use WP_Error;
use RemoteDataBlocks\Config\Auth\GoogleServiceAccountKey;

class GoogleAuth {
const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
/**
* The whitelisted scopes that are allowed to be used when generating a token.
* This avoids this being used to get tokens with broad scopes.
*/
const ALLOWED_SCOPES = [
'https://www.googleapis.com/auth/drive.readonly', // Drive Readonly
'https://www.googleapis.com/auth/spreadsheets.readonly', // Sheets Readonly
];

private static function get_allowed_scopes( array $scopes ): array {
return array_values( array_intersect( $scopes, self::ALLOWED_SCOPES ) );
}

/**
* Generate a token from a service account key.
*
* @param array $raw_service_account_key The service account key.
* @param array $scopes The scopes to generate the token for.
* @return WP_Error|string The token or an error.
*/
public static function generate_token_from_service_account_key(
array $raw_service_account_key,
array $scopes
): WP_Error|string {
$filtered_scopes = self::get_allowed_scopes( $scopes );

if ( empty( $filtered_scopes ) ) {
return new WP_Error(
'google_auth_error',
__( 'No valid scopes provided', 'remote-data-blocks' )
);
}

$scope = implode( ' ', $filtered_scopes );

$service_account_key = GoogleServiceAccountKey::from_array( $raw_service_account_key );
if ( is_wp_error( $service_account_key ) ) {
return $service_account_key;
}

$jwt = self::generate_jwt( $service_account_key, $scope );
$token_uri = $service_account_key->token_uri;

return self::get_token_using_jwt( $jwt, $token_uri );
}

private static function get_token_using_jwt( string $jwt, string $token_uri ): WP_Error|string {
$response = wp_remote_post(
$token_uri,
[
'body' => [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
],
]
);

if ( is_wp_error( $response ) ) {
return new WP_Error(
'google_auth_error',
__( 'Failed to retrieve access token', 'remote-data-blocks' )
);
}

$response_body = wp_remote_retrieve_body( $response );
$response_data = json_decode( $response_body, true );

if ( ! isset( $response_data['access_token'] ) ) {
return new WP_Error(
'google_auth_error',
__( 'Invalid response from Google Auth', 'remote-data-blocks' )
);
}

return $response_data['access_token'];
}

private static function generate_jwt(
GoogleServiceAccountKey $service_account_key,
string $scope
): string {
$header = self::generate_jwt_header();
$payload = self::generate_jwt_payload(
$service_account_key->client_email,
$service_account_key->token_uri,
$scope
);

$base64_url_header = base64_encode( wp_json_encode( $header ) );
$base64_url_payload = base64_encode( wp_json_encode( $payload ) );

$signature = self::generate_jwt_signature(
$base64_url_header,
$base64_url_payload,
$service_account_key->private_key
);
$base64_url_signature = base64_encode( $signature );

return $base64_url_header . '.' . $base64_url_payload . '.' . $base64_url_signature;
}

private static function generate_jwt_signature(
string $base64_url_header,
string $base64_url_payload,
string $private_key
): string {
$signature_input = $base64_url_header . '.' . $base64_url_payload;

openssl_sign( $signature_input, $signature, $private_key, 'sha256' );
return $signature;
}

private static function generate_jwt_header(): array {
$header = [
'alg' => 'RS256',
'typ' => 'JWT',
];

return $header;
}

private static function generate_jwt_payload(
string $client_email,
string $token_uri,
string $scope
): array {
$now = time();
$expiry = $now + self::TOKEN_EXPIRY_SECONDS;

$payload = [
'iss' => $client_email,
'scope' => $scope,
'aud' => $token_uri,
'exp' => $expiry,
'iat' => $now,
];

return $payload;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

namespace RemoteDataBlocks\Config\Auth;

use WP_Error;

class GoogleServiceAccountKey {
public string $type;
public string $project_id;
public string $private_key_id;
public string $private_key;
public string $client_email;
public string $client_id;
public string $auth_uri;
public string $token_uri;
public string $auth_provider_x509_cert_url;
public string $client_x509_cert_url;
public string $universe_domain;

/**
* Validate the raw service account data.
*
* This function checks if the required fields are present in the raw service account data.
* It returns a WP_Error if any of the required fields are missing or invalid.
*
* Currently only validates the fields which are required to generate a JWT for getting Google
* Access Token.
*
* @param array $raw_service_account The raw service account data to validate.
* @return true|WP_Error Returns true if validation passes, or a WP_Error array if validation fails.
*/
public static function validate( array $raw_service_account_key ): WP_Error|true {
if ( ! isset( $raw_service_account_key['type'] ) ) {
return new WP_Error( 'missing_type', __( 'type is required', 'remote-data-blocks' ) );
}

if ( 'service_account' !== $raw_service_account_key['type'] ) {
return new WP_Error( 'invalid_type', __( 'type must be service_account', 'remote-data-blocks' ) );
}

if ( ! isset( $raw_service_account_key['project_id'] ) ) {
return new WP_Error( 'missing_project_id', __( 'project_id is required', 'remote-data-blocks' ) );
}

if ( ! isset( $raw_service_account_key['private_key'] ) ) {
return new WP_Error( 'missing_private_key', __( 'private_key is required', 'remote-data-blocks' ) );
}

if ( ! isset( $raw_service_account_key['client_email'] ) ) {
return new WP_Error( 'missing_client_email', __( 'client_email is required', 'remote-data-blocks' ) );
}

if ( ! isset( $raw_service_account_key['token_uri'] ) ) {
return new WP_Error( 'missing_token_uri', __( 'token_uri is required', 'remote-data-blocks' ) );
}

return true;
}

public function __construct( array $raw_service_account_key ) {
$this->type = $raw_service_account_key['type'];
$this->project_id = $raw_service_account_key['project_id'];
$this->private_key_id = $raw_service_account_key['private_key_id'];
$this->private_key = $raw_service_account_key['private_key'];
$this->client_email = $raw_service_account_key['client_email'];
$this->client_id = $raw_service_account_key['client_id'];
$this->auth_uri = $raw_service_account_key['auth_uri'];
$this->token_uri = $raw_service_account_key['token_uri'];
$this->auth_provider_x509_cert_url = $raw_service_account_key['auth_provider_x509_cert_url'];
$this->client_x509_cert_url = $raw_service_account_key['client_x509_cert_url'];
$this->universe_domain = $raw_service_account_key['universe_domain'];
}

public static function from_array( array $raw_service_account_key ): WP_Error|GoogleServiceAccountKey {
$validation_error = self::validate( $raw_service_account_key );
if ( is_wp_error( $validation_error ) ) {
return new WP_Error(
'invalid_service_account_key',
__( 'Invalid service account key:', 'remote-data-blocks' ) . ' ' . $validation_error->get_error_message(),
$validation_error
);
}

return new self( $raw_service_account_key );
}
}
4 changes: 4 additions & 0 deletions inc/plugin-settings/plugin-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace RemoteDataBlocks;

use RemoteDataBlocks\REST\DatasourceController;
use RemoteDataBlocks\REST\AuthController;
use function wp_get_environment_type;
use function wp_is_development_mode;

Expand Down Expand Up @@ -37,6 +38,9 @@ public static function settings_page_content() {
public static function init_rest_routes() {
$controller = new DatasourceController();
$controller->register_routes();

$auth_controller = new AuthController();
$auth_controller->register_routes();
}

public static function enqueue_settings_assets( $admin_page ) {
Expand Down
62 changes: 62 additions & 0 deletions inc/rest/auth-controller/auth-controller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

namespace RemoteDataBlocks\REST;

use WP_REST_Controller;
use WP_REST_Request;
use RemoteDataBlocks\Config\Auth\GoogleAuth;

defined( 'ABSPATH' ) || exit();
defined( 'ABSPATH' ) || exit();

class AuthController extends WP_REST_Controller {
public function __construct() {
$this->namespace = REMOTE_DATA_BLOCKS__REST_NAMESPACE;
$this->rest_base = 'auth';
}

public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/google/token',
[
'methods' => 'POST',
'callback' => [ $this, 'get_google_auth_token' ],
'permission_callback' => [ $this, 'get_google_auth_token_permissions_check' ],
]
);
}

public function get_google_auth_token( WP_REST_Request $request ) {
$params = $request->get_json_params();
$credentials = $params['credentials'] ?? null;
$scopes = $params['scopes'] ?? [];
$type = $params['type'] ?? null;

if ( ! $credentials || ! $type || ! $scopes ) {
return new \WP_Error(
'missing_parameters',
__( 'Credentials, type and scopes are required.', 'remote-data-blocks' ),
array( 'status' => 400 )
);
}

if ( 'service_account' === $type ) {
$token = GoogleAuth::generate_token_from_service_account_key( $credentials, $scopes );
if ( is_wp_error( $token ) ) {
return rest_ensure_response( $token );
}
return rest_ensure_response( [ 'token' => $token ] );
}

return new \WP_Error(
'invalid_type',
__( 'Invalid type. Supported types: service_account', 'remote-data-blocks' ),
array( 'status' => 400 )
);
}

public function get_google_auth_token_permissions_check( $request ) {
return current_user_can( 'manage_options' );
}
}
43 changes: 42 additions & 1 deletion inc/rest/datasource-crud/datasource-crud.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

namespace RemoteDataBlocks\REST;

use RemoteDataBlocks\Config\Auth\GoogleServiceAccountKey;
use WP_Error;

class DatasourceCRUD {
const CONFIG_OPTION_NAME = 'remote_data_blocks_config';
const DATA_SOURCE_TYPES = [ 'airtable', 'shopify' ];
const DATA_SOURCE_TYPES = [ 'airtable', 'shopify', 'google-sheets' ];

public static function is_uuid4( string $maybe_uuid ) {
return preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $maybe_uuid );
Expand Down Expand Up @@ -94,6 +95,44 @@ public static function validate_shopify_source( $source ) {
];
}

public static function validate_google_sheets_source( $source ) {
$service_account_key = GoogleServiceAccountKey::from_array( $source->credentials );
if ( is_wp_error( $service_account_key ) ) {
return $service_account_key;
}

// Validate spreadsheet is not empty and is an object with id and name fields with string values
if ( empty( $source->spreadsheet ) ) {
return new WP_Error( 'missing_spreadsheet', __( 'Missing spreadsheet.', 'remote-data-blocks' ) );
}

if ( empty( $source->spreadsheet['id'] ) || empty( $source->spreadsheet['name'] ) ) {
return new WP_Error( 'invalid_spreadsheet', __( 'Invalid spreadsheet. Must have id and name fields.', 'remote-data-blocks' ) );
}

// Validate sheet is not empty and is an object with id integer and name string fields
if ( empty( $source->sheet ) ) {
return new WP_Error( 'missing_sheet', __( 'Missing sheet.', 'remote-data-blocks' ) );
}

if ( ! isset( $source->sheet['id'] ) || ! is_int( $source->sheet['id'] ) ) {
return new WP_Error( 'invalid_sheet', __( 'Invalid sheet. Must have id field with integer value.', 'remote-data-blocks' ) );
}

if ( empty( $source->sheet['name'] ) ) {
return new WP_Error( 'missing_sheet_name', __( 'Missing sheet name.', 'remote-data-blocks' ) );
}

return (object) [
'uuid' => $source->uuid,
'service' => 'google-sheets',
'credentials' => $service_account_key,
'spreadsheet' => $source->spreadsheet,
'sheet' => $source->sheet,
'slug' => sanitize_text_field( $source->slug ),
];
}

public static function validate_source( $source ) {
if ( ! is_object( $source ) ) {
return new WP_Error( 'invalid_data_source', __( 'Invalid data source.', 'remote-data-blocks' ) );
Expand Down Expand Up @@ -123,6 +162,8 @@ public static function validate_source( $source ) {
return self::validate_airtable_source( $source );
case 'shopify':
return self::validate_shopify_source( $source );
case 'google-sheets':
return self::validate_google_sheets_source( $source );
default:
return new WP_Error( 'unsupported_data_source', __( 'Unsupported data source.', 'remote-data-blocks' ) );
}
Expand Down
1 change: 1 addition & 0 deletions src/data-sources/AddDataSourceModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const AddDataSourceModal = ( { onSubmit }: AddDataSourceModalProps ) => {
{ label: __( 'Choose a Service' ), value: '' },
{ label: 'Airtable', value: 'airtable' },
{ label: 'Shopify', value: 'shopify' },
{ label: 'Google Sheets', value: 'google-sheets' },
] }
onChange={ value => setSelectedService( value as DataSourceType ) }
/>
Expand Down
Loading