From 893a22ee4c545aacd9131480ac0299fbfc481f3c Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 4 May 2026 15:05:37 -0600 Subject: [PATCH] fix: harden cookie-less sso token redirects --- inc/sso/class-sso.php | 259 ++++++++++++++++++++++++++++++------------ 1 file changed, 187 insertions(+), 72 deletions(-) diff --git a/inc/sso/class-sso.php b/inc/sso/class-sso.php index 945ea803e..07aa5fd37 100644 --- a/inc/sso/class-sso.php +++ b/inc/sso/class-sso.php @@ -261,6 +261,8 @@ public function startup(): void { */ add_filter('determine_current_user', [$this, 'determine_current_user'], 90); + add_action('init', [$this, 'handle_cookie_less_sso_token'], 4); + add_action('init', [$this, 'convert_bearer_into_auth_cookies']); add_filter('removable_query_args', [$this, 'add_sso_removable_query_args']); @@ -274,6 +276,9 @@ public function startup(): void { // If user is already logged in and visiting login page with SSO params, redirect to subsite. add_action('login_init', [$this, 'handle_already_logged_in_on_login_page']); + // Custom login pages (e.g. /login/) do not fire login_init. + add_action('template_redirect', [$this, 'handle_already_logged_in_on_login_page'], 1); + /** * Adds the SSO scripts to the head of the front-end * and the login page to try to perform a SSO flow. @@ -512,7 +517,7 @@ public function handle_server($response_type = 'redirect'): void { */ private function handle_main_site_logged_in_user($response_type): void { - $return_url = $this->input('return_url', get_home_url()); + $return_url = $this->get_sso_return_url(get_home_url()); $redirect_to = $this->input('redirect_to', admin_url()); // If user is logged in, redirect back to the subsite with verification. @@ -535,11 +540,8 @@ private function handle_main_site_logged_in_user($response_type): void { exit; } - // Redirect back to the subsite with verification code. - $url = add_query_arg([ - 'sso_verify' => $verification_code, - 'redirect_to' => $redirect_to, - ], $return_url); + $url = $this->add_cookie_less_sso_token($return_url, get_current_user_id()); + $url = add_query_arg('redirect_to', $redirect_to, $url); wp_safe_redirect($url, 302, 'WP-Ultimo-SSO'); exit; @@ -562,72 +564,206 @@ public function handle_login_redirect($redirect_to, $requested_redirect_to, $use return $redirect_to; } - // Check if this is part of an SSO flow (we have return_url or already came from subsite). - $return_url = $this->input('return_url', ''); + $return_url = $this->get_sso_return_url($redirect_to); + + if ( ! empty($return_url) ) { + return $this->add_cookie_less_sso_token($return_url, $user->ID); + } + + // Default: send to the subsite dashboard or main site admin. + return $redirect_to; + } + + /** + * Generate a time-limited SSO token for cookie-less authentication. + * + * @since 2.0.11 + * + * @param int $user_id User ID. + * @param string $audience Token audience URL. + * @return string The token. + */ + private function generate_sso_token(int $user_id, string $audience): string { + $audience_host = strtolower((string) wp_parse_url($audience, PHP_URL_HOST)); + + // Token expires in 5 minutes. + $expiry = time() + 300; + $jti = wp_generate_uuid4(); + + $payload = wp_json_encode([ + 'user_id' => $user_id, + 'exp' => $expiry, + 'aud' => $audience_host, + 'jti' => $jti, + ]); + + set_site_transient('wu_sso_magic_' . $jti, 1, 300); + + // HMAC-signed token. + $hmac = hash_hmac('sha256', $payload, wp_salt('auth')); + + return rtrim(strtr(base64_encode($hmac . '::' . $payload), '+/', '-_'), '='); + } + + /** + * Add a cookie-less SSO token to a cross-domain URL. + * + * @since 2.0.11 + * + * @param string $url URL to decorate. + * @param int $user_id User ID. + * @return string + */ + private function add_cookie_less_sso_token(string $url, int $user_id): string { + if ( ! $this->is_cross_domain_url($url) || $user_id <= 0 ) { + return $url; + } + + return add_query_arg('wu_sso_token', $this->generate_sso_token($user_id, $url), $url); + } + + /** + * Return the SSO return URL from request params or a direct redirect_to URL. + * + * @since 2.0.11 + * + * @param string $fallback Fallback URL. + * @return string + */ + private function get_sso_return_url(string $fallback = ''): string { + $return_url = $this->input('return_url', ''); + $redirect_to = $this->input('redirect_to', $fallback); + + if ( ! empty($redirect_to) && $this->is_cross_domain_url($redirect_to) ) { + return esc_url_raw($redirect_to); + } - // Also extract return_url from redirect_to if it's encoded as a query param if ( empty($return_url) && ! empty($redirect_to) ) { $parsed = wp_parse_url($redirect_to, PHP_URL_QUERY); + if ( $parsed ) { parse_str($parsed, $query_params); + if ( ! empty($query_params['return_url']) ) { $return_url = $query_params['return_url']; } } } - if ( ! empty($return_url) ) { - // Check if redirecting to a different domain (needs magic token for cookie-less auth) - $return_host = wp_parse_url($return_url, PHP_URL_HOST); - $main_host = wp_parse_url(get_site_url(), PHP_URL_HOST); - - if ( $return_host && $return_host !== $main_host ) { - // Generate a time-limited magic token for cookie-less authentication - $token = $this->generate_sso_token($user->ID); - $return_url = add_query_arg('wu_sso_token', $token, $return_url); - } + return esc_url_raw($return_url ?: $fallback); + } + + /** + * Validate and consume a cookie-less SSO token on the target site. + * + * @since 2.0.11 + * @return void + */ + public function handle_cookie_less_sso_token(): void { + $token = $this->input('wu_sso_token', ''); - // Get the subsite URL and redirect there. - return $return_url; + if ( empty($token) ) { + return; } - // If redirect_to points to a subsite (different domain), use that. - if ( ! empty($redirect_to) && wu_is_same_domain() === false ) { - // Generate magic token for cross-domain redirect - $redirect_host = wp_parse_url($redirect_to, PHP_URL_HOST); - $main_host = wp_parse_url(get_site_url(), PHP_URL_HOST); + $result = $this->validate_sso_token($token); - if ( $redirect_host && $redirect_host !== $main_host ) { - $token = $this->generate_sso_token($user->ID); - $redirect_to = add_query_arg('wu_sso_token', $token, $redirect_to); - } + if ( is_wp_error($result) ) { + wu_log_add(self::LOG_FILE_NAME, $result->get_error_message(), LogLevel::ERROR); + return; + } - return $redirect_to; + $user_id = (int) $result['user_id']; + + wp_set_auth_cookie($user_id, true); + wp_set_current_user($user_id); + + $redirect_to = $this->input('redirect_to', admin_url()); + $redirect_to = remove_query_arg('wu_sso_token', $redirect_to); + + wp_safe_redirect(wp_validate_redirect($redirect_to, admin_url()), 302, 'WP-Ultimo-SSO'); + exit; + } + + /** + * Validate a cookie-less SSO token. + * + * @since 2.0.11 + * + * @param string $token Token to validate. + * @return array|\WP_Error + */ + private function validate_sso_token(string $token) { + $token = strtr($token, '-_', '+/'); + $padding = strlen($token) % 4; + + if ( $padding ) { + $token .= str_repeat('=', 4 - $padding); } - // Default: send to the subsite dashboard or main site admin. - return $redirect_to; + $decoded = base64_decode($token, true); + + if ( ! $decoded || false === strpos($decoded, '::') ) { + return new \WP_Error('invalid_token', __('Invalid SSO token format.', 'ultimate-multisite')); + } + + [$expected_hmac, $payload_json] = explode('::', $decoded, 2); + $hmac = hash_hmac('sha256', $payload_json, wp_salt('auth')); + + if ( ! hash_equals($hmac, $expected_hmac) ) { + return new \WP_Error('invalid_signature', __('Invalid SSO token signature.', 'ultimate-multisite')); + } + + $payload = json_decode($payload_json, true); + + if ( json_last_error() !== JSON_ERROR_NONE ) { + return new \WP_Error('invalid_payload', __('Invalid SSO token payload.', 'ultimate-multisite')); + } + + if ( empty($payload['exp']) || $payload['exp'] < time() ) { + return new \WP_Error('token_expired', __('SSO token has expired.', 'ultimate-multisite')); + } + + $current_host = strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST)); + $audience = strtolower((string) ($payload['aud'] ?? '')); + + if ( empty($audience) || $audience !== $current_host ) { + return new \WP_Error('invalid_audience', __('Invalid SSO token audience.', 'ultimate-multisite')); + } + + $jti = (string) ($payload['jti'] ?? ''); + + if ( empty($jti) || ! get_site_transient('wu_sso_magic_' . $jti) ) { + return new \WP_Error('invalid_token', __('SSO token has already been used or is invalid.', 'ultimate-multisite')); + } + + delete_site_transient('wu_sso_magic_' . $jti); + + $user = get_user_by('id', (int) ($payload['user_id'] ?? 0)); + + if ( ! $user ) { + return new \WP_Error('user_not_found', __('User not found.', 'ultimate-multisite')); + } + + return [ + 'user_id' => $user->ID, + ]; } /** - * Generate a time-limited SSO token for cookie-less authentication. + * Check if a URL points to a different host from the main site. * * @since 2.0.11 * - * @param int $user_id User ID. - * @return string The token. + * @param string $url URL to check. + * @return bool */ - private function generate_sso_token(int $user_id): string { - // Token expires in 5 minutes - $expiry = time() + 300; - $payload = wp_json_encode([ - 'user_id' => $user_id, - 'exp' => $expiry, - ]); - // HMAC-signed token - $hmac = hash_hmac('sha256', $payload, wp_salt('auth')); + private function is_cross_domain_url(string $url): bool { + $host = strtolower((string) wp_parse_url($url, PHP_URL_HOST)); + $main_host = strtolower((string) wp_parse_url(get_site_url(), PHP_URL_HOST)); + $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME)); - return base64_encode($hmac . '::' . $payload); + return ! empty($host) && ! empty($main_host) && $host !== $main_host && in_array($scheme, ['http', 'https'], true); } /** @@ -647,40 +783,19 @@ public function handle_already_logged_in_on_login_page(): void { // Check if this is an SSO flow (sso param or return_url param present) $sso_action = $this->input('sso', ''); - $return_url = $this->input('return_url', ''); - - // Also extract return_url from redirect_to if present - if ( empty($return_url) ) { - $redirect_to = $this->input('redirect_to', ''); - if ( $redirect_to ) { - $parsed = wp_parse_url($redirect_to, PHP_URL_QUERY); - if ( $parsed ) { - parse_str($parsed, $query_params); - if ( ! empty($query_params['return_url']) ) { - $return_url = $query_params['return_url']; - } - } - } - } + $return_url = $this->get_sso_return_url(); // Check for SSO flow - either sso param or return_url pointing to different domain if ( empty($sso_action) && empty($return_url) ) { return; } - // Get the subsite URL - $return_host = wp_parse_url($return_url, PHP_URL_HOST); - $main_host = wp_parse_url(get_site_url(), PHP_URL_HOST); - - // Only redirect if actually going to a different domain - if ( ! $return_host || $return_host === $main_host ) { + if ( ! $this->is_cross_domain_url($return_url) ) { return; } - // Generate token and redirect to subsite - $token = $this->generate_sso_token(get_current_user_id()); - - $redirect_url = add_query_arg('wu_sso_token', $token, $return_url); + // Generate token and redirect to subsite. + $redirect_url = $this->add_cookie_less_sso_token($return_url, get_current_user_id()); wp_safe_redirect($redirect_url, 302, 'WP-Ultimo-SSO'); exit; @@ -697,7 +812,7 @@ public function handle_already_logged_in_on_login_page(): void { public function modify_login_form_defaults($defaults): array { // Check if this is part of an SSO flow. - $return_url = $this->input('return_url', ''); + $return_url = $this->get_sso_return_url(); if ( ! empty($return_url) ) { // Set redirect_to to the subsite URL.