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
42 changes: 40 additions & 2 deletions src/ai-authorization/application/token-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -202,17 +202,23 @@ public function token_request( WP_User $user ): void {
$code_verifier = $this->code_verifier->generate( $user->user_email );
$this->code_verifier_repository->store_code_verifier( $user->ID, $code_verifier->get_code(), $code_verifier->get_created_at() );

$callback_url = $this->urls->get_callback_url();
$refresh_callback_url = $this->urls->get_refresh_callback_url();

$request_body = [
'service' => 'openai',
'code_challenge' => \hash( 'sha256', $code_verifier->get_code() ),
'license_site_url' => WPSEO_Utils::get_home_url(),
'user_id' => (string) $user->ID,
'callback_url' => $this->urls->get_callback_url(),
'refresh_callback_url' => $this->urls->get_refresh_callback_url(),
'callback_url' => $callback_url,
'refresh_callback_url' => $refresh_callback_url,
];

$this->request_handler->handle( new Request( '/token/request', $request_body ) );

// Store a per-user hash of the callback URL to detect future site URL changes.
$this->user_helper->update_meta( $user->ID, '_yoast_wpseo_ai_generator_callback_url_hash', \md5( $callback_url ) );

// The callback saves the metadata. Because that is in another session, we need to delete the current cache here. Or we may get the old token.
\wp_cache_delete( $user->ID, 'user_meta' );
}
Expand Down Expand Up @@ -306,6 +312,12 @@ public function has_token_expired( string $jwt ): bool {
* @throws RuntimeException Unable to retrieve the access or refresh token.
*/
public function get_or_request_access_token( WP_User $user ): string {
// If the site URL has changed since callback URLs were registered, delete stale tokens.
if ( $this->have_callback_urls_changed( $user ) ) {
$this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt' );
$this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_refresh_jwt' );
}

$access_jwt = $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt', true );
if ( ! \is_string( $access_jwt ) || $access_jwt === '' ) {
$this->token_request( $user );
Expand All @@ -330,4 +342,30 @@ public function get_or_request_access_token( WP_User $user ): string {
}

// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

/**
* Checks whether the callback URLs have changed since the last token request.
*
* Detects site URL changes (e.g., migrating from a staging URL to a production domain)
* that would leave stale callback URLs registered with the Yoast AI service.
* Uses a per-user hash so each user independently detects the change and re-registers.
* The hash is immune to wp search-replace operations.
*
* When no hash is stored (first run after upgrade), returns true to force a fresh
* token_request(). This ensures existing sites with stale callback URLs self-heal
* without manual intervention.
*
* @param WP_User $user The current user.
*
* @return bool Whether the callback URLs may have changed.
*/
private function have_callback_urls_changed( WP_User $user ): bool {
$registered_hash = $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_generator_callback_url_hash', true );

if ( ! \is_string( $registered_hash ) || $registered_hash === '' ) {
return true;
}

return $registered_hash !== \md5( $this->urls->get_callback_url() );
}
}
42 changes: 40 additions & 2 deletions src/ai/authorization/application/token-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,17 +204,23 @@ public function token_request( WP_User $user ): void {
$code_verifier = $this->code_verifier->generate( $user->user_email );
$this->code_verifier_repository->store_code_verifier( $user->ID, $code_verifier->get_code(), $code_verifier->get_created_at() );

$callback_url = $this->urls->get_callback_url();
$refresh_callback_url = $this->urls->get_refresh_callback_url();

$request_body = [
'service' => 'openai',
'code_challenge' => \hash( 'sha256', $code_verifier->get_code() ),
'license_site_url' => WPSEO_Utils::get_home_url(),
'user_id' => (string) $user->ID,
'callback_url' => $this->urls->get_callback_url(),
'refresh_callback_url' => $this->urls->get_refresh_callback_url(),
'callback_url' => $callback_url,
'refresh_callback_url' => $refresh_callback_url,
];

$this->request_handler->handle( new Request( '/token/request', $request_body ) );

// Store a per-user hash of the callback URL to detect future site URL changes.
$this->user_helper->update_meta( $user->ID, '_yoast_wpseo_ai_generator_callback_url_hash', \md5( $callback_url ) );

// The callback saves the metadata. Because that is in another session, we need to delete the current cache here. Or we may get the old token.
\wp_cache_delete( $user->ID, 'user_meta' );
}
Expand Down Expand Up @@ -308,6 +314,12 @@ public function has_token_expired( string $jwt ): bool {
* @throws RuntimeException Unable to retrieve the access or refresh token.
*/
public function get_or_request_access_token( WP_User $user ): string {
// If the site URL has changed since callback URLs were registered, delete stale tokens.
if ( $this->have_callback_urls_changed( $user ) ) {
$this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt' );
$this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_refresh_jwt' );
}

$access_jwt = $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt', true );
if ( ! \is_string( $access_jwt ) || $access_jwt === '' ) {
$this->token_request( $user );
Expand All @@ -332,4 +344,30 @@ public function get_or_request_access_token( WP_User $user ): string {
}

// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

/**
* Checks whether the callback URLs have changed since the last token request.
*
* Detects site URL changes (e.g., migrating from a staging URL to a production domain)
* that would leave stale callback URLs registered with the Yoast AI service.
* Uses a per-user hash so each user independently detects the change and re-registers.
* The hash is immune to wp search-replace operations.
*
* When no hash is stored (first run after upgrade), returns true to force a fresh
* token_request(). This ensures existing sites with stale callback URLs self-heal
* without manual intervention.
*
* @param WP_User $user The current user.
*
* @return bool Whether the callback URLs may have changed.
*/
private function have_callback_urls_changed( WP_User $user ): bool {
$registered_hash = $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_generator_callback_url_hash', true );

if ( ! \is_string( $registered_hash ) || $registered_hash === '' ) {
return true;
}

return $registered_hash !== \md5( $this->urls->get_callback_url() );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ protected function setUp(): void {
$this->urls = Mockery::mock( WordPress_URLs::class );
$this->url_helper = Mockery::mock( Url_Helper::class );

// Default: have_callback_urls_changed() returns false by providing a matching per-user hash.
$default_callback_url = 'https://example.com/wp-json/yoast/v1/ai_generator/callback';
$this->user_helper->shouldReceive( 'get_meta' )
->with( Mockery::any(), '_yoast_wpseo_ai_generator_callback_url_hash', true )
->andReturn( \md5( $default_callback_url ) )
->byDefault();
$this->user_helper->shouldReceive( 'update_meta' )->zeroOrMoreTimes()->byDefault();
$this->urls->shouldReceive( 'get_callback_url' )->andReturn( $default_callback_url )->byDefault();
$this->urls->shouldReceive( 'get_refresh_callback_url' )->andReturn( 'https://example.com/wp-json/yoast/v1/ai_generator/refresh_callback' )->byDefault();

$this->instance = new Token_Manager( $this->access_token_repository, $this->code_verifier, $this->consent_handler, $this->refresh_token_repository, $this->user_helper, $this->request_handler, $this->code_verifier_repository, $this->urls );
}

Expand Down
Loading
Loading