Skip to content

Commit d2ec6ee

Browse files
Store callback URL hash per-user instead of site-wide
Fixes multi-user scenario where only the first user to trigger the URL change detection would re-register callback URLs. Now each user independently detects the change via their own user meta hash and re-registers on their next "Generate with AI" click. Also improves multisite: since wp_usermeta is network-wide, users switching between sites automatically detect the different REST API endpoints and re-register with the correct callback URLs.
1 parent 51cb56c commit d2ec6ee

3 files changed

Lines changed: 38 additions & 48 deletions

File tree

src/ai/authorization/application/token-manager.php

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,8 @@ public function token_request( WP_User $user ): void {
218218

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

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

224224
// 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.
225225
\wp_cache_delete( $user->ID, 'user_meta' );
@@ -315,7 +315,7 @@ public function has_token_expired( string $jwt ): bool {
315315
*/
316316
public function get_or_request_access_token( WP_User $user ): string {
317317
// If the site URL has changed since callback URLs were registered, delete stale tokens.
318-
if ( $this->have_callback_urls_changed() ) {
318+
if ( $this->have_callback_urls_changed( $user ) ) {
319319
$this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt' );
320320
$this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_refresh_jwt' );
321321
}
@@ -350,18 +350,21 @@ public function get_or_request_access_token( WP_User $user ): string {
350350
*
351351
* Detects site URL changes (e.g., migrating from a staging URL to a production domain)
352352
* that would leave stale callback URLs registered with the Yoast AI service.
353-
* Uses a hash so the stored value is immune to wp search-replace operations.
353+
* Uses a per-user hash so each user independently detects the change and re-registers.
354+
* The hash is immune to wp search-replace operations.
354355
*
355356
* When no hash is stored (first run after upgrade), returns true to force a fresh
356357
* token_request(). This ensures existing sites with stale callback URLs self-heal
357358
* without manual intervention.
358359
*
360+
* @param WP_User $user The current user.
361+
*
359362
* @return bool Whether the callback URLs may have changed.
360363
*/
361-
private function have_callback_urls_changed(): bool {
362-
$registered_hash = \get_option( 'yoast_ai_generator_callback_url_hash', '' );
364+
private function have_callback_urls_changed( WP_User $user ): bool {
365+
$registered_hash = $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_generator_callback_url_hash', true );
363366

364-
if ( $registered_hash === '' ) {
367+
if ( ! \is_string( $registered_hash ) || $registered_hash === '' ) {
365368
return true;
366369
}
367370

tests/Unit/AI/Authorization/Application/Token_Manager/Abstract_Token_Manager_Test.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,13 @@ protected function setUp(): void {
113113
$this->urls = Mockery::mock( WordPress_URLs::class );
114114
$this->url_helper = Mockery::mock( Url_Helper::class );
115115

116-
// Default: have_callback_urls_changed() returns false by providing a matching hash.
116+
// Default: have_callback_urls_changed() returns false by providing a matching per-user hash.
117117
$default_callback_url = 'https://example.com/wp-json/yoast/v1/ai_generator/callback';
118-
Monkey\Functions\expect( 'get_option' )
119-
->with( 'yoast_ai_generator_callback_url_hash', '' )
118+
$this->user_helper->shouldReceive( 'get_meta' )
119+
->with( Mockery::any(), '_yoast_wpseo_ai_generator_callback_url_hash', true )
120120
->andReturn( \md5( $default_callback_url ) )
121121
->byDefault();
122-
Monkey\Functions\stubs( [ 'update_option' ] );
122+
$this->user_helper->shouldReceive( 'update_meta' )->zeroOrMoreTimes()->byDefault();
123123
$this->urls->shouldReceive( 'get_callback_url' )->andReturn( $default_callback_url )->byDefault();
124124
$this->urls->shouldReceive( 'get_refresh_callback_url' )->andReturn( 'https://example.com/wp-json/yoast/v1/ai_generator/refresh_callback' )->byDefault();
125125

tests/Unit/AI/Authorization/Application/Token_Manager/Callback_Url_Change_Test.php

Lines changed: 24 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,10 @@ public function test_stale_callback_url_triggers_token_re_request() {
4040
$code = 'test-code-verifier';
4141
$created_at = 1_640_995_200;
4242

43-
// The stored hash differs from the current callback URL hash.
44-
Monkey\Functions\expect( 'get_option' )
45-
->with( 'yoast_ai_generator_callback_url_hash', '' )
46-
->once()
43+
// The stored per-user hash differs from the current callback URL hash.
44+
$this->user_helper
45+
->shouldReceive( 'get_meta' )
46+
->with( 123, '_yoast_wpseo_ai_generator_callback_url_hash', true )
4747
->andReturn( $old_callback_hash );
4848

4949
$this->urls
@@ -108,13 +108,11 @@ public function test_stale_callback_url_triggers_token_re_request() {
108108
->expects( 'handle' )
109109
->once();
110110

111-
// Capture the update_option call to verify the hash is stored.
112-
$stored_option = [];
113-
Monkey\Functions\when( 'update_option' )->alias(
114-
static function () use ( &$stored_option ) {
115-
$stored_option = \func_get_args();
116-
},
117-
);
111+
// The new callback URL hash should be stored per-user.
112+
$this->user_helper
113+
->expects( 'update_meta' )
114+
->with( 123, '_yoast_wpseo_ai_generator_callback_url_hash', \md5( $new_callback_url ) )
115+
->once();
118116

119117
Monkey\Functions\expect( 'wp_cache_delete' )
120118
->with( 123, 'user_meta' )
@@ -129,8 +127,6 @@ static function () use ( &$stored_option ) {
129127
$result = $this->instance->get_or_request_access_token( $user );
130128

131129
$this->assertEquals( $new_access_jwt, $result );
132-
$this->assertEquals( 'yoast_ai_generator_callback_url_hash', $stored_option[0] );
133-
$this->assertEquals( \md5( $new_callback_url ), $stored_option[1] );
134130
}
135131

136132
/**
@@ -184,10 +180,10 @@ public function test_empty_stored_hash_forces_token_re_request() {
184180
$code = 'test-code-verifier';
185181
$created_at = 1_640_995_200;
186182

187-
// No stored hash — return empty to trigger re-request.
188-
Monkey\Functions\expect( 'get_option' )
189-
->with( 'yoast_ai_generator_callback_url_hash', '' )
190-
->once()
183+
// No stored per-user hash — return empty to trigger re-request.
184+
$this->user_helper
185+
->shouldReceive( 'get_meta' )
186+
->with( 123, '_yoast_wpseo_ai_generator_callback_url_hash', true )
191187
->andReturn( '' );
192188

193189
// Stale tokens should be deleted.
@@ -252,13 +248,11 @@ public function test_empty_stored_hash_forces_token_re_request() {
252248
->expects( 'handle' )
253249
->once();
254250

255-
// Capture the update_option call to verify the hash is stored.
256-
$stored_option = [];
257-
Monkey\Functions\when( 'update_option' )->alias(
258-
static function () use ( &$stored_option ) {
259-
$stored_option = \func_get_args();
260-
},
261-
);
251+
// The new callback URL hash should be stored per-user.
252+
$this->user_helper
253+
->expects( 'update_meta' )
254+
->with( 123, '_yoast_wpseo_ai_generator_callback_url_hash', \md5( $new_callback_url ) )
255+
->once();
262256

263257
Monkey\Functions\expect( 'wp_cache_delete' )
264258
->with( 123, 'user_meta' )
@@ -273,12 +267,10 @@ static function () use ( &$stored_option ) {
273267
$result = $this->instance->get_or_request_access_token( $user );
274268

275269
$this->assertEquals( $new_access_jwt, $result );
276-
$this->assertEquals( 'yoast_ai_generator_callback_url_hash', $stored_option[0] );
277-
$this->assertEquals( \md5( $new_callback_url ), $stored_option[1] );
278270
}
279271

280272
/**
281-
* Tests that token_request stores the callback URL hash.
273+
* Tests that token_request stores the callback URL hash per-user.
282274
*
283275
* @return void
284276
*/
@@ -336,21 +328,16 @@ public function test_token_request_stores_callback_url_hash() {
336328
->expects( 'handle' )
337329
->once();
338330

339-
// Capture the update_option call to verify the hash is stored.
340-
$stored_option = [];
341-
Monkey\Functions\when( 'update_option' )->alias(
342-
static function () use ( &$stored_option ) {
343-
$stored_option = \func_get_args();
344-
},
345-
);
331+
// Verify the per-user hash is stored after successful token request.
332+
$this->user_helper
333+
->expects( 'update_meta' )
334+
->with( 123, '_yoast_wpseo_ai_generator_callback_url_hash', \md5( $callback_url ) )
335+
->once();
346336

347337
Monkey\Functions\expect( 'wp_cache_delete' )
348338
->with( 123, 'user_meta' )
349339
->once();
350340

351341
$this->instance->token_request( $user );
352-
353-
$this->assertEquals( 'yoast_ai_generator_callback_url_hash', $stored_option[0] );
354-
$this->assertEquals( \md5( $callback_url ), $stored_option[1] );
355342
}
356343
}

0 commit comments

Comments
 (0)