From 3e0b828150183cf90398ba762a4a07d08350115f Mon Sep 17 00:00:00 2001 From: Steve Breker Date: Tue, 4 Jun 2024 14:30:20 -0700 Subject: [PATCH] Allow selection of secondary OIDC provider Add the ability to define a secondary OIDC provider for authentication and select it using a query param on the request URL. E.g. Use the 'sample_provider' provider by modifying the AtoM URL before pressing "Log in with SSO": http://127.0.0.1:63001/index.php?secondary=sample_provider --- plugins/arOidcPlugin/config/app.yml | 89 ++++++---- plugins/arOidcPlugin/lib/arOidc.class.php | 17 +- plugins/arOidcPlugin/lib/oidcUser.class.php | 157 ++++++++++++++++-- .../oidc/actions/loginAction.class.php | 10 +- 4 files changed, 216 insertions(+), 57 deletions(-) diff --git a/plugins/arOidcPlugin/config/app.yml b/plugins/arOidcPlugin/config/app.yml index 100be215a4..a9a8f40615 100644 --- a/plugins/arOidcPlugin/config/app.yml +++ b/plugins/arOidcPlugin/config/app.yml @@ -1,13 +1,46 @@ ## OIDC Plugin configuration. all: oidc: + # 'providers' is a list of one or many oidc providers. The provider identified in + # 'primary_provider_name' setting will be selected when authenticating using the "Log in + # with SSO" button in AtoM. Additional providers can be selected by adding a query param + # to the URL before selecting "Log in with SSO". See 'provider_query_param_name' setting + # for more info. + # + # Any number of secondary providers can be configured in this list. Provider names must be + # unique. + # # OIDC provider endpoint settings: # Default for Dex: 'http://dex:5556/dex' - # Default for Keycloak direct using https: 'https://keycloak:8443/realms/artefactual' - # NOTE: Always configure using SSL in production. - provider_url: 'https://keycloak:8443/realms/artefactual' - client_id: 'artefactual-atom' - client_secret: 'example-secret' + # Default for Keycloak direct using https: 'https://keycloak:8443/realms/client' + # NOTE: Always configure url using SSL in production. + providers: + primary: + url: 'https://keycloak:8443/realms/artefactual' + client_id: 'artefactual-atom' + client_secret: 'example-secret' + + #sample_provider: + #url: 'https://keycloak:8443/realms/sample' + #client_id: 'sample-atom' + #client_secret: 'example-secret' + + # Identifies the primary OIDC provider and corresponds to an entry in 'providers' above. + # The default provider name is 'primary'. If this setting is not defined, AtoM will default + # to the provider named "primary". + # + # primary_provider_name: primary + + # This setting enables the use of additional providers and identifies the name of the query + # param that can be added to the refering URL when selecting "Log in with SSO" button in AtoM. + # To use: append the query param and the provider name to the URL before selecting + # "Log in with SSO". Must be uncommented to activate use of secondary providers. + # + # E.g. Use the 'sample_provider' provider by modifying the URL before pressing "Log in with SSO": + # http://127.0.0.1:63001/index.php?secondary=sample_provider + # + # provider_query_param_name: secondary + # Localhost port 63001 (127.0.0.1:63001) is used as a placeholder and # should be replaced with your AtoM site's public IP and port. # NOTE: Always configure using SSL in production. @@ -43,43 +76,43 @@ all: server_cert: false scopes: - - 'openid' - # Use with Dex - # - 'offline_access' - - 'profile' - - 'email' - # Use with Dex - # - 'groups' + - 'openid' + # Use with Dex + # - 'offline_access' + - 'profile' + - 'email' + # Use with Dex + # - 'groups' # Settings for parsing OIDC groups into AtoM group membership. # Set set_groups_from_attributes to true to enable. set_groups_from_attributes: true user_groups: - administrator: - attribute_value: 'atom-admin' - group_id: 100 - editor: - attribute_value: 'atom-editor' - group_id: 101 - contributor: - attribute_value: 'atom-contributor' - group_id: 102 - translator: - attribute_value: 'atom-translator' - group_id: 103 + administrator: + attribute_value: 'atom-admin' + group_id: 100 + editor: + attribute_value: 'atom-editor' + group_id: 101 + contributor: + attribute_value: 'atom-contributor' + group_id: 102 + translator: + attribute_value: 'atom-translator' + group_id: 103 # Identify token which contains role claims. Options are 'access-token', # 'id-token', 'verified-claims', or 'user-info'. # 'set_groups_from_attributes' must be 'true' to enable. - roles_source: access-token + roles_source: 'access-token' # Identify the location of role claims within the token identified in # `roles_source` above. This is an array containing the node path to # locate the roles array in the OIDC token. By default this is found # in Keycloak's access token under 'realm_access'/'roles'. roles_path: - - realm_access - - roles + - 'realm_access' + - 'roles' # Identify how IAM users are matched to users in AtoM. Two values are allowed: # user_matching_source: oidc-email @@ -89,7 +122,7 @@ all: # Using oidc-email requires the 'email' scope to be set above in the # 'scopes' setting. 'email' is an optional user setup field in Keycloak but # MUST be set if matching to pre-existing AtoM user accounts is going to work. - user_matching_source: oidc-email + user_matching_source: 'oidc-email' # Activate or disable the automatic creation of AtoM user records from OIDC # endpoint details. Allowed settings are: diff --git a/plugins/arOidcPlugin/lib/arOidc.class.php b/plugins/arOidcPlugin/lib/arOidc.class.php index 0309c06536..0a43c097fa 100644 --- a/plugins/arOidcPlugin/lib/arOidc.class.php +++ b/plugins/arOidcPlugin/lib/arOidc.class.php @@ -37,20 +37,7 @@ public static function getOidcInstance() return; } - $provider_url = sfConfig::get('app_oidc_provider_url', ''); - if (empty($provider_url)) { - throw new Exception('Invalid OIDC provider URL. Please review the app_oidc_provider_url parameter in plugin app.yml.'); - } - $client_id = sfConfig::get('app_oidc_client_id', ''); - if (empty($client_id)) { - throw new Exception('Invalid OIDC client id. Please review the app_oidc_client_id parameter in plugin app.yml.'); - } - $client_secret = sfConfig::get('app_oidc_client_secret', ''); - if (empty($client_secret)) { - throw new Exception('Invalid OIDC client secret. Please review the app_oidc_client_secret parameter in plugin app.yml.'); - } - - $oidc = new OpenIDConnectClient($provider_url, $client_id, $client_secret); + $oidc = new OpenIDConnectClient(); // Validate requested scopes. $scopesArray = sfConfig::get('app_oidc_scopes', []); @@ -65,7 +52,7 @@ public static function getOidcInstance() // Validate redirect URL. $redirectUrl = sfConfig::get('app_oidc_redirect_url', ''); if (empty($redirectUrl)) { - throw new Exception('Invalid OIDC redirect URL. Please review the app_oidc_provider_url parameter in plugin app.yml.'); + throw new Exception('Invalid OIDC redirect URL. Please review the app_oidc_redirect_url parameter in plugin app.yml.'); } $oidc->setRedirectURL($redirectUrl); diff --git a/plugins/arOidcPlugin/lib/oidcUser.class.php b/plugins/arOidcPlugin/lib/oidcUser.class.php index 7f74181634..84692ce32e 100644 --- a/plugins/arOidcPlugin/lib/oidcUser.class.php +++ b/plugins/arOidcPlugin/lib/oidcUser.class.php @@ -43,16 +43,27 @@ public function initialize(sfEventDispatcher $dispatcher, sfStorage $storage, $o * * @param null|mixed $username * @param null|mixed $password - * - * @return bool */ - public function authenticate($username = null, $password = null) + public function authenticate($username = null, $password = null): bool { $authenticated = false; $user = null; $authenticateResult = false; $email = null; + $this->logger->err(sprintf('%s', json_encode(sfConfig::getAll()))); + + // Get provider ID from session storage as it may have been set elsewhere. + $providerId = $this->getSessionProviderId(); + // Validate and set provider ID in session. + if (null !== $providerId = $this->validateProviderId($providerId, true)) { + // Set Provider details in OIDC client. + $result = $this->setOidcProviderDetails($providerId); + } + if (null === $providerId || !isset($result) || false === $result) { + return $authenticated; + } + if (isset($_REQUEST['code'])) { $this->logger->info('OIDC request "code" is set.'); } @@ -183,10 +194,8 @@ public function authenticate($username = null, $password = null) /** * Returns bool value indicating if this user is authenticated. - * - * @return bool */ - public function isAuthenticated() + public function isAuthenticated(): bool { $authenticated = parent::isAuthenticated(); @@ -210,7 +219,11 @@ public function isAuthenticated() if (null !== $expiryTime && $currentTime >= $expiryTime) { try { $this->logger->info('ID token expired - using refresh token to extend session.'); - $refreshResult = $this->oidcClient->refreshToken($refreshToken); + $providerId = $this->getSessionProviderId(); + // Set provider details in the OIDC client using provider id. + if (true === $this->setOidcProviderDetails($providerId)) { + $refreshResult = $this->oidcClient->refreshToken($refreshToken); + } // Validate the new refresh token. If the refresh token is invalid, the user is logged out. if (!isset($refreshResult->refresh_token) || empty($refreshResult->refresh_token)) { @@ -245,7 +258,7 @@ public function isAuthenticated() /** * Logout from AtoM and the OIDC server. */ - public function logout() + public function logout(): void { $idToken = $this->getAttribute('oidc-token', null); $this->unsetAttributes(); @@ -262,13 +275,135 @@ public function logout() // so $this->oidcClient->signOut will fail. // https://github.com/dexidp/dex/issues/1697 try { - $this->oidcClient->signOut($idToken, $logoutRedirectUrl); + // Get saved session provider id. + $providerId = $this->getSessionProviderId(); + // Set provider details in the OIDC client using provider id. + if (true === $result = $this->setOidcProviderDetails($providerId)) { + $this->oidcClient->signOut($idToken, $logoutRedirectUrl); + } + $this->setProviderId(); } catch (Exception $e) { + $this->setProviderId(); $this->logger->err($e->__toString().PHP_EOL); } } } + // Parse the query params from a URL. If a param matches the provider ID selector + // then return the value. + public function parseProviderIdFromURL(string $url): ?string + { + if (empty($url)) { + return null; + } + + $providerQueryParamName = sfConfig::get('app_oidc_provider_query_param_name', ''); + if (empty($providerQueryParamName)) { + return null; + } + + $urlParts = parse_url(strip_tags($url)); + parse_str($urlParts['query'], $queryParts); + + // Test if valid query param selector name. + if (isset($queryParts[$providerQueryParamName])) { + $providerId = $queryParts[$providerQueryParamName]; + } + + // A provider ID was specified. + if (isset($providerId)) { + return $providerId; + } + + return null; + } + + // Get provider ID from session storage. + public function getSessionProviderId(): string + { + return $this->getAttribute('oidc-session-provider-id', ''); + } + + // Set provider in session storage. + public function setSessionProviderId(string $providerId = ''): void + { + $this->setAttribute('oidc-session-provider-id', $providerId); + } + + // Determine provider ID, test if valid and if so, save in session storage. + public function validateProviderId(string $providerId = '', bool $setSessionProviderId = false): ?string + { + // If not available get primary provider. + if (empty($providerId)) { + $providerId = sfConfig::get('app_oidc_primary_provider_name', 'primary'); + } + + // Get OIDC provider list. If none are configured this is an error. + $providers = sfConfig::get('app_oidc_providers', []); + if (empty($providers)) { + $this->logger->err('OIDC providers not found in app.yml - check plugin configuration. Unable to authenticate using OIDC.'); + + return null; + } + + // Test if provider ID is valid. + if (!empty($providerId) + && isset($providers[$providerId]) + ) { + // Save provider ID in session storage. + if (true === $setSessionProviderId) { + $this->setSessionProviderId($providerId); + } + + return $providerId; + } + + // Provider ID specified does not match any configured providers. + $this->logger->err('OIDC provider matching unsuccessful - check plugin configuration. Unable to authenticate using OIDC.'); + + return null; + } + + // Look up provider details from provider ID and set values in the OIDC client object. + protected function setOidcProviderDetails(string $providerId = ''): bool + { + if (empty($providerId)) { + $this->logger->err('OIDC providers is empty - ensure setSessionProviderId() is called before calling setOidcProviderDetails(). Unable to authenticate using OIDC.'); + + return false; + } + + // Get configured provider details from ID. + $providers = sfConfig::get('app_oidc_providers', []); + if (empty($providers)) { + $this->logger->err('OIDC providers not found in app.yml - check plugin configuration. Unable to authenticate using OIDC.'); + + return false; + } + + // Get provider from list. + if (!empty($providerId) + && isset($providers[$providerId]) + ) { + $provider = $providers[$providerId]; + } + + // Set provider details in OIDC Client. + if (isset($provider['url'], $provider['client_id'], $provider['client_secret']) + ) { + $this->oidcClient->setProviderUrl($provider['url']); + $this->oidcClient->setClientID($provider['client_id']); + $this->oidcClient->setClientSecret($provider['client_secret']); + $this->oidcClient->setIssuer($provider['url']); + + return true; + } + + $this->logger->err('OIDC provider matching unsuccessful - check plugin provider configuration. Unable to authenticate using OIDC.'); + + return false; + } + /** * getTokenContents() maps the location of the role info as set in app.yml * to a function that extracts these claims details. @@ -306,7 +441,7 @@ protected function getTokenContents($tokenType) * * @return array $roles */ - protected function parseOidcRoleClaims($claims, $pathArray) + protected function parseOidcRoleClaims($claims, $pathArray): array { $currentElement = $claims; foreach ($pathArray as $key) { @@ -373,7 +508,7 @@ protected function setGroupsFromOidcGroups($user, array $groups) /** * Clear out session vars holding auth info. */ - private function unsetAttributes() + private function unsetAttributes(): void { $this->setAttribute('oidc-token', ''); $this->setAttribute('oidc-expiry', ''); diff --git a/plugins/arOidcPlugin/modules/oidc/actions/loginAction.class.php b/plugins/arOidcPlugin/modules/oidc/actions/loginAction.class.php index 9a92f3da46..db7d3f2fb9 100644 --- a/plugins/arOidcPlugin/modules/oidc/actions/loginAction.class.php +++ b/plugins/arOidcPlugin/modules/oidc/actions/loginAction.class.php @@ -31,18 +31,22 @@ public function execute($request) // user/list (for example) if the user was attempting to access a secure resource. When redirected // back from the OIDC endpoint, the referrer will be empty. if ($request->isMethod('post') && !empty($request->getReferer())) { - $this->context->user->setAttribute('atom-login-referrer', $request->getReferer()); + $this->context->user->setAttribute('atom-login-referer', $request->getReferer()); } if ($request->isMethod('post') || isset($_REQUEST['code'])) { - $this->getUser()->authenticate(); + if (null !== $providerId = $this->context->user->parseProviderIdFromUrl($this->context->user->getAttribute('atom-login-referer', null))) { + $this->context->user->validateProviderId($providerId, true); + } + + $this->context->user->authenticate(); } // Redirect to module/action the user was trying to reach before being redirected // to the OIDC IAM system for authentication. We prefer a redirect to a forward so that the ticket // parameter is not accidentally exposed in the user's browser. if (null !== $redirectUrl = $this->context->user->getAttribute('atom-login-referrer', null)) { - $this->context->user->setAttribute('atom-login-referrer', null); + $this->context->user->setAttribute('atom-login-referer', null); $this->redirect($redirectUrl); }