Skip to content

Commit 3453290

Browse files
authored
Merge pull request #23287 from Yoast/DE/resource-servers
feat(myyoast-client): add RFC 8707 resource indicator support
2 parents acbee9a + a3bf339 commit 3453290

25 files changed

Lines changed: 1915 additions & 299 deletions

src/myyoast-client/application/authorization-code-handler.php

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Discovery_Interface;
2020
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\ID_Token_Validator_Interface;
2121
use Yoast\WP\SEO\MyYoast_Client\Domain\Auth_Flow_State;
22+
use Yoast\WP\SEO\MyYoast_Client\Domain\Resource_Indicator;
2223
use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Set;
2324
use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Encoding\Base64url;
2425
use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
@@ -101,16 +102,17 @@ public function __construct(
101102
*
102103
* Generates PKCE challenge, state, and nonce, and stores them in the expiring store.
103104
*
104-
* @param int $user_id The WordPress user ID.
105-
* @param string $redirect_uri The callback redirect URI.
106-
* @param string[] $scopes The scopes to request.
107-
* @param string|null $return_url The URL to return the user to after authorization completes.
105+
* @param int $user_id The WordPress user ID.
106+
* @param string $redirect_uri The callback redirect URI.
107+
* @param string[] $scopes The scopes to request.
108+
* @param Resource_Indicator $resource_indicator The RFC 8707 resource indicator the issued token should be bound to.
109+
* @param string|null $return_url The URL to return the user to after authorization completes.
108110
*
109111
* @return string The authorization URL to redirect the user to.
110112
*
111113
* @throws Authorization_Flow_Exception If any of the auth flow prerequisites (registration, discovery, random number generation, or state parameter validation) fails.
112114
*/
113-
public function get_authorization_url( int $user_id, string $redirect_uri, array $scopes = [], ?string $return_url = null ): string {
115+
public function get_authorization_url( int $user_id, string $redirect_uri, array $scopes, Resource_Indicator $resource_indicator, ?string $return_url = null ): string {
114116
if ( $user_id <= 0 ) {
115117
throw new Authorization_Flow_Exception( 'invalid_user', 'A valid WordPress user ID is required to start the authorization flow.' );
116118
}
@@ -146,7 +148,7 @@ public function get_authorization_url( int $user_id, string $redirect_uri, array
146148
}
147149

148150
try {
149-
$flow_state = new Auth_Flow_State( $code_verifier, $state, $nonce, $redirect_uri, $return_url );
151+
$flow_state = new Auth_Flow_State( $code_verifier, $state, $nonce, $redirect_uri, $return_url, $resource_indicator );
150152
} catch ( InvalidArgumentException $e ) {
151153
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
152154
throw new Authorization_Flow_Exception( 'invalid_state', $e->getMessage(), 0, $e );
@@ -174,6 +176,10 @@ public function get_authorization_url( int $user_id, string $redirect_uri, array
174176
$params['nonce'] = $nonce;
175177
}
176178

179+
if ( ! $resource_indicator->is_default() ) {
180+
$params['resource'] = $resource_indicator->value();
181+
}
182+
177183
return $auth_endpoint . '?' . \http_build_query( $params, '', '&', \PHP_QUERY_RFC3986 );
178184
}
179185

@@ -208,8 +214,9 @@ public function exchange_code( int $user_id, string $code, string $state ): Toke
208214
// Clean up the stored flow state.
209215
$this->expiring_store->delete_for_user( self::CURRENT_AUTH_FLOW_STATE_KEY, $user_id );
210216

211-
$grant = new Authorization_Code_Grant( $code, $flow_state->get_redirect_uri(), $flow_state->get_code_verifier() );
212-
$token_set = $this->grant_handler->request_token( $grant );
217+
$resource_indicator = $flow_state->get_resource_indicator();
218+
$grant = new Authorization_Code_Grant( $code, $flow_state->get_redirect_uri(), $flow_state->get_code_verifier() );
219+
$token_set = $this->grant_handler->request_token( $grant, $resource_indicator );
213220

214221
// Validate ID token nonce (replay protection) if an ID token was returned.
215222
$this->validate_id_token_nonce( $token_set, $flow_state );

src/myyoast-client/application/myyoast-client-cleanup.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ public function execute(): void {
7373
$this->logger->warning( 'MyYoast client deregistration failed during cleanup: {error}', [ 'error' => $e->getMessage() ] );
7474
}
7575

76-
$this->user_token_storage->delete_all();
77-
$this->token_storage->delete();
76+
$this->user_token_storage->delete_all_issuers();
77+
$this->token_storage->delete_all_issuers();
7878
$this->client_registration->delete_local_data();
7979
}
8080
}

src/myyoast-client/application/myyoast-client.php

Lines changed: 96 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Site_URL_Provider_Interface;
1818
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Token_Storage_Interface;
1919
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\User_Token_Storage_Interface;
20+
use Yoast\WP\SEO\MyYoast_Client\Domain\Exceptions\Invalid_Resource_Exception;
2021
use Yoast\WP\SEO\MyYoast_Client\Domain\HTTP_Response;
2122
use Yoast\WP\SEO\MyYoast_Client\Domain\Registered_Client;
23+
use Yoast\WP\SEO\MyYoast_Client\Domain\Resource_Indicator;
2224
use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Set;
2325
use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Type_Hint;
2426
use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
@@ -204,17 +206,19 @@ public function rotate_dpop_keys(): void {
204206
/**
205207
* Builds the authorization URL for the user authorization flow.
206208
*
207-
* @param int $user_id The WordPress user ID.
208-
* @param string $redirect_uri The callback redirect URI.
209-
* @param string[] $scopes The scopes to request.
210-
* @param string|null $return_url The URL to return the user to after authorization completes.
209+
* @param int $user_id The WordPress user ID.
210+
* @param string $redirect_uri The callback redirect URI.
211+
* @param string[] $scopes The scopes to request.
212+
* @param string|null $resource_indicator The RFC 8707 resource indicator the issued token should be bound to.
213+
* @param string|null $return_url The URL to return the user to after authorization completes.
211214
*
212215
* @return string The authorization URL.
213216
*
214217
* @throws Authorization_Flow_Exception If registration, discovery, or parameter validation fails.
218+
* @throws Invalid_Resource_Exception If the resource indicator is malformed.
215219
*/
216-
public function get_authorization_url( int $user_id, string $redirect_uri, array $scopes = [], ?string $return_url = null ): string {
217-
return $this->auth_code_handler->get_authorization_url( $user_id, $redirect_uri, $scopes, $return_url );
220+
public function get_authorization_url( int $user_id, string $redirect_uri, array $scopes = [], ?string $resource_indicator = null, ?string $return_url = null ): string {
221+
return $this->auth_code_handler->get_authorization_url( $user_id, $redirect_uri, $scopes, new Resource_Indicator( $resource_indicator ), $return_url );
218222
}
219223

220224
/**
@@ -238,21 +242,24 @@ public function exchange_authorization_code( int $user_id, string $code, string
238242
/**
239243
* Returns a valid site-level access token (client_credentials).
240244
*
241-
* @param string[] $scopes The service:* scopes to request.
245+
* @param string[] $scopes The service:* scopes to request.
246+
* @param string|null $resource_indicator The RFC 8707 resource indicator the token should be bound to, or null for the default resource.
242247
*
243248
* @return Token_Set The site-level token set.
244249
*
245-
* @throws Token_Request_Failed_Exception If the token request fails.
246-
* @throws Token_Storage_Exception If encrypting the token set for storage fails.
250+
* @throws Invalid_Resource_Exception If the resource indicator is malformed.
251+
* @throws Token_Request_Failed_Exception If the token request fails. May also throw Token_Storage_Exception when encrypting the token set for storage fails.
247252
*/
248-
public function get_site_token( array $scopes = [] ): Token_Set {
249-
$cached = $this->token_storage->get();
253+
public function get_site_token( array $scopes = [], ?string $resource_indicator = null ): Token_Set {
254+
$indicator = new Resource_Indicator( $resource_indicator );
255+
256+
$cached = $this->token_storage->get( $indicator );
250257
if ( $cached !== null && ! $cached->is_expired() && $cached->has_scopes( $scopes ) ) {
251258
return $cached;
252259
}
253260

254261
$grant = new Client_Credentials_Grant( $scopes, $this->site_url_provider->get() );
255-
$token_set = $this->grant_handler->request_token( $grant );
262+
$token_set = $this->grant_handler->request_token( $grant, $indicator );
256263
$this->token_storage->store( $token_set );
257264

258265
return $token_set;
@@ -261,14 +268,18 @@ public function get_site_token( array $scopes = [] ): Token_Set {
261268
/**
262269
* Returns a valid user-level access token, auto-refreshing if expired.
263270
*
264-
* @param int $user_id The WordPress user ID.
265-
* @param string[] $required_scopes Optional scopes required for the token; if provided, no token will be returned unless it has at least these scopes.
266-
* This is to avoid refreshing a token that would trigger an immediate re-authorization due to missing scopes.
271+
* @param int $user_id The WordPress user ID.
272+
* @param string[] $required_scopes Optional scopes required for the token; if provided, no token will be returned unless it has at least these scopes.
273+
* This is to avoid refreshing a token that would trigger an immediate re-authorization due to missing scopes.
274+
* @param string|null $resource_indicator The RFC 8707 resource indicator the token should be bound to, or null for the default resource.
267275
*
268276
* @return Token_Set|null The user token set, or null if the user hasn't authorized.
277+
*
278+
* @throws Invalid_Resource_Exception If the resource indicator is malformed.
269279
*/
270-
public function get_user_token( int $user_id, array $required_scopes = [] ): ?Token_Set {
271-
$token_set = $this->user_token_storage->get( $user_id );
280+
public function get_user_token( int $user_id, array $required_scopes = [], ?string $resource_indicator = null ): ?Token_Set {
281+
$indicator = new Resource_Indicator( $resource_indicator );
282+
$token_set = $this->user_token_storage->get( $user_id, $indicator );
272283
if ( $token_set === null ) {
273284
return null;
274285
}
@@ -287,26 +298,22 @@ public function get_user_token( int $user_id, array $required_scopes = [] ): ?To
287298
return null;
288299
}
289300

301+
// Refresh must keep the audience binding (RFC 8707 §2); pull it from the stored token.
302+
$bound_resource = $token_set->get_resource_indicator();
303+
304+
// If the client was just re-registered, this refresh will fail with invalid_grant.
305+
// error_count is 0 on first attempt; after one invalid_grant it becomes 1.
306+
// On the second consecutive invalid_grant (error_count >= 1), clear and give up.
307+
$grant = new Refresh_Token_Grant( $refresh_token );
308+
$lock_key = 'wpseo_myyoast_refresh:' . \hash( 'sha256', $refresh_token );
290309
try {
291-
// If the client was just re-registered, this refresh will fail with invalid_grant.
292-
// error_count is 0 on first attempt; after one invalid_grant it becomes 1.
293-
// On the second consecutive invalid_grant (error_count >= 1), clear and give up.
294-
$grant = new Refresh_Token_Grant( $refresh_token );
295-
$lock_key = 'wpseo_myyoast_refresh:' . \hash( 'sha256', $refresh_token );
296310
$new_token_set = $this->lock_helper->execute(
297311
$lock_key,
298-
function () use ( $grant ) {
299-
return $this->grant_handler->request_token( $grant );
312+
function () use ( $grant, $bound_resource ) {
313+
return $this->grant_handler->request_token( $grant, $bound_resource );
300314
},
301315
self::REFRESH_LOCK_TTL_IN_SECONDS,
302316
);
303-
try {
304-
$this->user_token_storage->store( $user_id, $new_token_set );
305-
} catch ( Token_Storage_Exception $e ) {
306-
// Next request will re-refresh from the old stored token.
307-
$this->logger->warning( 'Failed to persist refreshed token: {error}', [ 'error' => $e->getMessage() ] );
308-
}
309-
return $new_token_set;
310317
} catch ( Lock_Timeout_Exception $e ) {
311318
// Concurrent refresh in progress, treat as transient failure.
312319
$this->logger->debug( 'Skipping token refresh for user {user_id}: concurrent refresh in progress.', [ 'user_id' => $user_id ] );
@@ -315,7 +322,7 @@ function () use ( $grant ) {
315322
if ( $e->get_error_code() === 'invalid_grant' ) {
316323
if ( $token_set->get_error_count() >= 1 ) {
317324
$this->logger->warning( 'Repeated invalid_grant for user {user_id}, clearing stored tokens.', [ 'user_id' => $user_id ] );
318-
$this->user_token_storage->delete( $user_id );
325+
$this->user_token_storage->delete( $user_id, $indicator );
319326
return null;
320327
}
321328

@@ -329,28 +336,42 @@ function () use ( $grant ) {
329336

330337
return null;
331338
}
339+
try {
340+
$this->user_token_storage->store( $user_id, $new_token_set );
341+
} catch ( Token_Storage_Exception $e ) {
342+
// Next request will re-refresh from the old stored token.
343+
$this->logger->warning( 'Failed to persist refreshed token: {error}', [ 'error' => $e->getMessage() ] );
344+
}
345+
return $new_token_set;
332346
}
333347

334348
/**
335-
* Whether the given user has authorized with MyYoast.
349+
* Whether the given user has authorized with MyYoast for a resource.
336350
*
337-
* @param int $user_id The WordPress user ID.
351+
* @param int $user_id The WordPress user ID.
352+
* @param string|null $resource_indicator The RFC 8707 resource indicator, or null for the default resource.
338353
*
339354
* @return bool
355+
*
356+
* @throws Invalid_Resource_Exception If the resource indicator is malformed.
340357
*/
341-
public function has_user_token( int $user_id ): bool {
342-
return $this->user_token_storage->get( $user_id ) !== null;
358+
public function has_user_token( int $user_id, ?string $resource_indicator = null ): bool {
359+
return $this->user_token_storage->get( $user_id, new Resource_Indicator( $resource_indicator ) ) !== null;
343360
}
344361

345362
/**
346-
* Revokes the user's tokens and clears storage.
363+
* Revokes the user's tokens for a resource and clears storage.
347364
*
348-
* @param int $user_id The WordPress user ID.
365+
* @param int $user_id The WordPress user ID.
366+
* @param string|null $resource_indicator The RFC 8707 resource indicator, or null for the default resource.
349367
*
350368
* @return void
369+
*
370+
* @throws Invalid_Resource_Exception If the resource indicator is malformed.
351371
*/
352-
public function revoke_user_token( int $user_id ): void {
353-
$token_set = $this->user_token_storage->get( $user_id );
372+
public function revoke_user_token( int $user_id, ?string $resource_indicator = null ): void {
373+
$indicator = new Resource_Indicator( $resource_indicator );
374+
$token_set = $this->user_token_storage->get( $user_id, $indicator );
354375
if ( $token_set === null ) {
355376
return;
356377
}
@@ -360,7 +381,25 @@ public function revoke_user_token( int $user_id ): void {
360381
$this->revocation_handler->revoke( $token_set->get_refresh_token(), Token_Type_Hint::REFRESH_TOKEN );
361382
}
362383

363-
$this->user_token_storage->delete( $user_id );
384+
$this->user_token_storage->delete( $user_id, $indicator );
385+
}
386+
387+
/**
388+
* Revokes every user token across all resource buckets ("log out everywhere").
389+
*
390+
* @param int $user_id The WordPress user ID.
391+
*
392+
* @return void
393+
*/
394+
public function revoke_all_user_tokens( int $user_id ): void {
395+
$tokens = $this->user_token_storage->get_all( $user_id );
396+
foreach ( $tokens as $token_set ) {
397+
$this->revocation_handler->revoke( $token_set->get_access_token(), Token_Type_Hint::ACCESS_TOKEN );
398+
if ( $token_set->get_refresh_token() !== null ) {
399+
$this->revocation_handler->revoke( $token_set->get_refresh_token(), Token_Type_Hint::REFRESH_TOKEN );
400+
}
401+
$this->user_token_storage->delete( $user_id, $token_set->get_resource_indicator() );
402+
}
364403
}
365404

366405
/**
@@ -381,12 +420,25 @@ public function revoke_token(
381420
}
382421

383422
/**
384-
* Clears the site-level token.
423+
* Clears the site-level token for a resource bucket.
424+
*
425+
* @param string|null $resource_indicator The RFC 8707 resource indicator, or null for the default resource.
426+
*
427+
* @return void
428+
*
429+
* @throws Invalid_Resource_Exception If the resource indicator is malformed.
430+
*/
431+
public function clear_site_token( ?string $resource_indicator = null ): void {
432+
$this->token_storage->delete( new Resource_Indicator( $resource_indicator ) );
433+
}
434+
435+
/**
436+
* Clears every site-level token across resource buckets.
385437
*
386438
* @return void
387439
*/
388-
public function clear_site_token(): void {
389-
$this->token_storage->delete();
440+
public function clear_all_site_tokens(): void {
441+
$this->token_storage->delete_all();
390442
}
391443

392444
/**

src/myyoast-client/application/oauth-grant-handler.php

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface;
1414
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Discovery_Interface;
1515
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\OAuth_Server_Client_Interface;
16+
use Yoast\WP\SEO\MyYoast_Client\Domain\Resource_Indicator;
1617
use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Set;
1718
use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
1819
use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
@@ -81,15 +82,19 @@ public function __construct(
8182
* Executes a token endpoint request using the provided grant strategy.
8283
*
8384
* Ensures the client is registered, creates a client assertion, merges
84-
* grant-specific parameters, and sends the request.
85+
* grant-specific parameters, and sends the request. The resource indicator
86+
* is added to the body (unless it's the default-resource instance) and
87+
* stamped onto the resulting Token_Set so storage and audit code can
88+
* introspect the audience.
8589
*
86-
* @param Grant_Interface $grant The grant strategy providing grant-specific parameters.
90+
* @param Grant_Interface $grant The grant strategy providing grant-specific parameters.
91+
* @param Resource_Indicator $resource_indicator The resource indicator (RFC 8707) the grant targets. Use Resource_Indicator::default() for the default resource.
8792
*
8893
* @return Token_Set The token set from the response.
8994
*
9095
* @throws Token_Request_Failed_Exception If the token request fails.
9196
*/
92-
public function request_token( Grant_Interface $grant ): Token_Set {
97+
public function request_token( Grant_Interface $grant, Resource_Indicator $resource_indicator ): Token_Set {
9398
$registered_client = $this->client_registration->ensure_registered();
9499

95100
try {
@@ -118,6 +123,12 @@ public function request_token( Grant_Interface $grant ): Token_Set {
118123
$grant->get_grant_params(),
119124
);
120125

126+
// RFC 8707 resource indicator is cross-cutting — independent of grant type.
127+
// The Resource_Indicator value object proves the value is already canonical.
128+
if ( ! $resource_indicator->is_default() ) {
129+
$body['resource'] = $resource_indicator->value();
130+
}
131+
121132
$result = $this->oauth_server_client->request(
122133
'POST',
123134
$token_endpoint,
@@ -156,10 +167,15 @@ public function request_token( Grant_Interface $grant ): Token_Set {
156167
}
157168

158169
try {
159-
return Token_Set::from_response( $body );
170+
$token_set = Token_Set::from_response( $body );
160171
} catch ( InvalidArgumentException $e ) {
161172
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
162173
throw new Token_Request_Failed_Exception( 'invalid_token_response', $e->getMessage(), 0, $e );
163174
}
175+
176+
// Per RFC 8707's trust model (§2, §4), the client is authoritative for the
177+
// canonical resource indicator. We always stamp the requested value on the
178+
// result rather than honouring any echoed `resource` field from the AS.
179+
return $token_set->with_resource_indicator( $resource_indicator );
164180
}
165181
}

0 commit comments

Comments
 (0)