From ffa298d41c6198c39f028ad419c3c3e94afeed0f Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle <15628033+EdouardVanbelle@users.noreply.github.com> Date: Fri, 29 Dec 2023 19:10:37 +0100 Subject: [PATCH] OAuth: feat: use OIDC claims on user creation (#9286) Signed-off-by: Edouard Vanbelle --- config/defaults.inc.php | 11 ++++ program/include/rcmail_oauth.php | 90 ++++++++++++++++++++++++++++++++ tests/Rcmail/Oauth.php | 57 ++++++++++++++++++++ 3 files changed, 158 insertions(+) diff --git a/config/defaults.inc.php b/config/defaults.inc.php index 7eb75a7ea..d6c4b5ce8 100644 --- a/config/defaults.inc.php +++ b/config/defaults.inc.php @@ -407,6 +407,17 @@ $config['oauth_cache'] = 'db'; // Optional: cache ttl $config['oauth_cache_ttl'] = '8h'; +// Optional: map OIDC claims to Roundcube keys during the account creation +// format: roundcube_key => array of claims (the first claim found and defined will be used) +// more informations on claims: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims +// roundcube_key can be: user_name, user_email, language +// default value: +$config['oauth_user_create_map'] = [ + 'user_name' => ['name'], + 'user_email' => ['email'], + 'language' => ['locale'], +]; + ///// Example config for Gmail // Register your service at https://console.developers.google.com/ diff --git a/program/include/rcmail_oauth.php b/program/include/rcmail_oauth.php index 74603f1d6..914ed1aaa 100644 --- a/program/include/rcmail_oauth.php +++ b/program/include/rcmail_oauth.php @@ -70,6 +70,9 @@ class rcmail_oauth /** @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) */ static protected $config_mapper = [ 'issuer' => 'issuer', @@ -156,6 +159,13 @@ class rcmail_oauth '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), @@ -170,6 +180,7 @@ class rcmail_oauth $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([ @@ -179,6 +190,18 @@ class rcmail_oauth ], 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([ + 'message' => "use of key `{$key}` in `oauth_user_create_map` is not allowed", + 'file' => __FILE__, + 'line' => __LINE__, + ], 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'], @@ -329,6 +352,7 @@ class rcmail_oauth $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']); @@ -1146,6 +1170,72 @@ class rcmail_oauth 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]) && strlen($identity[$oidc_claim]) > 0) { + $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([ + 'message' => "user_create: ignoring invalid email '{$value}' (from claim '{$oidc_claim}')", + 'file' => __FILE__, + 'line' => __LINE__, + ], true, false); + 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([ + 'message' => "user_create: ignoring language '{$value}' (from claim '{$oidc_claim}')", + 'file' => __FILE__, + 'line' => __LINE__, + ], true, false); + 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 * diff --git a/tests/Rcmail/Oauth.php b/tests/Rcmail/Oauth.php index 7151bf1e8..9be00c165 100644 --- a/tests/Rcmail/Oauth.php +++ b/tests/Rcmail/Oauth.php @@ -4,6 +4,14 @@ use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; +class rcmail_oauth_test extends rcmail_oauth +{ + public function forge_login_phase($data) + { + $this->login_phase = $data; + } +} + /** * Test class to test rcmail_oauth class */ @@ -336,6 +344,55 @@ class Rcmail_RcmailOauth extends ActionTestCase $this->assertFalse(isset($response['token']['access_token'])); } + /** + * Test user_create() method + */ + function test_valid_user_create() + { + $oauth = new rcmail_oauth_test(); + $oauth->init(); + + // fake identity + $oauth->forge_login_phase([ + 'token' => [ + 'identity' => [ + 'email' => 'jdoe@faké.dômain', + 'name' => 'John Doe', + 'locale' => 'en-US', + ], + ], + ]); + $answer = $oauth->user_create([]); + + $this->assertSame($answer, [ + 'user_name' => 'John Doe', + 'user_email' => 'jdoe@xn--fak-dma.xn--dmain-6ta', + 'language' => 'en_US', + ]); + } + + function test_invalid_user_create() + { + $oauth = new rcmail_oauth_test(); + $oauth->init(); + + // fake identity + $oauth->forge_login_phase([ + 'token' => [ + 'identity' => [ + 'email' => 'bad-domain', + 'name' => 'John Doe', + 'locale' => '/martian', + ], + ], + ]); + $answer = $oauth->user_create([]); + + //only user_name can be defined + $this->assertSame($answer, ['user_name' => 'John Doe']); + } + + /** * Test refresh_access_token() method */