Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions packages/wordpress-plugin/includes/class-spacefast-frames-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -73,21 +73,19 @@ 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));
set_transient(
'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,
Expand All @@ -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
);
}

Expand All @@ -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,
Expand All @@ -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',
)
Expand All @@ -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
);
Expand Down Expand Up @@ -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'])) {
Comment thread
batuhan marked this conversation as resolved.
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);
Expand All @@ -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(
Expand All @@ -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(),
)
);
Expand Down
70 changes: 70 additions & 0 deletions packages/wordpress-plugin/tests/unit.php
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand All @@ -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
{
Expand Down Expand Up @@ -54,3 +74,53 @@ 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.');
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');
} 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";
Loading