<?php

/*
 +-----------------------------------------------------------------------+
 | This file is part of the Roundcube Webmail client                     |
 |                                                                       |
 | Copyright (C) The Roundcube Dev Team                                  |
 |                                                                       |
 | Licensed under the GNU General Public License version 3 or            |
 | any later version with exceptions for skins & plugins.                |
 | See the README file for a full license statement.                     |
 |                                                                       |
 | CONTENTS:                                                             |
 |   Roundcube OAuth2 utilities                                          |
 |   @see https://datatracker.ietf.org/doc/html/rfc6749                  |
 |   please note that it implements Oauth2 and OpenID Connect extension  |
 +-----------------------------------------------------------------------+
 | Author: Thomas Bruederli <roundcube@gmail.com>                        |
 +-----------------------------------------------------------------------+
*/

use GuzzleHttp\Client as HttpClient;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\MessageFormatter;

/**
 * Roundcube OAuth2 utilities
 */
class rcmail_oauth
{
    public const TOKEN_REFRESHED = 1;
    public const TOKEN_STILL_VALID = 0;
    public const TOKEN_REFRESH_FAILED = -1;
    public const TOKEN_NOT_FOUND = -2;
    public const TOKEN_ERROR = -3;
    public const TOKEN_REFRESH_EXPIRED = -4;
    public const TOKEN_REVOKED = -5;
    public const TOKEN_COMPROMISED = -6;

    public const JWKS_CACHE_TTL = 30; // TTL for JWKS (in seconds)

    /** @var rcmail */
    protected $rcmail;

    /** @var array */
    protected $options = [];

    /** @var array */
    protected $jwks;

    /** @var ?string */
    protected $last_error;

    /** @var bool */
    protected $no_redirect = false;

    /** @var ?rcube_cache */
    protected $cache;

    /** @var HttpClient */
    protected $http_client;

    /** @var string */
    protected $logout_redirect_url;

    /** @var ?array parameters used during the login phase */
    protected $login_phase;

    /** @var array list of allowed keys in user_create_map (note that user and host are protected) */
    protected static $user_create_allowed_keys = ['user_name', 'user_email', 'language'];

    /** @var array map of .well-known entries to config (discovery URI) */
    protected static $config_mapper = [
        'issuer' => 'issuer',
        'authorization_endpoint' => 'auth_uri',
        'token_endpoint' => 'token_uri',
        'userinfo_endpoint' => 'identity_uri',
        'end_session_endpoint' => 'logout_uri',
        'jwks_uri' => 'jwks_uri',
    ];

    /** @var array map PKCE code_challenge_method to hash method */
    protected static $pkce_mapper = [
        'S256' => 'sha256',
        // plain method is not implemented: @see RFC7636 4.2: "If the client is capable of using "S256", it MUST use "S256"
    ];

    /** @var ?rcmail_oauth */
    protected static $instance;

    /**
     * Singleton factory
     *
     * @return rcmail_oauth The one and only instance
     */
    public static function get_instance($options = [])
    {
        if (!self::$instance) {
            self::$instance = new self($options);
            self::$instance->init();
        }

        return self::$instance;
    }

    /**
     * Helper to log oauth
     */
    private function logger($level, $message): void
    {
        $token = $this->login_phase['token'] ?? $_SESSION['oauth_token'] ?? [];
        $sub = $token['identity']['sub'] ?? '-';
        $ses = $token['session_state'] ?? '-';
        rcube::write_log('oauth', sprintf('%s: [ip=%s sub=%s ses=%s] %s', $level, rcube_utils::remote_ip(), $sub, $ses, $message));
    }

    /**
     * Helper to log oauth debug message (only if `oauth_debug`is true)
     *
     * XXX for debug only, please use rcube::raise_error to raise errors in a centralized place
     */
    public function log_debug(...$args): void
    {
        if ($this->options['debug']) {
            $this->logger('DEBUG', sprintf(...$args));
        }
    }

    /**
     * Object constructor
     *
     * @param array $options Config options:
     */
    public function __construct($options = [])
    {
        $this->rcmail = rcmail::get_instance();

        // use `oauth_cache` to define engine & `oauth_cache_ttl` to define ttl (default 1d))
        $this->cache = $this->rcmail->get_cache_shared('oauth');

        $this->options = (array) $options + [
            'provider' => $this->rcmail->config->get('oauth_provider'),
            'provider_name' => $this->rcmail->config->get('oauth_provider_name', 'OAuth'),
            'auth_uri' => $this->rcmail->config->get('oauth_auth_uri'),
            'config_uri' => $this->rcmail->config->get('oauth_config_uri'),
            'issuer' => $this->rcmail->config->get('oauth_issuer'),
            'logout_uri' => $this->rcmail->config->get('oauth_logout_uri'),
            'token_uri' => $this->rcmail->config->get('oauth_token_uri'),
            'jwks_uri' => $this->rcmail->config->get('oauth_jwks_uri'),
            'client_id' => $this->rcmail->config->get('oauth_client_id'),
            'client_secret' => $this->rcmail->config->get('oauth_client_secret'),
            'identity_uri' => $this->rcmail->config->get('oauth_identity_uri'),
            'identity_fields' => $this->rcmail->config->get('oauth_identity_fields', ['email']),
            'user_create_map' => $this->rcmail->config->get('oauth_user_create_map', [
                // rc key => OIDC Claim @see: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims )
                'user_name' => ['name'],
                'user_email' => ['email'],
                'language' => ['locale'],
            ]),
            'scope' => $this->rcmail->config->get('oauth_scope', ''),
            'timeout' => $this->rcmail->config->get('oauth_timeout', 10),
            'verify_peer' => $this->rcmail->config->get('oauth_verify_peer', true),
            'auth_parameters' => $this->rcmail->config->get('oauth_auth_parameters', []),
            'login_redirect' => $this->rcmail->config->get('oauth_login_redirect', false),
            'pkce' => $this->rcmail->config->get('oauth_pkce', 'S256'),
            'password_claim' => $this->rcmail->config->get('oauth_password_claim'),
            'debug' => $this->rcmail->config->get('oauth_debug', false),
            // One of: XOAUTH2, OAUTHBEARER, OAUTH
            'auth_type' => $this->rcmail->config->get('oauth_auth_type') ?: 'OAUTH',
        ];

        // http_options will be used in test phase to add a mock
        if (!isset($options['http_options'])) {
            $options['http_options'] = [];
        }

        // sanity check on PKCE value
        if ($this->options['pkce'] && !array_key_exists($this->options['pkce'], self::$pkce_mapper)) {
            // will stops on error
            rcube::raise_error("PKCE method not supported (oauth_pkce='{$this->options['pkce']}')", true, true);
        }

        // sanity check that configuration user_create_map contains only allowed keys
        foreach ($this->options['user_create_map'] as $key => $ignored) {
            if (!in_array($key, self::$user_create_allowed_keys)) {
                // will stops on error
                rcube::raise_error("Use of key `{$key}` in `oauth_user_create_map` is not allowed", true, true);
            }
        }

        // prepare a http client with the correct options
        $this->http_client = $this->rcmail->get_http_client((array) $options['http_options'] + [
            'timeout' => $this->options['timeout'],
            'verify' => $this->options['verify_peer'],
        ]);
    }

    /**
     * discover .well-known/oidc-configuration according config_uri and complete options
     *
     * use cache if defined
     *
     * @see https://datatracker.ietf.org/doc/html/rfc8414
     */
    protected function discover(): void
    {
        $config_uri = $this->options['config_uri'];
        if (empty($config_uri)) {
            return;
        }

        $key_cache = 'discovery.' . md5($config_uri);

        try {
            $data = $this->cache ? $this->cache->get($key_cache) : null;

            if ($data === null) {
                // Caveat: if .well-known URL is not answering it will break login display (will not display the button)
                $response = $this->http_client->get($config_uri);
                $data = json_decode($response->getBody(), true);

                $this->log_debug('fetched OIDC config: %s', json_encode($data));

                // sanity check
                if (!isset($data['issuer'])) {
                    throw new \RuntimeException('incorrect response from %s', $config_uri);
                }

                // cache answer
                if ($this->cache) {
                    $this->cache->set($key_cache, $data);
                }
            }

            // map discovery to our options
            foreach (self::$config_mapper as $config_key => $options_key) {
                if (!empty($data[$config_key])) {
                    $this->options[$options_key] = $data[$config_key];
                }
            }

            // check if pkce method is supported by this server
            if ($this->options['pkce'] && isset($data['code_challenge_methods_supported']) && is_array($data['code_challenge_methods_supported'])) {
                if (!in_array($this->options['pkce'], $data['code_challenge_methods_supported'])) {
                    rcube::raise_error("OAuth server does not support this PKCE method (oauth_pkce='{$this->options['pkce']}')", true);
                }
            }
        } catch (\Exception $e) {
            rcube::raise_error("Error fetching {$config_uri} : {$e->getMessage()}", true);
        }
    }

    /**
     * Fetch JWKS certificates (use cache if active)
     */
    protected function fetch_jwks(): void
    {
        if (!$this->options['jwks_uri']) {
            // not activated
            return;
        }

        if ($this->jwks !== null) {
            // already defined
            return;
        }

        $jwks_uri = $this->options['jwks_uri'];
        $key_cache = 'jwks.' . md5($jwks_uri);
        $this->jwks = $this->cache ? $this->cache->get($key_cache) : null;

        if ($this->jwks !== null && $this->jwks['expires'] > time()) {
            return;
        }

        // not in cache, fetch json web key set
        $response = $this->http_client->get($jwks_uri);
        $this->jwks = json_decode($response->getBody(), true);

        $this->log_debug('fetched jwks: %s', json_encode($this->jwks));

        // sanity check
        if (!isset($this->jwks['keys'])) {
            $this->log_debug('incorrect jwks response from %s', $jwks_uri);
        } elseif ($this->cache) {
            // this is a hack because we cannot specify the TTL in the shared_cache
            // and cache must not be too high as the Identity Provider can rotate it's keys
            $this->jwks['expires'] = time() + self::JWKS_CACHE_TTL;
            $this->cache->set($key_cache, $this->jwks);
        }
    }

    /**
     * Initialize this instance
     */
    public function init(): void
    {
        // important must be called before is_enabled()
        $this->discover();

        if (!$this->is_enabled()) {
            return;
        }

        if ($this->cache === null) {
            $this->log_debug('cache is disabled');
        }

        // subscribe to storage and smtp init events
        $this->rcmail->plugins->register_hook('loginform_content', [$this, 'loginform_content']);
        $this->rcmail->plugins->register_hook('startup', [$this, 'startup']);

        $this->rcmail->plugins->register_hook('storage_init', [$this, 'storage_init']);
        $this->rcmail->plugins->register_hook('smtp_connect', [$this, 'smtp_connect']);
        $this->rcmail->plugins->register_hook('managesieve_connect', [$this, 'managesieve_connect']);

        $this->rcmail->plugins->register_hook('authenticate', [$this, 'authenticate']);
        $this->rcmail->plugins->register_hook('login_after', [$this, 'login_after']);
        $this->rcmail->plugins->register_hook('login_failed', [$this, 'login_failed']);
        $this->rcmail->plugins->register_hook('user_create', [$this, 'user_create']);
        $this->rcmail->plugins->register_hook('logout_after', [$this, 'logout_after']);
        $this->rcmail->plugins->register_hook('unauthenticated', [$this, 'unauthenticated']);

        $this->rcmail->plugins->register_hook('refresh', [$this, 'refresh']);
        $this->rcmail->plugins->register_hook('keep-alive', [$this, 'refresh']);
    }

    /**
     * Check if OAuth is generally enabled in config
     *
     * @return bool
     */
    public function is_enabled()
    {
        return !empty($this->options['provider'])
            && !empty($this->options['token_uri'])
            && !empty($this->options['client_id']);
    }

    /**
     * Check if current OAUTH token is valid, attempt to refresh if possible/needed.
     *
     * Method intended for plugins that need to make sure the OAUTH "session" is valid.
     *
     * @return bool
     */
    public function is_token_valid()
    {
        $state = isset($_SESSION['oauth_token']) ? $this->check_token_validity($_SESSION['oauth_token']) : self::TOKEN_ERROR;

        return $state == self::TOKEN_REFRESHED || $state == self::TOKEN_STILL_VALID;
    }

    /**
     * Getter for the last error occurred
     *
     * @return mixed
     */
    public function get_last_error()
    {
        return $this->last_error;
    }

    /**
     * Getter for OAuth options
     *
     * @return array OAuth configuration options
     */
    public function get_options()
    {
        return $this->options;
    }

    /**
     * Callback for `loginform_content` hook
     *
     * Append Oauth button on login page if defined (this is a hook)
     * can also hide default user/pass form if flag oauth_login_redirect is true
     */
    public function loginform_content(array $form_content)
    {
        // hide login form fields when `oauth_login_redirect` is configured
        if ($this->options['login_redirect']) {
            $form_content['hidden'] = [];
            $form_content['inputs'] = [];
            $form_content['buttons'] = [];
        }

        $link_attr = [
            'href' => $this->rcmail->url(['action' => 'oauth']),
            'id' => 'rcmloginoauth',
            'class' => 'button oauth ' . $this->options['provider'],
        ];

        $provider = $this->options['provider_name'];
        $button = html::a($link_attr, $this->rcmail->gettext(['name' => 'oauthlogin', 'vars' => ['provider' => $provider]]));

        $form_content['buttons']['oauthlogin'] = ['outterclass' => 'oauthlogin', 'content' => $button];

        return $form_content;
    }

    // TODO: move it into an helper class
    protected static function base64url_decode($encoded)
    {
        return base64_decode(strtr($encoded, '-_', '+/'), true);
    }

    protected static function base64url_encode($payload)
    {
        return rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
    }

    /**
     * Helper method to decode a JWT and check payload OIDC consistency
     *
     * @param string $jwt
     *
     * @return array Hash array with decoded body
     */
    public function jwt_decode($jwt)
    {
        [$headb64, $bodyb64, $cryptob64] = explode('.', $jwt);

        $header = json_decode(static::base64url_decode($headb64), true);
        $body = json_decode(static::base64url_decode($bodyb64), true);
        // $crypto = static::base64url_decode($cryptob64);

        // jwks_uri defined, will check JWT signature
        if ($this->options['jwks_uri']) {
            $this->fetch_jwks();
            $jwk = null;

            // FIXME: As far as I understand JWT tokens may not include 'kid' claim (it's optional)
            if (!isset($header['kid']) && count($this->jwks['keys']) == 1) {
                $jwk = $this->jwks['keys'][0];
            } else {
                $kid = $header['kid'] ?? null;
                $alg = $header['alg'];

                foreach ($this->jwks['keys'] as $current_jwk) {
                    if ($current_jwk['kid'] === $kid) {
                        $jwk = $current_jwk;
                        break;
                    }
                }
            }

            if ($jwk === null) {
                throw new \RuntimeException('JWS key to verify JWT not found');
            }

            // check algorithm matches ('alg' is optional)
            if (isset($jwk['alg']) && isset($header['alg']) && $jwk['alg'] != $header['alg']) {
                throw new \RuntimeException('JWS key verification failed. Wrong algorithm.');
            }

            // TODO should check signature, note will use https://github.com/firebase/php-jwt later as it requires ^php7.4
        }

        // FIXME depends on body type: ID, Logout, Bearer, Refresh,
        if (isset($body['azp']) && $body['azp'] !== $this->options['client_id']) {
            throw new \RuntimeException('Failed to validate JWT: invalid azp value');
        } elseif (isset($body['aud']) && !in_array($this->options['client_id'], (array) $body['aud'])) {
            throw new \RuntimeException('Failed to validate JWT: invalid aud value');
        } elseif (!isset($body['azp']) && !isset($body['aud'])) {
            throw new \RuntimeException('Failed to validate JWT: missing aud/azp value');
        }

        // if defined in parameters, check that issuer match
        if (isset($this->options['issuer']) && $body['iss'] !== $this->options['issuer']) {
            throw new \RuntimeException('Failed to validate JWT: issuer mismatch');
        }

        // check that token is not an outdated message
        if (isset($body['exp']) && (time() > $body['exp'])) {
            throw new \RuntimeException('Failed to validate JWT: expired message');
        }

        $this->log_debug('jwt: %s', json_encode($body));

        return $body;
    }

    /**
     * Compose a fully qualified redirect URI for auth requests
     *
     * @return string
     */
    public function get_redirect_uri()
    {
        $url = $this->rcmail->url([]);

        // rewrite redirect URL to not contain query parameters because some providers do not support this
        $url = preg_replace('/\?.*/', '', $url);

        // Get rid of the use_secure_urls token from the path
        // It can happen after you log out that the token is still in the current request path
        if ($len = $this->rcmail->config->get('use_secure_urls')) {
            $length = $len > 1 ? $len : 16;
            $url = preg_replace("~^/[0-9a-zA-Z]{{$length}}/~", '/', $url);
        }

        $url = rcube_utils::resolve_url($url);

        return slashify($url) . 'index.php/login/oauth';
    }

    /**
     * Login action: redirect to `oauth_auth_uri`
     *
     * Authorization Code Request
     *
     * @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
     */
    public function login_redirect(): void
    {
        if (empty($this->options['auth_uri']) || empty($this->options['client_id'])) {
            // log error about missing config options
            rcube::raise_error("Missing required OAuth config options 'oauth_auth_uri', 'oauth_client_id'", true);
            return;
        }

        // create a secret string (OAuth security)
        $_SESSION['oauth_state'] = rcube_utils::random_bytes(12);

        // create a nonce (OIDC security)
        $_SESSION['oauth_nonce'] = rcube_utils::random_bytes(32);

        // compose full oauth login uri
        $query = [
            'response_type' => 'code',
            'client_id' => $this->options['client_id'],
            'scope' => $this->options['scope'],
            'redirect_uri' => $this->get_redirect_uri(),
            'state' => $_SESSION['oauth_state'],
            'nonce' => $_SESSION['oauth_nonce'],
        ];

        // implementation of PKCE @see: rfc7636
        if ($this->options['pkce']) {
            $code_verifier = rcube_utils::random_bytes(64);
            $code_challenge_method = $this->options['pkce'];
            $hash_method = self::$pkce_mapper[$code_challenge_method];

            // do not store it in clear, do not want it to be readable
            $_SESSION['oauth_code_verifier'] = $this->rcmail->encrypt($code_verifier);

            $query += [
                'code_challenge_method' => $code_challenge_method,
                'code_challenge' => self::base64url_encode(hash($hash_method, $code_verifier, true)),
            ];
        }

        $this->log_debug("requesting authorization code via a redirect to %s with scope='%s' and pkce method=%s",
            $this->options['auth_uri'], $this->options['scope'], $this->options['pkce']);

        $delimiter = strpos($this->options['auth_uri'], '?') > 0 ? '&' : '?';
        $url = $this->options['auth_uri'] . $delimiter . http_build_query($query + (array) $this->options['auth_parameters']);

        $this->last_error = null; // clean last error
        $this->rcmail->output->redirect($url);  // exit
    }

    /**
     * Call OIDC to get identity for a given authorization
     *
     * @param string $authorization the Bearer authorization
     *
     * @return array|null The identity
     *
     * @see: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
     */
    protected function fetch_userinfo($authorization)
    {
        if (empty($this->options['identity_uri'])) {
            // service not available
            return null;
        }

        $identity_response = $this->http_client->get($this->options['identity_uri'], [
            'headers' => [
                'Authorization' => $authorization,
                'Accept' => 'application/json',
            ],
        ]);

        return json_decode($identity_response->getBody(), true);
    }

    /**
     * Request access token with auth code returned from oauth login
     *
     * @param string $auth_code
     * @param string $state
     *
     * @return bool true on access token, false on error
     *
     * @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
     */
    public function request_access_token($auth_code, $state = null)
    {
        $oauth_token_uri = $this->options['token_uri'];
        $oauth_client_id = $this->options['client_id'];
        $oauth_client_secret = $this->options['client_secret'];

        try {
            // sanity check
            if (empty($oauth_token_uri) || empty($oauth_client_id) || empty($oauth_client_secret)) {
                throw new \RuntimeException("Missing required OAuth config options 'oauth_token_uri', 'oauth_client_id', 'oauth_client_secret'");
            }

            // validate state parameter against $_SESSION['oauth_state']
            if (!isset($_SESSION['oauth_state']) || ($_SESSION['oauth_state'] !== $state)) {
                throw new \RuntimeException('state parameter mismatch');
            }

            $this->rcmail->session->remove('oauth_state');

            $this->log_debug('requesting a grant_type=authorization_code to %s', $oauth_token_uri);

            $form = [
                'grant_type' => 'authorization_code',
                'code' => $auth_code,
                'client_id' => $oauth_client_id,
                'client_secret' => $oauth_client_secret,
                'redirect_uri' => $this->get_redirect_uri(),
            ];

            if ($this->options['pkce']) {
                $form['code_verifier'] = $this->rcmail->decrypt($_SESSION['oauth_code_verifier']);
            }

            $response = $this->http_client->post($oauth_token_uri, ['form_params' => $form]);
            $data = json_decode($response->getBody(), true);

            [$authorization, $identity] = $this->parse_tokens('authorization_code', $data);

            $username = null;

            if ($identity) {
                // note that id_token values depend on scopes
                foreach ($this->options['identity_fields'] as $field) {
                    if (isset($identity[$field])) {
                        $username = $identity[$field];
                        break;
                    }
                }
            }

            // request user identity (email)
            if (empty($username)) {
                $fetched_identity = $this->fetch_userinfo($authorization);

                $this->log_debug('fetched identity: %s', json_encode($fetched_identity, true));

                if (!empty($fetched_identity)) {
                    $identity = $fetched_identity;

                    foreach ($this->options['identity_fields'] as $field) {
                        if (isset($identity[$field])) {
                            $username = $identity[$field];
                            break;
                        }
                    }
                }
            }

            $data['auth_type'] = $this->options['auth_type'];

            // Backends with no XOAUTH2/OAUTHBEARER support
            if ($pass_claim = $this->options['password_claim']) {
                if (empty($identity[$pass_claim])) {
                    throw new \Exception("Password claim ({$pass_claim}) not found");
                }
                $authorization = $identity[$pass_claim];
                unset($identity[$pass_claim]);
                unset($data['auth_type']);
            }

            // store the full identity (usually contains `sub`, `name`, `preferred_username`, `given_name`, `family_name`, `locale`, `email`)
            $data['identity'] = $identity;

            // the username
            $data['username'] = $username;

            $this->mask_auth_data($data);

            $this->rcmail->plugins->exec_hook('oauth_login', array_merge($data, [
                'username' => $username,
                'identity' => $identity,
            ]));

            $this->last_error = null; // clean last error

            // return auth data
            $this->login_phase = [
                'username' => $username,
                'authorization' => $authorization, // the payload to authentificate through IMAP, SMTP, SIEVE .. servers
                'token' => $data,
                'nonce' => $_SESSION['oauth_nonce'],
            ];

            if ($this->options['pkce']) {
                // store crypted code_verifier because session is going to be killed
                $this->login_phase['code_verifier'] = $_SESSION['oauth_code_verifier'];
            }

            // Preserve the originally requested URL through session kill (stored by unauthenticated hook)
            if (!empty($_SESSION['oauth_redirect_uri'])) {
                $this->login_phase['redirect_uri'] = $_SESSION['oauth_redirect_uri'];
                $this->log_debug('preserving redirect URI for post-login: %s', $_SESSION['oauth_redirect_uri']);
            }

            return true;
        } catch (RequestException $e) {
            $this->last_error = 'OAuth token request failed: ' . $e->getMessage();
            $this->no_redirect = true;
            $formatter = new MessageFormatter();

            rcube::raise_error($this->last_error . '; ' . $formatter->format($e->getRequest(), $e->getResponse()), true);
        } catch (\Exception $e) {
            $this->last_error = 'OAuth token request failed: ' . $e->getMessage();
            $this->no_redirect = true;

            rcube::raise_error($this->last_error, true);
        }

        return false;
    }

    /**
     * Obtain a new access token using the refresh_token grant type
     *
     * If successful, this will update the `oauth_token` entry in
     * session data.
     *
     * @return array|false Updated authorization data
     *
     * @see https://datatracker.ietf.org/doc/html/rfc6749#section-6
     */
    public function refresh_access_token(array $token)
    {
        $oauth_token_uri = $this->options['token_uri'];
        $oauth_client_id = $this->options['client_id'];
        $oauth_client_secret = $this->options['client_secret'];

        // send token request to get a real access token for the given auth code
        try {
            $this->log_debug('requesting a grant_type=refresh_token to %s', $oauth_token_uri);

            $form = [
                'grant_type' => 'refresh_token',
                'refresh_token' => $this->rcmail->decrypt($token['refresh_token']),
                'client_id' => $oauth_client_id,
                'client_secret' => $oauth_client_secret,
            ];

            if ($this->options['pkce']) {
                $form['code_verifier'] = $this->rcmail->decrypt($_SESSION['oauth_code_verifier']);
            }

            $response = $this->http_client->post($oauth_token_uri, ['form_params' => $form]);
            $data = json_decode($response->getBody(), true);

            [$authorization, $identity] = $this->parse_tokens('refresh_token', $data, $token);

            // Backends with no XOAUTH2/OAUTHBEARER support
            if (($pass_claim = $this->options['password_claim']) && isset($identity[$pass_claim])) {
                $authorization = $identity[$pass_claim];
                unset($identity[$pass_claim]);
            }

            // update access token stored as password
            $_SESSION['password'] = $this->rcmail->encrypt($authorization);

            $this->mask_auth_data($data);

            // update session data
            $_SESSION['oauth_token'] = array_merge($token, $data);

            $this->rcmail->plugins->exec_hook('oauth_refresh_token', $data);

            $this->last_error = null; // clean last error

            return [
                'token' => $data,
                'authorization' => $authorization,
            ];
        } catch (RequestException $e) {
            $this->last_error = 'OAuth refresh token request failed: ' . $e->getMessage();
            $formatter = new MessageFormatter();
            rcube::raise_error($this->last_error . '; ' . $formatter->format($e->getRequest(), $e->getResponse()), true);

            // refrehsing token failed, mark session as expired
            if ($e->getCode() >= 400 && $e->getCode() < 500) {
                $this->rcmail->kill_session();
            }
        } catch (\Exception $e) {
            $this->last_error = 'OAuth refresh token request failed: ' . $e->getMessage();
            rcube::raise_error($this->last_error, true);
        }

        return false;
    }

    /**
     * Store a token revokation for a given sub (can be used by the backchannel logout)
     *
     * Warning: cache TTL should be at least > refresh_token frequency
     *
     * @param string $sub the sub of the identity
     */
    public function schedule_token_revocation($sub): void
    {
        if ($this->cache === null) {
            rcube::raise_error('Received a token revocation request, you must activate `oauth_cache` to enable this feature', true);
            return;
        }

        $this->cache->set("revoke_{$sub}", time());
    }

    /**
     * Check is a token has been revoked (condition: sub match & token is older than the request timestamp)
     *
     * @param array $token the token to verify
     *
     * @return bool true if token revoked
     */
    protected function is_token_revoked($token)
    {
        if ($this->cache === null) {
            // oops cache not enabled
            return false;
        }

        if (empty($token['identity']['sub'])) {
            return false;
        }

        $revoked_time = $this->cache->get("revoke_{$token['identity']['sub']}");

        if (!$revoked_time) {
            return false;
        }

        if ($token['created_at'] < $revoked_time) {
            return true;
        }

        return false;
    }

    /**
     * Parse and update the token from a grant request
     *
     * @param string $grant_type    The request type
     * @param array  $data          The payload from the request (will be updated)
     * @param array  $previous_data The data from a previous request
     *
     * @return array Token properties:
     *               1st element: the bearer authorization to use on different transports
     *               2nd element: the decoded identity
     */
    protected function parse_tokens($grant_type, &$data, $previous_data = null)
    {
        $this->log_debug('received token(s) from a grant request %s: %s', $grant_type, json_encode($data));

        // sanity check, check that payload correctly contains access_token
        if (!isset($data['access_token'])) {
            throw new \RuntimeException('access_token missing in answer, error from server');
        }

        // refresh_token is optional
        if (!isset($data['refresh_token'])) {
            $this->log_debug('no refresh token found in the payload');
        }

        // (> 0, it means that all token generated before this timestamp date are compromisd and that we need to download a new version of JWKS)
        if (!empty($data['not-before-policy']) && $data['not-before-policy'] > 0) {
            $this->log_debug('all tokens generated before %s timestmp are compromised', $data['not-before-policy']);
        }

        // please note that id_token / identity may have changed, could be interesting to grab it and refresh values, right now it is not used
        // decode JWT id_token if provided
        $identity = null;
        if (isset($data['id_token'])) {
            $identity = $this->jwt_decode($data['id_token']);

            // Ensure that the identity have the same 'nonce', but not on token refresh (per the OIDC spec.)
            if ($grant_type != 'refresh_token' || isset($identity['nonce'])) {
                if (!isset($identity['nonce']) || $identity['nonce'] !== $_SESSION['oauth_nonce']) {
                    throw new \RuntimeException("identity's nonce mismatch");
                }
            }
        }

        // creation time. Information also present in JWT, but it is faster here
        $data['created_at'] = time();

        $refresh_interval = $this->rcmail->config->get('refresh_interval');

        if (empty($data['expires_in'])) {
            // expires_in is recommended but not required
            // TODO: This probably should be a config option
            $data['expires'] = null;
        } elseif (!isset($data['refresh_token'])) {
            // refresh_token is optional, there will be no refreshes
            $data['expires'] = time() + $data['expires_in'] - 5;
        } elseif ($data['expires_in'] <= $refresh_interval) {
            rcube::raise_error(sprintf('Token TTL (%s) is smaller than refresh_interval (%s)', $data['expires_in'], $refresh_interval), true);
            // note: remove 10 sec by security (avoid tangent issues)
            $data['expires'] = time() + $data['expires_in'] - 10;
        } else {
            // try to request a refresh before it's too late according to the refesh interval
            // note: remove 10 sec by security (avoid tangent issues)
            $data['expires'] = time() + $data['expires_in'] - $refresh_interval - 10;
        }

        if (isset($data['refresh_expires_in'])) {
            $data['refresh_expires'] = time() + $data['refresh_expires_in'];
        }

        if (strcasecmp($data['token_type'], 'Bearer') == 0) {
            // always normalize Bearer (uppercase then lower case)
            $authorization = sprintf('Bearer %s', $data['access_token']);
        } else {
            // unknown token type, do not alter it
            $authorization = sprintf('%s %s', $data['token_type'], $data['access_token']);
        }

        return [$authorization, $identity];
    }

    /**
     * Modify some properties of the received auth response
     *
     * @param array $data
     */
    protected function mask_auth_data(&$data): void
    {
        // remove by security access_token as it is crypted in $_SESSION['password']
        unset($data['access_token']);

        // encrypt refresh token if provided
        if (isset($data['refresh_token'])) {
            $data['refresh_token'] = $this->rcmail->encrypt($data['refresh_token']);
        }

        // encrypt the ID token, it may contain sensitive info (that we don't need at this point)
        if (isset($data['id_token'])) {
            $data['id_token'] = $this->rcmail->encrypt($data['id_token']);
        }
    }

    /**
     * Check the given access token data if still valid
     *
     * ... and attempt to refresh if possible.
     *
     * @param array $token OAuth token
     *
     * @return int
     */
    protected function check_token_validity($token)
    {
        if ($this->is_token_revoked($token)) {
            $this->log_debug('abort, token for sub %s has been revoked', $token['identity']['sub']);
            // in a such case, we are blocked, can only kill session
            $this->rcmail->kill_session();
            return self::TOKEN_REVOKED;
        }

        if (!isset($token['expires']) || $token['expires'] > time()) {
            return self::TOKEN_STILL_VALID;
        }

        if (!isset($token['refresh_token'])) {
            $this->log_debug('abort, no refresh token');
            // in this case we are blocked, can only kill session
            $this->rcmail->kill_session();
            return self::TOKEN_REFRESH_FAILED;
        }

        if (isset($token['refresh_expires']) && $token['refresh_expires'] < time()) {
            $this->log_debug('abort, refresh token has expired');
            // in this case we are blocked, can only kill session
            $this->rcmail->kill_session();
            return self::TOKEN_REFRESH_EXPIRED;
        }

        if (!empty($this->last_error)) {
            // TODO: challenge this part, what about transcient errors ?
            $this->log_debug('abort, got an previous error %s', $this->last_error);
            return self::TOKEN_ERROR;
        }

        if ($this->refresh_access_token($token) === false) {
            // FIXME: can have 2 kind of errors: transcient (can retry) or non recoverable error
            // currently it's up to the refresh_token to kill_session if necessary
            $this->log_debug('token refresh failed: %s', $this->last_error);
            return self::TOKEN_REFRESH_FAILED;
        }

        return self::TOKEN_REFRESHED;
    }

    /**
     * Callback for 'refresh' hook
     *
     * @param array $options
     *
     * @return array
     */
    public function refresh($options)
    {
        if (isset($_SESSION['oauth_token'])) {
            $this->check_token_validity($_SESSION['oauth_token']);
        }

        return $options;
    }

    /**
     * Callback for 'storage_init' hook
     *
     * @param array $options
     *
     * @return array
     */
    public function storage_init($options)
    {
        if ($options['driver'] !== 'imap') {
            return $options;
        }

        if ($this->login_phase) {
            if (isset($this->login_phase['token']['auth_type'])) {
                $options['auth_type'] = $this->login_phase['token']['auth_type'];
            }
        } elseif (isset($_SESSION['oauth_token'])) {
            if ($this->check_token_validity($_SESSION['oauth_token']) === self::TOKEN_REFRESHED) {
                $options['password'] = $this->rcmail->decrypt($_SESSION['password']);
            }

            if (isset($_SESSION['oauth_token']['auth_type'])) {
                $options['auth_type'] = $_SESSION['oauth_token']['auth_type'];
            }
        }

        return $options;
    }

    /**
     * Callback for 'smtp_connect' hook
     *
     * @param array $options
     *
     * @return array
     */
    public function smtp_connect($options)
    {
        $smtp_user = $options['smtp_user'];
        $smtp_pass = $options['smtp_pass'];

        // skip XOAUTH2 authorization, if indicated
        if (($smtp_user == '') || ($smtp_pass == '')) {
            return $options;
        }

        if (isset($_SESSION['oauth_token'])) {
            // check token validity
            $this->check_token_validity($_SESSION['oauth_token']);

            $options['smtp_user'] = '%u';
            $options['smtp_pass'] = '%p';

            if (isset($_SESSION['oauth_token']['auth_type'])) {
                $options['smtp_auth_type'] = $_SESSION['oauth_token']['auth_type'];
            }
        }

        return $options;
    }

    /**
     * Callback for 'managesieve_connect' hook
     *
     * @param array $options
     *
     * @return array
     */
    public function managesieve_connect($options)
    {
        if (isset($_SESSION['oauth_token'])) {
            // check token validity
            $this->check_token_validity($_SESSION['oauth_token']);

            if (isset($_SESSION['oauth_token']['auth_type'])) {
                $options['auth_type'] = $_SESSION['oauth_token']['auth_type'];
            }
        }

        return $options;
    }

    /**
     * Callback for 'authenticate' hook
     *
     * @param array $options
     *
     * @return array the authenticate parameters
     */
    public function authenticate($options)
    {
        if (!$this->login_phase) {
            return $options;
        }

        $options['user'] = $this->login_phase['username'];
        $options['pass'] = $this->login_phase['authorization'];
        $this->rcmail->config->set('login_password_maxlen', strlen($options['pass']));

        $this->log_debug('calling authenticate for user %s', $options['user']);

        // Make plugins aware that SSO is in use
        $options['sso'] = true;

        // Restore the originally requested URL by setting $_POST['_url']
        // This allows Roundcube's built-in redirect handling to restore the original request
        if (!empty($this->login_phase['redirect_uri'])) {
            $_POST['_url'] = $this->login_phase['redirect_uri'];
            $this->log_debug('setting $_POST[_url] for post-login redirect: %s', $this->login_phase['redirect_uri']);
        }

        return $options;
    }

    /**
     * Callback for 'login_after' hook
     *
     * @param array $options
     *
     * @return array
     */
    public function login_after($options)
    {
        if (!$this->login_phase) {
            return $options;
        }

        // store important data to new freshly created session
        $_SESSION['oauth_token'] = $this->login_phase['token'];
        $_SESSION['oauth_nonce'] = $this->login_phase['nonce'];
        if ($this->options['pkce'] && isset($this->login_phase['code_verifier'])) {
            $_SESSION['oauth_code_verifier'] = $this->login_phase['code_verifier'];
        }

        $this->log_debug('login successful for OIDC sub=%s with username=%s which is rcube-id=%s',
            $this->login_phase['token']['identity']['sub'], $this->login_phase['username'], $this->rcmail->user->ID);

        // login phase is terminated
        $this->login_phase = null;

        return $options;
    }

    /**
     * Callback for 'user_create' hook (create user using OIDC claims))
     *
     * @param array $data user_create parameters (user_name, user_email, language))
     *
     * @return array $data key/values to setup user's identity
     */
    public function user_create($data)
    {
        if (!$this->login_phase) {
            return $data;
        }

        if (!isset($this->login_phase['token']['identity'])) {
            $this->log_debug("identity not found, was the scope 'openid' defined?");
            return $data;
        }

        $identity = $this->login_phase['token']['identity'];

        foreach ($this->options['user_create_map'] as $rc_key => $oidc_claims) {
            $oidc_claims = (array) $oidc_claims;
            foreach ($oidc_claims as $oidc_claim) {
                // use the first defined claim
                if (isset($identity[$oidc_claim]) && is_string($identity[$oidc_claim]) && $identity[$oidc_claim] !== '') {
                    $value = $identity[$oidc_claim];
                    // normalize and check well known keys
                    switch ($rc_key) {
                        case 'user_email':
                            // normalize to punicode for intl. domains (IDN)
                            $value = rcube_utils::idn_to_ascii($value);
                            // check format
                            if (!rcube_utils::check_email($value, false)) {
                                rcube::raise_error("user_create: ignoring invalid email '{$value}' (from claim '{$oidc_claim}')", true);
                                continue 2; // continue on next foreach iteration
                            }

                            break;
                        case 'language':
                            // normalize language
                            $value = strtr($value, '-', '_');
                            // sanity check no extra chars than an language format (RFC5646)
                            if (!preg_match('/^[a-z0-9_]{2,8}$/i', $value)) {
                                rcube::raise_error("user_create: ignoring language '{$value}' (from claim '{$oidc_claim}')", true);
                                continue 2; // continue on next foreach iteration
                            }

                            break;
                    }
                    $data[$rc_key] = $value;

                    $this->log_debug('user_create: setting %s=%s (from claim %s)', $rc_key, $value, $oidc_claim);
                    break; // no need to continue
                }
            }
        }

        return $data;
    }

    /**
     * Callback for 'logout_after' hook
     *
     * @param array $options Hook parameters
     *
     * @return array
     */
    public function logout_after(array $options)
    {
        $this->no_redirect = true;

        if ($this->logout_redirect_url) {
            // propagate logout request to the identity provider
            $this->rcmail->output->redirect($this->logout_redirect_url); // exit
        }

        return $options;
    }

    /**
     * Callback for 'startup' hook
     *
     * @params array $args array containing task and action
     *
     * @return array the arguments provided in entry and altered if so
     */
    public function startup(array $args)
    {
        if (!$this->is_enabled()) {
            return $args;
        }

        if ($args['task'] == 'login' && $args['action'] == 'oauth') {
            // handle oauth login requests
            $oauth_handler = new rcmail_action_login_oauth();
            $handler_answer = $oauth_handler->run();
            if ($handler_answer && is_array($handler_answer)) {
                // on success, handler will request next action = login
                $args = $handler_answer + $args;
            }
        } elseif ($args['task'] == 'login' && $args['action'] == 'backchannel') {
            // handle oauth login requests
            $oauth_handler = new rcmail_action_login_oauth_backchannel();
            $oauth_handler->run();
        } elseif ($args['task'] == 'logout') {
            // handle only logout task
            $this->handle_logout();
        }

        return $args;
    }

    /**
     * Implement OpenID Connect RP-Initiated Logout 1.0
     *
     * will generate during the logout task the RP-initiated Logout URL and
     * store it in `logout_redirect_url`
     *
     * @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html
     */
    public function handle_logout(): void
    {
        // if no logout URI, or no refresh token, safe to give up
        if (!$this->options['logout_uri'] || !isset($_SESSION['oauth_token'])) {
            return;
        }

        // refresh token only if expired, we do this to ensure it will propagate session close to IDP
        switch ($this->check_token_validity($_SESSION['oauth_token'])) {
            case self::TOKEN_REFRESHED:
            case self::TOKEN_STILL_VALID:
                // token still ok or refreshed
                break;
            default:
                // got an error, cannot request IDP to cleanup other sessions
                return;
        }

        // generate redirect URL for post-logout
        $params = [
            'post_logout_redirect_uri' => $this->rcmail->url([], true, true),
            'client_id' => $this->options['client_id'],
        ];

        if (isset($_SESSION['oauth_token']['id_token'])) {
            $params['id_token_hint'] = $this->rcmail->decrypt($_SESSION['oauth_token']['id_token']);
        }

        $this->logout_redirect_url = $this->options['logout_uri'] . '?' . http_build_query($params);
        $this->log_debug('creating logout call: %s', $this->logout_redirect_url);
    }

    /**
     * Callback for 'login_failed' hook
     *
     * @param array $options
     *
     * @return array
     */
    public function login_failed($options)
    {
        // no redirect on imap login failures
        $this->no_redirect = true;
        $this->login_phase = null;
        return $options;
    }

    /**
     * Callback for 'unauthenticated' hook
     *
     * @param array $options
     *
     * @return array
     */
    public function unauthenticated($options)
    {
        // Store the originally requested URL query string for post-authentication redirect
        // We store just the query string (not full URL) so it can be used directly with $_POST['_url']
        if (!empty($_SERVER['QUERY_STRING']) && !$this->rcmail->output->ajax_call) {
            // Only store if it's not a login or oauth action (prevents redirect loops)
            if (!preg_match('/(_task=login|_task=logout|_action=oauth)/', $_SERVER['QUERY_STRING'])) {
                $_SESSION['oauth_redirect_uri'] = $_SERVER['QUERY_STRING'];
                $this->log_debug('storing original query string for post-auth redirect: %s', $_SERVER['QUERY_STRING']);
            }
        }

        if (
            $this->options['login_redirect']
            && !$this->rcmail->output->ajax_call
            && !$this->no_redirect
            && (empty($options['error']) || $options['error'] === 'sessionerror')
            && $options['http_code'] === 200
        ) {
            $this->login_redirect();
        }

        return $options;
    }
}
