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 <edouard@vanbelle.fr>
Co-authored-by: Aleksander Machniak <alec@alec.pl>
This commit is contained in:
Edouard Vanbelle
2023-12-17 09:13:07 +01:00
committed by GitHub
parent f5d7673baa
commit 588a879107
8 changed files with 1104 additions and 266 deletions

View File

@@ -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://<your roundcube instance>/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 <default log path>/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/

View File

@@ -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);

View File

@@ -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();

View File

@@ -0,0 +1,99 @@
<?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. |
| |
| PURPOSE: |
| Implementation of backchannel logout from IDP |
| |
| @see https://openid.net/specs/openid-connect-backchannel-1_0.html |
| |
| URL to declare: <roundcube instance>/index.php/login/backchannel |
+-----------------------------------------------------------------------+
| Author: Thomas Bruederli <roundcube@gmail.com> |
+-----------------------------------------------------------------------+
*/
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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);

View File

@@ -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;

View File

@@ -1,5 +1,9 @@
<?php
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
/**
* Test class to test rcmail_oauth class
*
@@ -7,6 +11,54 @@
*/
class Rcmail_RcmailOauth extends ActionTestCase
{
// created a valid and enabled oauth instance
private $config = [
'provider' => '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();
}
}