Files
roundcubemail/program/include/rcmail_oauth.php
2026-01-25 13:03:05 +01:00

1368 lines
49 KiB
PHP

<?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;
}
}