From 0875ef8a7886fe91ce75a737798d3d89da246c4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Sat, 3 Oct 2026 13:31:25 +0000 Subject: [PATCH 1/2] Fix WordPress Frames OAuth callback encoding and person grants --- .../includes/class-spacefast-frames-api.php | 20 +++--- packages/wordpress-plugin/tests/unit.php | 68 +++++++++++++++++++ 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/packages/wordpress-plugin/includes/class-spacefast-frames-api.php b/packages/wordpress-plugin/includes/class-spacefast-frames-api.php index 269d820..0e8e4bd 100644 --- a/packages/wordpress-plugin/includes/class-spacefast-frames-api.php +++ b/packages/wordpress-plugin/includes/class-spacefast-frames-api.php @@ -64,7 +64,7 @@ public static function start_oauth(): string 'client_name' => sprintf(__('Spacefast for %s', 'spacefast-frames'), wp_parse_url(home_url('/'), PHP_URL_HOST)), 'client_uri' => 'https://github.com/spacefast/frames', 'redirect_uris' => array($redirect), - 'token_endpoint_auth_method' => 'client_secret_post', + 'token_endpoint_auth_method' => 'none', 'grant_types' => array('authorization_code', 'refresh_token'), 'response_types' => array('code'), 'type' => 'web', @@ -73,7 +73,6 @@ public static function start_oauth(): string ) ); $client_id = self::required_string($registration, 'client_id'); - $client_secret = self::required_string($registration, 'client_secret'); $state = self::random_token(32); $verifier = self::random_token(48); $challenge = self::base64url(hash('sha256', $verifier, true)); @@ -81,13 +80,12 @@ public static function start_oauth(): string 'spacefast_frames_oauth_' . hash('sha256', $state), array( 'client_id' => $client_id, - 'client_secret' => self::seal($client_secret), 'verifier' => self::seal($verifier), 'api_base' => self::api_base(), ), 10 * MINUTE_IN_SECONDS ); - return add_query_arg( + return self::api_base() . '/v1/auth/oauth2/authorize?' . http_build_query( array( 'client_id' => $client_id, 'redirect_uri' => $redirect, @@ -98,7 +96,9 @@ public static function start_oauth(): string 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', ), - self::api_base() . '/v1/auth/oauth2/authorize' + '', + '&', + PHP_QUERY_RFC3986 ); } @@ -112,7 +112,6 @@ public static function finish_oauth(string $code, string $state): void } $api_base = untrailingslashit((string) ($pending['api_base'] ?? '')); $client_id = (string) ($pending['client_id'] ?? ''); - $client_secret = self::unseal((string) ($pending['client_secret'] ?? '')); $verifier = self::unseal((string) ($pending['verifier'] ?? '')); $tokens = self::token_request( $api_base, @@ -121,7 +120,6 @@ public static function finish_oauth(string $code, string $state): void 'code' => $code, 'redirect_uri' => self::redirect_uri(), 'client_id' => $client_id, - 'client_secret' => $client_secret, 'code_verifier' => $verifier, 'resource' => $api_base . '/v1', ) @@ -130,7 +128,6 @@ public static function finish_oauth(string $code, string $state): void array( 'api_base' => $api_base, 'client_id' => $client_id, - 'client_secret' => self::seal($client_secret), ), $tokens ); @@ -187,6 +184,9 @@ public static function request(string $method, string $path, ?array $body = null private static function fresh_connection(): array { $connection = self::connection(); + if (!empty($connection['client_secret'])) { + throw new RuntimeException(__('Reconnect Spacefast to continue.', 'spacefast-frames')); + } $expires = (int) ($connection['expires_at'] ?? 0); if ($expires > 0 && $expires <= time() + 60 && !empty($connection['refresh_token'])) { return self::refresh(false); @@ -201,8 +201,7 @@ private static function refresh(bool $force): array return $connection; } $refresh = self::unseal((string) ($connection['refresh_token'] ?? '')); - $secret = self::unseal((string) ($connection['client_secret'] ?? '')); - if ($refresh === '' || $secret === '') { + if ($refresh === '' || !empty($connection['client_secret'])) { throw new RuntimeException(__('Reconnect Spacefast to continue.', 'spacefast-frames')); } $tokens = self::token_request( @@ -211,7 +210,6 @@ private static function refresh(bool $force): array 'grant_type' => 'refresh_token', 'refresh_token' => $refresh, 'client_id' => (string) ($connection['client_id'] ?? ''), - 'client_secret' => $secret, 'resource' => self::resource(), ) ); diff --git a/packages/wordpress-plugin/tests/unit.php b/packages/wordpress-plugin/tests/unit.php index 005abb0..1e961f5 100644 --- a/packages/wordpress-plugin/tests/unit.php +++ b/packages/wordpress-plugin/tests/unit.php @@ -2,6 +2,25 @@ declare(strict_types=1); define('ABSPATH', __DIR__); +define('MINUTE_IN_SECONDS', 60); +define('SPACEFAST_FRAMES_VERSION', 'test'); +$options = array(); +$transients = array(); +function get_option($name, $default = false) { return $GLOBALS['options'][$name] ?? $default; } +function update_option($name, $value, $autoload = null) { $GLOBALS['options'][$name] = $value; } +function delete_option($name) { unset($GLOBALS['options'][$name]); } +function set_transient($name, $value, $ttl) { $GLOBALS['transients'][$name] = $value; } +function get_transient($name) { return $GLOBALS['transients'][$name] ?? false; } +function delete_transient($name) { unset($GLOBALS['transients'][$name]); } +function untrailingslashit($value) { return rtrim($value, '/'); } +function esc_url_raw($value) { return $value; } +function admin_url($path) { return 'https://wordpress.example/wp-admin/' . $path; } +function is_wp_error($value) { return false; } +function wp_remote_retrieve_response_code($response) { return $response['response']['code']; } +function wp_remote_retrieve_body($response) { return $response['body']; } +function wp_remote_request($url, $args) { return ($GLOBALS['transport'])($url, $args); } +function wp_remote_post($url, $args) { return wp_remote_request($url, $args); } + function __($message, $domain = null) { return $message; } function home_url($path = '') { return 'https://wordpress.example' . $path; } function sanitize_text_field($value) { return trim((string) $value); } @@ -10,6 +29,7 @@ function wp_parse_url($url, $component = -1) { return parse_url($url, $component function wp_salt($scheme = 'auth') { return 'spacefast-wordpress-unit-test-' . $scheme; } require_once dirname(__DIR__) . '/includes/class-spacefast-frames-plugin.php'; +require_once dirname(__DIR__) . '/includes/class-spacefast-frames-api.php'; function call_private(string $method, mixed ...$arguments): mixed { @@ -54,3 +74,51 @@ function same(mixed $expected, mixed $actual, string $message): void $refused = true; } same(true, $refused, 'A modified block grant is refused.'); + + +$challenge = ''; +$GLOBALS['transport'] = function ($url, $args) use (&$challenge) { + if (str_ends_with($url, '/register')) { + $registration = json_decode($args['body'], true); + same('none', $registration['token_endpoint_auth_method'], 'Frames registers a public client for person authority.'); + same(array(admin_url('options-general.php?page=spacefast-frames&spacefast_oauth=callback')), $registration['redirect_uris'], 'The complete callback query is registered.'); + $body = array('client_id' => 'frames-test-client'); + } elseif (str_ends_with($url, '/token')) { + $fields = $args['body']; + same(false, array_key_exists('client_secret', $fields), 'Person grants do not send a client secret.'); + same('frames-test-client', $fields['client_id'], 'The registered client owns the token request.'); + if ($fields['grant_type'] === 'authorization_code') { + same($challenge, rtrim(strtr(base64_encode(hash('sha256', $fields['code_verifier'], true)), '+/', '-_'), '='), 'The exchange proves the authorize request PKCE challenge.'); + same(admin_url('options-general.php?page=spacefast-frames&spacefast_oauth=callback'), $fields['redirect_uri'], 'The exchange preserves the registered callback.'); + $body = array('access_token' => 'initial-test-token', 'refresh_token' => 'test-refresh', 'expires_in' => 900); + } else { + same('test-refresh', $fields['refresh_token'], 'Refresh uses the stored token.'); + $body = array('access_token' => 'refreshed-test-token', 'expires_in' => 900); + } + } else { + same('Bearer refreshed-test-token', $args['headers']['Authorization'], 'API calls use the refreshed person credential.'); + $body = array('data' => array('id' => 'frame-test-session')); + } + return array('response' => array('code' => 200), 'body' => json_encode($body)); +}; + +$authorize = Spacefast_Frames_API::start_oauth(); +parse_str(parse_url($authorize, PHP_URL_QUERY), $parameters); +same(admin_url('options-general.php?page=spacefast-frames&spacefast_oauth=callback'), $parameters['redirect_uri'], 'Authorize keeps the callback query inside redirect_uri.'); +same(false, array_key_exists('spacefast_oauth', $parameters), 'Callback parameters cannot escape into authorize parameters.'); +$challenge = $parameters['code_challenge']; +Spacefast_Frames_API::finish_oauth('test-code', $parameters['state']); +same(false, array_key_exists('client_secret', Spacefast_Frames_API::connection()), 'The connection stores no confidential client authority.'); +$options['spacefast_frames_connection']['expires_at'] = time() - 1; +same(array('data' => array('id' => 'frame-test-session')), Spacefast_Frames_API::request('POST', '/v1/spaces/spc_test/frame-session', array('path' => '/')), 'An expired person credential refreshes before launching a Frame.'); + +$options['spacefast_frames_connection']['client_secret'] = 'old-confidential-connection'; +$refused = false; +try { + Spacefast_Frames_API::request('GET', '/v1/spaces'); +} catch (RuntimeException $error) { + same('Reconnect Spacefast to continue.', $error->getMessage(), 'An old app credential asks the administrator to reconnect.'); + $refused = true; +} +same(true, $refused, 'Existing confidential connections cannot launch Frames with app authority.'); +echo "WordPress unit tests passed.\n"; From 42edb0186748802954198fb03ca4bd6bc6d5f07c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Sat, 3 Oct 2026 22:32:50 +0000 Subject: [PATCH 2/2] Offer reconnection for legacy app credentials --- .../wordpress-plugin/includes/class-spacefast-frames-api.php | 2 +- packages/wordpress-plugin/tests/unit.php | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/wordpress-plugin/includes/class-spacefast-frames-api.php b/packages/wordpress-plugin/includes/class-spacefast-frames-api.php index 0e8e4bd..cfc7365 100644 --- a/packages/wordpress-plugin/includes/class-spacefast-frames-api.php +++ b/packages/wordpress-plugin/includes/class-spacefast-frames-api.php @@ -32,7 +32,7 @@ public static function resource(): string public static function connected(): bool { $connection = self::connection(); - return !empty($connection['client_id']) && !empty($connection['access_token']); + return empty($connection['client_secret']) && !empty($connection['client_id']) && !empty($connection['access_token']); } public static function save_api_base(string $value): void diff --git a/packages/wordpress-plugin/tests/unit.php b/packages/wordpress-plugin/tests/unit.php index 1e961f5..78de63c 100644 --- a/packages/wordpress-plugin/tests/unit.php +++ b/packages/wordpress-plugin/tests/unit.php @@ -109,10 +109,12 @@ function same(mixed $expected, mixed $actual, string $message): void $challenge = $parameters['code_challenge']; Spacefast_Frames_API::finish_oauth('test-code', $parameters['state']); same(false, array_key_exists('client_secret', Spacefast_Frames_API::connection()), 'The connection stores no confidential client authority.'); +same(true, Spacefast_Frames_API::connected(), 'A person credential shows as connected.'); $options['spacefast_frames_connection']['expires_at'] = time() - 1; same(array('data' => array('id' => 'frame-test-session')), Spacefast_Frames_API::request('POST', '/v1/spaces/spc_test/frame-session', array('path' => '/')), 'An expired person credential refreshes before launching a Frame.'); $options['spacefast_frames_connection']['client_secret'] = 'old-confidential-connection'; +same(false, Spacefast_Frames_API::connected(), 'Legacy app credentials offer the Connect action again.'); $refused = false; try { Spacefast_Frames_API::request('GET', '/v1/spaces');