Skip to content
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
40 changes: 12 additions & 28 deletions admin/section/class-convertkit-admin-section-general.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,17 @@ private function check_credentials() {

// Bail if no access and refresh token exist.
if ( ! $this->settings->has_access_and_refresh_token() ) {
return;
// Redirect to General screen, which will now show the ConvertKit_Admin_Section_OAuth screen, because
// the Plugin has no access token.
wp_safe_redirect(
add_query_arg(
array(
'page' => $this->settings_key,
),
'options-general.php'
)
);
exit();
}

// Initialize the API.
Expand All @@ -152,37 +162,11 @@ private function check_credentials() {

// If the request succeeded, no need to perform further actions.
if ( ! is_wp_error( $this->account ) ) {
// Remove any existing persistent notice.
WP_ConvertKit()->get_class( 'admin_notices' )->delete( 'authorization_failed' );

return;
}

// Depending on the error code, maybe persist a notice in the WordPress Administration until the user
// Depending on the error code, display an error notice in the settings screen until the user
// fixes the problem.
switch ( $this->account->get_error_data( $this->account->get_error_code() ) ) {
case 401:
// Access token either expired or was revoked in ConvertKit.
// Remove from settings.
$this->settings->delete_credentials();

// Display a site wide notice.
WP_ConvertKit()->get_class( 'admin_notices' )->add( 'authorization_failed' );

// Redirect to General screen, which will now show the ConvertKit_Admin_Section_OAuth screen, because
// the Plugin has no access token.
wp_safe_redirect(
add_query_arg(
array(
'page' => $this->settings_key,
),
'options-general.php'
)
);
exit();
}

// Output a non-401 error now.
$this->output_error( $this->account->get_error_message() );

}
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "project",
"license": "GPLv3",
"require": {
"convertkit/convertkit-wordpress-libraries": "2.1.0"
"convertkit/convertkit-wordpress-libraries": "dev-add-hooks-on-request-error"
},
"require-dev": {
"php-webdriver/webdriver": "^1.0",
Expand Down
41 changes: 40 additions & 1 deletion includes/class-convertkit-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ public function __construct() {
add_action( 'convertkit_api_get_access_token', array( $this, 'update_credentials' ), 10, 2 );
add_action( 'convertkit_api_refresh_token', array( $this, 'update_credentials' ), 10, 2 );

// Delete credentials if the API class uses a invalid access token.
// This prevents the Plugin making repetitive API requests that will 401.
add_action( 'convertkit_api_access_token_invalid', array( $this, 'maybe_delete_credentials' ), 10, 2 );

}

/**
Expand Down Expand Up @@ -629,6 +633,9 @@ public function update_credentials( $result, $client_id ) {
return;
}

// Remove any existing persistent notice.
WP_ConvertKit()->get_class( 'admin_notices' )->delete( 'authorization_failed' );

$this->save(
array(
'access_token' => $result['access_token'],
Expand All @@ -646,7 +653,36 @@ public function update_credentials( $result, $client_id ) {
}

/**
* Deletes any existing access token, refresh token and its expiry from the Plugin settings.
* Deletes the stored access token, refresh token and its expiry from the Plugin settings,
* and clears any existing scheduled WordPress Cron event to refresh the token on expiry,
* when either:
* - The access token is invalid
* - The access token expired, and refreshing failed
*
* @since 3.1.0
*
* @param WP_Error $result Error result.
* @param string $client_id OAuth Client ID used for the Access and Refresh Tokens.
*/
public function maybe_delete_credentials( $result, $client_id ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

What happens if the server returns a 5xx error? Would we just delete the credentials? What about a 429 rate limit?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

maybe_delete_credentials is only called when the Kit WordPress Libraries fires specific hooks:

Code in this PR that checks for these hooks:
https://github.com/Kit/convertkit-wordpress/blob/delete-invalid-access-token-from-settings/includes/class-convertkit-settings.php#L52-L55

Other errors, such as a 429 or 5xx, won't result in tokens being deleted from the Plugin's settings. Libraries return a WP_Error, which the Plugin then handles accordingly.

Copy link
Contributor

@noelherrick noelherrick Nov 12, 2025

Choose a reason for hiding this comment

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

But if we get a non-401 from the refresh itself, wouldn't that cause the tokens to be deleted?

Copy link
Contributor Author

@n7studios n7studios Nov 13, 2025

Choose a reason for hiding this comment

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

Noted. Removed convertkit_api_refresh_token_error hook, ensuring access token only deleted if the API class uses an invalid access token, not an expired one.


// Don't delete these credentials if they're not for this Client ID.
// They're for another Kit Plugin that uses OAuth.
if ( $client_id !== CONVERTKIT_OAUTH_CLIENT_ID ) {
return;
}

// Persist an error notice in the WordPress Administration until the user fixes the problem.
WP_ConvertKit()->get_class( 'admin_notices' )->add( 'authorization_failed' );

// Delete the credentials from the Plugin settings.
$this->delete_credentials();

}

/**
* Deletes any existing access token, refresh token and its expiry from the Plugin settings,
* and clears any existing scheduled WordPress Cron event to refresh the token on expiry.
*
* @since 2.5.0
*/
Expand All @@ -660,6 +696,9 @@ public function delete_credentials() {
)
);

// Clear any existing scheduled WordPress Cron event.
wp_clear_scheduled_hook( 'convertkit_refresh_token' );

}

/**
Expand Down
15 changes: 7 additions & 8 deletions includes/class-wp-convertkit.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,12 @@ private function initialize_admin() {
return;
}

$this->classes['admin_bulk_edit'] = new ConvertKit_Admin_Bulk_Edit();
$this->classes['admin_cache_plugins'] = new ConvertKit_Admin_Cache_Plugins();
$this->classes['admin_category'] = new ConvertKit_Admin_Category();
$this->classes['admin_landing_page'] = new ConvertKit_Admin_Landing_Page();
$this->classes['admin_notices'] = new ConvertKit_Admin_Notices();
$this->classes['admin_post'] = new ConvertKit_Admin_Post();
$this->classes['admin_quick_edit'] = new ConvertKit_Admin_Quick_Edit();

$this->classes['admin_bulk_edit'] = new ConvertKit_Admin_Bulk_Edit();
$this->classes['admin_cache_plugins'] = new ConvertKit_Admin_Cache_Plugins();
$this->classes['admin_category'] = new ConvertKit_Admin_Category();
$this->classes['admin_landing_page'] = new ConvertKit_Admin_Landing_Page();
$this->classes['admin_post'] = new ConvertKit_Admin_Post();
$this->classes['admin_quick_edit'] = new ConvertKit_Admin_Quick_Edit();
$this->classes['admin_restrict_content'] = new ConvertKit_Admin_Restrict_Content();
$this->classes['admin_settings'] = new ConvertKit_Admin_Settings();
$this->classes['admin_setup_wizard_landing_page'] = new ConvertKit_Admin_Setup_Wizard_Landing_Page();
Expand Down Expand Up @@ -178,6 +176,7 @@ private function initialize_frontend() {
*/
private function initialize_global() {

$this->classes['admin_notices'] = new ConvertKit_Admin_Notices();
$this->classes['admin_refresh_resources'] = new ConvertKit_Admin_Refresh_Resources();
$this->classes['ajax'] = new ConvertKit_AJAX();
$this->classes['blocks_convertkit_broadcasts'] = new ConvertKit_Block_Broadcasts();
Expand Down
48 changes: 48 additions & 0 deletions tests/Integration/APITest.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,60 @@ public function testAccessTokenRefreshedAndSavedWhenExpired()
// Run request, which will trigger the above filters as if the token expired and refreshes automatically.
$result = $this->api->get_account();

// Confirm no WP_Error is returned.
$this->assertNotInstanceOf( 'WP_Error', $result );
$this->assertIsArray( $result );

// Confirm "new" tokens now exist in the Plugin's settings, which confirms the `convertkit_api_refresh_token` hook was called when
// the tokens were refreshed.
$this->assertEquals( $settings->get_access_token(), $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'] );
$this->assertEquals( $settings->get_refresh_token(), $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'] );
}

/**
* Test that the Access Token, Refresh Token and Token Expiry are deleted from the Plugin's settings
* when the Access Token used is invalid.
*
* @since 3.1.0
*/
public function testAccessTokenDeletedWhenInvalid()
{
// Save an invalid access token and refresh token in the Plugin's settings.
$settings = new \ConvertKit_Settings();
$settings->save(
array(
'access_token' => 'invalidAccessToken',
'refresh_token' => $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'],
'token_expires' => time() + 10000,
)
);

// Confirm the tokens saved.
$this->assertEquals( $settings->get_access_token(), 'invalidAccessToken' );
$this->assertEquals( $settings->get_refresh_token(), $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'] );

// Initialize the API using the invalid refresh token.
$api = new \ConvertKit_API_V4(
$_ENV['CONVERTKIT_OAUTH_CLIENT_ID'],
$_ENV['KIT_OAUTH_REDIRECT_URI'],
$settings->get_access_token(),
$settings->get_refresh_token()
);

// Run request.
$result = $api->get_account();

// Confirm a WP_Error is returned.
$this->assertInstanceOf( 'WP_Error', $result );
$this->assertEquals( $result->get_error_code(), 'convertkit_api_error' );
$this->assertEquals( $result->get_error_message(), 'The access token is invalid' );

// Confirm tokens removed from the Plugin's settings, which confirms the `convertkit_api_access_token_invalid` hook was called when the tokens were deleted.
$this->assertEmpty( $settings->get_access_token() );
$this->assertEmpty( $settings->get_refresh_token() );
$this->assertEmpty( $settings->get_token_expiry() );
}

/**
* Test that a WordPress Cron event is created when an access token is obtained.
*
Expand Down
3 changes: 3 additions & 0 deletions tests/Support/Helper/KitPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,9 @@ public function resetKitPlugin($I)
$I->dontHaveOptionInDatabase('convertkit_tags');
$I->dontHaveOptionInDatabase('convertkit_tags_last_queried');

// Persistent notices.
$I->dontHaveOptionInDatabase('convertkit-admin-notices');

// Review Request.
$I->dontHaveOptionInDatabase('convertkit-review-request');
$I->dontHaveOptionInDatabase('convertkit-review-dismissed');
Expand Down
2 changes: 1 addition & 1 deletion wp-convertkit.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
require_once CONVERTKIT_PLUGIN_PATH . '/includes/cron-functions.php';
require_once CONVERTKIT_PLUGIN_PATH . '/includes/functions.php';
require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-wp-convertkit.php';
require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-admin-notices.php';
require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-ajax.php';
require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-broadcasts-exporter.php';
require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-broadcasts-importer.php';
Expand Down Expand Up @@ -108,7 +109,6 @@
require_once CONVERTKIT_PLUGIN_PATH . '/admin/class-convertkit-admin-cache-plugins.php';
require_once CONVERTKIT_PLUGIN_PATH . '/admin/class-convertkit-admin-category.php';
require_once CONVERTKIT_PLUGIN_PATH . '/admin/class-convertkit-admin-landing-page.php';
require_once CONVERTKIT_PLUGIN_PATH . '/admin/class-convertkit-admin-notices.php';
require_once CONVERTKIT_PLUGIN_PATH . '/admin/class-convertkit-admin-post.php';
require_once CONVERTKIT_PLUGIN_PATH . '/admin/class-convertkit-admin-refresh-resources.php';
require_once CONVERTKIT_PLUGIN_PATH . '/admin/class-convertkit-admin-restrict-content.php';
Expand Down