Skip to content
Closed
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
259 changes: 187 additions & 72 deletions inc/sso/class-sso.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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);
}

/**
Expand All @@ -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;
Expand All @@ -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.
Expand Down
Loading