Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion oidc/class/oidc.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ class OIDC extends FOGController
// column in OIDCManager::createSql().
'jitProvision' => 'opJITProvision',
'allowapi' => 'opAllowAPI',
// Signing out of FOG also ends the session at the provider (#15).
// Off by default: it is only the right answer where FOG is the only
// application behind that provider. See the note on the column in
// OIDCManager::createSql().
'singleLogout' => 'opSingleLogout',
'icon' => 'opIcon'
];
/**
Expand Down Expand Up @@ -269,12 +274,53 @@ public static function normalizeScopes($scopes)
* @return string
*/
public static function redirectUri()
{
return self::absoluteUrl(self::CALLBACK_PATH);
}
/**
* Where a provider sends the browser after ending its own session.
*
* management/login.php rather than management/index.php, and the
* difference is the whole point: index.php is the page an install with
* forced redirect on (#17) bounces straight back to the provider. A
* signed-out user landing there would be silently signed back in by the
* SSO session that was just ended -- or, if it really was ended, sent
* around the loop again. login.php always renders FOG's own form
* (fogproject#1175).
*
* This URL has to be registered at the provider as a post-logout
* redirect URI, the same way the callback does. Providers that follow
* the spec refuse an unregistered one and show their own error page
* instead of coming back, so the management page prints it next to the
* setting rather than leaving an admin to work out why logout ends
* somewhere unexpected.
*
* @return string
*/
public static function postLogoutUri()
{
return self::absoluteUrl('management/login.php');
}
/**
* An absolute https URL for a path inside this FOG install.
*
* Built from FOG_WEB_HOST and FOG_WEB_ROOT rather than from the request
* for the reason spelled out on redirectUri(): these values are
* registered at a provider ahead of time and compared byte for byte, so
* they cannot be whatever the browser last claimed the server was
* called.
*
* @param string $path a path relative to the webroot
*
* @return string
*/
public static function absoluteUrl($path)
{
$host = trim((string)self::getSetting('FOG_WEB_HOST'), '/');
return sprintf(
'https://%s%s',
$host,
rtrim(self::webrootBase(), '/') . self::CALLBACK_PATH
rtrim(self::webrootBase(), '/') . '/' . ltrim((string)$path, '/')
);
}
/**
Expand Down
119 changes: 119 additions & 0 deletions oidc/class/oidcflow.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ class OIDCFlow extends FOGBase
* @var string
*/
const SESSION_KEY = 'FOG_OIDC_FLOW';
/**
* Where the material for RP-initiated logout is kept (#15).
*
* Separate from SESSION_KEY because it has the opposite lifetime: the
* flow values are single use and deleted the moment the callback reads
* them, while this has to survive for as long as the session it belongs
* to -- it is read at logout, which may be days later.
*
* @var string
*/
const LOGOUT_KEY = 'FOG_OIDC_LOGOUT';
/**
* How long a started flow stays usable, in seconds.
*
Expand Down Expand Up @@ -224,13 +235,121 @@ public static function callback()
// this session from a password one, and the break-glass rules
// count sessions by how they were made.
$user->establishSession(OIDC::AUTH_SOURCE);

/*
* After establishSession(), not before: the wipe above empties
* $_SESSION wholesale, so anything written earlier in this
* request would be thrown away with the identity it was
* guarding against.
*/
self::_rememberLogout($provider, $config, $token);

self::_redirect(
OIDC::webrootBase() . 'management/index.php'
);
} catch (\Exception $e) {
self::_fail($e->getMessage());
}
}
/**
* Stores what RP-initiated logout will need, if it is wanted.
*
* Recorded now rather than fetched at logout, and that is the whole
* design. Discovery is a network request; putting one on the sign-out
* path means a provider that has gone away turns "log out" into a page
* that hangs and then fails, at the exact moment somebody is trying to
* leave. Everything needed is already in hand here.
*
* The ID token is kept because id_token_hint is what tells the provider
* WHICH session to end, and it is the only thing that lets it skip the
* "are you sure you want to sign out?" interstitial. It is a token this
* session already holds the fruits of; the session file is not a weaker
* place to keep it than the authenticated session itself.
*
* @param OIDC $provider the provider signed in with
* @param array $config its discovery document
* @param array $token the token response
*
* @return void
*/
private static function _rememberLogout($provider, array $config, array $token)
{
if ('1' !== (string)$provider->get('singleLogout')) {
return;
}
$endpoint = (string)($config['end_session_endpoint'] ?? '');
if (0 !== stripos($endpoint, 'https://')) {
/*
* An admin turned this on and it cannot work -- the provider
* publishes no end_session_endpoint, or publishes a plaintext
* one. Logging out will silently just be a FOG logout, which is
* indistinguishable from the setting being off, so say so
* somewhere an admin can find it. Not a thrown exception: the
* sign-in itself succeeded and refusing it here would turn a
* logout limitation into a login failure.
*/
error_log(
sprintf(
'FOG OIDC: provider %d has single logout enabled but'
. ' published no https end_session_endpoint; signing out'
. ' of FOG will not end the provider session',
(int)$provider->get('id')
)
);
return;
}
$_SESSION[self::LOGOUT_KEY] = [
'provider' => (int)$provider->get('id'),
'endpoint' => $endpoint,
'idToken' => (string)$token['id_token']
];
}
/**
* The provider logout URL for this session, or '' for none.
*
* Called from the USER_LOGGING_OUT listener, which core fires BEFORE it
* destroys the session -- so the values stored at callback time are
* still readable here, and this is the last moment they are.
*
* The provider row is re-read rather than trusted from the session. An
* admin who turns single logout off means it from that moment, not from
* the next time everybody happens to sign in; and a provider that has
* since been deleted or disabled must not have a URL built from a row
* that no longer says anything.
*
* @return string
*/
public static function logoutUrl()
{
if (session_status() !== PHP_SESSION_ACTIVE) {
return '';
}
$stored = $_SESSION[self::LOGOUT_KEY] ?? null;
// Single use. A second call must not produce a second redirect, and
// the session is about to be destroyed anyway.
unset($_SESSION[self::LOGOUT_KEY]);
if (!is_array($stored) || empty($stored['endpoint'])) {
return '';
}
$provider = self::getClass('OIDC', (int)($stored['provider'] ?? 0));
if (!$provider->isValid()
|| '1' !== (string)$provider->get('enabled')
|| '1' !== (string)$provider->get('singleLogout')
) {
return '';
}
$query = [
'id_token_hint' => (string)$stored['idToken'],
'post_logout_redirect_uri' => OIDC::postLogoutUri(),
// Sent alongside id_token_hint because some providers key the
// post-logout redirect allow-list on the client rather than on
// the token, and it is ignored by the ones that do not.
'client_id' => (string)$provider->get('clientId')
];
return $stored['endpoint']
. (false === strpos($stored['endpoint'], '?') ? '?' : '&')
. http_build_query($query);
}
/**
* Starts (or resumes) the session this flow needs.
*
Expand Down
23 changes: 23 additions & 0 deletions oidc/class/oidcmanager.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public function createSql()
'opEnabled',
'opJITProvision',
'opAllowAPI',
'opSingleLogout',
'opIcon'
],
[
Expand All @@ -69,6 +70,7 @@ public function createSql()
"ENUM('0', '1')",
"ENUM('0', '1')",
"ENUM('0', '1')",
"ENUM('0', '1')",
'VARCHAR(255)'
],
[
Expand All @@ -86,6 +88,7 @@ public function createSql()
false,
false,
false,
false,
false
],
[
Expand Down Expand Up @@ -122,6 +125,14 @@ public function createSql()
// being allowed into FOG.
"'0'",
"'0'",
// Signing out of FOG also signing the user out of the
// provider is off by default, and that is not timidity: it
// is only right when FOG is the only thing behind that
// provider. Where an install shares an identity provider
// with a mail client and a ticket system, ending the SSO
// session because somebody left FOG is a surprise that
// reaches applications FOG has nothing to do with.
"'0'",
"'fa fa-id-badge'"
],
[
Expand All @@ -145,6 +156,7 @@ public function createSql()
false,
false,
false,
false,
false
],
'InnoDB',
Expand Down Expand Up @@ -193,6 +205,17 @@ function () {
function () {
return self::getClass('OIDCUserGrantManager')->install();
},
// 6 - RP-initiated logout, per provider (#15). Appended rather
// than folded into step 0, because installdb() SKIPS the first
// pSchema steps instead of replaying them: an install that has
// already passed step 0 would never see an edit to it, and would
// carry a providers table without this column forever.
//
// applyUpdates() tolerates 1060 (duplicate column), so an
// install created fresh from createSql() -- which already has
// the column -- runs this harmlessly too.
"ALTER TABLE `OIDCProviders` ADD COLUMN `opSingleLogout` "
. "ENUM('0', '1') NOT NULL DEFAULT '0'",
];
}
/**
Expand Down
92 changes: 92 additions & 0 deletions oidc/hooks/oidclogout.hook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php
/**
* Ends the identity provider's session when FOG's ends.
*
* PHP version 7.4+
*
* @category OIDCLogout
* @package FOGProject
* @author Tom Elliott <tommygunsster@gmail.com>
* @license http://opensource.org/licenses/gpl-3.0 GPLv3
* @link https://fogproject.org
*/
/**
* Ends the identity provider's session when FOG's ends.
*
* @category OIDCLogout
* @package FOGProject
* @author Tom Elliott <tommygunsster@gmail.com>
* @license http://opensource.org/licenses/gpl-3.0 GPLv3
* @link https://fogproject.org
*/
class OIDCLogout extends Hook
{
/**
* The name of this hook.
*
* @var string
*/
public $name = 'OIDCLogout';
/**
* The description.
*
* @var string
*/
public $description = 'End the provider session when FOG\'s session ends.';
/**
* For posterity.
*
* @var bool
*/
public $active = true;
/**
* The node to work with.
*
* @var string
*/
public $node = 'oidc';
/**
* Initialize object.
*
* @return void
*/
public function __construct()
{
parent::__construct();
$this->registerInstalled([
['USER_LOGGING_OUT', 'providerLogout']
]);
}
/**
* Sends a signing-out user to the provider's end_session endpoint.
*
* Without this, clicking Log out destroys FOG's session and leaves the
* provider's SSO session untouched -- so clicking the provider button
* again re-authenticates silently and drops the same person straight
* back into the same account. There is then no way to sign in as
* somebody else short of clearing cookies, and on an account carrying
* uAuthSource='oidc' (which core refuses a local password) there is no
* way to sign in as anybody else at all.
*
* Only for a session that was actually made by this plugin: the values
* this reads are written by OIDCFlow::callback() and by nothing else, so
* a password session has nothing stored and this returns without
* touching $redirect. An install with no provider using the setting
* therefore behaves exactly as it did before.
*
* The hook fires before core destroys the session, which is what makes
* the stored ID token still readable -- see User::logout().
*
* @param mixed $arguments where to send the browser instead
*
* @return void
*/
public function providerLogout($arguments)
{
$url = OIDCFlow::logoutUrl();
if ('' === $url) {
return;
}
$arguments['redirect'] = $url;
}
}
1 change: 1 addition & 0 deletions oidc/js/fog.oidc.export.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
{data: 'enabled'},
{data: 'jitProvision', visible: false},
{data: 'allowapi', visible: false},
{data: 'singleLogout', visible: false},
{data: 'icon', visible: false}
]);
})(jQuery);
Loading
Loading