metadata. * * @throws Registration_Not_Found_Exception If the server reports the registration is gone (HTTP 401/404). * @throws Rate_Limited_Exception If the server rate-limited the request (HTTP 429). * @throws Registration_Failed_Exception If the read fails for any other reason. */ public function read_registration(): array { $registered_client = $this->get_registered_client(); if ( $registered_client === null ) { throw new Registration_Failed_Exception( 'Not registered.' ); } $result = $this->http_client->authenticated_request( 'GET', $registered_client->get_registration_client_uri(), $registered_client->get_registration_access_token(), Auth_Token_Type::BEARER, [ 'timeout' => 10, 'headers' => [ 'Accept' => 'application/json' ], ], ); if ( $result->is_transport_failure() ) { $error_message = (string) $result->get_body_value( 'error_description', '' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. throw new Registration_Failed_Exception( 'Failed to read registration: ' . $error_message ); } if ( $result->get_status() === 401 || $result->get_status() === 404 ) { $this->logger->warning( 'Registration is no longer valid (HTTP {status}), clearing local registration.', [ 'status' => $result->get_status() ] ); $this->forget_registration(); throw new Registration_Not_Found_Exception( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. 'Registration is no longer valid (HTTP ' . $result->get_status() . ').', ); } if ( $result->get_status() === 429 ) { $this->logger->warning( 'Registration read was rate-limited (HTTP 429).' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. throw new Rate_Limited_Exception( 'Registration read was rate-limited (HTTP 429).', $this->get_retry_after_seconds( $result ) ); } if ( ! $result->is_successful() ) { $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) ); throw new Registration_Failed_Exception( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. \sprintf( 'Registration read returned HTTP %d: %s', $result->get_status(), $error_message ), ); } $body = $result->get_body(); if ( ! \is_array( $body ) || empty( $body['client_id'] ) ) { throw new Registration_Failed_Exception( 'Invalid response from registration endpoint.' ); } // The server is authoritative: heal local data that has drifted from it (for example when a // site migration rewrote the stored redirect URIs directly in the database, bypassing the // registration round-trip). The GET body carries no RAT, so store_credentials preserves the // stored one. $this->store_credentials( $body ); return $body; } /** * Rotates the registration key pair by updating the registration with a new JWKS (RFC 7592 PUT). * * @return Registered_Client The updated credentials (with new RAT). * * @throws Registration_Failed_Exception If the rotation fails. */ public function rotate_registration_keys(): Registered_Client { $registered_client = $this->get_registered_client(); if ( $registered_client === null ) { throw new Registration_Failed_Exception( 'Not registered.' ); } // Generate a new key pair in memory — only persist after server confirms. $new_key_pair = $this->key_pair_manager->generate_key_pair(); $new_jwk = $this->key_pair_manager->get_public_key_jwk( $new_key_pair ); // Build the update request body from stored metadata with the new JWKS. // Per RFC 7592 §2.2, server-assigned fields MUST NOT be included. $request_body = $this->build_update_request_body( $registered_client->get_metadata() ); $request_body['jwks'] = [ 'keys' => [ $new_jwk ] ]; $request_body['software_statement'] = $this->issuer_config->get_software_statement(); // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Encoding for HTTP request body, not user-facing output. $json = \wp_json_encode( $request_body ); if ( $json === false ) { throw new Registration_Failed_Exception( 'Failed to JSON-encode registration request body.' ); } $result = $this->http_client->authenticated_request( 'PUT', $registered_client->get_registration_client_uri(), $registered_client->get_registration_access_token(), Auth_Token_Type::BEARER, [ 'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], 'body' => $json, 'timeout' => 15, ], ); if ( ! $result->is_successful() ) { $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) ); throw new Registration_Failed_Exception( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. \sprintf( 'Key rotation returned HTTP %d: %s', $result->get_status(), $error_message ), ); } $body = $result->get_body(); if ( ! \is_array( $body ) || empty( $body['client_id'] ) ) { throw new Registration_Failed_Exception( 'Key rotation returned invalid response.' ); } // Server confirmed — now persist the new key pair locally. $this->key_pair_manager->store_key_pair( Key_Pair_Manager::PURPOSE_REGISTRATION, $new_key_pair ); // Store the new RAT atomically. return $this->store_credentials( $body ); } /** * Updates the registered redirect URIs in place (RFC 7592 PUT). * * Preserves the client_id, registration access token, and key pair. Verification state for * URIs that remain in the set is preserved; URIs no longer present are dropped. * * @param string[] $redirect_uris The new exact set of redirect URIs. * * @return Registered_Client The updated credentials. * * @throws Registration_Not_Found_Exception If the server reports the registration is gone (HTTP 401/404). * @throws Rate_Limited_Exception If the server rate-limited the request (HTTP 429). * @throws Registration_Failed_Exception If the update fails for any other reason. */ private function update_redirect_uris( array $redirect_uris ): Registered_Client { $registered_client = $this->get_registered_client(); if ( $registered_client === null ) { throw new Registration_Failed_Exception( 'Not registered.' ); } // Per RFC 7592 §2.2, server-assigned fields MUST NOT be included; keep the existing key pair. $request_body = $this->build_update_request_body( $registered_client->get_metadata() ); $request_body['redirect_uris'] = \array_values( $redirect_uris ); $request_body['software_statement'] = $this->issuer_config->get_software_statement(); // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Encoding for HTTP request body, not user-facing output. $json = \wp_json_encode( $request_body ); if ( $json === false ) { throw new Registration_Failed_Exception( 'Failed to JSON-encode registration request body.' ); } $result = $this->http_client->authenticated_request( 'PUT', $registered_client->get_registration_client_uri(), $registered_client->get_registration_access_token(), Auth_Token_Type::BEARER, [ 'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], 'body' => $json, 'timeout' => 15, ], ); if ( $result->get_status() === 401 || $result->get_status() === 404 ) { $this->logger->warning( 'Registration is no longer valid on update (HTTP {status}), clearing local registration.', [ 'status' => $result->get_status() ] ); $this->forget_registration(); throw new Registration_Not_Found_Exception( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. 'Registration is no longer valid (HTTP ' . $result->get_status() . ').', ); } if ( $result->get_status() === 429 ) { $this->logger->warning( 'Registration update was rate-limited (HTTP 429).' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. throw new Rate_Limited_Exception( 'Registration update was rate-limited (HTTP 429).', $this->get_retry_after_seconds( $result ) ); } if ( ! $result->is_successful() ) { $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) ); throw new Registration_Failed_Exception( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. \sprintf( 'Redirect URI update returned HTTP %d: %s', $result->get_status(), $error_message ), ); } $body = $result->get_body(); if ( ! \is_array( $body ) || empty( $body['client_id'] ) ) { throw new Registration_Failed_Exception( 'Redirect URI update returned invalid response.' ); } return $this->store_credentials( $body ); } /** * Deletes the client registration from the server (RFC 7592 DELETE) and clears local data. * * @return bool True if deleted or already not registered, false on network failure. */ public function deregister(): bool { $credentials = $this->get_registered_client(); if ( $credentials === null ) { return true; } $result = $this->http_client->authenticated_request( 'DELETE', $credentials->get_registration_client_uri(), $credentials->get_registration_access_token(), Auth_Token_Type::BEARER, [ 'timeout' => 10 ], ); $this->forget_registration(); return ! $result->is_transport_failure(); } /** * Deletes the stored registration credentials. * * @return void */ public function forget_registration(): void { unset( $this->cached_registered_clients[ $this->get_option_key() ] ); \delete_option( $this->get_option_key() ); } /** * Deletes all local registration data (credentials, key pairs, caches). * * @return void */ public function delete_local_data(): void { $this->forget_registration(); $this->key_pair_manager->delete_key_pair( Key_Pair_Manager::PURPOSE_REGISTRATION ); $this->key_pair_manager->delete_key_pair( Key_Pair_Manager::PURPOSE_DPOP ); $this->discovery_client->invalidate_cache(); $suffix = $this->issuer_config->get_issuer_key(); \delete_transient( 'wpseo_myyoast_jwks_' . $suffix ); \delete_transient( 'wpseo_myyoast_dpop_nonce_' . $suffix ); } /** * Rotates the DPoP key pair (local only, no server coordination). * * @return void */ public function rotate_dpop_keys(): void { $this->key_pair_manager->rotate_key_pair( Key_Pair_Manager::PURPOSE_DPOP ); } /** * Whether the given redirect URI has completed the OAuth authorization-code flow on this site. * * The state lives on the stored registration: it is pruned to the current redirect-URI set * whenever those change, and invalidated when the client is deregistered. * * @param string $redirect_uri The redirect URI to check. * * @return bool */ public function is_uri_validated( string $redirect_uri ): bool { $registered_client = $this->get_registered_client(); return $registered_client !== null && $registered_client->is_uri_validated( $redirect_uri ); } /** * Records that the given redirect URI has completed the authorization-code flow. * * No-op when the site is not registered or the URI was already recorded. Idempotent: * `update_option()` short-circuits when the stored value is unchanged. * * @param string $redirect_uri The redirect URI that completed the auth-code flow. * * @return void */ public function mark_uri_validated( string $redirect_uri ): void { $registered_client = $this->get_registered_client(); if ( $registered_client === null ) { return; } $validated_uris = $registered_client->get_validated_uris(); if ( \in_array( $redirect_uri, $validated_uris, true ) ) { return; } $validated_uris[] = $redirect_uri; $option_key = $this->get_option_key(); $stored = \get_option( $option_key, [] ); if ( \is_array( $stored ) ) { $stored['validated_uris'] = $validated_uris; \update_option( $option_key, $stored, false ); } $this->cached_registered_clients[ $option_key ] = $registered_client->with_validated_uris( $validated_uris ); } /** * Stores the DCR response credentials securely. * * A registration read (RFC 7592 GET) response carries no registration access token; when the * body omits the RAT, the existing stored RAT is preserved rather than overwritten with an * empty value. * * @param array> $response_body The parsed DCR response body. * * @return Registered_Client The stored credentials. */ private function store_credentials( array $response_body ): Registered_Client { $option_key = $this->get_option_key(); $existing = $this->get_registered_client(); // The RFC 7592 GET response never re-sends the RAT, so a missing key means "keep the stored // one" — encrypting the absent value would brick every future management call. Only a body // that explicitly carries a RAT (DCR / PUT) replaces it. if ( \array_key_exists( 'registration_access_token', $response_body ) ) { $rat = $response_body['registration_access_token']; $encrypted_rat = $this->encryption->encrypt( $rat, self::ENCRYPTION_CONTEXT ); } else { // Reuse the already-decrypted RAT and its stored ciphertext rather than re-encrypting. $rat = ( $existing !== null ) ? $existing->get_registration_access_token() : ''; $stored = \get_option( $option_key, [] ); $encrypted_rat = ( \is_array( $stored ) ) ? ( $stored['encrypted_rat'] ?? '' ) : ''; } // Strip the RAT from metadata — it is stored encrypted separately. $metadata = $response_body; unset( $metadata['registration_access_token'] ); // Preserve validation state across an in-place update or key rotation (same client_id), but // reset it for a fresh registration: a new client_id means the redirect URIs must be // re-validated from scratch. Always prune to the new redirect-URI set so a removed URI loses // its verification and an added one starts unverified. $validated_uris = []; if ( $existing !== null && $existing->get_client_id() === $response_body['client_id'] ) { $new_redirect_uris = ( $metadata['redirect_uris'] ?? [] ); if ( \is_array( $new_redirect_uris ) ) { $validated_uris = \array_values( \array_intersect( $existing->get_validated_uris(), $new_redirect_uris ) ); } } \update_option( $option_key, [ 'client_id' => $response_body['client_id'], 'encrypted_rat' => $encrypted_rat, 'registration_client_uri' => ( $response_body['registration_client_uri'] ?? '' ), 'metadata' => $metadata, 'validated_uris' => $validated_uris, ], false, ); $this->cached_registered_clients[ $option_key ] = new Registered_Client( $response_body['client_id'], $rat, ( $response_body['registration_client_uri'] ?? '' ), $metadata, $validated_uris, ); return $this->cached_registered_clients[ $option_key ]; } /** * Returns the issuer-scoped option key for storing registration data. * * @return string The option key. */ private function get_option_key(): string { return self::OPTION_KEY_PREFIX . $this->issuer_config->get_issuer_key(); } /** * Extracts the `Retry-After` value (in seconds) from a 429 response, if any. * * @param HTTP_Response $result The 429 response. * * @return int|null Seconds until retry, or null when absent or unparseable. */ private function get_retry_after_seconds( HTTP_Response $result ): ?int { $headers = $result->get_headers(); return Rate_Limited_Exception::parse_retry_after( ( $headers['retry-after'] ?? null ) ); } /** * Performs the actual DCR registration request. * * @param string[] $redirect_uris The OAuth redirect URIs to register. * * @return Registered_Client The registration result. * * @throws Registration_Temporarily_Unavailable_Exception If the server temporarily refuses new registrations (HTTP 503). * @throws Rate_Limited_Exception If the server rate-limited the request (HTTP 429). * @throws Registration_Failed_Exception If registration fails for any other reason. */ private function do_register( array $redirect_uris ): Registered_Client { try { $registration_endpoint = $this->discovery_client->get_document()->get_registration_endpoint(); } catch ( Discovery_Failed_Exception | Server_Capability_Exception $e ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. throw new Registration_Failed_Exception( 'OIDC discovery failed: ' . $e->getMessage(), 0, $e ); } $software_statement = $this->issuer_config->get_software_statement(); $initial_access_token = $this->issuer_config->get_initial_access_token(); if ( $software_statement === '' || $initial_access_token === '' ) { throw new Registration_Failed_Exception( 'Software statement and initial access token must be configured.' ); } // Ensure a registration key pair exists. $key_pair = $this->key_pair_manager->get_or_create_key_pair( Key_Pair_Manager::PURPOSE_REGISTRATION ); $public_jwk = $this->key_pair_manager->get_public_key_jwk( $key_pair ); $request_body = [ 'software_statement' => $software_statement, 'redirect_uris' => $redirect_uris, 'grant_types' => [ 'authorization_code', 'refresh_token', 'client_credentials' ], 'token_endpoint_auth_method' => 'private_key_jwt', 'jwks' => [ 'keys' => [ $public_jwk ] ], 'dpop_bound_access_tokens' => true, ]; // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Encoding for HTTP request body, not user-facing output. $json = \wp_json_encode( $request_body ); if ( $json === false ) { throw new Registration_Failed_Exception( 'Failed to JSON-encode DCR request body.' ); } $result = $this->http_client->request( 'POST', $registration_endpoint, [ 'headers' => [ 'Authorization' => 'Bearer ' . $initial_access_token, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], 'body' => $json, 'timeout' => 15, ], ); if ( $result->is_transport_failure() ) { $error_message = (string) $result->get_body_value( 'error_description', '' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. throw new Registration_Failed_Exception( 'DCR request failed: ' . $error_message ); } // The server temporarily refuses new registrations (rollout brake engaged). // Surface it as a typed transient failure carrying the (display-only) retry hint. if ( $result->get_status() === 503 && $result->get_body_value( 'error' ) === 'temporarily_unavailable' ) { $error_message = (string) $result->get_body_value( 'error_description', 'Client registration is temporarily disabled.' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. throw new Registration_Temporarily_Unavailable_Exception( $error_message, $this->get_retry_after_seconds( $result ) ); } if ( $result->get_status() === 429 ) { $this->logger->warning( 'DCR was rate-limited (HTTP 429).' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. throw new Rate_Limited_Exception( 'DCR was rate-limited (HTTP 429).', $this->get_retry_after_seconds( $result ) ); } if ( $result->get_status() !== 201 ) { $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) ); throw new Registration_Failed_Exception( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. \sprintf( 'DCR returned HTTP %d: %s', $result->get_status(), $error_message ), ); } $body = $result->get_body(); if ( ! \is_array( $body ) || empty( $body['client_id'] ) ) { throw new Registration_Failed_Exception( 'DCR returned invalid response.' ); } return $this->store_credentials( $body ); } /** * Strips server-assigned fields from metadata for a RFC 7592 PUT request. * * Per RFC 7592 §2.2, the update request body MUST NOT include fields * that are assigned by the server (e.g. registration_client_uri, * client_id_issued_at, client_secret, client_secret_expires_at). * The software_statement is also stripped since a fresh one is provided. * * phpcs:disable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint -- OAuth metadata is an associative array with heterogeneous values. * * @param array $metadata The stored client metadata. * * @return array The metadata suitable for a PUT request body. * * phpcs:enable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint */ private function build_update_request_body( array $metadata ): array { unset( $metadata['registration_access_token'], $metadata['registration_client_uri'], $metadata['client_id_issued_at'], $metadata['client_secret'], $metadata['client_secret_expires_at'], $metadata['software_statement'], ); return $metadata; } }