SAML/Shibboleth with multiple IdP or optional on regular login page

This commit is contained in:
Ralf Becker 2020-06-10 15:19:08 +02:00
parent 06d6887744
commit 4c131c1866
15 changed files with 457 additions and 284 deletions

View File

@ -57,6 +57,14 @@ egw_LAB.wait(function()
{ "svg": egw_webserverUrl+"/api/templates/default/images/login_discourse.svg", "url": "https://help.egroupware.org" },
{ "svg": egw_webserverUrl+"/api/templates/default/images/login_github.svg", "url": "https://github.com/EGroupware/egroupware" }
]);
// automatic submit of SAML IdP selection
jQuery('select.onChangeSubmit').on('change', function() {
if (this.value) {
this.form.method = 'GET';
this.form.submit();
}
});
});
});

View File

@ -70,6 +70,7 @@ $setup_info['api']['hooks']['vfs_rmdir'] = 'EGroupware\\Api\\Vfs\\Sharing::vfsUp
// hook to update SimpleSAMLphp config
$setup_info['api']['hooks']['setup_config'] = \EGroupware\Api\Auth\Saml::class.'::setupConfig';
$setup_info['api']['hooks']['login_discovery'] = \EGroupware\Api\Auth\Saml::class.'::discovery';
// installation checks
$setup_info['api']['check_install'] = array(

View File

@ -53,20 +53,38 @@ class Auth
/**
* Constructor
*
* @param Backend $type =null default is type from session / auth or login, or if not set config
* @throws Exception\AssertionFailed if backend is not an Auth\Backend
*/
function __construct()
function __construct($type=null)
{
$this->backend = self::backend();
$this->backend = self::backend($type);
}
/**
* Get current backend
*
* @return string
*/
public function backendType()
{
return Cache::getSession(__CLASS__, 'backend');
}
/**
* Instanciate a backend
*
* @param Backend $type =null
* Type will be stored in session, to automatic use the same type eg. for conditional use of SAML.
*
* @param Backend $type =null default is type from session / auth or login, or if not set config
* @return Auth\Backend|Auth\BackendSSO
*/
static function backend($type=null)
{
if (is_null($type))
{
$type = Cache::getSession(__CLASS__, 'backend') ?: null;
}
// do we have a hostname specific auth type set
if (is_null($type) && !empty($GLOBALS['egw_info']['server']['auth_type_host']) &&
Header\Http::host() === $GLOBALS['egw_info']['server']['auth_type_hostname'])
@ -88,18 +106,49 @@ class Auth
{
throw new Exception\AssertionFailed("Auth backend class $backend_class is NO EGroupware\\Api\Auth\\Backend!");
}
Cache::setSession(__CLASS__, 'backend', $type);
return $backend;
}
/**
* Attempt a SSO login
*
* A different then the default backend can be selected by setting request parameter auth to the backend or
* setting "auth=$backend" to an arbitrary value eg. with a submit button named like that.
* To secure this behavior the server config "${auth}_discovery" has to be set (to a non-empty value)!
*
* @return string sessionid on successful login or null
* @throws Exception\AssertionFailed
*/
static function login()
{
$backend = self::backend();
if (!empty($_REQUEST['auth']))
{
$type = $_REQUEST['auth'];
}
elseif (($auth = array_filter($_REQUEST, function($key)
{
return substr($key, 0, 5) === 'auth=';
}, ARRAY_FILTER_USE_KEY)))
{
$type = substr(key($auth), 5);
}
// to not allow enabling all sort of auth plugins by simply calling login.php?auth=xyz we require the
// plugin to be enabled via "${auth}_discovery" server config
if (!empty($type) && empty($GLOBALS['egw_info']['server'][$type.'_discovery']))
{
$type = null;
}
// now we need a (not yet authenticated) session so SAML / auth source selected "survives" eg. the SAML redirects
if (!empty($type) && !Session::get_sessionid())
{
session_start();
Session::egw_setcookie(Session::EGW_SESSION_NAME, session_id());
}
$backend = self::backend($type ?? null);
return $backend instanceof Auth\BackendSSO ? $backend->login() : null;
}
@ -110,11 +159,9 @@ class Auth
* @return null
* @throws Exception\AssertionFailed
*/
static function logout()
function logout()
{
$backend = self::backend();
return $backend instanceof Auth\BackendSSO ? $backend->logout() : null;
return $this->backend instanceof Auth\BackendSSO ? $this->backend->logout() : null;
}
/**
@ -125,11 +172,9 @@ class Auth
*
* @return array of needed keys in session
*/
static function needSession()
function needSession()
{
$backend = self::backend();
return method_exists($backend, 'needSession') ? $backend->needSession() : [];
return method_exists($this->backend, 'needSession') ? $this->backend->needSession() : [];
}
/**

View File

@ -1,6 +1,6 @@
<?php
/**
* EGroupware API - Authentication via SAML or everything supported by SimpleSAMLphp
* EGroupware API - Authentication via SAML, Shibboleth or everything supported by SimpleSAMLphp
*
* @link https://www.egroupware.org
* @link https://simplesamlphp.org/docs/stable/
@ -16,59 +16,46 @@ use SimpleSAML;
use EGroupware\Api\Exception;
/**
* Authentication based on SAML or everything supported by SimpleSAMLphp
* Authentication based on SAML, Shibboleth or everything supported by SimpleSAMLphp
*
* SimpleSAMLphp is installed together with EGroupware and a default configuration is created in EGroupware
* files subdirectory "saml", once "Saml" is set as authentication method in setup and eg. the login page is loaded.
* files subdirectory "saml" eg. when you first store it's configuration in Setup > Configuration > SAML/Shibboleth
*
* It will NOT work, before you configure at least one IdP (Identity Provider) for the default-sp (Service Provider) in saml/authsourcres.php:
* Storing setup configuration modifies the following files:
* a) $files_dir/saml/config.php
* b) $files_dir/saml/authsources.php (only "default-sp" is used currently)
* c) $files_dir/saml/metadata/*
* d) $files_dir/saml/cert/*
* Modification is only on certain values, everything else can be edited to suit your needs.
*
* // An authentication source which can authenticate against both SAML 2.0
* // and Shibboleth 1.3 IdPs.
* 'default-sp' => [
* 'saml:SP',
* Initially also a key-pair is generated as $files_dir/saml/cert/saml.{pem,crt}.
* If you want or have to use a different certificate, best replace these with your files (they are referenced multiple times!).
* They must stay in the files directory and can NOT be symlinks to eg. /etc, as only files dir is mounted into the container!
*
* // The entity ID of this SP.
* // Can be NULL/unset, in which case an entity ID is generated based on the metadata URL.
* 'entityID' => null,
* Authentication / configuration can be tested independent of EGroupware by using https://example.org/egroupware/saml/
* with the "admin" user and password stored in cleartext in $files_dir/saml/config.php under 'auth.adminpassword'.
*
* // The entity ID of the IdP this SP should contact.
* // Can be NULL/unset, in which case the user will be shown a list of available IdPs.
* 'idp' => 'https://samltest.id/saml/idp',
*
* And the IdP's metadata in saml/metadata/saml20-idp-remote.php
*
* $metadata['https://samltest.id/saml/idp'] = [
* 'SingleSignOnService' => 'https://samltest.id/idp/profile/SAML2/Redirect/SSO',
* 'SingleLogoutService' => 'https://samltest.id/idp/profile/Logout',
* 'certificate' => 'samltest.id.pem',
* ];
*
* https://samltest.id/ is just a SAML / Shibboleth test side allowing AFTER uploading your metadata to test with a couple of static test-accounts.
*
* The metadata can be downloaded by via https://example.org/egroupware/saml/ under Federation, it also allows to test the authentication.
* The required (random) Admin password can be found in /var/lib/egrouwpare/default/saml/config.php searching for auth.adminpassword.
*
* Alternativly you can also modify the following metadata example by replacing https://example.org/ with your domain:
*
* <?xml version="1.0"?>
* <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://example.org/egroupware/saml/module.php/saml/sp/metadata.php/default-sp">
* <md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol urn:oasis:names:tc:SAML:1.1:protocol">
* <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://example.org/egroupware/saml/module.php/saml/sp/saml2-logout.php/default-sp"/>
* <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://example.org/egroupware/saml/module.php/saml/sp/saml2-acs.php/default-sp" index="0"/>
* <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:1.0:profiles:browser-post" Location="https://example.org/egroupware/saml/module.php/saml/sp/saml1-acs.php/default-sp" index="1"/>
* <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://example.org/egroupware/saml/module.php/saml/sp/saml2-acs.php/default-sp" index="2"/>
* <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:1.0:profiles:artifact-01" Location="https://example.org/egroupware/saml/module.php/saml/sp/saml1-acs.php/default-sp/artifact" index="3"/>
* </md:SPSSODescriptor>
* <md:ContactPerson contactType="technical">
* <md:GivenName>Admin</md:GivenName>
* <md:SurName>Name</md:SurName>
* <md:EmailAddress>mailto:admin@example.org</md:EmailAddress>
* </md:ContactPerson>
* </md:EntityDescriptor>
* There are basically three possible scenarios currently supported:
* a) a single IdP and SAML configured as authentication method
* --> gives full SSO (login page is never displayed, it directly redirects to the IdP)
* b) one or multiple IdP, a discovery label and an other authentication type eg. SQL configured
* --> uses the login page for local accounts plus a button or selectbox (depending on number of IdPs) to start SAML login
* c) multiple IdP and SAML configured as authentication method
* --> SimpleSAML discovery/selection page with a checkbox to remember the selection (SSO after first selection)
*/
class Saml implements BackendSSO
{
/**
* Which entry in authsources.php to use.
*
* Setup > configuration always modifies "default-sp"
*
* A different SP can be configured via header.inc.php by adding at the end:
*
* EGroupware\Api\Auth\Saml::$auth_source = "other-sp";
*/
static public $auth_source = 'default-sp';
/**
* Constructor
*/
@ -79,7 +66,7 @@ class Saml implements BackendSSO
}
/**
* authentication against SAML
* Authentication against SAML
*
* @param string $username username of account to authenticate
* @param string $passwd corresponding password
@ -89,7 +76,7 @@ class Saml implements BackendSSO
function authenticate($username, $passwd, $passwd_type='text')
{
// login (redirects to IdP)
$as = new SimpleSAML\Auth\Simple('default-sp');
$as = new SimpleSAML\Auth\Simple(self::$auth_source);
$as->requireAuth();
return true;
@ -125,12 +112,13 @@ class Saml implements BackendSSO
function login()
{
// login (redirects to IdP)
$as = new SimpleSAML\Auth\Simple('default-sp');
$as->requireAuth();
$as = new SimpleSAML\Auth\Simple(self::$auth_source);
$as->requireAuth(preg_match('|^https://|', $_REQUEST['auth=saml']) ?
['saml:idp' => $_REQUEST['auth=saml']] : []);
// cleanup session for EGroupware
/* cleanup session for EGroupware: currently NOT used as we share the session with SimpleSAMLphp
$session = SimpleSAML\Session::getSessionFromRequest();
$session->cleanup();
$session->cleanup();*/
// get attributes for (automatic) account creation
$attrs = $as->getAttributes();
@ -159,8 +147,8 @@ class Saml implements BackendSSO
*/
function logout()
{
$as = new SimpleSAML\Auth\Simple('default-sp');
$as->logout();
$as = new SimpleSAML\Auth\Simple(self::$auth_source);
if ($as->isAuthenticated()) $as->logout();
}
/**
@ -173,7 +161,48 @@ class Saml implements BackendSSO
*/
function needSession()
{
return ['SimpleSAMLphp_SESSION'];
return ['SimpleSAMLphp_SESSION', Api\Session::EGW_APPSESSION_VAR]; // Auth stores backend via Cache::setSession()
}
const IDP_DISPLAY_NAME = 'OrganizationDisplayName';
/**
* Display a IdP selection / discovery
*
* Will be displayed if IdP(s) are added in setup and a discovery label is specified.
*
* @return string|null html to display in login page or null to disable the selection
*/
static public function discovery()
{
if (empty($GLOBALS['egw_info']['server']['saml_discovery']) ||
!($metadata = self::metadata()))
{
return null;
}
//error_log(__METHOD__."() metadata=".json_encode($metadata));
$lang = Api\Translation::$userlang;
$select = ['' => $GLOBALS['egw_info']['server']['saml_discovery']];
foreach($metadata as $idp => $data)
{
$select[$idp] = $data[self::IDP_DISPLAY_NAME][$lang] ?: $data[self::IDP_DISPLAY_NAME]['en'];
}
return count($metadata) > 1 ?
Api\Html::select('auth=saml', '', $select, true, 'class="onChangeSubmit"') :
Api\Html::input('auth=saml', $GLOBALS['egw_info']['server']['saml_discovery'], 'submit', 'formmethod="get"');
}
/**
* @return array IdP => metadata pairs
*/
static public function metadata($files_dir=null)
{
$metadata = [];
if (file_exists($file = ($files_dir ?: $GLOBALS['egw_info']['server']['files_dir']).'/saml/metadata/saml20-idp-remote.php'))
{
include $file;
}
return $metadata;
}
const ASYNC_JOB_ID = 'saml_metadata_refresh';
@ -190,11 +219,7 @@ class Saml implements BackendSSO
{
$config =& $location['newsettings'];
/*error_log(__METHOD__."() ".json_encode(array_filter($config, function($value, $key) {
return substr($key, 0, 5) === 'saml_' || $key === 'auth_type';
}, ARRAY_FILTER_USE_BOTH), JSON_UNESCAPED_SLASHES));*/
if (empty($config['saml_idp'])) return; // nothing to do, if not idp defined
if (empty($config['saml_idp'])) return; // nothing to do, if no idp defined
if (file_exists($config['files_dir'].'/saml/config.php'))
{
@ -204,7 +229,6 @@ class Saml implements BackendSSO
// install or remove async job to refresh metadata
static $freq2times = [
'hourly' => ['min' => 4], // hourly at minute 4
'daily' => ['min' => 4, 'hour' => 4], // daily at 4:04am
'weekly' => ['min' => 4, 'hour' => 4, 'dow' => 5], // Saturdays as 4:04am
];
@ -219,12 +243,37 @@ class Saml implements BackendSSO
$async->cancel_timer(self::ASYNC_JOB_ID);
}
// only refresh metadata if we have to, or request by user
if ($config['saml_metadata_refresh'] !== 'no')
{
self::refreshMetadata($config);
$metadata = self::metadata($config['files_dir']);
$idps = self::splitIdP($config['saml_idp']);
foreach($idps as $idp)
{
if (!isset($metadata[$idp]))
{
$metadata = [];
break;
}
}
if (count($metadata) !== count($idps) || $config['saml_metadata_refresh'] === 'now')
{
self::refreshMetadata($config);
}
}
}
/**
* Split multiple IdP
*
* @param string $config
* @return string[]
*/
private static function splitIdP($config)
{
return preg_split('/[\n\r ]+/', trim($config)) ?: [];
}
/**
* Refresh metadata
*
@ -241,7 +290,8 @@ class Saml implements BackendSSO
$source = [
'src' => $config['saml_metadata'],
'whitelist' => [$config['saml_idp']], // only ready our idp, the whole thing can be huge
// only read/configure our idp(s), the whole thing can be huge
'whitelist' => self::splitIdP($config['saml_idp']),
];
if (!empty($config['saml_certificate']))
{
@ -271,7 +321,15 @@ class Saml implements BackendSSO
$GLOBALS['egw_info']['server']['usecookies'] = true;
$config['baseurlpath'] = Api\Framework::getUrl(Api\Egw::link('/saml/'));
$config['username_oid'] = [self::usernameOid($config)];
// if multiple IdP's are configured, do NOT specify one to let user select
if (count(self::splitIdP($config['saml_idp'])) > 1)
{
unset($config['saml_idp']);
}
else
{
$config['saml_idp'] = trim($config['saml_idp']);
}
// update config.php and default-sp in authsources.php
foreach([
'authsources.php' => [
@ -445,12 +503,18 @@ class Saml implements BackendSSO
case 'authsources.php':
$replacements = [
"'idp' => null," => "'idp' => ".self::quote($config['saml_idp']).',',
"'idp' => null," => "'idp' => ".self::quote(
count(self::splitIdP($config['saml_idp'])) <= 1 ? trim($config['saml_idp']) : null).',',
"'discoURL' => null," => "'discoURL' => null,\n\n".
// add our private and public keys
"\t'privatekey' => 'saml.pem',\n\n".
"\t// to include certificate in metadata\n".
"\t'certificate' => 'saml.crt',\n\n".
"\t// new certificates for rotation: add new, wait for IdP sync, swap old and new, wait, comment again\n".
"\t//'new_privatekey' => 'new-saml.pem',\n".
"\t//'new_certificate' => 'new-saml.crt',\n\n".
"\t// logout is NOT signed by default, but signature is required from the uni-kl.de IdP for logout\n".
"\t'sign.logout' => true,\n\n".
"\t'name' => [\n".
"\t\t'en' => ".self::quote($config['saml_sp'] ?: 'EGroupware').",\n".
"\t],\n\n".

View File

@ -402,7 +402,7 @@ class Cache
*/
static public function &getSession($app,$location,$callback=null,array $callback_params=array(),$expiration=0)
{
if (isset($_SESSION[Session::EGW_SESSION_ENCRYPTED]))
if (!isset($_SESSION) || isset($_SESSION[Session::EGW_SESSION_ENCRYPTED]))
{
if (Session::ERROR_LOG_DEBUG) error_log(__METHOD__.' called after session was encrypted --> ignored!');
return null; // can no longer store something in the session, eg. because commit_session() was called

View File

@ -585,7 +585,7 @@ class Db
{
foreach(get_included_files() as $file)
{
if (strpos($file,'adodb') !== false && !in_array($file,(array)$_SESSION['egw_required_files']))
if (strpos($file,'adodb') !== false && !in_array($file,(array)$_SESSION['egw_required_files']) && isset($_SESSION))
{
$_SESSION['egw_required_files'][] = $file;
//error_log(__METHOD__."() egw_required_files[] = $file");

View File

@ -81,6 +81,23 @@ class Login
$tmpl->set_var('2fa_class', 'et2_required');
}
}
// check if we need some discovery (select login options eg. a SAML IdP), hide it if not
$discovery = '';
foreach(Api\Hooks::process('login_discovery', [], true) as $app => $data)
{
if (!empty($data)) $discovery .= $data;
}
if (!empty($discovery))
{
$tmpl->set_var('discovery', $discovery);
}
else
{
$tmpl->set_block('login_form','discovery_block');
$tmpl->set_var('discovery_block', '');
}
// hide change-password fields, if not requested
if (!$change_passwd)
{

View File

@ -1444,9 +1444,11 @@ class Session
if (!$GLOBALS['egw_info']['user']['sessionid'] || $sessionid == $GLOBALS['egw_info']['user']['sessionid'])
{
// eg. SAML logout will fail, if there is no more session --> remove everything else
if (($needed = Auth::needSession()) && array_intersect($needed, array_keys($_SESSION)))
$auth = new Auth();
if (($needed = $auth->needSession()) && array_intersect($needed, array_keys($_SESSION)))
{
$_SESSION = array_intersect_key($_SESSION['SimpleSAMLphp_SESSION'], array_flip($needed));
$_SESSION = array_intersect_key($_SESSION, array_flip($needed));
Auth::backend($auth->backendType()); // backend is stored in session
return true;
}
if (self::ERROR_LOG_DEBUG) error_log(__METHOD__." ********* about to call session_destroy!");

View File

@ -39,6 +39,8 @@ elseif(strpos($redirectTarget, '[?&]cd=') !== false)
if ($verified)
{
$auth = new Api\Auth();
// remove remember me cookie on explicit logout, unless it is a second factor
if ($GLOBALS['egw']->session->removeRememberMeTokenOnLogout())
{
@ -53,7 +55,7 @@ Api\Session::egw_setcookie('kp3');
Api\Session::egw_setcookie('domain');
// SSO Logout (does not return for SSO systems)
Api\Auth::logout();
if (isset($auth)) $auth->logout();
// $GLOBALS['egw']->redirect($redirectTarget);
?>

File diff suppressed because it is too large Load Diff

View File

@ -265,7 +265,7 @@ div#loginMainDiv.stockLoginBackground {
margin-top: 7px;
width: auto;
}
input[type="submit"] {
input[type="submit"], select.onChangeSubmit {
background-color: #0a5ca5;
.color_0_gray;
.fontsize_xxl;
@ -275,6 +275,9 @@ div#loginMainDiv.stockLoginBackground {
&:focus {}
margin-top: 25px;
}
select.onChangeSubmit {
padding-left: 25px;
}
.registration {
font-size: 11px;
a:not(:first-child) {

View File

@ -28,6 +28,13 @@
<input type="hidden" name="account_type" value="u" />
</td>
</tr>
<!-- BEGIN discovery_block -->
<tr>
<td>
{discovery}
</td>
</tr>
<!-- END discovery_block -->
<tr>
<td>
<span class="field_icons username"></span>

View File

@ -75,6 +75,13 @@
</td>
</tr>
<!-- END domain_selection -->
<!-- BEGIN discovery_block -->
<tr>
<td>
{discovery}
</td>
</tr>
<!-- END discovery_block -->
<!-- BEGIN change_password -->
<tr>
<td>

View File

@ -9,21 +9,26 @@
* @subpackage authentication
*/
use EGroupware\Api;
// we have to set session-cookie name used by EGroupware!
ini_set('session.name', 'sessionid');
require_once __DIR__.'/../api/src/autoload.php';
$GLOBALS['egw_info'] = [
'flags' => [
//'currentapp' => 'login', // db connection, no auth
'noapi' => true, // no db connection, but autoloader, files_dir MUST be set correct!
],
'server' => [
'files_dir' => '/var/lib/egroupware/default/files',
'temp_dir' => '/tmp',
// default files and temp directories for name based instances (eg. our hosting) or container installation
'files_dir' => file_exists('/var/lib/egroupware/'.Api\Header\Http::host().'/files') ?
'/var/lib/egroupware/'.Api\Header\Http::host().'/files' : '/var/lib/egroupware/default/files',
'temp_dir' => file_exists('/var/lib/egroupware/'.Api\Header\Http::host().'/tmp') ?
'/var/lib/egroupware/'.Api\Header\Http::host().'/tmp' : '/tmp',
],
];
require_once __DIR__.'/../header.inc.php';
use EGroupware\Api;
Api\Auth\Saml::checkDefaultConfig();

View File

@ -478,9 +478,14 @@
<td colspan="2"><b>{lang_If_using_SAML_2.0 / Shibboleth / SimpleSAMLphp}:</b></td>
</tr>
<tr class="row_off">
<td>{lang_Label_to_display_as_option_on_login_page}:<br/>{lang_or_leave_empty_and_select_SAML_as_authentication_type_above_for_single_sign_on}</td>
<td><input name="newsettings[saml_discovery]" placeholder="{lang_University_Login}" value="{value_saml_discovery}" size="20" /></td>
</tr>
<tr class="row_on">
<td>{lang_Identity_Provider}:</td>
<td><input name="newsettings[saml_idp]" placeholder="https://idp.rhrk.uni-kl.de/idp/shibboleth" value="{value_saml_idp}" size="64" /></td>
<td>{lang_Identity_Provider}:<br/>{lang_You_can_specify_multiple_IdP_on_separate_lines.}</td>
<td><textarea name="newsettings[saml_idp]" placeholder="https://idp.rhrk.uni-kl.de/idp/shibboleth" rows="3" cols="64">{value_saml_idp}</textarea></td>
</tr>
<tr class="row_off">
@ -490,7 +495,6 @@
<select name="newsettings[saml_metadata_refresh]">
<option value="daily"{selected_saml_metadata_refresh_daily}>{lang_daily}</option>
<option value="weekly"{selected_saml_metadata_refresh_weekly}>{lang_weekly}</option>
<option value="hourly"{selected_saml_metadata_refresh_hourly}>{lang_hourly}</option>
<option value="no"{selected_saml_metadata_refresh_no}>{lang_not_automatic}</option>
<option value="now"{selected_saml_metadata_refresh_now}>{lang_just_now}</option>
</select>
@ -536,7 +540,11 @@
</tr>
<tr class="row_off">
<td colspan="2">{lang_The_used_SimpleSAMLphp_allows_a_lot_more_configuration_/_different_authentication_types_via_its_config_files in} {value_files_dir}/saml</td>
<td colspan="2">
{lang_The_used_SimpleSAMLphp_allows_a_lot_more_configuration_/_different_authentication_types_via_its_config_files in} {value_files_dir}/saml<br/>
{lang_More_information}: <a target="_blank" href="https://github.com/EGroupware/egroupware/blob/master/api/src/Auth/Saml.php#L19">
https://github.com/EGroupware/egroupware/blob/master/api/src/Auth/Saml.php</a>
</td>
</tr>
<tr class="row_off">