mirror of
https://github.com/roundcube/roundcubemail.git
synced 2026-02-20 01:21:20 +01:00
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:
@@ -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/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
99
program/actions/login/oauth_backchannel.php
Normal file
99
program/actions/login/oauth_backchannel.php
Normal 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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user