<?php

/**
 * MailHub one-click Webmail login for Roundcube 1.6 and 1.7.
 *
 * @license MIT
 */
class mailhub_sso extends rcube_plugin
{
    private $attempted = false;
    private $ticket;
    private $credential;
    private $audience;

    public static function info()
    {
        return [
            'name'    => 'MailHub Webmail SSO',
            'version' => '1.0.0',
            'license' => 'MIT',
        ];
    }

    public function init()
    {
        $this->load_config();
        $this->add_texts('localization/');
        $this->add_hook('startup', [$this, 'startup']);
        $this->add_hook('authenticate', [$this, 'authenticate']);
        $this->add_hook('login_after', [$this, 'loginAfter']);
        $this->add_hook('login_failed', [$this, 'loginFailed']);
        // logout_after runs after Roundcube has erased the encrypted password.
        // session_destroy covers logout and expiry while it is still available.
        $this->add_hook('session_destroy', [$this, 'revokeSession']);
    }

    public function startup($args)
    {
        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST'
            || !array_key_exists('mailhub_ticket', $_POST)
        ) {
            return $args;
        }

        $this->attempted = true;
        $ticket = rcube_utils::get_input_string('mailhub_ticket', rcube_utils::INPUT_POST);
        if ($this->validTicket($ticket)) {
            $this->ticket = $ticket;
        }

        // Roundcube's normal login POST requires its own CSRF token. The
        // authenticate hook validates and atomically exchanges MailHub's
        // one-time ticket instead, then marks this request as valid.
        $args['task'] = 'login';
        $args['action'] = 'login';

        return $args;
    }

    public function authenticate($args)
    {
        if (!$this->attempted) {
            return $args;
        }

        $audience = $this->configuredAudience();
        $imapHost = $this->configuredImapHost();
        if (!$this->ticket || !$audience || !$imapHost) {
            return $this->authenticationFailure($args);
        }

        $result = $this->request('/internal/webmail-sso/exchange', [
            'ticket'   => $this->ticket,
            'audience' => $audience,
        ], [200]);
        $this->ticket = null;

        if (!$this->validExchange($result)) {
            $issuedCredential = is_array($result) ? ($result['credential'] ?? null) : null;
            if ($this->validCredential($issuedCredential)) {
                $this->revokeCredential($issuedCredential, $audience);
            }
            return $this->authenticationFailure($args);
        }

        $this->credential = $result['credential'];
        $this->audience = $audience;

        // A valid ticket may intentionally switch an existing Roundcube
        // session to another mailbox. Destroy the old session only after the
        // ticket has been accepted, so invalid cross-site POSTs cannot log a
        // user out.
        if (!empty($_SESSION['user_id'])) {
            rcmail::get_instance()->kill_session();
        }

        $args['user'] = $result['username'];
        $args['pass'] = $result['credential'];
        $args['host'] = $imapHost;
        $args['cookiecheck'] = false;
        $args['valid'] = true;
        $args['abort'] = false;
        $args['error'] = null;

        return $args;
    }

    public function loginAfter($args)
    {
        if (!$this->credential || !$this->audience) {
            return $args;
        }

        // Roundcube already stores the IMAP password encrypted in its session.
        // Keep only a marker and the non-secret audience for logout revocation.
        $_SESSION['mailhub_sso_authenticated'] = true;
        $_SESSION['mailhub_sso_audience'] = $this->audience;

        // exec_hook merges returned values with the original query. Clear all
        // inherited keys first so a stale _url cannot override the fixed inbox.
        $redirect = array_fill_keys(array_keys($args), null);
        $redirect['_task'] = 'mail';
        $redirect['_mbox'] = 'INBOX';

        return $redirect;
    }

    public function loginFailed($args)
    {
        if ($this->credential && $this->audience) {
            $this->revokeCredential($this->credential, $this->audience);
            $this->credential = null;
        }

        return $args;
    }

    public function revokeSession($args)
    {
        if (empty($_SESSION['mailhub_sso_authenticated'])) {
            return $args;
        }

        $rcmail = rcmail::get_instance();
        $credential = $rcmail->get_user_password();
        $audience = $_SESSION['mailhub_sso_audience'] ?? null;
        if ($this->validCredential($credential) && $this->validAudience($audience)) {
            $this->revokeCredential($credential, $audience);
        }

        return $args;
    }

    private function authenticationFailure($args)
    {
        $this->ticket = null;
        $args['valid'] = true;
        $args['abort'] = true;
        $args['error'] = 'mailhub_sso.ssofailed';

        return $args;
    }

    private function revokeCredential($credential, $audience)
    {
        $this->request('/internal/webmail-sso/revoke', [
            'credential' => $credential,
            'audience'   => $audience,
        ], [200, 204]);
    }

    private function request($path, $payload, $acceptedStatuses)
    {
        $baseUrl = $this->configuredInternalBaseUrl();
        $secret = $this->readSecret();
        if (!$baseUrl || !$secret || !function_exists('curl_init')) {
            return null;
        }

        $body = json_encode($payload, JSON_UNESCAPED_SLASHES);
        if (!is_string($body)) {
            return null;
        }

        $curl = curl_init($baseUrl . $path);
        if (!$curl) {
            return null;
        }
        $options = [
            CURLOPT_POST            => true,
            CURLOPT_POSTFIELDS      => $body,
            CURLOPT_HTTPHEADER      => [
                'Accept: application/json',
                'Authorization: Bearer ' . $secret,
                'Content-Type: application/json',
            ],
            CURLOPT_RETURNTRANSFER  => true,
            CURLOPT_HEADER          => false,
            CURLOPT_FOLLOWLOCATION  => false,
            CURLOPT_CONNECTTIMEOUT_MS => 1500,
            CURLOPT_TIMEOUT_MS      => 5000,
            CURLOPT_NOSIGNAL        => true,
        ];
        if (defined('CURLOPT_PROTOCOLS')) {
            $options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
        }

        curl_setopt_array($curl, $options);
        $response = curl_exec($curl);
        $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        curl_close($curl);

        if (!is_string($response) || !in_array($status, $acceptedStatuses, true)) {
            return null;
        }

        if ($status === 204 || $response === '') {
            return [];
        }

        $decoded = json_decode($response, true);

        return is_array($decoded) ? $decoded : null;
    }

    private function configuredInternalBaseUrl()
    {
        $value = trim((string) rcmail::get_instance()->config->get('mailhub_sso_internal_base_url', ''));
        $parts = parse_url($value);
        if (!is_array($parts)
            || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
            || empty($parts['host'])
            || isset($parts['user'])
            || isset($parts['pass'])
            || isset($parts['query'])
            || isset($parts['fragment'])
            || !in_array($parts['path'] ?? '', ['', '/'], true)
        ) {
            return null;
        }

        return rtrim($value, '/');
    }

    private function configuredAudience()
    {
        $value = trim((string) rcmail::get_instance()->config->get('mailhub_sso_audience', ''));

        return $this->validAudience($value) ? $value : null;
    }

    private function validAudience($value)
    {
        if (!is_string($value)) {
            return false;
        }

        $parts = parse_url($value);

        return is_array($parts)
            && strtolower($parts['scheme'] ?? '') === 'https'
            && !empty($parts['host'])
            && !isset($parts['user'])
            && !isset($parts['pass'])
            && !isset($parts['query'])
            && !isset($parts['fragment'])
            && !isset($parts['path']);
    }

    private function configuredImapHost()
    {
        $value = trim((string) rcmail::get_instance()->config->get('mailhub_sso_imap_host', ''));

        return preg_match('/\A(?:ssl|tls):\/\/[A-Za-z0-9.-]+(?::\d{1,5})?\z/', $value) ? $value : null;
    }

    private function readSecret()
    {
        $path = (string) rcmail::get_instance()->config->get('mailhub_sso_secret_file', '');
        if ($path === '' || !is_file($path) || !is_readable($path)) {
            return null;
        }

        $secret = trim((string) @file_get_contents($path));

        return preg_match('/\A[0-9a-fA-F]{64,512}\z/', $secret) ? $secret : null;
    }

    private function validTicket($ticket)
    {
        return is_string($ticket)
            && preg_match('/\Amht_[A-Za-z0-9_-]{28,252}\z/', $ticket);
    }

    private function validCredential($credential)
    {
        return is_string($credential)
            && preg_match('/\Amhw_[A-Za-z0-9_-]{28,252}\z/', $credential);
    }

    private function validExchange($result)
    {
        if (!is_array($result)
            || !isset($result['username'], $result['credential'], $result['expiresAt'])
            || !is_string($result['username'])
            || !is_string($result['expiresAt'])
            || strlen($result['username']) < 3
            || strlen($result['username']) > 320
            || preg_match('/[\x00-\x20\x7f]/', $result['username'])
            || !preg_match('/\A[^\s@]+@[^\s@]+\.[^\s@]+\z/u', $result['username'])
            || !$this->validCredential($result['credential'])
        ) {
            return false;
        }

        $expiresAt = strtotime($result['expiresAt']);

        return $expiresAt !== false && $expiresAt > time() - 30;
    }
}
