<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\MyYoast_Client\Infrastructure\Token;
use Exception;
use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Token_Storage_Exception;
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Token_Storage_Interface;
use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Set;
use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption;
use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption_Exception;
use Yoast\WP\SEO\MyYoast_Client\Infrastructure\OIDC\Issuer_Config;
use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
use YoastSEO_Vendor\Psr\Log\NullLogger;
/**
* Stores and retrieves encrypted site-level tokens as a WordPress option.
*
* Used for client_credentials tokens (site-level, no user context).
* The option key is scoped by issuer so that switching issuers
* isolates all stored data.
*/
class Token_Storage implements Token_Storage_Interface, LoggerAwareInterface {
use LoggerAwareTrait;
private const OPTION_KEY_PREFIX = 'wpseo_myyoast_site_tokens_';
private const ENCRYPTION_CONTEXT = 'yoast-myyoast-site-tokens';
/**
* The encryption service.
*
* @var Encryption
*/
private $encryption;
/**
* The issuer configuration.
*
* @var Issuer_Config
*/
private $issuer_config;
/**
* Token_Storage constructor.
*
* @param Encryption $encryption The encryption service.
* @param Issuer_Config $issuer_config The issuer configuration.
*/
public function __construct( Encryption $encryption, Issuer_Config $issuer_config ) {
$this->encryption = $encryption;
$this->issuer_config = $issuer_config;
$this->logger = new NullLogger();
}
/**
* Stores a token set (encrypted).
*
* @param Token_Set $token_set The token set to store.
*
* @return void
*
* @throws Token_Storage_Exception If encryption fails.
*/
public function store( Token_Set $token_set ): void {
try {
// phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Encoding for encrypted storage, not user-facing output.
$json = \wp_json_encode( $token_set->to_array() );
if ( $json === false ) {
throw new Token_Storage_Exception( 'Failed to JSON-encode token set for storage.' );
}
$encrypted = $this->encryption->encrypt( $json, self::ENCRYPTION_CONTEXT );
}
catch ( Encryption_Exception $e ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
throw new Token_Storage_Exception( 'Failed to encrypt token set for storage: ' . $e->getMessage(), 0, $e );
}
\update_option( $this->get_option_key(), $encrypted, false );
}
/**
* Retrieves the stored token set.
*
* @return Token_Set|null The token set, or null if not stored or decryption fails.
*/
public function get(): ?Token_Set {
$stored = \get_option( $this->get_option_key(), '' );
if ( ! \is_string( $stored ) || $stored === '' ) {
return null;
}
try {
$decrypted = $this->encryption->decrypt( $stored, self::ENCRYPTION_CONTEXT );
$data = \json_decode( $decrypted, true, 512, \JSON_THROW_ON_ERROR );
if ( ! \is_array( $data ) || empty( $data['access_token'] ) ) {
return null;
}
return Token_Set::from_array( $data );
}
catch ( Exception $e ) {
$this->logger->error( 'Failed to decrypt stored site token: {error}', [ 'error' => $e->getMessage() ] );
return null;
}
}
/**
* Deletes the stored token set.
*
* @return void
*/
public function delete(): void {
\delete_option( $this->get_option_key() );
}
/**
* Returns the issuer-scoped option key.
*
* @return string The option key.
*/
private function get_option_key(): string {
return self::OPTION_KEY_PREFIX . $this->issuer_config->get_issuer_key();
}
}