From 588a8791073a5d0c3b6d2ace25df66bf422f72ac Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle <15628033+EdouardVanbelle@users.noreply.github.com> Date: Sun, 17 Dec 2023 09:13:07 +0100 Subject: [PATCH] OAuth improvements (#9217) - OAuth: Add `oauth_config_uri` - support OAuth/OpenIDC discovery (#8201) - OAuth: Add `oauth_logout_uri` - allow invalidating the OAUTH-Session on logout (#8057) - OAuth: Support for OpenID Connect RP-Initiated Logout (#9109) - OAuth: Add support of OAUTHBEARER (#9217) - OAuth: Add `oauth_debug` option (#9217) - OAuth: Fix: missing config `oauth_provider_name` in rcmail_oauth's constructor (#9217) - OAuth: Refactor: move display to the rcmail_oauth class and use `loginform_content` hook (#9217) Signed-off-by: Edouard Vanbelle Co-authored-by: Aleksander Machniak --- config/defaults.inc.php | 28 +- index.php | 6 - program/actions/login/oauth.php | 6 +- program/actions/login/oauth_backchannel.php | 99 ++ program/include/rcmail_oauth.php | 994 ++++++++++++++----- program/include/rcmail_output_html.php | 21 - program/lib/Roundcube/rcube_imap_generic.php | 9 +- tests/Rcmail/Oauth.php | 207 +++- 8 files changed, 1104 insertions(+), 266 deletions(-) create mode 100644 program/actions/login/oauth_backchannel.php diff --git a/config/defaults.inc.php b/config/defaults.inc.php index bc887a28d..29e75e2a0 100644 --- a/config/defaults.inc.php +++ b/config/defaults.inc.php @@ -338,6 +338,9 @@ $config['smtp_conn_options'] = null; // ---------------------------------- // Enable OAuth2 by defining a provider. Use 'generic' here +// if enabled you can activate the Backchannel Logout specifying: +// https:///index.php/login/backchannel to your Identity provider +// if you are using the backchannel, you mmust activate `oauth_cache` $config['oauth_provider'] = null; // Provider name to be displayed on the login button @@ -349,15 +352,29 @@ $config['oauth_client_id'] = null; // Mandatory: OAuth client secret $config['oauth_client_secret'] = null; +// Optional: the OIDC discovery URI (the 'https://.../.well-known/openid-configuration') +// if specified, the discovery will supersede `oauth_issuer`, `auth_auth_uri`, `oauth_token_uri`, `oauth_identity_uri`, `oauth_logout_uri`, `oauth_jwks_uri`) +// it is recommanded to activate a cache via `oauth_cache` and `oauth_cache_ttl` +$config['oauth_config_uri'] = null; + +// Optional: if defined will be used to check answer from issuer +$config['oauth_issuer'] = null; + +// Optional: if defined will download JWKS Certificate and check JWT signatures +$config['oauth_jwks_uri'] = null; + // Mandatory: URI for OAuth user authentication (redirect) $config['oauth_auth_uri'] = null; -// Mandatory: Endpoint for OAuth authentication requests (server-to-server) +// Mandatory or Optional if $oauth_config_uri is specified: Endpoint for OAuth authentication requests (server-to-server) $config['oauth_token_uri'] = null; // Optional: Endpoint to query user identity if not provided in auth response $config['oauth_identity_uri'] = null; +// Optional: Endpoint for OIDC Logout propagation +$config['oauth_logout_uri'] = null; + // Optional: timeout for HTTP requests to OAuth server $config['oauth_timeout'] = 10; @@ -377,6 +394,15 @@ $config['oauth_identity_fields'] = null; // Boolean: automatically redirect to OAuth login when opening Roundcube without a valid session $config['oauth_login_redirect'] = false; +// Optional: boolean, if true will generate debug information to /oauth.log +$config['oauth_debug'] = false; + +// Mandatory for backchannel, highly recommended when using `oauth_config_uri` or `oauth_jwks_uri` (Type of cache. Supported values: 'db', 'apc' and 'memcache' or 'memcached') +$config['oauth_cache'] = 'db'; + +// Optional: cache ttl +$config['oauth_cache_ttl'] = '8h'; + ///// Example config for Gmail // Register your service at https://console.developers.google.com/ diff --git a/index.php b/index.php index 1407e3205..9fb13a881 100644 --- a/index.php +++ b/index.php @@ -193,12 +193,6 @@ if ($RCMAIL->task == 'login' && $RCMAIL->action == 'login') { } } -// handle oauth login requests -else if ($RCMAIL->task == 'login' && $RCMAIL->action == 'oauth' && $RCMAIL->oauth->is_enabled()) { - $oauth_handler = new rcmail_action_login_oauth(); - $oauth_handler->run(); -} - // end session else if ($RCMAIL->task == 'logout' && isset($_SESSION['user_id'])) { $RCMAIL->request_security_check(rcube_utils::INPUT_GET | rcube_utils::INPUT_POST); diff --git a/program/actions/login/oauth.php b/program/actions/login/oauth.php index c41851158..ffebc3579 100644 --- a/program/actions/login/oauth.php +++ b/program/actions/login/oauth.php @@ -38,8 +38,8 @@ class rcmail_action_login_oauth extends rcmail_action // oauth success if ($auth && isset($auth['username'], $auth['authorization'], $auth['token'])) { - // enforce XOAUTH2 auth type - $rcmail->config->set('imap_auth_type', 'XOAUTH2'); + // enforce OAUTHBEARER/XOAUTH2 auth type + $rcmail->config->set('imap_auth_type', $rcmail->oauth->get_auth_type()); $rcmail->config->set('login_password_maxlen', strlen($auth['authorization'])); // use access_token and user info for IMAP login @@ -55,6 +55,8 @@ class rcmail_action_login_oauth extends rcmail_action // save OAuth token in session $_SESSION['oauth_token'] = $auth['token']; + $rcmail->oauth->log_debug('login successful for OIDC sub=%s with username=%s which is rcube-id=%s', $auth['token']['identity']['sub'], $auth['username'], $rcmail->user->ID); + // log successful login $rcmail->log_login(); diff --git a/program/actions/login/oauth_backchannel.php b/program/actions/login/oauth_backchannel.php new file mode 100644 index 000000000..dad237983 --- /dev/null +++ b/program/actions/login/oauth_backchannel.php @@ -0,0 +1,99 @@ +/index.php/login/backchannel | + +-----------------------------------------------------------------------+ + | Author: Thomas Bruederli | + +-----------------------------------------------------------------------+ + */ + +class rcmail_action_login_oauth_backchannel extends rcmail_action +{ + /** + * Request handler. + * + * @param array $args Arguments from the previous step(s) + */ + public function run($args = []) + { + $rcmail = rcmail::get_instance(); + + // default message + $answer = ['error' => 'invalid_request', 'error_description' => "Error, no action"]; + + //Beware we are in back-channel from OP (IDP) + $logout_token = rcube_utils::get_input_string('logout_token', rcube_utils::INPUT_POST); + + if (!empty($logout_token)) { + try { + $event = $rcmail->oauth->jwt_decode($logout_token); + + /* return event example + { + "typ":"Logout", // event type + "iat":1700263584, // emition date + "jti":"4a953d6e-dc6b-4cc1-8d29-cb54b2351d0a", // token identifier + "iss":"https://....", // issuer identifier + "aud":"my client id", // audience = client id + "sub":"82c8f487-df95-4960-972c-4e680c3c72f5", // subject + "sid":"28101815-0017-4ade-a550-e054bde07ded", // session + "events":{"http://schemas.openid.net/event/backchannel-logout":[]} + } + */ + + if ($event['typ'] !== 'Logout') { + throw new RuntimeException('handle only Logout events'); + } + if (!isset($event['sub'])) { + throw new RuntimeException('event has no "sub"'); + } + + $rcmail->oauth->log_debug('backchannel: logout event received, schedule a revocation for token\'s sub: %s', $event['sub']); + $rcmail->oauth->schedule_token_revocation($event['sub']); + + http_response_code(200); // 204 works also + header('Content-Type: application/json; charset=UTF-8'); + header('Cache-Control: no-store'); + echo '{}'; + exit; + } + catch (\Exception $e) { + rcube::raise_error([ + 'message' => $e->getMessage(), + 'file' => __FILE__, + 'line' => __LINE__, + ], true, false + ); + $answer['error_description'] = "Error decoding JWT"; + } + } + else { + rcube::raise_error([ + 'message' => sprintf('oidc backchannel called from %s without any parameter', rcube_utils::remote_addr()), + 'file' => __FILE__, + 'line' => __LINE__, + ], true, false + ); + } + + http_response_code(400); + header('Content-Type: application/json; charset=UTF-8'); + header('Cache-Control: no-store'); + echo json_encode($answer); + exit; + } +} diff --git a/program/include/rcmail_oauth.php b/program/include/rcmail_oauth.php index 47dd9f8b0..bfb87331b 100644 --- a/program/include/rcmail_oauth.php +++ b/program/include/rcmail_oauth.php @@ -12,6 +12,8 @@ | | | 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 | +-----------------------------------------------------------------------+ @@ -28,18 +30,57 @@ use GuzzleHttp\MessageFormatter; */ class rcmail_oauth { + const TOKEN_REFRESHED = 1; + const TOKEN_STILL_VALID = 0; + const TOKEN_REFRESH_FAILED = -1; + const TOKEN_NOT_FOUND = -2; + const TOKEN_ERROR = -3; + const TOKEN_REFRESH_EXPIRED = -4; + const TOKEN_REVOKED = -5; + const TOKEN_COMPROMISED = -6; + + const JWKS_CACHE_TTL = 30; // TTL for JWKS (in seconds) + + // prepare the OAUTHBEARER which is now the official protocol (rfc 7628) + // but currently implement mostly the formal XOAUTH2 + /** @var string */ + protected $auth_type = 'XOAUTH2'; + /** @var rcmail */ protected $rcmail; /** @var array */ protected $options = []; + /** @var array */ + protected $jwks; + /** @var string */ protected $last_error = null; /** @var bool */ protected $no_redirect = false; + /** @var rcube_cache */ + protected $cache; + + /** @var \GuzzleHttp\Client */ + protected $http_client; + + /** @var string */ + protected $logout_redirect_url; + + /* helper to map .well-known entry to config (discovery URI) */ + /** @var array */ + static protected $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 rcmail_oauth */ static protected $instance; @@ -58,6 +99,32 @@ class rcmail_oauth return self::$instance; } + /** + * Helper to log oauth + * + * @return void + */ + private function logger($level, $message) + { + $sub = $_SESSION['oauth_token']['identity']['sub'] ?? '-'; + $ses = $_SESSION['oauth_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 + * + * @return void + */ + public function log_debug(...$args) + { + if ($this->options['debug']) { + $this->logger('DEBUG', sprintf(...$args)); + } + } + /** * Object constructor * @@ -65,21 +132,144 @@ class rcmail_oauth */ public function __construct($options = []) { - $this->rcmail = rcmail::get_instance(); + $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']), - 'scope' => $this->rcmail->config->get('oauth_scope'), - 'timeout' => $this->rcmail->config->get('oauth_timeout', 10), + '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), + 'debug' => $this->rcmail->config->get('oauth_debug', false), ]; + + // http_options will be used in test phase to add a mock + if (!isset($options['http_options'])) { + $options['http_options'] = []; + } + + // 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 + * + * @return void + * + * @see https://datatracker.ietf.org/doc/html/rfc8414 + */ + protected function discover() + { + $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); + + //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])) { + rcube::raise_error([ + 'message' => "key {$config_key} not found in answer of {$config_uri}", + 'file' => __FILE__, + 'line' => __LINE__, + ], true, false + ); + } + else { + $this->options[$options_key] = $data[$config_key]; + } + } + } + catch (\Exception $e) { + rcube::raise_error([ + 'message' => "Error fetching {$config_uri} : {$e->getMessage()}", + 'file' => __FILE__, + 'line' => __LINE__, + ], true, false + ); + } + } + + /** + * Fetch JWKS certificates (use cache if active) + * + * @return void + */ + protected function fetch_jwks() + { + 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); + + //sanity check + if (!isset($this->jwks['keys'])) { + $this->log_debug("incorrect jwks response from %s", $jwks_uri); + } + else if ($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); + } } /** @@ -87,18 +277,29 @@ class rcmail_oauth * * @return void */ - protected function init() + public function init() { - // subscribe to storage and smtp init events - if ($this->is_enabled()) { - $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('logout_after', [$this, 'logout_after']); - $this->rcmail->plugins->register_hook('login_failed', [$this, 'login_failed']); - $this->rcmail->plugins->register_hook('unauthenticated', [$this, 'unauthenticated']); - $this->rcmail->plugins->register_hook('refresh', [$this, 'refresh']); + // 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('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('logout_after', [$this, 'logout_after']); + $this->rcmail->plugins->register_hook('login_failed', [$this, 'login_failed']); + $this->rcmail->plugins->register_hook('unauthenticated', [$this, 'unauthenticated']); + $this->rcmail->plugins->register_hook('refresh', [$this, 'refresh']); + $this->rcmail->plugins->register_hook('startup', [$this, 'startup']); + $this->rcmail->plugins->register_hook('loginform_content', [$this, 'loginform_content']); } /** @@ -113,6 +314,122 @@ class rcmail_oauth !empty($this->options['client_id']); } + /** + * Getter for the last error occurred + * + * @return mixed + */ + public function get_last_error() + { + return $this->last_error; + } + + + /** + * 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 + * + * @return void + */ + 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; + } + + + public static function base64url_decode($encoded) + { + return base64_decode(strtr($encoded, '-_', '+/'), true); + } + + /** + * 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) + { + $body = []; + + list($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); + + if ($this->options['jwks_uri']) { + // jwks_uri defined, will check JWT signature + + $this->fetch_jwks(); + + $kid = $header['kid']; + $alg = $header['alg']; + + $jwk = null; + + 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"); + } + + // TODO: check alg. matches + // 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'); + } + else if (isset($body['aud']) && !in_array($this->options['client_id'], (array) $body['aud'])) { + throw new RuntimeException('Failed to validate JWT: invalid aud value'); + } + else if (!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 * @@ -128,65 +445,17 @@ class rcmail_oauth return slashify($url) . 'index.php/login/oauth'; } - /** - * Getter for the last error occurred - * - * @return mixed - */ - public function get_last_error() - { - return $this->last_error; - } - - /** - * Helper method to decode a JWT - * - * @param string $jwt - * @return array Hash array with decoded body - */ - public function jwt_decode($jwt) - { - list($headb64, $bodyb64, $cryptob64) = explode('.', strtr($jwt, '-_', '+/')); - - $header = json_decode(base64_decode($headb64), true); - $body = json_decode(base64_decode($bodyb64), true); - - if (isset($body['azp']) && $body['azp'] !== $this->options['client_id']) { - throw new RuntimeException('Failed to validate JWT: invalid azp value'); - } - else if (isset($body['aud']) && !in_array($this->options['client_id'], (array) $body['aud'])) { - throw new RuntimeException('Failed to validate JWT: invalid aud value'); - } - else if (!isset($body['azp']) && !isset($body['aud'])) { - throw new RuntimeException('Failed to validate JWT: missing aud/azp value'); - } - - return $body; - } - /** * Login action: redirect to `oauth_auth_uri` * + * Authorization Request + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 + * * @return void */ public function login_redirect() { - if (!empty($this->options['auth_uri']) && !empty($this->options['client_id'])) { - // create a secret string - $_SESSION['oauth_state'] = rcube_utils::random_bytes(12); - - // compose full oauth login uri - $delimiter = strpos($this->options['auth_uri'], '?') > 0 ? '&' : '?'; - $query = http_build_query([ - 'response_type' => 'code', - 'client_id' => $this->options['client_id'], - 'scope' => $this->options['scope'], - 'redirect_uri' => $this->get_redirect_uri(), - 'state' => $_SESSION['oauth_state'], - ] + (array) $this->options['auth_parameters']); - $this->rcmail->output->redirect($this->options['auth_uri'] . $delimiter . $query); // exit - } - else { + if (empty($this->options['auth_uri']) || empty($this->options['client_id'])) { // log error about missing config options rcube::raise_error([ 'message' => "Missing required OAuth config options 'oauth_auth_uri', 'oauth_client_id'", @@ -194,7 +463,54 @@ class rcmail_oauth 'line' => __LINE__, ], true, false ); + return; } + + // create a secret string + $_SESSION['oauth_state'] = rcube_utils::random_bytes(12); + + // compose full oauth login uri + $delimiter = strpos($this->options['auth_uri'], '?') > 0 ? '&' : '?'; + $query = http_build_query([ + 'response_type' => 'code', + 'client_id' => $this->options['client_id'], + 'scope' => $this->options['scope'], + 'redirect_uri' => $this->get_redirect_uri(), + 'state' => $_SESSION['oauth_state'], + ] + (array) $this->options['auth_parameters']); + + $this->log_debug('requesting authorization with scope: %s', $this->options['scope']); + + $this->last_error = null; // clean last error + $this->rcmail->output->redirect($this->options['auth_uri'] . $delimiter . $query); // exit + } + + /** + * Call ODIC to get identity for an given authorization + * + * @param string $authorization the Bearer authorization + * + * @return array the identity + * + * @see: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + */ + protected function fetch_userinfo($authorization) + { + $oauth_identity_uri = $this->options['identity_uri']; + + if (empty($oauth_identity_uri)) { + // service not available + return; + } + + $identity_response = $this->http_client->get($oauth_identity_uri, [ + 'headers' => [ + 'Authorization' => $authorization, + 'Accept' => 'application/json', + ], + ]); + + return json_decode($identity_response->getBody(), true); } /** @@ -207,139 +523,116 @@ class rcmail_oauth * `username` as the authentication user name * `authorization` as the oauth authorization string " " * `token` as the complete oauth response to be stored in session + * + * @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']; - $oauth_identity_uri = $this->options['identity_uri']; - if (!empty($oauth_token_uri) && !empty($oauth_client_secret)) { - try { - // validate state parameter against $_SESSION['oauth_state'] - if (!empty($_SESSION['oauth_state']) && $_SESSION['oauth_state'] !== $state) { - throw new RuntimeException('Invalid state parameter'); + 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); + $response = $this->http_client->post($oauth_token_uri, [ + 'form_params' => [ + 'grant_type' => 'authorization_code', + 'code' => $auth_code, + 'client_id' => $oauth_client_id, + 'client_secret' => $oauth_client_secret, + 'redirect_uri' => $this->get_redirect_uri(), + ], + ]); + $data = json_decode($response->getBody(), true); + + $authorization = $this->parse_tokens('authorization_code', $data); + + $username = null; + $identity = null; + + // decode JWT id_token if provided + if (!empty($data['id_token'])) { + $identity = $this->jwt_decode($data['id_token']); + + // note that id_token values depend on scopes + foreach ($this->options['identity_fields'] as $field) { + if (isset($identity[$field])) { + $username = $identity[$field]; + break; + } } + } - // send token request to get a real access token for the given auth code - $client = rcube::get_instance()->get_http_client([ - 'timeout' => $this->options['timeout'], - 'verify' => $this->options['verify_peer'], - ]); + // request user identity (email) + if (empty($username)) { + $fetched_identity = $this->fetch_userinfo($authorization); - $response = $client->post($oauth_token_uri, [ - 'form_params' => [ - 'code' => $auth_code, - 'client_id' => $oauth_client_id, - 'client_secret' => $oauth_client_secret, - 'redirect_uri' => $this->get_redirect_uri(), - 'grant_type' => 'authorization_code', - ], - ]); + $this->log_debug("fetched identity: %s", json_encode($fetched_identity, true)); - $data = json_decode($response->getBody(), true); + if (!empty($fetched_identity)) { - // auth success - if (!empty($data['access_token'])) { - $username = null; - $identity = null; - $authorization = sprintf('%s %s', $data['token_type'], $data['access_token']); + $identity = $fetched_identity; - // decode JWT id_token if provided - if (!empty($data['id_token'])) { - try { - $identity = $this->jwt_decode($data['id_token']); - foreach ($this->options['identity_fields'] as $field) { - if (isset($identity[$field])) { - $username = $identity[$field]; - break; - } - } - } catch (\Exception $e) { - // log error - rcube::raise_error([ - 'message' => $e->getMessage(), - 'file' => __FILE__, - 'line' => __LINE__, - ], true, false - ); + foreach ($this->options['identity_fields'] as $field) { + if (isset($identity[$field])) { + $username = $identity[$field]; + break; } } - - // request user identity (email) - if (empty($username) && !empty($oauth_identity_uri)) { - $identity_response = $client->get($oauth_identity_uri, [ - 'headers' => [ - 'Authorization' => $authorization, - 'Accept' => 'application/json', - ], - ]); - - $identity = json_decode($identity_response->getBody(), true); - - foreach ($this->options['identity_fields'] as $field) { - if (isset($identity[$field])) { - $username = $identity[$field]; - break; - } - } - } - - $data['identity'] = $username; - $this->mask_auth_data($data); - - $this->rcmail->session->remove('oauth_state'); - - $this->rcmail->plugins->exec_hook('oauth_login', array_merge($data, [ - 'username' => $username, - 'identity' => $identity, - ])); - - // remove some data we don't want to store in session - unset($data['id_token']); - - // return auth data - return [ - 'username' => $username, - 'authorization' => $authorization, - 'token' => $data, - ]; - } - else { - throw new Exception('Unexpected response from OAuth service'); } } - catch (RequestException $e) { - $this->last_error = "OAuth token request failed: " . $e->getMessage(); - $this->no_redirect = true; - $formatter = new MessageFormatter(); - rcube::raise_error([ - 'message' => $this->last_error . '; ' . $formatter->format($e->getRequest(), $e->getResponse()), - 'file' => __FILE__, - 'line' => __LINE__, - ], true, false - ); + // store the full identity (usually contains `sub`, `name`, `preferred_username`, `given_name`, `family_name`, `locale`, `email`) + $data['identity'] = $identity; - return false; - } - catch (Exception $e) { - $this->last_error = "OAuth token request failed: " . $e->getMessage(); - $this->no_redirect = true; + // the username + $data['username'] = $username; - rcube::raise_error([ - 'message' => $this->last_error, - 'file' => __FILE__, - 'line' => __LINE__, - ], true, false - ); + $this->mask_auth_data($data); - return false; - } + $this->rcmail->plugins->exec_hook('oauth_login', array_merge($data, [ + 'username' => $username, + 'identity' => $identity, + ])); + + $this->last_error = null; // clean last error + + // return auth data + return [ + 'username' => $username, + 'authorization' => $authorization, // the payload to authentificate through IMAP, SMTP, SIEVE .. servers + 'token' => $data, + ]; } - else { - $this->last_error = "Missing required OAuth config options 'oauth_token_uri', 'oauth_client_id', 'oauth_client_secret'"; + catch (RequestException $e) { + $this->last_error = "OAuth token request failed: " . $e->getMessage(); + $this->no_redirect = true; + $formatter = new MessageFormatter(); + + rcube::raise_error([ + 'message' => $this->last_error . '; ' . $formatter->format($e->getRequest(), $e->getResponse()), + 'file' => __FILE__, + 'line' => __LINE__, + ], true, false + ); + + return false; + } + catch (Exception $e) { + $this->last_error = "OAuth token request failed: " . $e->getMessage(); + $this->no_redirect = true; rcube::raise_error([ 'message' => $this->last_error, @@ -359,6 +652,8 @@ class rcmail_oauth * session data. * * @return array Updated authorization data + * + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-6 */ public function refresh_access_token(array $token) { @@ -368,38 +663,34 @@ class rcmail_oauth // send token request to get a real access token for the given auth code try { - $client = rcube::get_instance()->get_http_client([ - 'timeout' => $this->options['timeout'], - 'verify' => $this->options['verify_peer'], - ]); - $response = $client->post($oauth_token_uri, [ - 'form_params' => [ - 'client_id' => $oauth_client_id, - 'client_secret' => $oauth_client_secret, - 'refresh_token' => $this->rcmail->decrypt($token['refresh_token']), - 'grant_type' => 'refresh_token', - ], + $this->log_debug('requesting a grant_type=refresh_token to %s', $oauth_token_uri); + $response = $this->http_client->post($oauth_token_uri, [ + 'form_params' => [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $this->rcmail->decrypt($token['refresh_token']), + 'client_id' => $oauth_client_id, + 'client_secret' => $oauth_client_secret, + ], ]); $data = json_decode($response->getBody(), true); - // auth success - if (!empty($data['access_token'])) { - // update access token stored as password - $authorization = sprintf('%s %s', $data['token_type'], $data['access_token']); - $_SESSION['password'] = $this->rcmail->encrypt($authorization); + $authorization = $this->parse_tokens('refresh_token', $data, $token); - $this->mask_auth_data($data); + // update access token stored as password + $_SESSION['password'] = $this->rcmail->encrypt($authorization); - // update session data - $_SESSION['oauth_token'] = array_merge($token, $data); + $this->mask_auth_data($data); - $this->rcmail->plugins->exec_hook('oauth_refresh_token', $data); + // update session data + $_SESSION['oauth_token'] = array_merge($token, $data); - return [ - 'token' => $data, - 'authorization' => $authorization, - ]; - } + $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(); @@ -431,6 +722,125 @@ class rcmail_oauth } } + /** + * 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 + * + * @return void + */ + public function schedule_token_revocation($sub) + { + if ($this->cache === null) { + rcube::raise_error(['message' => "received a token revocation request, you must activate `oauth_cache` to enable this feature"], true, false); + 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; + } + + $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 string the bearer authorization to use on different transports + */ + protected function parse_tokens($grant_type, &$data, $previous_data=null) + { + // TODO move it into to log_info ? + $this->log_debug('received tokens from a grant request "%s": session: %s with scope %s, access_token type %s exp in %ss, refresh_token exp in %ss, id_token present: %s, not-before-policy: %s', + $grant_type, + $data['session_state'], $data['scope'], + $data['token_type'], $data['expires_in'], + $data['refresh_expires_in'], + isset($data['id_token']), + ($data['not-before-policy'] ?? null) + ); + + if (is_array($previous_data)) { + $this->log_debug( + 'changes: session_state: %s, access_token: %s, refresh_token: %s, id_token: %s', + isset($previous_data['session_state']) ? $previous_data['session_state'] !== $data['session_state'] : null, + isset($previous_data['access_token']) ? $previous_data['access_token'] !== $data['access_token'] : null, + isset($previous_data['refresh_token']) ? $previous_data['refresh_token'] !== $data['refresh_token'] : null, + isset($previous_data['id_token']) ? $previous_data['id_token'] !== $data['id_token'] : null, + ); + } + + // sanity check, check that payload correctly contains access_token + if (empty($data['access_token'])) { + throw new RuntimeException('access_token missing ins answer, error from server'); + } + + // sanity check, check that payload correctly contains access_token + if (empty($data['refresh_token'])) { + throw new RuntimeException('refresh_token missing ins answer, error from server'); + } + + // (> 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']); + // TODO + } + + // please note that id_token / identity may have changed, could be interesting to grab it and refresh values, right now it is not used + + //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 ($data['expires_in'] <= $refresh_interval) { + rcube::raise_error([ + 'message' => sprintf('Warning token expiration (%s) will expire before the refresh_interval (%s)', $data['expires_in'], $refresh_interval), + ], true, false); + // 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 refesh interval + // note: remove 10 sec by security (avoid tangent issues) + $data['expires'] = time() + $data['expires_in'] - $refresh_interval - 10; + } + + $data['refresh_expires'] = time() + $data['refresh_expires_in']; + + $authorization = sprintf('%s %s', $data['token_type'], $data['access_token']); + + return $authorization; + } + /** * Modify some properties of the received auth response * @@ -439,8 +849,8 @@ class rcmail_oauth */ protected function mask_auth_data(&$data) { - // compute absolute token expiration date - $data['expires'] = time() + $data['expires_in'] - 10; + // 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'])) { @@ -454,14 +864,73 @@ class rcmail_oauth * ... and attempt to refresh if possible. * * @param array $token - * @return bool + * @return integer */ protected function check_token_validity($token) { - if ($token['expires'] < time() && isset($token['refresh_token']) && empty($this->last_error)) { - return $this->refresh_access_token($token) !== false; + if (!isset($token['refresh_token'])) { + return self::TOKEN_NOT_FOUND; } - return false; + + 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 ($token['expires'] > time()) { + return self::TOKEN_STILL_VALID; + } + + if (isset($token['refresh_expires']) && $token['refresh_expires'] < time()) { + $this->log_debug("abort, reresh token has expired"); + // in a such 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 recovreable error + // currently it's up to refresh_access_token to kill_session is 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 void + */ + public function refresh($options) + { + if (!isset($_SESSION['oauth_token'])) { + return; + } + + $this->check_token_validity($_SESSION['oauth_token']); + } + + + /** + * Returns the auth_type to use + * + * @return string + * the auth type: XOAUTH or OAUTHBEARER + */ + public function get_auth_type() + { + return $this->auth_type; } /** @@ -473,13 +942,10 @@ class rcmail_oauth public function storage_init($options) { if (isset($_SESSION['oauth_token']) && $options['driver'] === 'imap') { - // check token validity - if ($this->check_token_validity($_SESSION['oauth_token'])) { + if ($this->check_token_validity($_SESSION['oauth_token']) === self::TOKEN_REFRESHED) { $options['password'] = $this->rcmail->decrypt($_SESSION['password']); } - - // enforce XOAUTH2 authorization type - $options['auth_type'] = 'XOAUTH2'; + $options['auth_type'] = $this->auth_type; } return $options; @@ -497,10 +963,10 @@ class rcmail_oauth // check token validity $this->check_token_validity($_SESSION['oauth_token']); - // enforce XOAUTH2 authorization type + // enforce AUTHBEARER/XOAUTH2 authorization type $options['smtp_user'] = '%u'; $options['smtp_pass'] = '%p'; - $options['smtp_auth_type'] = 'XOAUTH2'; + $options['smtp_auth_type'] = $this->auth_type; } return $options; @@ -518,8 +984,8 @@ class rcmail_oauth // check token validity $this->check_token_validity($_SESSION['oauth_token']); - // enforce XOAUTH2 authorization type - $options['auth_type'] = 'XOAUTH2'; + // enforce AUTHBEARER/XOAUTH2 authorization type + $options['auth_type'] = $this->auth_type; } return $options; @@ -531,9 +997,89 @@ class rcmail_oauth * @param array $options * @return array */ - public function logout_after($options) + 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 + } + } + + /** + * 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(); + $oauth_handler->run(); + } + else if ($args['task'] == 'login' && $args['action'] == 'backchannel') { + // handle oauth login requests + $oauth_handler = new rcmail_action_login_oauth_backchannel(); + $oauth_handler->run(); + } + else if ($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` + * + * @return void + * + * @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html + */ + public function handle_logout() + { + // 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'] = $_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); } /** @@ -569,18 +1115,4 @@ class rcmail_oauth return $options; } - - - /** - * Callback for 'refresh' hook - * - * @param array $options - * @return void - */ - public function refresh($options) - { - if (isset($_SESSION['oauth_token'])) { - $this->check_token_validity($_SESSION['oauth_token']); - } - } } diff --git a/program/include/rcmail_output_html.php b/program/include/rcmail_output_html.php index ab243460e..4eee2abf0 100644 --- a/program/include/rcmail_output_html.php +++ b/program/include/rcmail_output_html.php @@ -2415,27 +2415,6 @@ EOF; $form_content['buttons']['submit'] = ['outterclass' => 'formbuttons', 'content' => $button]; } - // add oauth login button - if ($this->config->get('oauth_auth_uri') && $this->config->get('oauth_provider')) { - // hide login form fields when `oauth_login_redirect` is configured - if ($this->config->get('oauth_login_redirect')) { - $form_content['hidden'] = []; - $form_content['inputs'] = []; - $form_content['buttons'] = []; - } - - $link_attr = [ - 'href' => $this->app->url(['action' => 'oauth']), - 'id' => 'rcmloginoauth', - 'class' => 'button oauth ' . $this->config->get('oauth_provider') - ]; - - $provider = $this->config->get('oauth_provider_name', 'OAuth'); - $button = html::a($link_attr, $this->app->gettext(['name' => 'oauthlogin', 'vars' => ['provider' => $provider]])); - - $form_content['buttons']['oauthlogin'] = ['outterclass' => 'oauthlogin', 'content' => $button]; - } - $data = $this->app->plugins->exec_hook('loginform_content', $form_content); $this->add_gui_object('loginform', $form_name); diff --git a/program/lib/Roundcube/rcube_imap_generic.php b/program/lib/Roundcube/rcube_imap_generic.php index 90812f678..3d625d65e 100644 --- a/program/lib/Roundcube/rcube_imap_generic.php +++ b/program/lib/Roundcube/rcube_imap_generic.php @@ -780,9 +780,11 @@ class rcube_imap_generic $line = $this->readReply(); $result = $this->parseResult($line); } - else if ($type == 'XOAUTH2') { - $auth = base64_encode("user=$user\1auth=$pass\1\1"); - $this->putLine($this->nextTag() . " AUTHENTICATE XOAUTH2 $auth", true, true); + else if (($type == 'XOAUTH2') || ($type == 'OAUTHBEARER')) { + $auth = ($type == 'XOAUTH2') + ? base64_encode("user=$user\1auth=$pass\1\1") // XOAUTH: original extension, still widely used + : base64_encode("n,a=$user,\1auth=$pass\1\1"); // OAUTHBEARER: official RFC 7628 + $this->putLine($this->nextTag() . " AUTHENTICATE $type $auth", true, true); $line = trim($this->readReply()); @@ -988,6 +990,7 @@ class rcube_imap_generic case 'PLAIN': case 'LOGIN': case 'XOAUTH2': + case 'OAUTHBEARER': $result = $this->authenticate($user, $password, $auth_method); break; diff --git a/tests/Rcmail/Oauth.php b/tests/Rcmail/Oauth.php index 1fd2c9773..aab2a8298 100644 --- a/tests/Rcmail/Oauth.php +++ b/tests/Rcmail/Oauth.php @@ -1,5 +1,9 @@ 'test', + 'token_uri' => 'https://test/token', + 'auth_uri' => 'https://test/auth', + 'identity_uri' => 'https://test/ident', + 'issuer' => 'https://test/', + // Do not set JWKS + 'client_id' => 'some-client', + 'client_secret' => 'very-secure', + 'scope' => 'plop', + ]; + + private $identity = [ + "sub" => "82c8f487-df95-4960-972c-4e680c3c72f5", + "name" => "John Doe", + "preferred_username" => "John D", + "given_name" => "John", + "family_name" => "Doe", + "email" => "j.doe@test.fake", + "email_verified" => true, + "locale" => "en" + ]; + + private function generate_fake_id_token() + { + $id_token_payload = (array) [ + "typ" => "ID", // this is a token id + "exp" => (time() + 600), + "iat" => time(), + "auth_time" => time(), + "jti" => "uniq-id", + "iss" => $this->config['issuer'], + "aud" => $this->config['client_id'], + "azp" => $this->config['client_id'], + "session_state" => "fake-session", + "acr" => "1", + "sid" => "65f8d42c-dbbd-4f76-b5f3-44b540e4253a", + ] + $this->identity; + + //Right now our code does not check signature + $jwt_header = strtr(base64_encode(json_encode(["alg" => "NONE", "typ" => "JWT"])), '+/', '-_'); + $jwt_body = strtr(base64_encode(json_encode($id_token_payload)), '+/', '-_'); + $jwt_signature = ''; // NONE alg + + return implode(".", [$jwt_header, $jwt_body, $jwt_signature]); + } + /** * Test jwt_decode() method with an invalid token */ @@ -66,6 +118,50 @@ class Rcmail_RcmailOauth extends ActionTestCase $this->assertFalse($oauth->is_enabled()); } + /** + * Test is_enabled() method + */ + function test_is_enabled_with_token_url() + { + $oauth = new rcmail_oauth($this->config); + $oauth->init(); + + $this->assertTrue($oauth->is_enabled()); + } + + /** + * Test discovery method + */ + function test_discovery() + { + //fake discovery response + $config_answer = [ + 'issuer' => 'https://test/issuer', + 'authorization_endpoint' => 'https://test/auth', + 'token_endpoint' => 'https://test/token', + 'userinfo_endpoint' => 'https://test/userinfo', + 'end_session_endpoint' => 'https://test/logout', + 'jwks_uri' => 'https://test/jwks' + ]; + + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode($config_answer)) + ]); + $handler = HandlerStack::create($mock); + + //provide only the config + $oauth = new rcmail_oauth([ + 'provider' => 'example', + 'config_uri' => 'https://test/config', + 'client_id' => 'some-client', + 'http_options' => ['handler' => $handler] + ]); + $oauth->init(); + + //if discovery succeed, should be enabled + $this->assertTrue($oauth->is_enabled()); + } + /** * Test get_redirect_uri() method */ @@ -81,7 +177,47 @@ class Rcmail_RcmailOauth extends ActionTestCase */ function test_login_redirect() { - $this->markTestIncomplete(); + $output = $this->initOutput(rcmail_action::MODE_HTTP, 'login', ''); + + $oauth = new rcmail_oauth($this->config); + $oauth->init(); + + try { + $oauth->login_redirect(); + } + catch (ExitException $e) { + $result = $e->getMessage(); + $ecode = $e->getCode(); + } + # return type: Location: https://localhost/auth/auth?response_type=code&client_id=some-client&scope=&redirect_uri=http%3A%2F%2F%2Fvendor%2Fbin%2Fphpunit%2Findex.php%2Flogin%2Foauth&state=HphWFK5EBHAr + //$result = $output->getOutput(); + + $this->assertSame(OutputHtmlMock::E_REDIRECT, $ecode); + $this->assertMatchesRegularExpression('|^Location: https://test/auth\?.*|', $result); + + list($base, $query) = explode('?', substr($result, 10)); + parse_str($query, $map); + + $this->assertEquals($this->config['scope'], $map['scope']); + $this->assertEquals($this->config['client_id'], $map['client_id']); + $this->assertEquals('code', $map['response_type']); + $this->assertEquals($_SESSION['oauth_state'], $map['state']); + $this->assertMatchesRegularExpression('!http.*/login/oauth!', $map['redirect_uri']); + } + + /** + * Test request_access_token() method with a wrong state + */ + function test_request_access_token_with_wrong_state() + { + $oauth = new rcmail_oauth($this->config); + $oauth->init(); + + $_SESSION['oauth_state'] = "random-state"; + $response = $oauth->request_access_token('fake-code', 'mismatch-state'); + + // should be false as state do not match + $this->assertFalse($response); } /** @@ -89,7 +225,73 @@ class Rcmail_RcmailOauth extends ActionTestCase */ function test_request_access_token() { - $this->markTestIncomplete(); + $payload = [ + 'token_type' => 'Bearer', + 'access_token' => 'FAKE-ACCESS-TOKEN', + 'expires_in' => 300, + 'refresh_token' => 'FAKE-REFRESH-TOKEN', + 'refresh_expires_in' => 1800, + 'id_token' => $this->generate_fake_id_token(), // inject a generated identity + 'not-before-policy' => 0, + 'session_state' => 'fake-session', + 'scope' => 'openid profile email' + ]; + + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode($payload)) + ]); + $handler = HandlerStack::create($mock); + $oauth = new rcmail_oauth((array) $this->config + [ + 'http_options' => ['handler' => $handler] + ]); + $oauth->init(); + + $_SESSION['oauth_state'] = "random-state"; // ensure state identiquals + $response = $oauth->request_access_token('fake-code', 'random-state'); + + $this->assertTrue(is_array($response)); + $this->assertEquals('Bearer FAKE-ACCESS-TOKEN', $response['authorization']); + $this->assertEquals($this->identity['email'], $response['username']); + $this->assertTrue(isset($response['token'])); + $this->assertFalse(isset($response['token']['access_token'])); + } + + /** + * Test request_access_token() method without identity, code will have to fetch the identity using the access token + */ + function test_request_access_token_without_id_token() + { + $payload = [ + 'token_type' => 'Bearer', + 'access_token' => 'FAKE-ACCESS-TOKEN', + 'expires_in' => 300, + 'refresh_token' => 'FAKE-REFRESH-TOKEN', + 'refresh_expires_in' => 1800, + 'not-before-policy' => 0, + 'session_state' => 'fake-session', + 'scope' => 'openid profile email' + ]; + + //TODO should create a specific Mock to check request and validate it + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode($payload)), // the request access + new Response(200, ['Content-Type' => 'application/json'], json_encode($this->identity)) // call to userinfo + ]); + $handler = HandlerStack::create($mock); + + $oauth = new rcmail_oauth((array) $this->config + [ + 'http_options' => ['handler' => $handler] + ]); + $oauth->init(); + + $_SESSION['oauth_state'] = "random-state"; // ensure state identiquals + $response = $oauth->request_access_token('fake-code', 'random-state'); + + $this->assertTrue(is_array($response)); + $this->assertEquals('Bearer FAKE-ACCESS-TOKEN', $response['authorization']); + $this->assertEquals($this->identity['email'], $response['username']); + $this->assertTrue(isset($response['token'])); + $this->assertFalse(isset($response['token']['access_token'])); } /** @@ -97,6 +299,7 @@ class Rcmail_RcmailOauth extends ActionTestCase */ function test_refresh_access_token() { + //FIXME $this->markTestIncomplete(); } }