$body */ public static function store(int $userId, array $body): bool { $access = (string) ($body['accessToken'] ?? $body['access_token'] ?? ''); $refresh = (string) ($body['refreshToken'] ?? $body['refresh_token'] ?? ''); $expiresIn = (int) ($body['accessTokenExpiresIn'] ?? $body['expires_in'] ?? 0); if ($userId <= 0 || $access === '' || $refresh === '' || strlen($access) > 512 || strlen($refresh) > 512) { return false; } $sealed = self::seal((string) wp_json_encode([ 'access' => $access, 'expires' => time() + max(0, $expiresIn), 'refresh' => $refresh, ])); if ($sealed === null) { return false; } update_user_meta($userId, self::META, $sealed); return true; } public static function has(int $userId): bool { return self::read($userId) !== null; } public static function forget(int $userId): void { delete_user_meta($userId, self::META); } /** * The member's access token for a call the plugin makes on their behalf, * renewed when it is due (or when `$force`, after Sarcio refused it). * * @return array{access?:string,error?:string,reconnect?:bool} */ public static function accessToken(int $userId, bool $force = false): array { $tokens = self::read($userId); if ($tokens === null) { return ['reconnect' => true]; } if ($force || $tokens['expires'] - self::EARLY_SEC <= time()) { $tokens = self::refresh($userId, $tokens['refresh']); if (isset($tokens['reconnect']) || isset($tokens['error'])) { return $tokens; } } return ['access' => $tokens['access']]; } public static function refreshAll(): void { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- once a day from WP-Cron, to renew each stored sign-in. $users = get_users(['fields' => 'ID', 'meta_key' => self::META]); foreach ($users as $userId) { $tokens = self::read((int) $userId); if ($tokens !== null) { self::refresh((int) $userId, $tokens['refresh']); } } } public function disconnect(): void { if (!current_user_can(ReviewPage::CAPABILITY)) { wp_die(esc_html__('You cannot do that.', 'sarcio'), 403); } check_admin_referer(self::DISCONNECT_ACTION); $userId = get_current_user_id(); $tokens = self::read($userId); self::forget($userId); if ($tokens !== null) { wp_remote_post(Options::apiUrl() . '/api/oauth/revoke', [ 'body' => (string) wp_json_encode(['token' => $tokens['refresh']]), 'headers' => ['Content-Type' => 'application/json'], 'redirection' => 0, 'timeout' => 5, ]); } wp_safe_redirect(admin_url('admin.php?page=' . ReviewPage::SLUG)); exit; } /** @return array{access:string,expires:int,refresh:string}|array{reconnect:bool}|array{error:string} */ private static function refresh(int $userId, string $presented): array { $locked = self::lock($userId); try { wp_cache_delete($userId, 'user_meta'); $current = self::read($userId); if ($current === null) { return ['reconnect' => true]; } if ($current['refresh'] !== $presented && $current['expires'] - self::EARLY_SEC > time()) { return $current; } $response = wp_remote_post(Options::apiUrl() . '/api/oauth/token', [ 'body' => (string) wp_json_encode([ 'client_id' => self::CLIENT_ID, 'grant_type' => 'refresh_token', 'refresh_token' => $current['refresh'], ]), 'headers' => ['Content-Type' => 'application/json'], 'redirection' => 0, 'timeout' => 10, ]); if (is_wp_error($response)) { return ['error' => __('Could not reach Sarcio. Check your connection and try again.', 'sarcio')]; } $status = (int) wp_remote_retrieve_response_code($response); $body = json_decode((string) wp_remote_retrieve_body($response), true); if ($status === 400 || $status === 401) { self::forget($userId); return ['reconnect' => true]; } if (($status !== 200 && $status !== 201) || !is_array($body) || !self::store($userId, $body)) { return ['error' => __('Sarcio could not renew your sign-in right now. Try again in a moment.', 'sarcio')]; } return self::read($userId) ?? ['reconnect' => true]; } finally { if ($locked) { self::unlock($userId); } } } /** @return array{access:string,expires:int,refresh:string}|null */ private static function read(int $userId): ?array { if ($userId <= 0) { return null; } $sealed = get_user_meta($userId, self::META, true); if (!is_string($sealed) || $sealed === '') { return null; } $plain = self::open($sealed); $data = $plain === null ? null : json_decode($plain, true); if (!is_array($data) || !is_string($data['access'] ?? null) || !is_string($data['refresh'] ?? null)) { return null; } return ['access' => $data['access'], 'expires' => (int) ($data['expires'] ?? 0), 'refresh' => $data['refresh']]; } private static function key(): string { return sodium_crypto_generichash(wp_salt('auth') . '|sarcio-account', '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES); } private static function seal(string $plain): ?string { try { $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); return base64_encode($nonce . sodium_crypto_secretbox($plain, $nonce, self::key())); } catch (\Throwable) { return null; } } private static function open(string $sealed): ?string { $raw = base64_decode($sealed, true); if ($raw === false || strlen($raw) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) { return null; } try { $plain = sodium_crypto_secretbox_open( substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), self::key(), ); } catch (\Throwable) { return null; } return $plain === false ? null : $plain; } private static function lock(int $userId): bool { global $wpdb; if (!isset($wpdb) || !is_object($wpdb)) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- a named lock, so two requests never spend one refresh token. return (string) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', self::lockName($userId), self::LOCK_WAIT_SEC)) === '1'; } private static function unlock(int $userId): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- releases the lock taken above. $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', self::lockName($userId))); } private static function lockName(int $userId): string { return 'sarcio_account_' . get_current_blog_id() . '_' . $userId; } }