diff --git a/openid_connect.routing.yml b/openid_connect.routing.yml index f74ebfe30a19b82b7b976315944b3d8d9ba167b8..e5168d257b99586b63abc605ac80cab21b1153bd 100644 --- a/openid_connect.routing.yml +++ b/openid_connect.routing.yml @@ -84,6 +84,14 @@ openid_connect.logout: _csrf_token: 'TRUE' options: no_cache: TRUE + _csrf_confirm_form_route: 'openid_connect.logout.confirm' + +openid_connect.logout.confirm: + path: '/user/logout/confirm' + defaults: + _form: '\Drupal\openid_connect\Form\UserLogoutConfirmation' + requirements: + _user_is_logged_in: 'TRUE' openid_connect.login: path: '/user/login/openid_connect' diff --git a/openid_connect.services.yml b/openid_connect.services.yml index 52ca9b14bb5978d1921c86048d65e754bdf2ea6b..30d8258b0c1fb367711391a344ddfd25328008c3 100644 --- a/openid_connect.services.yml +++ b/openid_connect.services.yml @@ -43,3 +43,8 @@ services: openid_connect.autodiscover: class: Drupal\openid_connect\OpenIDConnectAutoDiscover Drupal\openid_connect\OpenIDConnectAutoDiscover: '@openid_connect.autodiscover' + + Drupal\openid_connect\Service\LogoutService: + arguments: + # Wire arguments manually until externalauth provides autowiring aliases. + $authmap: '@externalauth.authmap' diff --git a/src/Controller/OpenIDConnectRedirectController.php b/src/Controller/OpenIDConnectRedirectController.php index 3271c542558c5222ff57d63114a30a0eb7b05ee5..0cbbd79ae98e54011b2733301c556f841f768ec1 100644 --- a/src/Controller/OpenIDConnectRedirectController.php +++ b/src/Controller/OpenIDConnectRedirectController.php @@ -12,7 +12,6 @@ use Drupal\Core\Language\LanguageManagerInterface; use Drupal\Core\Logger\LoggerChannelTrait; use Drupal\Core\Messenger\MessengerTrait; use Drupal\Core\Routing\Access\AccessInterface; -use Drupal\Core\Routing\TrustedRedirectResponse; use Drupal\Core\Session\AccountProxyInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\Url; @@ -23,6 +22,7 @@ use Drupal\openid_connect\OpenIDConnectClientEntityInterface; use Drupal\openid_connect\OpenIDConnectSessionInterface; use Drupal\openid_connect\OpenIDConnectStateTokenInterface; use Drupal\openid_connect\Plugin\OpenIDConnectClientInterface; +use Drupal\openid_connect\Service\LogoutService; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RequestStack; @@ -118,6 +118,13 @@ class OpenIDConnectRedirectController implements ContainerInjectionInterface, Ac */ protected $claims; + /** + * The logout service. + * + * @var \Drupal\openid_connect\Service\LogoutService + */ + protected $openIdLogoutService; + /** * The constructor. * @@ -143,8 +150,23 @@ class OpenIDConnectRedirectController implements ContainerInjectionInterface, Ac * The entity type manager. * @param \Drupal\openid_connect\OpenIDConnectClaims $claims * The OpenID claims service. + * @param \Drupal\openid_connect\Service\LogoutService $openid_logout_service + * The logout service for openid connect. */ - public function __construct(OpenIDConnect $openid_connect, OpenIDConnectStateTokenInterface $state_token, RequestStack $request_stack, OpenIDConnectSessionInterface $session, ConfigFactoryInterface $config_factory, AuthmapInterface $authmap, AccountProxyInterface $current_user, ModuleHandlerInterface $module_handler, LanguageManagerInterface $language_manager, EntityTypeManagerInterface $entity_type_manager, OpenIDConnectClaims $claims) { + public function __construct( + OpenIDConnect $openid_connect, + OpenIDConnectStateTokenInterface $state_token, + RequestStack $request_stack, + OpenIDConnectSessionInterface $session, + ConfigFactoryInterface $config_factory, + AuthmapInterface $authmap, + AccountProxyInterface $current_user, + ModuleHandlerInterface $module_handler, + LanguageManagerInterface $language_manager, + EntityTypeManagerInterface $entity_type_manager, + OpenIDConnectClaims $claims, + LogoutService $openid_logout_service, + ) { $this->openIDConnect = $openid_connect; $this->stateToken = $state_token; $this->requestStack = $request_stack; @@ -156,6 +178,7 @@ class OpenIDConnectRedirectController implements ContainerInjectionInterface, Ac $this->languageManager = $language_manager; $this->entityTypeManager = $entity_type_manager; $this->claims = $claims; + $this->openIdLogoutService = $openid_logout_service; } /** @@ -173,7 +196,8 @@ class OpenIDConnectRedirectController implements ContainerInjectionInterface, Ac $container->get('module_handler'), $container->get('language_manager'), $container->get('entity_type.manager'), - $container->get('openid_connect.claims') + $container->get('openid_connect.claims'), + $container->get('Drupal\openid_connect\Service\LogoutService') ); } @@ -408,64 +432,9 @@ class OpenIDConnectRedirectController implements ContainerInjectionInterface, Ac /** * Redirect after logout. */ - public function redirectLogout() { - // Set default URL. - $language = $this->languageManager->getCurrentLanguage(); - $default_url = Url::fromRoute('<front>', [], ['language' => $language])->toString(TRUE); - $response = new RedirectResponse($default_url->getGeneratedUrl()); - - // @todo The fact that the user has a connected account doesn't necessarily - // mean that it was used for the login. This info should probably be kept - // in the session. - // Get client names for this user based on its username. - $mapped_users = $this->authmap->getAll($this->currentUser->id()); - if (is_array($mapped_users) & !empty($mapped_users)) { - foreach (array_keys($mapped_users) as $key) { - // strlen('openid_connect.') = 15. - $client_name = substr($key, 15); - - // Perform log out. - if (!empty($client_name)) { - /** @var \Drupal\openid_connect\Entity\OpenIDConnectClientEntity $entity */ - $entity = current($this->entityTypeManager->getStorage('openid_connect_client')->loadByProperties(['id' => $client_name])); - if ($entity) { - $endpoints = $entity->getPlugin()->getEndpoints(); - - $config = $this->configFactory->get('openid_connect.settings'); - - $redirect_logout = $config->get('redirect_logout'); - $redirect_logout_url = empty($redirect_logout) ? FALSE : Url::fromUri('internal:/' . ltrim($redirect_logout, '/'), ['language' => $language]); - - // Destroy session if provider supports it. - $end_session_enabled = $config->get('end_session_enabled') ?? FALSE; - if ($end_session_enabled && !empty($endpoints['end_session'])) { - $url_options = [ - 'query' => ['id_token_hint' => $this->session->retrieveIdToken()], - ]; - if ($redirect_logout_url) { - $url_options['query']['post_logout_redirect_uri'] = $redirect_logout_url->setAbsolute()->toString(TRUE)->getGeneratedUrl(); - } - $redirect = Url::fromUri($endpoints['end_session'], $url_options)->toString(TRUE); - $response = new TrustedRedirectResponse($redirect->getGeneratedUrl()); - $response->addCacheableDependency($redirect); - } - else { - if (!$end_session_enabled) { - $this->messenger()->addWarning($this->t('@provider does not support log out. You are logged out of this site but not out of the OpenID Connect provider.', ['@provider' => $entity->label()])); - } - if ($redirect_logout_url) { - $url = $redirect_logout_url->toString(TRUE)->getGeneratedUrl(); - $response = new TrustedRedirectResponse($url); - $response->addCacheableDependency($url); - } - } - $rsp = ['response' => &$response]; - $context = ['client' => $client_name]; - $this->moduleHandler->alter('openid_connect_redirect_logout', $rsp, $context); - } - } - } - } + public function redirectLogout(): RedirectResponse { + // Get the expected redirect response. + $response = $this->openIdLogoutService->getLogoutRedirectResponse(); // Logout from Drupal. user_logout(); return $response; diff --git a/src/Form/UserLogoutConfirmation.php b/src/Form/UserLogoutConfirmation.php new file mode 100644 index 0000000000000000000000000000000000000000..8591df5ec643665253d0ee87236b180b2300ef11 --- /dev/null +++ b/src/Form/UserLogoutConfirmation.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\openid_connect\Form; + +use Drupal\Core\Form\FormStateInterface; +use Drupal\openid_connect\Service\LogoutService; +use Drupal\user\Form\UserLogoutConfirm; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provide a user logout confirmation form for OpenID logouts. + */ +final class UserLogoutConfirmation extends UserLogoutConfirm { + + /** + * {@inheritDoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('Drupal\openid_connect\Service\LogoutService') + ); + } + + /** + * Constructor for the UserLogoutConfirmation form. + */ + public function __construct( + protected readonly LogoutService $logoutService, + ) {} + + /** + * {@inheritDoc} + */ + public function getFormId(): string { + return 'openid_connect_user_logout'; + } + + /** + * {@inheritDoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + // Get the response prior to logging the user out. + $response = $this->logoutService->getLogoutRedirectResponse(); + user_logout(); + // Get the expected OpenID Redirect. + $form_state->setResponse($response); + } + +} diff --git a/src/Service/LogoutService.php b/src/Service/LogoutService.php new file mode 100644 index 0000000000000000000000000000000000000000..0afe2cc39eb023ad6cba65f590ce4e874346ff35 --- /dev/null +++ b/src/Service/LogoutService.php @@ -0,0 +1,255 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\openid_connect\Service; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\Core\Logger\LoggerChannelFactoryInterface; +use Drupal\Core\Routing\TrustedRedirectResponse; +use Drupal\Core\Session\AccountProxyInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Url; +use Drupal\externalauth\AuthmapInterface; +use Drupal\openid_connect\OpenIDConnectClientEntityInterface; +use Drupal\openid_connect\OpenIDConnectSessionInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; + +/** + * Handle custom logouts with OpenID Connect. + */ +class LogoutService { + + use StringTranslationTrait; + + /** + * Construct a logout service class. + */ + public function __construct( + protected readonly ConfigFactoryInterface $configFactory, + protected readonly AuthmapInterface $authmap, + protected readonly AccountProxyInterface $currentUser, + protected readonly ModuleHandlerInterface $moduleHandler, + protected readonly LanguageManagerInterface $languageManager, + protected readonly EntityTypeManagerInterface $entityTypeManager, + protected readonly OpenIDConnectSessionInterface $session, + protected readonly LoggerChannelFactoryInterface $loggerChannelFactory, + ) { + } + + /** + * Get the redirect response to be used when a member logs out. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * The redirect to the provider and/or the internal redirect. + */ + public function getLogoutRedirectResponse(): RedirectResponse { + $response = $this->getDefaultResponse(); + // If both end session and logout redirect are disabled, return the default. + if ( + !$this->isEndSessionEnabled() && + !$this->isLogoutRedirectEnabled() + ) { + return $response; + } + + // If the user isn't mapped to any OpenID Clients, return the default. + if (!$this->hasMappedUsers()) { + return $response; + } + + // Get the openid connect client used for the login. + $provider = $this->getLoginProvider(); + // Guard the provider. If the user is not connected to OpenID + // then we want to default the logout. + if (is_null($provider)) { + return $response; + } + $logoutRedirectUrl = $this->getLogoutRedirectUrl(); + + // Default the response to the home page. + $response = new TrustedRedirectResponse('internal:/<front>'); + + // If the logout redirect is enabled, set the default redirect. + if ($this->isLogoutRedirectEnabled()) { + $redirectUrl = $logoutRedirectUrl->toString(TRUE)->getGeneratedUrl(); + $response->setTrustedTargetUrl($redirectUrl); + $response->addCacheableDependency($redirectUrl); + } + + if ( + $this->isEndSessionEnabled() && + $this->providerHasEndSessionEndpoint($provider) + ) { + // This will override the redirect only, which is expected. + $urlOptions = [ + 'query' => ['id_token_hint' => $this->session->retrieveIdToken()], + ]; + if ($logoutRedirectUrl) { + $urlOptions['query']['post_logout_redirect_uri'] = $logoutRedirectUrl->setAbsolute()->toString(TRUE)->getGeneratedUrl(); + } + $redirectUrl = Url::fromUri($this->getProviderEndSessionEndpoint($provider), $urlOptions)->toString(TRUE); + $response = new TrustedRedirectResponse($redirectUrl->getGeneratedUrl()); + $response->addCacheableDependency($redirectUrl); + } + + // If the end session is expected and the provider doesn't + // have an endpoint configured, write to the logs of a misconfiguration. + if ( + $this->isEndSessionEnabled() && + !$this->providerHasEndSessionEndpoint($provider) + ) { + // Alert the logs of a misconfiguration. + $this->loggerChannelFactory->get('openid_connect')->warning( + sprintf('%s does not support log out. Drupal session was expired, but the session at the identity provider remains.', $provider->label()) + ); + } + + $clientName = $provider?->getPlugin()?->getPluginId() ?? 'unknown'; + + $rsp = ['response' => &$response]; + $context = ['client' => $clientName]; + $this->moduleHandler->alter('openid_connect_redirect_logout', $rsp, $context); + + return $response; + } + + /** + * Default any redirects to the home page. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * A default redirect response. + */ + protected function getDefaultResponse(): RedirectResponse { + $language = $this->languageManager->getCurrentLanguage(); + $default_url = Url::fromRoute('<front>', [], ['language' => $language])->toString(TRUE); + return new RedirectResponse($default_url->getGeneratedUrl()); + } + + /** + * Check if the redirect logout setting is enabled. + * + * @return bool + * True if the redirect logout setting has a value. + */ + protected function isLogoutRedirectEnabled(): bool { + return !empty($this->configFactory->get('openid_connect.settings')->get('redirect_logout')); + } + + /** + * Get the redirect logout value as a Url. + * + * @return \Drupal\Core\Url|null + * The redirect logout value transformed as a Url object. + */ + protected function getLogoutRedirectUrl(): ?Url { + $redirectLogout = $this->configFactory->get('openid_connect.settings')->get('redirect_logout'); + if (empty($redirectLogout)) { + return NULL; + } + + return Url::fromUri(sprintf('internal:/%s', ltrim($redirectLogout, '/')), ['language' => $this->languageManager->getCurrentLanguage()]); + } + + /** + * Check if the end session from provider setting is enabled. + * + * @return bool + * True if the end session configuration is enabled. + */ + protected function isEndSessionEnabled(): bool { + return $this->configFactory->get('openid_connect.settings')->get('end_session_enabled') ?? FALSE; + } + + /** + * Check if the provider has an end session endpoint defined. + * + * @param \Drupal\openid_connect\OpenIDConnectClientEntityInterface $provider + * The open id provider to check for an end session endpoint. + * + * @return bool + * True if the provider has a value defined for the end session endpoint. + */ + protected function providerHasEndSessionEndpoint(OpenIDConnectClientEntityInterface $provider): bool { + // Pull the end_session endpoint from the endpoint array. + ['end_session' => $end_session_endpoint] = $provider->getPlugin()->getEndpoints(); + return !empty($end_session_endpoint); + } + + /** + * Get the end session endpoint from the provider. + * + * @param \Drupal\openid_connect\OpenIDConnectClientEntityInterface $provider + * The open id provider to retrieve an end session endpoint. + * + * @return string|null + * The endpoint if defined, otherwise null. + */ + protected function getProviderEndSessionEndpoint(OpenIDConnectClientEntityInterface $provider): ?string { + // Pull the end_session endpoint from the endpoints array. + ['end_session' => $end_session_endpoint] = $provider->getPlugin()->getEndpoints(); + // Return the endpoint if available. + return !empty($end_session_endpoint) ? $end_session_endpoint : NULL; + } + + /** + * Does openid connect have mapped data for the currently logged in user. + * + * @return bool + * True if the user has logged in with an openid connect client. + */ + protected function hasMappedUsers(): bool { + $test = $this->getMappedUsers(); + return !empty($test); + } + + /** + * Get all openid connect user mappings for the logged in user. + * + * @return array + * All openid connect user mappings for the logged in user. + */ + protected function getMappedUsers(): array { + return $this->authmap->getAll($this->currentUser->id()); + } + + /** + * Get the assumed provider that the user logged in with. + * + * @return \Drupal\openid_connect\OpenIDConnectClientEntityInterface|null + * The openid connect provider or null if not found. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + protected function getLoginProvider(): ?OpenIDConnectClientEntityInterface { + // @todo The fact that the user has a connected account doesn't necessarily + // mean that it was used for the login. This info should probably be kept + // in the session. + $provider = NULL; + foreach (array_keys($this->getMappedUsers()) as $mappedUserKey) { + // Removing the 'openid_connect.' prefix (which is 15 characters long) + // This will provide the client name as it was stored in the + // external authmap table. + $client_name = substr($mappedUserKey, 15); + if (empty($client_name)) { + continue; + } + $entities = $this->entityTypeManager + ->getStorage('openid_connect_client') + ->loadByProperties(['id' => $client_name]); + + // If there is a provider, set it and break the loop. + if (!empty($entities)) { + $provider = current($entities); + break; + } + } + + return $provider; + } + +} diff --git a/tests/src/Functional/AutoLoginTest.php b/tests/src/Functional/AutoLoginTest.php index a6c5151ca460df9cd77e7fea6cb302d7935f2534..dced8c1851c81b86592f713823e36af44511f7a3 100644 --- a/tests/src/Functional/AutoLoginTest.php +++ b/tests/src/Functional/AutoLoginTest.php @@ -4,15 +4,12 @@ declare(strict_types=1); namespace Drupal\Tests\openid_connect\Functional; -use Drupal\Core\Url; -use Drupal\Tests\BrowserTestBase; - /** * Tests the auto login process. * * @group openid_connect */ -class AutoLoginTest extends BrowserTestBase { +class AutoLoginTest extends OpenIdConnectTestBase { const OIDC_LABEL = 'Label For OIDC Client'; @@ -101,19 +98,4 @@ class AutoLoginTest extends BrowserTestBase { ->addressEquals('https://example.com/oauth2/authorize'); } - /** - * Override the drupalLogout() method. - * - * Normal logout field validation breaks when the autostart - * setting is enabled. This override removes those assertions. - */ - protected function drupalLogout(): void { - $destination = Url::fromRoute('user.page')->toString(); - $this->drupalGet(Url::fromRoute('user.logout.confirm', options: ['query' => ['destination' => $destination]])); - // Target the submit button using the name rather than the value to work - // regardless of the user interface language. - $this->submitForm([], 'op', 'user-logout-confirm'); - $this->drupalResetSession(); - } - } diff --git a/tests/src/Functional/LogoutUserTest.php b/tests/src/Functional/LogoutUserTest.php index b3525957b52930eee416cbf27bc7a712b2cd123a..63497cc06a3e6e0462a6d1a0c015b0b7b8ff292d 100644 --- a/tests/src/Functional/LogoutUserTest.php +++ b/tests/src/Functional/LogoutUserTest.php @@ -16,11 +16,14 @@ class LogoutUserTest extends BrowserTestBase { use OpenIdClientTestTrait; + const CLIENT_ID = 'test'; + /** * {@inheritdoc} */ protected static $modules = [ 'openid_connect', + 'externalauth', 'user', 'block', ]; @@ -30,37 +33,153 @@ class LogoutUserTest extends BrowserTestBase { */ protected $defaultTheme = 'stark'; + /** + * The logout url. + * + * @var \Drupal\Core\Url + */ + protected Url $logoutUrl; + + /** + * The string representation of the logout url. + * + * @var string + */ + protected string $logoutUrlPlain; + + /** + * The confirmation url. + * + * @var \Drupal\Core\Url + */ + protected Url $logoutConfirmUrl; + + /** + * The test client. + * + * @var \Drupal\openid_connect\OpenIDConnectClientEntityInterface + */ + protected $openIdConnectClient; + /** * {@inheritdoc} */ protected function setUp(): void { parent::setUp(); - $this->createTestClient('test', 'Test OIDC Client'); + $this->openIdConnectClient = $this->createTestClient(self::CLIENT_ID, 'Test OIDC Client'); $this->placeBlock('system_menu_block:account'); + $this->placeBlock('system_messages_block'); + $this->logoutUrlPlain = '/user/logout'; + $this->logoutUrl = Url::fromRoute('user.logout'); + $this->logoutConfirmUrl = Url::fromRoute('openid_connect.logout.confirm'); } /** * Confirm CSRF token is required to log out. */ public function testCsrfOnLogout(): void { - $logoutUrl = Url::fromRoute('user.logout'); + $this->toggleEndSessionSetting(FALSE); $account = $this->createUser(); $this->drupalLogin($account); // Test missing csrf token does not log the user out. - $this->drupalGet($logoutUrl); + // Assert that the user is shown the confirmation form instead. + $this->drupalGet($this->logoutUrlPlain); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->addressEquals($this->logoutConfirmUrl); + // Confirm the cancel link does not logout the user. + $this->clickLink('Cancel'); $this->assertTrue($this->drupalUserIsLoggedIn($account)); - $this->assertSession()->statusCodeEquals(403); // Test invalid csrf token does not log the user out. - $this->drupalGet($logoutUrl, ['query' => ['token' => '123']]); + // Also confirm the confirmation form is shown instead. + $this->drupalGet($this->logoutUrl, ['query' => ['token' => '123']]); $this->assertTrue($this->drupalUserIsLoggedIn($account)); - $this->assertSession()->statusCodeEquals(403); + $this->assertSession()->statusCodeEquals(200); + // Assert the confirmation form is shown. + $this->assertSession()->addressEquals($this->logoutConfirmUrl); + // Test the confirmation form. + $this->submitForm([], 'Log out'); + $this->assertFalse($this->drupalUserIsLoggedIn($account)); - // Test with valid logout link. + // Test with a valid logout link. + $this->drupalResetSession(); + $this->drupalLogin($account); $this->drupalGet('user'); $this->getSession()->getPage()->clickLink('Log out'); $this->assertFalse($this->drupalUserIsLoggedIn($account)); } + /** + * Test the OpenID Redirect settings with the logout confirmation forms. + */ + public function testOpenIdRedirectOnLogout(): void { + // Enable end session logout redirect. + $this->toggleEndSessionSetting(TRUE); + // Get the endpoint of the client for assertions. + $endpoints = $this->openIdConnectClient->getPlugin()->getEndpoints(); + $account = $this->createUser(); + // Link the account to the external auth table. + $authmap = \Drupal::service('externalauth.authmap'); + $authmap->save($account, sprintf('openid_connect.%s', self::CLIENT_ID), $this->randomMachineName()); + + // Confirm a valid logout redirects to the end session endpoint. + $this->drupalLogin($account); + $this->drupalGet('user'); + $this->getSession()->getPage()->clickLink('Log out'); + $this->assertSession()->addressEquals($endpoints['end_session']); + $this->assertFalse($this->drupalUserIsLoggedIn($account)); + $this->drupalResetSession(); + + // Confirm the logout confirmation form + // redirects to the end session endpoint. + $this->drupalLogin($account); + $this->drupalGet($this->logoutUrlPlain); + $this->assertSession()->statusCodeEquals(200); + // Assert the confirmation form is shown. + $this->assertSession()->addressEquals($this->logoutConfirmUrl); + // Test the confirmation form. + $this->submitForm([], 'Log out'); + $this->assertSession()->addressEquals($endpoints['end_session']); + $this->assertFalse($this->drupalUserIsLoggedIn($account)); + $this->drupalResetSession(); + + // Confirm the regular logout redirect is working. + $this->toggleEndSessionSetting(FALSE); + $this->setRedirectLogoutUrl('/path/to/redirect'); + $this->drupalLogin($account); + $this->drupalGet('user'); + $this->getSession()->getPage()->clickLink('Log out'); + $this->assertSession()->addressEquals('/path/to/redirect'); + $this->assertFalse($this->drupalUserIsLoggedIn($account)); + $this->drupalResetSession(); + + // Confirm the end session without an endpoint goes to the redirect url. + $client = $this->getTestClient(self::CLIENT_ID); + $this->assertNotNull($client); + $plugin = $client->getPlugin(); + $clientConfig = $plugin->getConfiguration(); + $clientConfig['end_session_endpoint'] = ''; + $plugin->setConfiguration($clientConfig); + $client->save(); + $this->toggleEndSessionSetting(TRUE); + $this->setRedirectLogoutUrl('/path/to/different/redirect'); + + $this->drupalLogin($account); + $this->drupalGet('user'); + $this->getSession()->getPage()->clickLink('Log out'); + $this->assertSession()->addressEquals('/path/to/different/redirect'); + $this->assertFalse($this->drupalUserIsLoggedIn($account)); + $this->drupalResetSession(); + + // Confirm empty redirects go to the home page. + $this->toggleEndSessionSetting(FALSE); + // Set the redirect to an empty string. + $this->setRedirectLogoutUrl(''); + $this->drupalLogin($account); + $this->drupalGet('user'); + $this->getSession()->getPage()->clickLink('Log out'); + $this->assertSession()->addressEquals(Url::fromRoute('<front>')); + } + } diff --git a/tests/src/Functional/OpenIdClientTestTrait.php b/tests/src/Functional/OpenIdClientTestTrait.php index 546f1b8b8b9265b8e9184116fa5270ccf7db4727..18e00e22f2cb8c8ff495e192c1868194193ae783 100644 --- a/tests/src/Functional/OpenIdClientTestTrait.php +++ b/tests/src/Functional/OpenIdClientTestTrait.php @@ -31,16 +31,15 @@ trait OpenIdClientTestTrait { 'id' => $clientId, 'label' => $clientLabel, 'plugin' => 'generic', - 'redirect_uri' => 'http://localhost', - 'grant_type' => 'authorization_code', - 'response_type' => 'code', - 'authorization_endpoint' => 'http://localhost/authorize', - 'token_endpoint' => 'http://localhost/token', - 'userinfo_endpoint' => 'http://localhost/userinfo', - 'jwks_uri' => 'http://localhost/jwks', - 'scopes' => ['openid email'], - 'client_secret' => 'test', 'status' => TRUE, + 'settings' => [ + 'authorization_endpoint' => 'http://localhost/authorize', + 'token_endpoint' => 'http://localhost/token', + 'userinfo_endpoint' => 'http://localhost/userinfo', + 'end_session_endpoint' => 'http://localhost/endsession', + 'scopes' => ['openid email'], + 'client_secret' => 'test', + ], ] ); $client->save(); @@ -48,4 +47,44 @@ trait OpenIdClientTestTrait { return $client; } + /** + * Retrieve a test client. + * + * @return \Drupal\openid_connect\OpenIDConnectClientEntityInterface|null + * The test client. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + public function getTestClient( + string $clientId, + ): OpenIDConnectClientEntityInterface { + return \Drupal::service('entity_type.manager') + ->getStorage('openid_connect_client')->load($clientId); + } + + /** + * Set the `redirect_logout` OpenID Setting. + * + * @param string $path + * The redirect path. + */ + public function setRedirectLogoutUrl(string $path): void { + $settingsConfig = \Drupal::configFactory()->getEditable('openid_connect.settings'); + $settingsConfig->set('redirect_logout', $path); + $settingsConfig->save(); + } + + /** + * Toggle the end session configuration setting. + * + * @param bool $enabled + * True for enabled, false for off. + */ + public function toggleEndSessionSetting(bool $enabled): void { + // Enable the end session endpoint. + $settingsConfig = \Drupal::configFactory()->getEditable('openid_connect.settings'); + $settingsConfig->set('end_session_enabled', $enabled); + $settingsConfig->save(); + } + } diff --git a/tests/src/Functional/OpenIdConnectTestBase.php b/tests/src/Functional/OpenIdConnectTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..4421771c872303e9d782005f0d626a8ad1d3fbba --- /dev/null +++ b/tests/src/Functional/OpenIdConnectTestBase.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\openid_connect\Functional; + +use Drupal\Core\Url; +use Drupal\Tests\BrowserTestBase; + +/** + * Common testing traits required for OpenID Connect. + */ +abstract class OpenIdConnectTestBase extends BrowserTestBase { + + /** + * Override the drupalLogout() method. + * + * Normal logout field validation breaks when the autostart + * setting is enabled. This override removes those assertions. + */ + public function drupalLogout(): void { + $destination = Url::fromRoute('user.page')->toString(); + $this->drupalGet(Url::fromRoute('user.logout.confirm', options: ['query' => ['destination' => $destination]])); + // Target the submit button using the name rather than the value to work + // regardless of the user interface language. + $this->submitForm([], 'op', 'openid-connect-user-logout'); + $this->drupalResetSession(); + } + +} diff --git a/tests/src/Functional/OpenIdConnectUiTest.php b/tests/src/Functional/OpenIdConnectUiTest.php index 47e70a21962509e20ba3956d97df0783ea58506c..2c7f3e845b570e85290d2322cd136a00bbc0264e 100644 --- a/tests/src/Functional/OpenIdConnectUiTest.php +++ b/tests/src/Functional/OpenIdConnectUiTest.php @@ -2,8 +2,6 @@ namespace Drupal\Tests\openid_connect\Functional; -use Drupal\Tests\BrowserTestBase; - /** * Functional test for openid connect clients. * @@ -11,7 +9,7 @@ use Drupal\Tests\BrowserTestBase; * * @group openid_connect */ -class OpenIdConnectUiTest extends BrowserTestBase { +class OpenIdConnectUiTest extends OpenIdConnectTestBase { use OpenIdClientTestTrait; @@ -105,6 +103,7 @@ class OpenIdConnectUiTest extends BrowserTestBase { $this->assertSession()->fieldNotExists('Password'); // Logout as the administrator. $this->drupalLogout(); + $this->drupalResetSession(); // Login as a new administrator who can manage their own password. $newAdmin = $this->createUser([ @@ -119,6 +118,7 @@ class OpenIdConnectUiTest extends BrowserTestBase { $this->assertSession()->fieldExists('Password'); // Logout as the new administrator. $this->drupalLogout(); + $this->drupalResetSession(); // Login as the normal user and confirm they can see their // password field. @@ -134,6 +134,7 @@ class OpenIdConnectUiTest extends BrowserTestBase { $this->assertSession()->statusCodeEquals(200); $this->assertSession()->fieldNotExists('Password'); $this->drupalLogout(); + $this->drupalResetSession(); // Link a new account with permission to set a password. // Confirm they are able to see the password field. @@ -144,7 +145,7 @@ class OpenIdConnectUiTest extends BrowserTestBase { $this->assertSession()->statusCodeEquals(200); $this->assertSession()->fieldExists('Password'); $this->drupalLogout(); - + $this->drupalResetSession(); } }