30, 'redirection' => 0, 'limit_response_size' => 65536, 'headers' => array('X-550W-Product' => 'plugin-wordpress', 'X-550W-Product-Version' => FIFTYW_MEDIA_VERSION, 'Content-Type' => $json ? 'application/json' : 'application/x-www-form-urlencoded'), 'body' => $json ? wp_json_encode($body) : $body, )); if (is_wp_error($response) || !in_array(wp_remote_retrieve_response_code($response), array(200, 201), true)) { throw new RuntimeException('OAuth request failed. Reconnect your account; no media was submitted.'); } if ($path === '/oauth2/revoke') { return array(); } $value = json_decode(wp_remote_retrieve_body($response), true); if (!is_array($value) || !empty($value['error'])) { throw new RuntimeException('Invalid OAuth response.'); } return $value; } public static function start() { self::crypt_key(); $callback = admin_url('admin-post.php?action=550w_oauth_callback'); $host = wp_parse_url($callback, PHP_URL_HOST); if (wp_parse_url($callback, PHP_URL_SCHEME) !== 'https' && $host !== '127.0.0.1') { throw new RuntimeException('OAuth requires HTTPS on your WordPress site. Use API Key on HTTP sites.'); } $registration = self::post('/oauth2/register', array( 'client_name' => '550W WordPress ' . FIFTYW_MEDIA_REGION, 'redirect_uris' => array($callback), 'token_endpoint_auth_method' => 'none', 'grant_types' => array('authorization_code', 'refresh_token'), 'response_types' => array('code'), 'scope' => self::SCOPE, 'resource' => self::resource(), )); $client = $registration['client_id'] ?? ''; if (!is_string($client) || !preg_match('/^[A-Za-z0-9._:-]{1,128}$/D', $client)) { throw new RuntimeException('Invalid OAuth client.'); } $state = bin2hex(random_bytes(32)); $verifier = rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '='); set_transient(self::key() . '_state_' . get_current_user_id(), array( 'state' => $state, 'verifier' => $verifier, 'client_id' => $client, 'callback' => $callback, ), 300); $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); return self::BASE . '/oauth2/authorize?' . http_build_query(array( 'client_id' => $client, 'redirect_uri' => $callback, 'response_type' => 'code', 'scope' => self::SCOPE, 'resource' => self::resource(), 'state' => $state, 'ui_locales' => fiftyw_media_site_locale(), 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', ), '', '&', PHP_QUERY_RFC3986); } private static function tokens($result, $client, $previous = null) { if ((array_key_exists('token_type', $result) && (!is_string($result['token_type']) || strtolower($result['token_type']) !== 'bearer')) || (array_key_exists('resource', $result) && $result['resource'] !== self::resource())) { throw new RuntimeException('OAuth token type or resource does not match this connection.'); } $access = $result['access_token'] ?? ''; $refresh = $result['refresh_token'] ?? $previous; $expires = $result['expires_in'] ?? 0; foreach (array($access, $refresh) as $token) { if (!is_string($token) || !$token || strlen($token) > 16384 || preg_match('/[\r\n]/', $token)) { throw new RuntimeException('Invalid OAuth token response.'); } } if (!is_numeric($expires) || $expires <= 0 || $expires > 86400) { throw new RuntimeException('Invalid OAuth expiry.'); } $value = array('region' => FIFTYW_MEDIA_REGION, 'client_id' => $client, 'access_token' => $access, 'refresh_token' => $refresh, 'expires_at' => time() + (int) $expires); self::save($value); return $value; } public static function callback($state, $code) { $key = self::key() . '_state_' . get_current_user_id(); $pending = get_transient($key); if (!is_array($pending) || !is_string($state) || !hash_equals($pending['state'], $state)) { throw new RuntimeException('Authorization expired or state mismatch. Connect again.'); } delete_transient($key); if (!is_string($code) || !$code || strlen($code) > 2048) { throw new RuntimeException('Authorization cancelled.'); } self::tokens(self::post('/oauth2/token', array( 'grant_type' => 'authorization_code', 'client_id' => $pending['client_id'], 'redirect_uri' => $pending['callback'], 'code' => $code, 'code_verifier' => $pending['verifier'], 'resource' => self::resource(), )), $pending['client_id']); } public static function access_token() { $value = self::load(); if (!$value) { throw new RuntimeException('Connect your 550W account first.'); } if ($value['expires_at'] <= time() + 60) { // Atomic option lock prevents concurrent refresh-token rotation. No automatic replay. $lock = self::key() . '_lock_' . get_current_user_id(); $held = (int) get_option($lock, 0); if ($held && $held < time() - 90) { // Compare-and-delete prevents deleting a freshly acquired lock. global $wpdb; // A conditional compare-and-delete cannot be expressed with delete_option; // only our per-user expired lock is touched. Cache invalidation follows. $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $lock, (string) $held)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery wp_cache_delete($lock, 'options'); } if (!add_option($lock, time(), '', false)) { throw new RuntimeException('Another account request is refreshing. Try querying again shortly.'); } try { $value = self::load(); if ($value['expires_at'] <= time() + 60) { $value = self::tokens(self::post('/oauth2/token', array( 'grant_type' => 'refresh_token', 'client_id' => $value['client_id'], 'refresh_token' => $value['refresh_token'], 'resource' => self::resource(), )), $value['client_id'], $value['refresh_token']); } } finally { delete_option($lock); } } return $value['access_token']; } public static function disconnect() { $value = self::load(); if ($value) { self::post('/oauth2/revoke', array('client_id' => $value['client_id'], 'token' => $value['refresh_token'], 'token_type_hint' => 'refresh_token')); } delete_user_meta(get_current_user_id(), self::key()); delete_transient(self::key() . '_state_' . get_current_user_id()); } }