Skip to content

Commit bba276c

Browse files
authored
Merge pull request #23425 from Yoast/fix/revoking-consent-creates-tokens
Revoke consent by invalidating tokens
2 parents 6dec938 + a112169 commit bba276c

21 files changed

Lines changed: 426 additions & 102 deletions

File tree

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

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ public function __construct(
113113
/**
114114
* Invalidates the access token.
115115
*
116+
* The locally stored JWTs are always cleared, even when the remote invalidation fails — the
117+
* remote exception still propagates to the caller, but no credentials are left behind.
118+
*
116119
* @param int $user_id The user ID.
117120
*
118121
* @return void
@@ -133,27 +136,26 @@ public function token_invalidate( int $user_id ): void {
133136
$access_jwt = '';
134137
}
135138

136-
$request_body = [
137-
'user_id' => (string) $user_id,
138-
];
139139
$request_headers = [
140140
'Authorization' => "Bearer $access_jwt",
141141
];
142142

143143
try {
144+
// The endpoint takes no request body; the user is identified by the access token.
144145
$this->request_handler->handle(
145146
new Request(
146147
'/token/invalidate',
147-
$request_body,
148+
[],
148149
$request_headers,
149150
),
150151
);
151152
} catch ( Unauthorized_Exception | Forbidden_Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Reason: Ignored on purpose.
152153
// If the credentials in our request were already invalid, our job is done and we continue to remove the tokens client-side.
154+
} finally {
155+
// Always clear the local tokens, even when the remote invalidation fails with an exception
156+
// that propagates: leaving credentials behind would contradict the intent of invalidating.
157+
$this->clear_tokens( $user_id );
153158
}
154-
155-
// Delete the stored JWT tokens.
156-
$this->clear_tokens( $user_id );
157159
}
158160

159161
/**
@@ -168,6 +170,31 @@ public function clear_tokens( int $user_id ): void {
168170
$this->refresh_token_repository->delete_token( $user_id );
169171
}
170172

173+
/**
174+
* Checks whether any JWT (access or refresh) is stored locally for the user.
175+
*
176+
* @param int $user_id The user ID.
177+
*
178+
* @return bool Whether a locally stored JWT exists.
179+
*/
180+
public function has_local_tokens( int $user_id ): bool {
181+
try {
182+
$this->access_token_repository->get_token( $user_id );
183+
184+
return true;
185+
} catch ( RuntimeException $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Reason: Ignored on purpose.
186+
// No access token; fall through to the refresh token check.
187+
}
188+
189+
try {
190+
$this->refresh_token_repository->get_token( $user_id );
191+
192+
return true;
193+
} catch ( RuntimeException $e ) {
194+
return false;
195+
}
196+
}
197+
171198
/**
172199
* Requests a new set of JWT tokens.
173200
*

src/ai-consent/application/consent-handler.php

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,14 @@ public function grant_consent( int $user_id ) {
109109
}
110110

111111
/**
112-
* Revokes the user's consent on the Yoast AI service and clears the local user meta.
112+
* Revokes the user's consent, both locally (user meta) and remotely (Yoast AI service).
113113
*
114114
* Security-first: the local meta is always cleared before the remote call, so consent is
115-
* revoked locally even if the remote DELETE fails. Any HTTP-layer exception is propagated
116-
* and its management is deferred to the caller.
115+
* revoked locally even if the remote `DELETE /user/consent` fails. Any locally stored JWTs
116+
* are then invalidated regardless of the remote outcome — credentials must not outlive
117+
* consent. The invalidation runs after the DELETE on purpose: authenticating the DELETE may
118+
* mint a fresh JWT, and invalidating afterwards catches that token too. Any HTTP-layer
119+
* exception is propagated and its management is deferred to the caller.
117120
*
118121
* @param int $user_id The user ID.
119122
*
@@ -129,7 +132,7 @@ public function grant_consent( int $user_id ) {
129132
* @throws Too_Many_Requests_Exception When the AI service responds with 429.
130133
* @throws Unauthorized_Exception When the AI service responds with 401.
131134
* @throws WP_Request_Exception When the underlying WordPress HTTP call fails.
132-
* @throws RuntimeException When the user is not found.
135+
* @throws RuntimeException When the user is not found.
133136
*/
134137
public function revoke_consent( int $user_id ) {
135138
$user = \get_user_by( 'id', $user_id );
@@ -140,11 +143,19 @@ public function revoke_consent( int $user_id ) {
140143
// Local consent is always revoked regardless of remote failures.
141144
$this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_consent' );
142145

143-
$jwt = $this->token_manager->get_or_request_access_token( $user );
144-
145-
$this->request_handler->handle(
146-
new Request( '/user/consent', [], [ 'Authorization' => "Bearer $jwt" ], Request::METHOD_DELETE ),
147-
);
146+
try {
147+
$jwt = $this->token_manager->get_or_request_access_token( $user );
148+
149+
$this->request_handler->handle(
150+
new Request( '/user/consent', [], [ 'Authorization' => "Bearer $jwt" ], Request::METHOD_DELETE ),
151+
);
152+
} finally {
153+
// Invalidate the JWTs — including ones minted to authenticate the DELETE above — so
154+
// credentials never outlive consent.
155+
if ( $this->token_manager->has_local_tokens( $user_id ) ) {
156+
$this->token_manager->token_invalidate( $user_id );
157+
}
158+
}
148159
}
149160

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

src/ai-consent/user-interface/consent-route.php

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,9 @@ public static function get_conditionals() {
7777
*/
7878
public function __construct( Consent_Handler $consent_handler, Token_Manager $token_manager, Logger $logger ) {
7979
$this->consent_handler = $consent_handler;
80-
$this->token_manager = $token_manager;
81-
$this->logger = $logger;
80+
// @TODO: Remove the token manager as soon as we don't care about BC, because it's no longer used.
81+
$this->token_manager = $token_manager;
82+
$this->logger = $logger;
8283
}
8384

8485
/**
@@ -122,9 +123,7 @@ public function consent( WP_REST_Request $request ): WP_REST_Response {
122123
$this->consent_handler->grant_consent( $user_id );
123124
}
124125
else {
125-
// Invalidate the token if the user revoked the consent.
126-
$this->token_manager->token_invalidate( $user_id );
127-
// Delete the consent at user level.
126+
// Revoke the consent locally and remotely (this also invalidates the JWT tokens).
128127
$this->consent_handler->revoke_consent( $user_id );
129128
}
130129
} catch ( Remote_Request_Exception | RuntimeException $e ) {

src/ai-http-request/domain/request.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,14 @@ public function get_action_path(): string {
7878
/**
7979
* Get the body of the request.
8080
*
81-
* @return array<string> The body of the request.
81+
* Returns null for an empty body: an empty PHP array is ambiguous once JSON-encoded (`[]` vs `{}`),
82+
* so an empty body is omitted from the request entirely rather than sent as an empty array, which
83+
* the AI service rejects.
84+
*
85+
* @return array<string>|null The body of the request, or null when there is no body to send.
8286
*/
83-
public function get_body(): array {
84-
return $this->body;
87+
public function get_body(): ?array {
88+
return ( $this->body === [] ) ? null : $this->body;
8589
}
8690

8791
/**

src/ai-http-request/infrastructure/api-client-interface.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ interface API_Client_Interface {
1414
/**
1515
* Performs a request to the API.
1616
*
17-
* @param string $action_path The action path for the request.
18-
* @param array<string> $body The body of the request.
19-
* @param array<string> $headers The headers for the request.
20-
* @param string $http_method The HTTP method for the request. One of `Request::METHOD_*`.
17+
* @param string $action_path The action path for the request.
18+
* @param array<string>|null $body The body of the request, or null/empty to send no body.
19+
* @param array<string> $headers The headers for the request.
20+
* @param string $http_method The HTTP method for the request. One of `Request::METHOD_*`.
2121
*
2222
* @return array<int|string|array<string>> The response from the API.
2323
*

src/ai-http-request/infrastructure/api-client.php

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ class API_Client implements API_Client_Interface {
2424
/**
2525
* Performs a request to the API.
2626
*
27-
* @param string $action_path The action path for the request.
28-
* @param array<string> $body The body of the request.
29-
* @param array<string> $headers The headers for the request.
30-
* @param string $http_method The HTTP method for the request. One of `Request::METHOD_*`.
27+
* @param string $action_path The action path for the request.
28+
* @param array<string>|null $body The body of the request, or null/empty to send no body.
29+
* @param array<string> $headers The headers for the request.
30+
* @param string $http_method The HTTP method for the request. One of `Request::METHOD_*`.
3131
*
3232
* @return array<int|string|array<string>> The response from the API.
3333
*
@@ -41,8 +41,10 @@ public function perform_request( string $action_path, $body, $headers, string $h
4141
'headers' => $headers,
4242
];
4343

44-
// Only POST sends a body to the AI API today; GET and DELETE endpoints do not.
45-
if ( $http_method === Request::METHOD_POST ) {
44+
// Only POST sends a body to the AI API today; GET and DELETE endpoints do not. An empty body is
45+
// omitted entirely: an empty array is ambiguous once JSON-encoded (`[]` vs `{}`) and the AI
46+
// service rejects it, so a bodyless POST is sent instead.
47+
if ( $http_method === Request::METHOD_POST && ! empty( $body ) ) {
4648
// phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Reason: We don't want the debug/pretty possibility.
4749
$arguments['body'] = WPSEO_Utils::format_json_encode( $body );
4850
}

src/ai/authentication/application/ai-request-sender.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,12 @@ public function grant_consent( WP_User $user ): Response {
154154
}
155155

156156
/**
157-
* Revokes the user's consent on the AI service.
157+
* Revokes the user's consent on the AI service via `DELETE /user/consent`.
158158
*
159-
* The strategy identifies the WP user to the service (the OAuth path injects `user_id` into the
160-
* query string for DELETE requests).
159+
* The strategy identifies the WP user to the service (the OAuth path appends the `user_id`
160+
* query parameter to the DELETE), so no body is built here. Note the legacy Token path may
161+
* provision a fresh JWT to authenticate the DELETE — Consent_Handler::revoke_consent()
162+
* invalidates any locally stored JWTs afterwards, so credentials never outlive consent.
161163
*
162164
* @param WP_User $user The WP user revoking consent.
163165
*

src/ai/authentication/application/oauth-auth-strategy.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ public function send( Request $request, WP_User $user ): Response {
120120
];
121121

122122
if ( $method === Request::METHOD_POST ) {
123-
$body = \array_merge( $request->get_body(), [ 'user_id' => $user_id ] );
123+
$body = \array_merge( ( $request->get_body() ?? [] ), [ 'user_id' => $user_id ] );
124124
$options['body'] = WPSEO_Utils::format_json_encode( $body );
125125
}
126126
else {

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

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ public function __construct(
115115
/**
116116
* Invalidates the access token.
117117
*
118+
* The locally stored JWTs are always cleared, even when the remote invalidation fails — the
119+
* remote exception still propagates to the caller, but no credentials are left behind.
120+
*
118121
* @param int $user_id The user ID.
119122
*
120123
* @return void
@@ -135,26 +138,26 @@ public function token_invalidate( int $user_id ): void {
135138
$access_jwt = '';
136139
}
137140

138-
$request_body = [
139-
'user_id' => (string) $user_id,
140-
];
141141
$request_headers = [
142142
'Authorization' => "Bearer $access_jwt",
143143
];
144144

145145
try {
146+
// The endpoint takes no request body; the user is identified by the access token.
146147
$this->request_handler->handle(
147148
new Request(
148149
'/token/invalidate',
149-
$request_body,
150+
[],
150151
$request_headers,
151152
),
152153
);
153154
} catch ( Unauthorized_Exception | Forbidden_Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Reason: Ignored on purpose.
154155
// If the credentials in our request were already invalid, our job is done and we continue to remove the tokens client-side.
156+
} finally {
157+
// Always clear the local tokens, even when the remote invalidation fails with an exception
158+
// that propagates: leaving credentials behind would contradict the intent of invalidating.
159+
$this->clear_tokens( $user_id );
155160
}
156-
157-
$this->clear_tokens( $user_id );
158161
}
159162

160163
/**
@@ -169,6 +172,31 @@ public function clear_tokens( int $user_id ): void {
169172
$this->refresh_token_repository->delete_token( $user_id );
170173
}
171174

175+
/**
176+
* Checks whether any JWT (access or refresh) is stored locally for the user.
177+
*
178+
* @param int $user_id The user ID.
179+
*
180+
* @return bool Whether a locally stored JWT exists.
181+
*/
182+
public function has_local_tokens( int $user_id ): bool {
183+
try {
184+
$this->access_token_repository->get_token( $user_id );
185+
186+
return true;
187+
} catch ( RuntimeException $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Reason: Ignored on purpose.
188+
// No access token; fall through to the refresh token check.
189+
}
190+
191+
try {
192+
$this->refresh_token_repository->get_token( $user_id );
193+
194+
return true;
195+
} catch ( RuntimeException $e ) {
196+
return false;
197+
}
198+
}
199+
172200
/**
173201
* Requests a new set of JWT tokens.
174202
*

src/ai/consent/application/consent-handler.php

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use RuntimeException;
88
use WP_User;
99
use Yoast\WP\SEO\AI\Authentication\Application\AI_Request_Sender_Factory;
10+
use Yoast\WP\SEO\AI\Authorization\Application\Token_Manager;
1011
use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Bad_Request_Exception;
1112
use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Consent_Required_Exception;
1213
use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Forbidden_Exception;
@@ -43,18 +44,28 @@ class Consent_Handler implements Consent_Handler_Interface {
4344
*/
4445
private $ai_request_sender_factory;
4546

47+
/**
48+
* The token manager, used to invalidate leftover legacy JWTs when consent is revoked.
49+
*
50+
* @var Token_Manager
51+
*/
52+
private $token_manager;
53+
4654
/**
4755
* Class constructor.
4856
*
4957
* @param User_Helper $user_helper The user helper.
5058
* @param AI_Request_Sender_Factory $ai_request_sender_factory The AI request sender factory.
59+
* @param Token_Manager $token_manager The token manager.
5160
*/
5261
public function __construct(
5362
User_Helper $user_helper,
54-
AI_Request_Sender_Factory $ai_request_sender_factory
63+
AI_Request_Sender_Factory $ai_request_sender_factory,
64+
Token_Manager $token_manager
5565
) {
5666
$this->user_helper = $user_helper;
5767
$this->ai_request_sender_factory = $ai_request_sender_factory;
68+
$this->token_manager = $token_manager;
5869
}
5970

6071
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- PHPCS doesn't take into account exceptions thrown in called methods.
@@ -99,8 +110,11 @@ public function grant_consent( int $user_id ) {
99110
* Revokes the user's consent on the Yoast AI service and clears the local user meta.
100111
*
101112
* Security-first: the local meta is always cleared before the remote call, so consent is
102-
* revoked locally even if the remote DELETE fails. Any HTTP-layer exception is propagated
103-
* and its management is deferred to the caller.
113+
* revoked locally even if the remote `DELETE /user/consent` fails. Any locally stored legacy
114+
* JWTs are then invalidated regardless of the remote outcome — credentials must not outlive
115+
* consent. The invalidation runs after the DELETE on purpose: the legacy Token path may mint
116+
* a fresh JWT to authenticate the DELETE, and invalidating afterwards catches that token too.
117+
* Any HTTP-layer exception is propagated and its management is deferred to the caller.
104118
*
105119
* @param int $user_id The user ID.
106120
*
@@ -129,7 +143,16 @@ public function revoke_consent( int $user_id ) {
129143
// Local consent is always revoked regardless of remote failures.
130144
$this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_consent' );
131145

132-
$this->ai_request_sender_factory->create( $user )->revoke_consent( $user );
146+
try {
147+
$this->ai_request_sender_factory->create( $user )->revoke_consent( $user );
148+
} finally {
149+
// Invalidate the legacy JWTs — including ones minted to authenticate the DELETE above —
150+
// so credentials never outlive consent. Skipped when no local JWTs exist (the OAuth path
151+
// without a leftover pre-OAuth grant).
152+
if ( $this->token_manager->has_local_tokens( $user_id ) ) {
153+
$this->token_manager->token_invalidate( $user_id );
154+
}
155+
}
133156
}
134157

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

0 commit comments

Comments
 (0)